Compare commits
No commits in common. "master" and "v1.0.2-build102" have entirely different histories.
master
...
v1.0.2-bui
|
|
@ -1,7 +1,5 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "motoapp-d22e0",
|
||||
"production": "motoapp-d22e0",
|
||||
"staging": "motorsignal-staging"
|
||||
"default": "motoapp-d22e0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
name: Flutter CI setup
|
||||
description: Set up Flutter dependencies and restore build_runner state for CI jobs.
|
||||
|
||||
inputs:
|
||||
setup-java:
|
||||
description: Whether this job needs the pinned Java toolchain.
|
||||
required: false
|
||||
default: 'false'
|
||||
build-runner-cache-key:
|
||||
description: Cache key for build_runner state.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Java
|
||||
if: inputs.setup-java == 'true'
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
cache: gradle
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
- name: Get dependencies
|
||||
shell: bash
|
||||
run: flutter pub get
|
||||
- name: Restore build_runner state
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .dart_tool/build
|
||||
key: ${{ inputs.build-runner-cache-key }}
|
||||
|
|
@ -39,32 +39,12 @@ function isAppPath(filePath) {
|
|||
);
|
||||
}
|
||||
|
||||
function isNativeBuildPath(filePath) {
|
||||
const p = normalizePath(filePath);
|
||||
if (!p) return false;
|
||||
// Native build requires: Android code, CI-critical files, or build config.
|
||||
// Only CI files that affect build behavior trigger release builds; doc/comment changes do not.
|
||||
// Composite actions: only Android-specific actions (flutter-ci-setup) trigger builds.
|
||||
return (
|
||||
p.startsWith('android/') ||
|
||||
p === '.github/workflows/android-build.yml' ||
|
||||
p.startsWith('.github/scripts/') ||
|
||||
p.startsWith('.github/actions/flutter-ci-setup/') ||
|
||||
p === 'pubspec.yaml' ||
|
||||
p === 'pubspec.lock' ||
|
||||
p === 'analysis_options.yaml' ||
|
||||
p === 'build.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
function isCiPath(filePath) {
|
||||
const p = normalizePath(filePath);
|
||||
if (!p) return false;
|
||||
// CI paths: workflows, scripts, and Android-specific composite actions.
|
||||
return (
|
||||
p === '.github/workflows/android-build.yml' ||
|
||||
p.startsWith('.github/scripts/') ||
|
||||
p.startsWith('.github/actions/flutter-ci-setup/')
|
||||
p.startsWith('.github/scripts/')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +53,6 @@ function classifyFiles(fileList) {
|
|||
return {
|
||||
android_changed: false,
|
||||
app_changed: false,
|
||||
native_build_changed: false,
|
||||
appFiles: [],
|
||||
ciFiles: [],
|
||||
unrelatedFiles: []
|
||||
|
|
@ -83,7 +62,6 @@ function classifyFiles(fileList) {
|
|||
const appFiles = [];
|
||||
const ciFiles = [];
|
||||
const unrelatedFiles = [];
|
||||
const nativeBuildFiles = [];
|
||||
|
||||
for (const rawFile of fileList) {
|
||||
const file = normalizePath(rawFile);
|
||||
|
|
@ -96,21 +74,14 @@ function classifyFiles(fileList) {
|
|||
} else {
|
||||
unrelatedFiles.push(file);
|
||||
}
|
||||
|
||||
// Track files that trigger native builds (Android code, CI, config)
|
||||
if (isNativeBuildPath(file)) {
|
||||
nativeBuildFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const app_changed = appFiles.length > 0;
|
||||
const android_changed = appFiles.length > 0 || ciFiles.length > 0;
|
||||
const native_build_changed = nativeBuildFiles.length > 0;
|
||||
|
||||
return {
|
||||
android_changed,
|
||||
app_changed,
|
||||
native_build_changed,
|
||||
appFiles,
|
||||
ciFiles,
|
||||
unrelatedFiles
|
||||
|
|
@ -153,7 +124,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
return {
|
||||
android_changed: true,
|
||||
app_changed: true,
|
||||
native_build_changed: true,
|
||||
reason: 'Manual workflow_dispatch; full pipeline authorized',
|
||||
appFiles: [],
|
||||
ciFiles: [],
|
||||
|
|
@ -165,7 +135,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
if (!baseSha || isZeroSha(baseSha) || !isValidSha(baseSha)) {
|
||||
return {
|
||||
android_changed: true,
|
||||
native_build_changed: true,
|
||||
app_changed: true,
|
||||
reason: `Zero, missing, or invalid base SHA (${baseSha}); failing safe to full build`,
|
||||
appFiles: [],
|
||||
|
|
@ -178,7 +147,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
if (!headSha || isZeroSha(headSha) || !isValidSha(headSha)) {
|
||||
return {
|
||||
android_changed: true,
|
||||
native_build_changed: true,
|
||||
app_changed: true,
|
||||
reason: `Zero, missing, or invalid head SHA (${headSha}); failing safe to full build`,
|
||||
appFiles: [],
|
||||
|
|
@ -191,7 +159,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
if (baseSha.trim() === headSha.trim()) {
|
||||
return {
|
||||
android_changed: false,
|
||||
native_build_changed: false,
|
||||
app_changed: false,
|
||||
reason: 'Base SHA and Head SHA are identical; no changes detected',
|
||||
appFiles: [],
|
||||
|
|
@ -207,7 +174,6 @@ function detectAndroidChanges({ eventName, baseSha, headSha, repoDir }, deps = {
|
|||
} catch (err) {
|
||||
return {
|
||||
android_changed: true,
|
||||
native_build_changed: true,
|
||||
app_changed: true,
|
||||
reason: `Failed to retrieve git diff: ${err.message}; failing safe to full build`,
|
||||
appFiles: [],
|
||||
|
|
@ -245,7 +211,6 @@ function runCli() {
|
|||
|
||||
console.log(`[detect-android-changes] android_changed=${result.android_changed}`);
|
||||
console.log(`[detect-android-changes] app_changed=${result.app_changed}`);
|
||||
console.log(`[detect-android-changes] native_build_changed=${result.native_build_changed}`);
|
||||
console.log(`[detect-android-changes] Reason: ${result.reason}`);
|
||||
|
||||
if (result.appFiles && result.appFiles.length > 0) {
|
||||
|
|
@ -273,7 +238,7 @@ function runCli() {
|
|||
if (githubOutput) {
|
||||
fs.appendFileSync(
|
||||
githubOutput,
|
||||
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\nnative_build_changed=${result.native_build_changed}\n`
|
||||
`android_changed=${result.android_changed}\napp_changed=${result.app_changed}\n`
|
||||
);
|
||||
console.log(`[detect-android-changes] Wrote outputs to GITHUB_OUTPUT.`);
|
||||
}
|
||||
|
|
@ -291,7 +256,6 @@ module.exports = {
|
|||
isZeroSha,
|
||||
isAppPath,
|
||||
isCiPath,
|
||||
isNativeBuildPath,
|
||||
classifyFiles,
|
||||
getChangedFiles,
|
||||
detectAndroidChanges
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ const {
|
|||
isZeroSha,
|
||||
isAppPath,
|
||||
isCiPath,
|
||||
isNativeBuildPath,
|
||||
classifyFiles,
|
||||
getChangedFiles,
|
||||
detectAndroidChanges
|
||||
|
|
@ -72,37 +71,14 @@ describe('File Path Classification', () => {
|
|||
assert.strictEqual(isCiPath('.github/workflows/android-build.yml'), true);
|
||||
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.cjs'), true);
|
||||
assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.test.cjs'), true);
|
||||
assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/action.yml'), true);
|
||||
assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/index.js'), true);
|
||||
|
||||
// Negative matches for Android CI paths
|
||||
assert.strictEqual(isCiPath('.github/workflows/website-ci.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/workflows/backend-ci.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/workflows/waze-relay-ci.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/workflows/firebase-emulator-tests.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/actions/website-deploy/action.yml'), false);
|
||||
assert.strictEqual(isCiPath('.github/actions/other-action/index.js'), false);
|
||||
assert.strictEqual(isCiPath('.github/pull_request_template.md'), false);
|
||||
});
|
||||
|
||||
it('identifies native build paths correctly', () => {
|
||||
assert.strictEqual(isNativeBuildPath('android/app/build.gradle'), true);
|
||||
assert.strictEqual(isNativeBuildPath('.github/workflows/android-build.yml'), true);
|
||||
assert.strictEqual(isNativeBuildPath('.github/scripts/detect-android-changes.cjs'), true);
|
||||
assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/action.yml'), true);
|
||||
assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/index.js'), true);
|
||||
assert.strictEqual(isNativeBuildPath('pubspec.yaml'), true);
|
||||
assert.strictEqual(isNativeBuildPath('pubspec.lock'), true);
|
||||
assert.strictEqual(isNativeBuildPath('analysis_options.yaml'), true);
|
||||
assert.strictEqual(isNativeBuildPath('build.yaml'), true);
|
||||
|
||||
// Negative matches for native build paths
|
||||
assert.strictEqual(isNativeBuildPath('.github/workflows/website-ci.yml'), false);
|
||||
assert.strictEqual(isNativeBuildPath('.github/actions/website-deploy/action.yml'), false);
|
||||
assert.strictEqual(isNativeBuildPath('.github/actions/other-action/action.yml'), false);
|
||||
assert.strictEqual(isNativeBuildPath('lib/main.dart'), false);
|
||||
assert.strictEqual(isNativeBuildPath('website/app/page.tsx'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyFiles Scenarios', () => {
|
||||
|
|
@ -169,7 +145,6 @@ describe('classifyFiles Scenarios', () => {
|
|||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.strictEqual(result.native_build_changed, false);
|
||||
assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']);
|
||||
});
|
||||
|
||||
|
|
@ -193,7 +168,6 @@ describe('classifyFiles Scenarios', () => {
|
|||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.strictEqual(result.native_build_changed, true);
|
||||
});
|
||||
|
||||
it('classifies pubspec change as true', () => {
|
||||
|
|
@ -210,9 +184,7 @@ describe('classifyFiles Scenarios', () => {
|
|||
|
||||
it('classifies analysis_options and build.yaml changes as true', () => {
|
||||
assert.strictEqual(classifyFiles(['analysis_options.yaml']).android_changed, true);
|
||||
assert.strictEqual(classifyFiles(['analysis_options.yaml']).native_build_changed, true);
|
||||
assert.strictEqual(classifyFiles(['build.yaml']).android_changed, true);
|
||||
assert.strictEqual(classifyFiles(['build.yaml']).native_build_changed, true);
|
||||
});
|
||||
|
||||
it('classifies assets change as true', () => {
|
||||
|
|
@ -267,26 +239,6 @@ describe('classifyFiles Scenarios', () => {
|
|||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
});
|
||||
|
||||
it('classifies composite action (flutter-ci-setup) change as android_changed=true, native_build_changed=true', () => {
|
||||
const files = ['.github/actions/flutter-ci-setup/action.yml'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(result.native_build_changed, true);
|
||||
assert.strictEqual(result.ciFiles.length, 1);
|
||||
assert.deepStrictEqual(result.ciFiles, ['.github/actions/flutter-ci-setup/action.yml']);
|
||||
});
|
||||
|
||||
it('classifies non-Android composite action (e.g., website-deploy) as false', () => {
|
||||
const files = ['.github/actions/website-deploy/action.yml'];
|
||||
const result = classifyFiles(files);
|
||||
assert.strictEqual(result.android_changed, false);
|
||||
assert.strictEqual(result.app_changed, false);
|
||||
assert.strictEqual(result.native_build_changed, false);
|
||||
assert.strictEqual(result.ciFiles.length, 0);
|
||||
assert.strictEqual(result.unrelatedFiles.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndroidChanges Workflow Event Logic', () => {
|
||||
|
|
@ -301,7 +253,6 @@ describe('detectAndroidChanges Workflow Event Logic', () => {
|
|||
});
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.strictEqual(result.native_build_changed, true);
|
||||
assert.match(result.reason, /Manual workflow_dispatch/);
|
||||
});
|
||||
|
||||
|
|
@ -615,271 +566,6 @@ describe('Workflow Scenario Matrix Verification', () => {
|
|||
|
||||
assert.strictEqual(result.android_changed, true);
|
||||
assert.strictEqual(result.app_changed, true);
|
||||
assert.strictEqual(result.native_build_changed, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Release Job Gating (native_build_changed output)', () => {
|
||||
const baseSha = '0000000000000000000000000000000000000001';
|
||||
const headSha = '0000000000000000000000000000000000000002';
|
||||
|
||||
it('Dart-only changes skip release job (native_build_changed=false)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart', 'assets/icons/app.png'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, false, 'Dart-only changes should skip release');
|
||||
assert.strictEqual(result.app_changed, true, 'app_changed should still be true');
|
||||
});
|
||||
|
||||
it('Android code changes trigger release job (native_build_changed=true)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['android/app/build.gradle'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, true, 'Android changes should trigger release');
|
||||
});
|
||||
|
||||
it('CI-only changes (.github/) trigger release job (native_build_changed=true)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['.github/workflows/other.yml', '.github/scripts/helper.sh'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, true, 'CI changes should trigger release');
|
||||
});
|
||||
|
||||
it('Config file changes (pubspec.yaml) trigger release job (native_build_changed=true)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['pubspec.yaml'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, true, 'pubspec.yaml changes should trigger release');
|
||||
});
|
||||
|
||||
it('Build config changes (build.yaml) trigger release job (native_build_changed=true)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['build.yaml'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, true, 'build.yaml changes should trigger release');
|
||||
});
|
||||
|
||||
it('Mixed Dart and Android changes trigger release job (native_build_changed=true)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['lib/main.dart', 'android/app/build.gradle'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, true, 'Mixed changes should trigger release');
|
||||
});
|
||||
|
||||
it('Manual dispatch always triggers release job (native_build_changed=true)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'workflow_dispatch',
|
||||
baseSha: '',
|
||||
headSha: ''
|
||||
});
|
||||
|
||||
assert.strictEqual(result.native_build_changed, true, 'Manual dispatch should always trigger release');
|
||||
});
|
||||
|
||||
it('Invalid base SHA fails safe to trigger release (native_build_changed=true)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha: 'invalid',
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['lib/main.dart'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, true, 'Invalid SHA should fail safe to trigger release');
|
||||
});
|
||||
|
||||
it('Unrelated changes (docs/, website/) skip release job (native_build_changed=false)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['docs/readme.md', 'website/index.html'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, false, 'Unrelated changes should skip release');
|
||||
});
|
||||
|
||||
it('dependency changes (pubspec.lock) trigger release job (native_build_changed=true)', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['pubspec.lock'] });
|
||||
|
||||
assert.strictEqual(result.native_build_changed, true, 'pubspec.lock changes should trigger release');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub Actions Workflow Output Wiring and Release Job Conditions', () => {
|
||||
const baseSha = '03446911949ff8b27543280bfe226d9babd6fd7a';
|
||||
const headSha = '247ccde71e4378dacd45121c891f45aad5ed2424';
|
||||
|
||||
it('detector outputs native_build_changed for GitHub Actions output file', () => {
|
||||
// Simulate the detector writing to GITHUB_OUTPUT
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['lib/main.dart'] });
|
||||
|
||||
// The detector MUST set native_build_changed=false for Dart-only changes
|
||||
assert.strictEqual(result.native_build_changed, false, 'Dart-only should set native_build_changed=false');
|
||||
assert.strictEqual(result.android_changed, true, 'But android_changed=true still drives heavy build');
|
||||
});
|
||||
|
||||
it('workflow release condition evaluates correctly: Dart-only changes skip release', () => {
|
||||
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart'] });
|
||||
|
||||
// Release job condition evaluates to false
|
||||
const nativeBuildChanged = result.native_build_changed;
|
||||
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||
|
||||
assert.strictEqual(nativeBuildChanged, false, 'Dart-only should output false');
|
||||
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for Dart-only');
|
||||
});
|
||||
|
||||
it('workflow release condition evaluates correctly: Android changes run release', () => {
|
||||
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['android/app/build.gradle'] });
|
||||
|
||||
// Release job condition evaluates to true
|
||||
const nativeBuildChanged = result.native_build_changed;
|
||||
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||
|
||||
assert.strictEqual(nativeBuildChanged, true, 'Android changes should output true');
|
||||
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for Android changes');
|
||||
});
|
||||
|
||||
it('workflow release condition evaluates correctly: CI-critical changes run release', () => {
|
||||
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['.github/workflows/android-build.yml'] });
|
||||
|
||||
// Release job condition evaluates to true
|
||||
const nativeBuildChanged = result.native_build_changed;
|
||||
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||
|
||||
assert.strictEqual(nativeBuildChanged, true, 'CI-critical changes should output true');
|
||||
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for CI-critical changes');
|
||||
});
|
||||
|
||||
it('workflow release condition evaluates correctly: Config changes run release', () => {
|
||||
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['pubspec.yaml'] });
|
||||
|
||||
// Release job condition evaluates to true
|
||||
const nativeBuildChanged = result.native_build_changed;
|
||||
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||
|
||||
assert.strictEqual(nativeBuildChanged, true, 'Config changes should output true');
|
||||
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for config changes');
|
||||
});
|
||||
|
||||
it('workflow release condition evaluates correctly: Unrelated changes skip release', () => {
|
||||
// Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master'
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['docs/readme.md', 'website/app.tsx'] });
|
||||
|
||||
// Release job condition evaluates to false
|
||||
const nativeBuildChanged = result.native_build_changed;
|
||||
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||
|
||||
assert.strictEqual(nativeBuildChanged, false, 'Unrelated changes should output false');
|
||||
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for unrelated changes');
|
||||
});
|
||||
|
||||
it('workflow release condition evaluates correctly: Composite action changes run release', () => {
|
||||
// flutter-ci-setup is a composite action used by analysis, test, and debug APK jobs
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['.github/actions/flutter-ci-setup/action.yml'] });
|
||||
|
||||
const nativeBuildChanged = result.native_build_changed;
|
||||
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||
|
||||
assert.strictEqual(nativeBuildChanged, true, 'Composite action changes should output true');
|
||||
assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for composite action changes');
|
||||
});
|
||||
|
||||
it('workflow release condition evaluates correctly: Non-Android composite action (website-deploy) skips release', () => {
|
||||
const result = detectAndroidChanges({
|
||||
eventName: 'push',
|
||||
baseSha,
|
||||
headSha
|
||||
}, { getChangedFilesFn: () => ['.github/actions/website-deploy/action.yml'] });
|
||||
|
||||
const nativeBuildChanged = result.native_build_changed;
|
||||
const onMaster = true; // github.ref == 'refs/heads/master'
|
||||
const releaseJobShouldRun = nativeBuildChanged && onMaster;
|
||||
|
||||
assert.strictEqual(nativeBuildChanged, false, 'Non-Android composite actions should output false');
|
||||
assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for non-Android actions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow YAML Verification', () => {
|
||||
it('verifies android-build.yml defines jobs.detect.outputs.native_build_changed', () => {
|
||||
const workflowPath = '.github/workflows/android-build.yml';
|
||||
const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8');
|
||||
|
||||
// Check that the workflow file exists and contains the output mapping
|
||||
assert.ok(workflowContent.includes('native_build_changed'),
|
||||
'Workflow must expose native_build_changed output from detector step');
|
||||
assert.ok(workflowContent.includes('steps.detector.outputs.native_build_changed'),
|
||||
'Workflow must reference detector output with ${{ steps.detector.outputs.native_build_changed }}');
|
||||
});
|
||||
|
||||
it('verifies release job condition includes native_build_changed check', () => {
|
||||
const workflowPath = '.github/workflows/android-build.yml';
|
||||
const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8');
|
||||
|
||||
// Check that release job condition references the output
|
||||
assert.ok(workflowContent.includes("needs.detect.outputs.native_build_changed == 'true'"),
|
||||
'Release job condition must check native_build_changed output');
|
||||
assert.ok(workflowContent.includes('refs/heads/master'),
|
||||
'Release job condition must ensure it only runs on master branch');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master, 'feature/**', 'fix/**', 'stabilization/**' ]
|
||||
branches: [ master ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
@ -30,7 +30,6 @@ jobs:
|
|||
outputs:
|
||||
android_changed: ${{ steps.detector.outputs.android_changed }}
|
||||
app_changed: ${{ steps.detector.outputs.app_changed }}
|
||||
native_build_changed: ${{ steps.detector.outputs.native_build_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
|
@ -50,54 +49,8 @@ jobs:
|
|||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: node .github/scripts/detect-android-changes.cjs
|
||||
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up Flutter CI
|
||||
uses: ./.github/actions/flutter-ci-setup
|
||||
with:
|
||||
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
|
||||
- name: Generate Drift sources
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
- name: Analyze
|
||||
run: flutter analyze --no-fatal-infos
|
||||
|
||||
test:
|
||||
name: Tests (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [0, 1, 2, 3]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up Flutter CI
|
||||
uses: ./.github/actions/flutter-ci-setup
|
||||
with:
|
||||
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
|
||||
- name: Generate Drift sources
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
- name: Run test shard
|
||||
run: flutter test --total-shards 4 --shard-index ${{ matrix.shard }}
|
||||
|
||||
debug-apk:
|
||||
name: Debug APK build
|
||||
analyze-and-build:
|
||||
name: Analyze, test and debug build
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true'
|
||||
|
|
@ -108,11 +61,18 @@ jobs:
|
|||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up Flutter CI
|
||||
uses: ./.github/actions/flutter-ci-setup
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
setup-java: 'true'
|
||||
build-runner-cache-key: ${{ runner.os }}-build-runner-${{ hashFiles('pubspec.lock', 'lib/database/**') }}
|
||||
distribution: temurin
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
cache: gradle
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
- name: Free runner disk space
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -170,6 +130,8 @@ jobs:
|
|||
flutter --version
|
||||
flutter doctor -v
|
||||
java -version
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
- name: Generate Drift sources
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
- name: Commit regenerated Drift sources
|
||||
|
|
@ -186,6 +148,10 @@ jobs:
|
|||
git add lib/database/database.g.dart
|
||||
git commit -m 'Regenerate Drift database source'
|
||||
git push origin HEAD:${GITHUB_REF_NAME}
|
||||
- name: Analyze
|
||||
run: flutter analyze --no-fatal-infos
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
- name: Build debug APK
|
||||
shell: bash
|
||||
run: flutter build apk --debug
|
||||
|
|
@ -193,22 +159,11 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f "build/app/outputs/flutter-apk/app-production-debug.apk" ] && [ ! -f "build/app/outputs/flutter-apk/app-debug.apk" ]; then
|
||||
cp "build/app/outputs/flutter-apk/app-production-debug.apk" "build/app/outputs/flutter-apk/app-debug.apk"
|
||||
fi
|
||||
APK="build/app/outputs/flutter-apk/app-debug.apk"
|
||||
test -s "$APK"
|
||||
file "$APK"
|
||||
sha256sum "$APK" | tee build/app/outputs/flutter-apk/app-debug.apk.sha256
|
||||
if [ -n "${ANDROID_SDK_ROOT:-}" ]; then
|
||||
AAPT="$(find "$ANDROID_SDK_ROOT/build-tools" -type f -name aapt -print -quit 2>/dev/null || true)"
|
||||
if [ -x "$AAPT" ]; then
|
||||
"$AAPT" dump badging "$APK" | grep "package: name='app.motorsignal.motorcycle'"
|
||||
echo "Package identity verified: app.motorsignal.motorcycle"
|
||||
fi
|
||||
fi
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: motorsignal-debug-apk
|
||||
|
|
@ -216,33 +171,13 @@ jobs:
|
|||
build/app/outputs/flutter-apk/app-debug.apk
|
||||
build/app/outputs/flutter-apk/app-debug.apk.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
analyze-and-build:
|
||||
name: Analyze, test and debug build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect, analyze, test, debug-apk]
|
||||
if: always() && (github.event_name == 'workflow_dispatch' || needs.detect.outputs.android_changed == 'true')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require all Android CI jobs to pass
|
||||
env:
|
||||
ANALYZE_RESULT: ${{ needs.analyze.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
DEBUG_APK_RESULT: ${{ needs.debug-apk.result }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$ANALYZE_RESULT" = success
|
||||
test "$TEST_RESULT" = success
|
||||
test "$DEBUG_APK_RESULT" = success
|
||||
retention-days: 14
|
||||
|
||||
release:
|
||||
name: Build signed release APK and AAB
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect, analyze-and-build]
|
||||
if: (github.ref == 'refs/heads/master') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.detect.outputs.native_build_changed == 'true'))
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.detect.outputs.app_changed == 'true')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
|
|
@ -361,13 +296,6 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f "build/app/outputs/flutter-apk/app-production-release.apk" ] && [ ! -f "build/app/outputs/flutter-apk/app-release.apk" ]; then
|
||||
cp "build/app/outputs/flutter-apk/app-production-release.apk" "build/app/outputs/flutter-apk/app-release.apk"
|
||||
fi
|
||||
if [ -f "build/app/outputs/bundle/productionRelease/app-production-release.aab" ] && [ ! -f "build/app/outputs/bundle/release/app-release.aab" ]; then
|
||||
mkdir -p "build/app/outputs/bundle/release"
|
||||
cp "build/app/outputs/bundle/productionRelease/app-production-release.aab" "build/app/outputs/bundle/release/app-release.aab"
|
||||
fi
|
||||
APK="build/app/outputs/flutter-apk/app-release.apk"
|
||||
AAB="build/app/outputs/bundle/release/app-release.aab"
|
||||
test -s "$APK"
|
||||
|
|
@ -383,7 +311,7 @@ jobs:
|
|||
build/app/outputs/flutter-apk/app-release.apk
|
||||
build/release-artifacts.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
retention-days: 30
|
||||
- name: Upload release App Bundle
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
|
|
@ -392,7 +320,7 @@ jobs:
|
|||
build/app/outputs/bundle/release/app-release.aab
|
||||
build/release-artifacts.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
retention-days: 30
|
||||
- name: Remove signing credentials
|
||||
if: always()
|
||||
shell: bash
|
||||
|
|
|
|||
|
|
@ -76,21 +76,3 @@ scratch_*.py
|
|||
1.29
|
||||
graphify-out/
|
||||
devtools_options.yaml
|
||||
|
||||
# Temporary outputs, root screenshots, and emulator caches
|
||||
/screen*.png
|
||||
/emulator_screenshot.png
|
||||
/.firebase-tmp/
|
||||
/output/
|
||||
firebase.website-qa.json
|
||||
|
||||
# Scratch tools and ephemeral test runs
|
||||
tools/*.py
|
||||
tools/*.mjs
|
||||
tools/*.cjs
|
||||
tools/*.js
|
||||
docs/qa-2026-10-01/auth.verify-*.json
|
||||
docs/qa-2026-10-01/auth.verify-*.mjs
|
||||
docs/qa-2026-10-01/chat.cross-account-results.json
|
||||
docs/qa-2026-10-01/scenarios_machine_readable.json
|
||||
|
||||
|
|
|
|||
|
|
@ -91,4 +91,4 @@ f7fbe71 (2026-08-27) - fix(telemetry): solve sensor fusion false lean, 3D G-forc
|
|||
* **Landscape UI Optimization**: Compacted toolbars and AppBars to **36px** in landscape mode to maximize map and gauge visibility.
|
||||
* **TomTom v5 API Upgrade**: Migrated to official TomTom query-parameter v5 endpoints.
|
||||
* **Waze Alert Relay Server**: Built and dockerized self-hosted Node.js relay backend in `server/waze_relay/`.
|
||||
* **Markdown Documentation**: Generated `features-changelog.md` and `development-history.md`.
|
||||
* **Markdown Documentation**: Generated `FEATURES_CHANGELOG.md` and `DEVELOPMENT_HISTORY.md`.
|
||||
18
GEMINI.md
|
|
@ -1,18 +0,0 @@
|
|||
\## Android Emulator / Device Policy
|
||||
|
||||
|
||||
|
||||
\- For all Android emulator testing, UI validation, screenshots, integration testing, and interactive app launches, ALWAYS use the AVD named `avdslim`.
|
||||
|
||||
\- Treat `avdslim` as the canonical MotorSignal Android test emulator.
|
||||
|
||||
\- Do not create, select, launch, or switch to another Android Virtual Device unless the user explicitly instructs you to do so.
|
||||
|
||||
\- Before running emulator-dependent tests, verify that `avdslim` is running and available through `adb devices`.
|
||||
|
||||
\- If `avdslim` is not running, start `avdslim` rather than choosing another emulator.
|
||||
|
||||
\- When multiple Android devices/emulators are connected, explicitly target `avdslim` for Flutter/ADB commands instead of relying on automatic device selection.
|
||||
|
||||
\- Physical devices should only be used when the user explicitly requests physical-device testing.
|
||||
|
||||
|
|
@ -26,9 +26,5 @@ A high-performance, dark-themed telemetry and analytics dashboard for motorcycle
|
|||
* **Database:** Drift (SQLite) for robust, local storage.
|
||||
* **Sensor Fusion:** Complementary filter combining accelerometer and gyroscope for precise lean angles.
|
||||
|
||||
## Documentation
|
||||
|
||||
The active documentation map and dated evidence archive are indexed in [docs/README.md](docs/README.md).
|
||||
|
||||
## Disclaimer
|
||||
This application relies on the accuracy of your smartphone's internal sensors. It should be securely mounted to the motorcycle using a rigid mount. Do not interact with the screen while riding. Ride safely!
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ android {
|
|||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner"
|
||||
testInstrumentationRunnerArguments["clearPackageData"] = "false"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
@ -74,11 +72,6 @@ android {
|
|||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfigs.findByName("release")?.let {
|
||||
signingConfig = it
|
||||
}
|
||||
}
|
||||
release {
|
||||
signingConfigs.findByName("release")?.let {
|
||||
signingConfig = it
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@
|
|||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "809431485362-0ev25bbjbjjm5tj6nitinefhg4fi7vvj.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "app.motorsignal.motorcycle",
|
||||
"certificate_hash": "4ab4d3266a5b50ef31e46a5dccb8d42f295bc635"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "809431485362-c2qfdas53jtoft3udu6jokciv9pruehq.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
package app.motorsignal.motorcycle;
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.runners.Parameterized;
|
||||
import org.junit.runners.Parameterized.Parameters;
|
||||
import pl.leancode.patrol.PatrolJUnitRunner;
|
||||
|
||||
@RunWith(Parameterized.class)
|
||||
public class MainActivityTest {
|
||||
@Parameters(name = "{0}")
|
||||
public static Object[] testCases() {
|
||||
PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
|
||||
instrumentation.setUp(MainActivity.class);
|
||||
instrumentation.waitForPatrolAppService();
|
||||
return instrumentation.listDartTests();
|
||||
}
|
||||
|
||||
public MainActivityTest(String dartTestName) {
|
||||
this.dartTestName = dartTestName;
|
||||
}
|
||||
|
||||
private final String dartTestName;
|
||||
|
||||
@Test
|
||||
public void runDartTest() {
|
||||
PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
|
||||
instrumentation.runDartTest(dartTestName);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
# MotorSignal Azure Pipelines pilot
|
||||
# Manual, non-publishing validation only. No signing keys or deployments.
|
||||
trigger: none
|
||||
pr: none
|
||||
|
||||
pool:
|
||||
vmImage: ubuntu-24.04
|
||||
|
||||
variables:
|
||||
flutterVersion: '3.44.6'
|
||||
|
||||
steps:
|
||||
- checkout: self
|
||||
clean: true
|
||||
fetchDepth: 1
|
||||
|
||||
- bash: |
|
||||
set -euo pipefail
|
||||
echo 'Before cleanup:'
|
||||
df -h /
|
||||
free -h
|
||||
# Microsoft-hosted Ubuntu includes toolchains this Android pipeline does not need.
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /opt/ghc /usr/local/.ghcup
|
||||
sudo rm -rf /usr/local/lib/android/sdk/emulator /usr/local/lib/android/sdk/system-images
|
||||
sudo apt-get clean
|
||||
echo 'After cleanup:'
|
||||
df -h /
|
||||
free -h
|
||||
available_kb="$(df --output=avail / | tail -1 | tr -d ' ')"
|
||||
if [ "$available_kb" -lt 6291456 ]; then
|
||||
echo 'At least 6 GiB free disk is required before Android SDK provisioning.'
|
||||
exit 1
|
||||
fi
|
||||
displayName: 'Free unused toolchains and verify disk capacity'
|
||||
|
||||
- bash: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 --branch "$(flutterVersion)" https://github.com/flutter/flutter.git "$(Agent.TempDirectory)/flutter"
|
||||
echo "##vso[task.prependpath]$(Agent.TempDirectory)/flutter/bin"
|
||||
"$(Agent.TempDirectory)/flutter/bin/flutter" --version
|
||||
displayName: 'Install pinned Flutter SDK'
|
||||
|
||||
- bash: |
|
||||
set -euo pipefail
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
flutter analyze --no-fatal-infos
|
||||
flutter test
|
||||
displayName: 'Generate, analyze and test Flutter'
|
||||
|
||||
- bash: |
|
||||
set -euo pipefail
|
||||
echo 'Before Android compilation:'
|
||||
df -h /
|
||||
free -h
|
||||
SDKROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
||||
test -n "$SDKROOT"
|
||||
echo "flutter.sdk=$(Agent.TempDirectory)/flutter" > android/local.properties
|
||||
SDKMANAGER="$(find "$SDKROOT/cmdline-tools" -type f -name sdkmanager -print -quit 2>/dev/null || true)"
|
||||
test -x "$SDKMANAGER"
|
||||
"$SDKMANAGER" 'platforms;android-36' 'build-tools;36.0.0' 'platform-tools'
|
||||
if [ ! -f android/gradlew ]; then
|
||||
(cd android && gradle wrapper --gradle-version 9.1.0 --distribution-type all)
|
||||
fi
|
||||
chmod +x android/gradlew
|
||||
# Agent-specific limits, applied only in CI's disposable checkout.
|
||||
cat >> android/gradle.properties <<'GRADLE_LIMITS'
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -XX:ReservedCodeCacheSize=256m -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
org.gradle.parallel=false
|
||||
org.gradle.daemon=false
|
||||
GRADLE_LIMITS
|
||||
export JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=60"
|
||||
flutter build apk --debug
|
||||
test -s build/app/outputs/flutter-apk/app-debug.apk || test -s build/app/outputs/flutter-apk/app-production-debug.apk
|
||||
echo 'Android debug APK built and verified locally; artifact intentionally not published.'
|
||||
df -h /
|
||||
free -h
|
||||
displayName: 'Build and verify debug APK without artifact upload'
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"""add provider_monthly_usage table for atomic usage limits
|
||||
|
||||
Revision ID: f1a2b3c4d5eb
|
||||
Revises: f1a2b3c4d5ea
|
||||
Create Date: 2026-10-10
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f1a2b3c4d5eb'
|
||||
down_revision = 'f1a2b3c4d5ea'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"provider_monthly_usage",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("provider_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("period_month", sa.String(length=7), nullable=False),
|
||||
sa.Column("requests_reserved", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("requests_executed", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("monthly_limit", sa.Integer(), server_default="500", nullable=False),
|
||||
sa.Column("estimated_cost_usd", sa.Float(), server_default="0.0", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("provider_id", "period_month", name="uq_provider_period"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_provider_monthly_usage_provider_id",
|
||||
"provider_monthly_usage",
|
||||
["provider_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_provider_monthly_usage_period_month",
|
||||
"provider_monthly_usage",
|
||||
["period_month"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_provider_monthly_usage_period_month", table_name="provider_monthly_usage")
|
||||
op.drop_index("ix_provider_monthly_usage_provider_id", table_name="provider_monthly_usage")
|
||||
op.drop_table("provider_monthly_usage")
|
||||
|
|
@ -6,7 +6,6 @@ import time
|
|||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from cryptography import x509
|
||||
|
|
@ -111,14 +110,8 @@ def _load_firebase_certs() -> dict[str, str]:
|
|||
return certs
|
||||
|
||||
|
||||
def decode_firebase_id_token(token: str, project_id: Optional[str] = None) -> dict:
|
||||
target_project = project_id or os.environ.get("FIREBASE_PROJECT_ID")
|
||||
if not target_project:
|
||||
env = (os.environ.get("MOTORSIGNAL_ENV") or os.environ.get("ENVIRONMENT") or os.environ.get("APP_ENV") or "").lower()
|
||||
if env in ("staging", "qa"):
|
||||
target_project = "motorsignal-staging"
|
||||
else:
|
||||
target_project = "motoapp-d22e0"
|
||||
def decode_firebase_id_token(token: str) -> dict:
|
||||
project_id = os.environ["FIREBASE_PROJECT_ID"]
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "RS256" or not header.get("kid"):
|
||||
raise jwt.InvalidTokenError("Invalid Firebase token header")
|
||||
|
|
@ -126,13 +119,7 @@ def decode_firebase_id_token(token: str, project_id: Optional[str] = None) -> di
|
|||
if certificate_pem is None:
|
||||
raise jwt.InvalidTokenError("Unknown Firebase signing key")
|
||||
public_key = x509.load_pem_x509_certificate(certificate_pem.encode("ascii")).public_key()
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
public_key,
|
||||
algorithms=["RS256"],
|
||||
audience=target_project,
|
||||
issuer=f"https://securetoken.google.com/{target_project}",
|
||||
)
|
||||
claims = jwt.decode(token, public_key, algorithms=["RS256"], audience=project_id, issuer=f"https://securetoken.google.com/{project_id}")
|
||||
subject = claims.get("sub")
|
||||
if not isinstance(subject, str) or not subject or len(subject) > 128:
|
||||
raise jwt.InvalidTokenError("Invalid Firebase subject")
|
||||
|
|
|
|||
|
|
@ -71,9 +71,6 @@ app = FastAPI(
|
|||
openapi_url=None,
|
||||
)
|
||||
|
||||
from road_alerts import road_alerts_router
|
||||
app.include_router(road_alerts_router, prefix="/api/v1/alerts", tags=["Road Alerts"])
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_reconcile_actions():
|
||||
|
|
|
|||
|
|
@ -475,29 +475,3 @@ class OpsCmsRevision(Base):
|
|||
|
||||
document: Mapped["OpsCmsDocument"] = relationship(back_populates="revisions")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Road Alerts & External Provider Usage Models
|
||||
# ============================================================
|
||||
|
||||
|
||||
class ProviderMonthlyUsage(Base):
|
||||
__tablename__ = "provider_monthly_usage"
|
||||
__table_args__ = (
|
||||
Index("ix_provider_period", "provider_id", "period_month", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
provider_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
period_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True) # Format: YYYY-MM
|
||||
requests_reserved: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
requests_executed: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
monthly_limit: Mapped[int] = mapped_column(nullable=False, default=500)
|
||||
estimated_cost_usd: Mapped[float] = mapped_column(nullable=False, default=0.0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
from road_alerts.router import router as road_alerts_router
|
||||
|
||||
__all__ = ["road_alerts_router"]
|
||||
|
|
@ -1,416 +0,0 @@
|
|||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GeographicCellCache:
|
||||
"""
|
||||
In-memory geographic cell cache that quantizes bounding boxes to avoid
|
||||
redundant third-party API calls across overlapping rider viewports.
|
||||
"""
|
||||
|
||||
def __init__(self, cell_size_degrees: float = 0.05, ttl_seconds: float = 300.0):
|
||||
self.cell_size = cell_size_degrees
|
||||
self.ttl = ttl_seconds
|
||||
self._cache: dict[str, dict[str, Any]] = {}
|
||||
self.hits: int = 0
|
||||
self.misses: int = 0
|
||||
|
||||
def _quantize(self, val: float) -> float:
|
||||
return round(math.floor(val / self.cell_size) * self.cell_size, 4)
|
||||
|
||||
def make_key(self, min_lat: float, min_lng: float, max_lat: float, max_lng: float) -> str:
|
||||
q_min_lat = self._quantize(min_lat)
|
||||
q_min_lng = self._quantize(min_lng)
|
||||
q_max_lat = self._quantize(max_lat)
|
||||
q_max_lng = self._quantize(max_lng)
|
||||
return f"{q_min_lat}:{q_min_lng}:{q_max_lat}:{q_max_lng}"
|
||||
|
||||
def get(self, min_lat: float, min_lng: float, max_lat: float, max_lng: float) -> Optional[list[Any]]:
|
||||
key = self.make_key(min_lat, min_lng, max_lat, max_lng)
|
||||
entry = self._cache.get(key)
|
||||
now = time.time()
|
||||
if entry and entry["expires_at"] > now:
|
||||
self.hits += 1
|
||||
return entry["data"]
|
||||
if entry:
|
||||
# Expired
|
||||
del self._cache[key]
|
||||
self.misses += 1
|
||||
return None
|
||||
|
||||
def set(self, min_lat: float, min_lng: float, max_lat: float, max_lng: float, data: list[Any]) -> None:
|
||||
key = self.make_key(min_lat, min_lng, max_lat, max_lng)
|
||||
now = time.time()
|
||||
self._cache[key] = {
|
||||
"data": data,
|
||||
"created_at": now,
|
||||
"expires_at": now + self.ttl,
|
||||
}
|
||||
|
||||
def clear(self) -> None:
|
||||
self._cache.clear()
|
||||
|
||||
@property
|
||||
def hit_ratio(self) -> float:
|
||||
total = self.hits + self.misses
|
||||
return round(self.hits / total, 3) if total > 0 else 0.0
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""
|
||||
Prevents cascading failures and protects request budget by tripping
|
||||
when external services fail consecutively or return rate-limit errors.
|
||||
"""
|
||||
|
||||
def __init__(self, failure_threshold: int = 5, recovery_timeout_sec: float = 300.0):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.recovery_timeout = recovery_timeout_sec
|
||||
self.failure_count: int = 0
|
||||
self.state: str = "CLOSED" # "CLOSED", "OPEN", "HALF_OPEN"
|
||||
self.last_failure_time: float = 0.0
|
||||
self.last_error_message: Optional[str] = None
|
||||
|
||||
def can_request(self) -> bool:
|
||||
if self.state == "CLOSED":
|
||||
return True
|
||||
now = time.time()
|
||||
if self.state == "OPEN":
|
||||
if now - self.last_failure_time > self.recovery_timeout:
|
||||
self.state = "HALF_OPEN"
|
||||
return True
|
||||
return False
|
||||
if self.state == "HALF_OPEN":
|
||||
# Allow single probe request
|
||||
return True
|
||||
return False
|
||||
|
||||
def record_success(self) -> None:
|
||||
self.failure_count = 0
|
||||
self.state = "CLOSED"
|
||||
self.last_error_message = None
|
||||
|
||||
def record_failure(self, error_message: str, is_rate_limit: bool = False) -> None:
|
||||
self.failure_count += 1
|
||||
self.last_failure_time = time.time()
|
||||
self.last_error_message = error_message
|
||||
if is_rate_limit or self.failure_count >= self.failure_threshold:
|
||||
self.state = "OPEN"
|
||||
|
||||
def trip(self, reason: str = "Manual kill switch") -> None:
|
||||
self.state = "OPEN"
|
||||
self.last_failure_time = time.time()
|
||||
self.last_error_message = reason
|
||||
|
||||
def reset(self) -> None:
|
||||
self.failure_count = 0
|
||||
self.state = "CLOSED"
|
||||
self.last_error_message = None
|
||||
|
||||
|
||||
class PersistentUsageTracker:
|
||||
"""
|
||||
Durable monthly usage and request-budget tracker.
|
||||
Persists request counts and cost estimates to disk to ensure
|
||||
process restarts or server reboots cannot reset billing safeguards.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider_id: str,
|
||||
monthly_budget_limit: int = 500,
|
||||
cost_per_request_usd: float = 0.005,
|
||||
engine: Optional[Any] = None,
|
||||
db_url: Optional[str] = None,
|
||||
state_file: Optional[Any] = None,
|
||||
):
|
||||
self.provider_id = provider_id
|
||||
self.monthly_budget_limit = monthly_budget_limit
|
||||
self.cost_per_request_usd = cost_per_request_usd
|
||||
self._engine = engine
|
||||
self._db_url = db_url
|
||||
if state_file and not self._db_url and not self._engine:
|
||||
self._db_url = f"sqlite:///{Path(state_file).resolve().as_posix()}"
|
||||
self._table_ensured = False
|
||||
|
||||
def get_engine(self) -> Optional[Any]:
|
||||
"""
|
||||
Resolves the SQLAlchemy Engine for quota and budget persistence.
|
||||
Returns None if store is unconfigured or unreachable (failing closed).
|
||||
"""
|
||||
if self._engine is not None:
|
||||
self._ensure_table(self._engine)
|
||||
return self._engine
|
||||
|
||||
if self._db_url:
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
self._engine = create_engine(self._db_url, pool_pre_ping=True)
|
||||
self._ensure_table(self._engine)
|
||||
return self._engine
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize engine from db_url: %s", e)
|
||||
return None
|
||||
|
||||
# Check DATABASE_URL environment variable
|
||||
db_url_env = os.environ.get("DATABASE_URL")
|
||||
if db_url_env:
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
self._engine = create_engine(db_url_env, pool_pre_ping=True)
|
||||
self._ensure_table(self._engine)
|
||||
return self._engine
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize engine from DATABASE_URL: %s", e)
|
||||
return None
|
||||
|
||||
# Attempt resolving MotorSignal application database engine
|
||||
if "DATABASE_USER" in os.environ and "DATABASE_NAME" in os.environ:
|
||||
try:
|
||||
from db import engine as app_engine
|
||||
self._engine = app_engine
|
||||
self._ensure_table(self._engine)
|
||||
return self._engine
|
||||
except Exception as e:
|
||||
logger.error("Failed to load MotorSignal app.db.engine: %s", e)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _ensure_table(self, engine: Any) -> None:
|
||||
if self._table_ensured:
|
||||
return
|
||||
try:
|
||||
from models import ProviderMonthlyUsage
|
||||
ProviderMonthlyUsage.__table__.create(bind=engine, checkfirst=True)
|
||||
self._table_ensured = True
|
||||
except Exception as e:
|
||||
logger.debug("Table check/creation completed: %s", e)
|
||||
|
||||
def reserve_request(self, now: Optional[datetime] = None) -> bool:
|
||||
"""
|
||||
Atomically reserves 1 request slot against the monthly limit in the database.
|
||||
Uses row-level atomic UPSERT with WHERE constraint to prevent concurrent
|
||||
workers from exceeding the allowance.
|
||||
FAILS CLOSED: Returns False if limit is reached or if database is unavailable.
|
||||
"""
|
||||
if self.monthly_budget_limit <= 0:
|
||||
return False
|
||||
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
period = current_time.strftime("%Y-%m")
|
||||
engine = self.get_engine()
|
||||
if engine is None:
|
||||
logger.error(
|
||||
"Budget store unavailable for provider %s. Failing closed.",
|
||||
self.provider_id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with engine.begin() as conn:
|
||||
stmt = text("""
|
||||
INSERT INTO provider_monthly_usage (
|
||||
provider_id, period_month, requests_reserved, requests_executed,
|
||||
monthly_limit, estimated_cost_usd, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
:provider_id, :period, 1, 0,
|
||||
:monthly_limit, 0.0, :now, :now
|
||||
)
|
||||
ON CONFLICT (provider_id, period_month)
|
||||
DO UPDATE SET
|
||||
requests_reserved = provider_monthly_usage.requests_reserved + 1,
|
||||
monthly_limit = :monthly_limit,
|
||||
updated_at = :now
|
||||
WHERE provider_monthly_usage.requests_reserved < :monthly_limit
|
||||
RETURNING requests_reserved, monthly_limit
|
||||
""")
|
||||
result = conn.execute(
|
||||
stmt,
|
||||
{
|
||||
"provider_id": self.provider_id,
|
||||
"period": period,
|
||||
"monthly_limit": self.monthly_budget_limit,
|
||||
"now": current_time,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is not None:
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Monthly budget reservation limit reached for %s in period %s (limit: %d).",
|
||||
self.provider_id,
|
||||
period,
|
||||
self.monthly_budget_limit,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Database error during reserve_request for %s: %s. Failing closed.",
|
||||
self.provider_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
def release_reservation(self, now: Optional[datetime] = None) -> None:
|
||||
"""
|
||||
Releases 1 unexecuted reserved slot if an upstream request was cancelled before sending.
|
||||
"""
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
period = current_time.strftime("%Y-%m")
|
||||
engine = self.get_engine()
|
||||
if engine is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with engine.begin() as conn:
|
||||
stmt = text("""
|
||||
UPDATE provider_monthly_usage
|
||||
SET requests_reserved = CASE WHEN requests_reserved > 0 THEN requests_reserved - 1 ELSE 0 END,
|
||||
updated_at = :now
|
||||
WHERE provider_id = :provider_id AND period_month = :period
|
||||
""")
|
||||
conn.execute(
|
||||
stmt,
|
||||
{
|
||||
"provider_id": self.provider_id,
|
||||
"period": period,
|
||||
"now": current_time,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Database error during release_reservation for %s: %s", self.provider_id, e)
|
||||
|
||||
def record_execution(self, success: bool = True, now: Optional[datetime] = None) -> None:
|
||||
"""
|
||||
Records that an actual upstream HTTP request was executed.
|
||||
Separately tracks executed requests and calculates estimated spend.
|
||||
"""
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
period = current_time.strftime("%Y-%m")
|
||||
engine = self.get_engine()
|
||||
if engine is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with engine.begin() as conn:
|
||||
stmt = text("""
|
||||
UPDATE provider_monthly_usage
|
||||
SET requests_executed = requests_executed + 1,
|
||||
estimated_cost_usd = round(CAST((requests_executed + 1) * :cost_per_req AS numeric), 4),
|
||||
updated_at = :now
|
||||
WHERE provider_id = :provider_id AND period_month = :period
|
||||
""")
|
||||
conn.execute(
|
||||
stmt,
|
||||
{
|
||||
"provider_id": self.provider_id,
|
||||
"period": period,
|
||||
"cost_per_req": self.cost_per_request_usd,
|
||||
"now": current_time,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Database error during record_execution for %s: %s", self.provider_id, e)
|
||||
|
||||
def record_request(self, now: Optional[datetime] = None) -> int:
|
||||
"""
|
||||
Backward-compatible helper reserving and recording 1 completed request.
|
||||
"""
|
||||
reserved = self.reserve_request(now=now)
|
||||
if reserved:
|
||||
self.record_execution(success=True, now=now)
|
||||
return self.requests_used
|
||||
|
||||
def can_request(self, now: Optional[datetime] = None) -> bool:
|
||||
"""
|
||||
Checks whether requests can be reserved for the current month.
|
||||
Fails closed (returns False) if budget is exhausted or database is down.
|
||||
"""
|
||||
if self.monthly_budget_limit <= 0:
|
||||
return False
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
period = current_time.strftime("%Y-%m")
|
||||
engine = self.get_engine()
|
||||
if engine is None:
|
||||
logger.error("Budget store unavailable for %s. Failing closed.", self.provider_id)
|
||||
return False
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
stmt = text("""
|
||||
SELECT requests_reserved, monthly_limit
|
||||
FROM provider_monthly_usage
|
||||
WHERE provider_id = :provider_id AND period_month = :period
|
||||
""")
|
||||
row = conn.execute(stmt, {"provider_id": self.provider_id, "period": period}).fetchone()
|
||||
if row is None:
|
||||
return True
|
||||
return int(row[0]) < int(row[1])
|
||||
except Exception as e:
|
||||
logger.error("Database error during can_request for %s: %s. Failing closed.", self.provider_id, e)
|
||||
return False
|
||||
|
||||
def get_stats(self, now: Optional[datetime] = None) -> tuple[int, int, float]:
|
||||
"""
|
||||
Returns (requests_reserved, requests_executed, estimated_cost_usd) for the period.
|
||||
"""
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
period = current_time.strftime("%Y-%m")
|
||||
engine = self.get_engine()
|
||||
if engine is None:
|
||||
return 0, 0, 0.0
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
stmt = text("""
|
||||
SELECT requests_reserved, requests_executed, estimated_cost_usd
|
||||
FROM provider_monthly_usage
|
||||
WHERE provider_id = :provider_id AND period_month = :period
|
||||
""")
|
||||
row = conn.execute(stmt, {"provider_id": self.provider_id, "period": period}).fetchone()
|
||||
if row is not None:
|
||||
return int(row[0]), int(row[1]), float(row[2])
|
||||
return 0, 0, 0.0
|
||||
except Exception:
|
||||
return 0, 0, 0.0
|
||||
|
||||
@property
|
||||
def requests_used(self) -> int:
|
||||
return self.get_stats()[1]
|
||||
|
||||
@property
|
||||
def requests_executed(self) -> int:
|
||||
return self.get_stats()[1]
|
||||
|
||||
@property
|
||||
def requests_reserved(self) -> int:
|
||||
return self.get_stats()[0]
|
||||
|
||||
@property
|
||||
def estimated_cost_usd(self) -> float:
|
||||
return self.get_stats()[2]
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""
|
||||
Disposes the underlying SQLAlchemy engine and releases connection pools.
|
||||
"""
|
||||
if self._engine is not None:
|
||||
try:
|
||||
self._engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from road_alerts.cache import CircuitBreaker, GeographicCellCache
|
||||
from road_alerts.models import NormalizedAlert, ProviderHealth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Georgia511Adapter:
|
||||
"""
|
||||
Experimental Adapter for Georgia 511 (GDOT) official traffic and incident feeds.
|
||||
|
||||
=== Upstream Endpoint & Architecture ===
|
||||
Base Endpoint: https://511ga.org/api/getEvents
|
||||
Alternative Feeds: GDOT Navigator XML / GeoJSON open feeds.
|
||||
|
||||
=== Access Requirements ===
|
||||
- API Key: Requires registration via the Georgia DOT 511 Developer Portal
|
||||
or formal open data licensing agreement from GDOT.
|
||||
- Configuration: Configured via environment variable `GA511_API_KEY`.
|
||||
- Live Verification Status: UNVERIFIED. Live production access has not yet
|
||||
been provisioned with GDOT; integration operates in EXPERIMENTAL mode.
|
||||
- Safe Default: Disabled by default (`GA511_ENABLED=false`). When enabled
|
||||
without an API key, fixture/mock mode is used to prevent unauthorized calls.
|
||||
|
||||
=== Response Parsing Specification ===
|
||||
Accepts GDOT Event objects:
|
||||
- `id` / `eventId`: Unique state event identifier.
|
||||
- `geometry` or `latitude`/`longitude`: WGS84 point coordinates.
|
||||
- `type` / `event_type`: Categorized into accident, roadClosure, or hazard.
|
||||
- `headline` / `description`: Roadway incident description.
|
||||
- `roadwayName`: Canonical highway name (e.g. "I-75 N", "I-285 W").
|
||||
|
||||
Rate limit: Strictly throttled with geographic cell caching (0.08°) and
|
||||
circuit breaking (3 failures = 5-minute trip).
|
||||
"""
|
||||
|
||||
PROVIDER_ID = "ga511_official"
|
||||
PROVIDER_NAME = "Georgia 511 (Experimental)"
|
||||
BASE_URL = "https://511ga.org/api/getEvents"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
):
|
||||
self.api_key = api_key if api_key is not None else os.environ.get("GA511_API_KEY", "")
|
||||
if enabled is not None:
|
||||
self.enabled = enabled
|
||||
else:
|
||||
self.enabled = os.environ.get("GA511_ENABLED", "false").lower() in ("true", "1")
|
||||
|
||||
self.fixture_mode = os.environ.get("GA511_FIXTURE_MODE", "false").lower() in ("true", "1")
|
||||
self.cache = GeographicCellCache(cell_size_degrees=0.08, ttl_seconds=300.0)
|
||||
self.circuit_breaker = CircuitBreaker(failure_threshold=3, recovery_timeout_sec=300.0)
|
||||
|
||||
self.request_count: int = 0
|
||||
self.success_count: int = 0
|
||||
self.error_count: int = 0
|
||||
self.last_success_at: Optional[str] = None
|
||||
self.last_error_at: Optional[str] = None
|
||||
self.last_error_message: Optional[str] = None
|
||||
|
||||
def get_health(self) -> ProviderHealth:
|
||||
if not self.enabled:
|
||||
status = "disabled_by_policy"
|
||||
elif not self.api_key and not self.fixture_mode:
|
||||
status = "auth_missing"
|
||||
elif not self.circuit_breaker.can_request():
|
||||
status = "circuit_open"
|
||||
else:
|
||||
status = "operational"
|
||||
|
||||
return ProviderHealth(
|
||||
provider_id=self.PROVIDER_ID,
|
||||
name=self.PROVIDER_NAME,
|
||||
enabled=self.enabled,
|
||||
status=status,
|
||||
request_count=self.request_count,
|
||||
success_count=self.success_count,
|
||||
error_count=self.error_count,
|
||||
cache_hit_count=self.cache.hits,
|
||||
cache_miss_count=self.cache.misses,
|
||||
cache_hit_ratio=self.cache.hit_ratio,
|
||||
stale_filtered_count=0,
|
||||
invalid_filtered_count=0,
|
||||
last_success_at=self.last_success_at,
|
||||
last_error_at=self.last_error_at,
|
||||
last_error_message=self.last_error_message,
|
||||
monthly_budget_limit=1000,
|
||||
monthly_requests_used=self.request_count,
|
||||
estimated_cost_usd=0.0, # Public state DOT API
|
||||
)
|
||||
|
||||
def normalize_event(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]:
|
||||
"""
|
||||
Normalizes a GDOT / 511GA event record into a NormalizedAlert.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
event_id = str(raw.get("id") or raw.get("eventId") or "")
|
||||
if not event_id:
|
||||
return None
|
||||
|
||||
lat = raw.get("latitude") or raw.get("lat")
|
||||
lng = raw.get("longitude") or raw.get("lon") or raw.get("lng")
|
||||
if lat is None or lng is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
lat = float(lat)
|
||||
lng = float(lng)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
if lat < -90.0 or lat > 90.0 or lng < -180.0 or lng > 180.0:
|
||||
return None
|
||||
|
||||
# Event type classification
|
||||
event_type = str(raw.get("type", "")).lower()
|
||||
headline = str(raw.get("headline", "")).lower()
|
||||
|
||||
category = "hazard"
|
||||
subtype = "official_incident"
|
||||
|
||||
if "incident" in event_type or "crash" in headline or "collision" in headline:
|
||||
category = "accident"
|
||||
subtype = "crash_investigation"
|
||||
elif "closure" in event_type or "closed" in headline:
|
||||
category = "roadClosure"
|
||||
subtype = "full_closure"
|
||||
elif "construction" in event_type or "work" in headline or "maintenance" in headline:
|
||||
category = "roadClosure"
|
||||
subtype = "construction"
|
||||
|
||||
reported_at = now
|
||||
expires_at = now + timedelta(hours=4)
|
||||
|
||||
return NormalizedAlert(
|
||||
id=f"ga511_{event_id}",
|
||||
source=self.PROVIDER_NAME,
|
||||
source_type="official_dot",
|
||||
category=category,
|
||||
subtype=subtype,
|
||||
latitude=round(lat, 6),
|
||||
longitude=round(lng, 6),
|
||||
road_name=raw.get("roadwayName") or raw.get("road"),
|
||||
city=raw.get("county"),
|
||||
description=raw.get("headline") or raw.get("description"),
|
||||
reported_at_utc=reported_at.isoformat(),
|
||||
fetched_at_utc=now.isoformat(),
|
||||
expires_at_utc=expires_at.isoformat(),
|
||||
confidence=0.95, # Official DOT feeds carry high verified confidence
|
||||
reliability=9.5,
|
||||
is_stale=False,
|
||||
raw_provider_id=event_id,
|
||||
)
|
||||
|
||||
def fetch_alerts(
|
||||
self,
|
||||
min_lat: float,
|
||||
min_lng: float,
|
||||
max_lat: float,
|
||||
max_lng: float,
|
||||
now: Optional[datetime] = None,
|
||||
) -> list[NormalizedAlert]:
|
||||
"""
|
||||
Fetches official Georgia 511 alerts.
|
||||
"""
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
|
||||
# 1. Check cache
|
||||
cached = self.cache.get(min_lat, min_lng, max_lat, max_lng)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 2. Check enablement
|
||||
if not self.enabled and not self.fixture_mode:
|
||||
return []
|
||||
|
||||
if not self.api_key and not self.fixture_mode:
|
||||
return []
|
||||
|
||||
if not self.circuit_breaker.can_request():
|
||||
return []
|
||||
|
||||
self.request_count += 1
|
||||
|
||||
try:
|
||||
# When fixture mode or mock response enabled
|
||||
sample_events = [
|
||||
{
|
||||
"id": "gdot_i75_01",
|
||||
"latitude": 33.7850,
|
||||
"longitude": -84.3950,
|
||||
"type": "construction",
|
||||
"headline": "GDOT lane closure on I-75 Northbound at Northside Dr",
|
||||
"roadwayName": "I-75 N",
|
||||
"county": "Fulton",
|
||||
},
|
||||
{
|
||||
"id": "gdot_ga20_01",
|
||||
"latitude": 34.2480,
|
||||
"longitude": -85.1950,
|
||||
"type": "incident",
|
||||
"headline": "Overturned truck blocking right lane on GA-20 Eastbound",
|
||||
"roadwayName": "GA-20 E",
|
||||
"county": "Floyd",
|
||||
},
|
||||
]
|
||||
|
||||
results = []
|
||||
for ev in sample_events:
|
||||
lat = ev["latitude"]
|
||||
lng = ev["longitude"]
|
||||
if min_lat <= lat <= max_lat and min_lng <= lng <= max_lng:
|
||||
alert = self.normalize_event(ev, current_time)
|
||||
if alert:
|
||||
results.append(alert)
|
||||
|
||||
self.cache.set(min_lat, min_lng, max_lat, max_lng, results)
|
||||
self.circuit_breaker.record_success()
|
||||
self.success_count += 1
|
||||
self.last_success_at = current_time.isoformat()
|
||||
return results
|
||||
|
||||
except Exception as exc:
|
||||
self.error_count += 1
|
||||
self.last_error_at = current_time.isoformat()
|
||||
self.last_error_message = str(exc)
|
||||
self.circuit_breaker.record_failure(str(exc))
|
||||
return []
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class NormalizedAlert(BaseModel):
|
||||
id: str = Field(description="Normalized unique alert identifier")
|
||||
source: str = Field(description="Attributed data source name")
|
||||
source_type: Literal[
|
||||
"native_rider",
|
||||
"experimental_external",
|
||||
"licensed_external",
|
||||
"official_dot",
|
||||
] = Field(description="Source classification")
|
||||
category: Literal[
|
||||
"police",
|
||||
"accident",
|
||||
"hazard",
|
||||
"roadClosure",
|
||||
"traffic",
|
||||
"pothole",
|
||||
"debris",
|
||||
"weatherHazard",
|
||||
"other",
|
||||
] = Field(description="Normalized MotorSignal alert category")
|
||||
subtype: Optional[str] = Field(default=None, description="Detailed subtype")
|
||||
latitude: float = Field(ge=-90.0, le=90.0, description="WGS84 latitude")
|
||||
longitude: float = Field(ge=-180.0, le=180.0, description="WGS84 longitude")
|
||||
road_name: Optional[str] = Field(default=None, description="Street or highway name")
|
||||
city: Optional[str] = Field(default=None, description="City or jurisdiction")
|
||||
description: Optional[str] = Field(default=None, description="Alert description")
|
||||
reported_at_utc: str = Field(description="ISO 8601 UTC creation timestamp")
|
||||
fetched_at_utc: str = Field(description="ISO 8601 UTC ingest timestamp")
|
||||
expires_at_utc: str = Field(description="ISO 8601 UTC expiration timestamp")
|
||||
confidence: Optional[float] = Field(
|
||||
default=None, ge=0.0, le=1.0, description="Normalized confidence score (0.0 to 1.0)"
|
||||
)
|
||||
reliability: Optional[float] = Field(default=None, description="Source reliability score")
|
||||
is_stale: bool = Field(default=False, description="Flag indicating if record exceeds freshness TTL")
|
||||
raw_provider_id: Optional[str] = Field(default=None, description="Original upstream provider/alert ID")
|
||||
|
||||
|
||||
|
||||
class AlertFeedResponse(BaseModel):
|
||||
success: bool
|
||||
count: int
|
||||
alerts: list[NormalizedAlert]
|
||||
cached: bool = False
|
||||
providers_polled: list[str] = Field(default_factory=list)
|
||||
timestamp_utc: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
|
||||
class ProviderHealth(BaseModel):
|
||||
provider_id: str
|
||||
name: str
|
||||
enabled: bool
|
||||
status: Literal[
|
||||
"operational",
|
||||
"circuit_open",
|
||||
"disabled_by_policy",
|
||||
"auth_missing",
|
||||
"rate_limited",
|
||||
"error",
|
||||
]
|
||||
request_count: int = 0
|
||||
success_count: int = 0
|
||||
error_count: int = 0
|
||||
cache_hit_count: int = 0
|
||||
cache_miss_count: int = 0
|
||||
cache_hit_ratio: float = 0.0
|
||||
stale_filtered_count: int = 0
|
||||
invalid_filtered_count: int = 0
|
||||
last_success_at: Optional[str] = None
|
||||
last_error_at: Optional[str] = None
|
||||
last_error_message: Optional[str] = None
|
||||
monthly_budget_limit: int = 500
|
||||
monthly_requests_used: int = 0
|
||||
estimated_cost_usd: float = 0.0
|
||||
|
||||
|
||||
class OpsHealthDashboardResponse(BaseModel):
|
||||
timestamp_utc: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
providers: list[ProviderHealth]
|
||||
total_requests: int
|
||||
total_cache_hits: int
|
||||
total_cost_usd: float
|
||||
|
||||
|
||||
class ProviderSwitchRequest(BaseModel):
|
||||
provider_id: str
|
||||
enabled: bool
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from threading import Lock
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
|
||||
from road_alerts.ga511_adapter import Georgia511Adapter
|
||||
from road_alerts.models import (
|
||||
AlertFeedResponse,
|
||||
NormalizedAlert,
|
||||
OpsHealthDashboardResponse,
|
||||
ProviderHealth,
|
||||
ProviderSwitchRequest,
|
||||
)
|
||||
from road_alerts.zyla_adapter import ZylaWazeAdapter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Global adapter instances
|
||||
zyla_adapter = ZylaWazeAdapter()
|
||||
ga511_adapter = Georgia511Adapter()
|
||||
|
||||
# Geographic boundary constraint: Maximum allowable span in degrees (approx 111 km x 111 km)
|
||||
MAX_GEOGRAPHIC_SPAN_DEGREES = 1.0
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Sliding window in-memory rate limiter for backend gateway endpoints.
|
||||
Protects upstream budgets and prevents abuse.
|
||||
"""
|
||||
|
||||
def __init__(self, max_requests: int = 60, window_seconds: float = 60.0):
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.timestamps = deque()
|
||||
self.lock = Lock()
|
||||
|
||||
def check_and_record(self) -> bool:
|
||||
now = time.time()
|
||||
with self.lock:
|
||||
cutoff = now - self.window_seconds
|
||||
while self.timestamps and self.timestamps[0] < cutoff:
|
||||
self.timestamps.popleft()
|
||||
if len(self.timestamps) >= self.max_requests:
|
||||
return False
|
||||
self.timestamps.append(now)
|
||||
return True
|
||||
|
||||
|
||||
# 60 requests per minute gateway rate limit
|
||||
gateway_rate_limiter = RateLimiter(max_requests=60, window_seconds=60.0)
|
||||
|
||||
|
||||
def verify_rate_limit():
|
||||
if not gateway_rate_limiter.check_and_record():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Rate limit exceeded. Maximum 60 requests per minute allowed.",
|
||||
)
|
||||
|
||||
|
||||
# Set of permitted static test fixture filenames
|
||||
ALLOWED_FIXTURES = {"zyla_rome_sample.json", "zyla_atlanta_sample.json"}
|
||||
|
||||
|
||||
def is_production_environment() -> bool:
|
||||
"""
|
||||
Returns True if the runtime environment indicates production or release.
|
||||
Prevents any test bypasses from executing in production builds.
|
||||
"""
|
||||
env_vars = [
|
||||
os.environ.get("MOTORSIGNAL_ENV", "").lower(),
|
||||
os.environ.get("ENVIRONMENT", "").lower(),
|
||||
os.environ.get("APP_ENV", "").lower(),
|
||||
os.environ.get("NODE_ENV", "").lower(),
|
||||
]
|
||||
return any(e in ("production", "prod", "staging", "release") for e in env_vars)
|
||||
|
||||
|
||||
def is_test_token_allowed() -> bool:
|
||||
"""
|
||||
Test-token bypasses are strictly forbidden in production.
|
||||
Only permitted in automated local test suites with explicit TESTING=true.
|
||||
"""
|
||||
if is_production_environment():
|
||||
return False
|
||||
return os.environ.get("TESTING", "").lower() in ("true", "1")
|
||||
|
||||
|
||||
def verify_gateway_or_user_auth(
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
x_gateway_key: Optional[str] = Header(None, alias="X-MotorSignal-Gateway-Key"),
|
||||
):
|
||||
"""
|
||||
Authenticates requests via either:
|
||||
1. A valid User Bearer Token (Firebase ID Token or MotorSignal JWT access token).
|
||||
2. A matching server-side gateway secret (X-MotorSignal-Gateway-Key), if explicitly configured.
|
||||
FAILS CLOSED: If neither is provided or valid, raises HTTP 401 Unauthorized.
|
||||
"""
|
||||
# 1. Server-to-server gateway key check (if configured in server environment)
|
||||
required_secret = os.environ.get("MOTORSIGNAL_GATEWAY_SECRET")
|
||||
if required_secret and x_gateway_key == required_secret:
|
||||
return True
|
||||
|
||||
# 2. Bearer token check (Firebase ID token or MotorSignal access token)
|
||||
if authorization:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and token.strip():
|
||||
token_val = token.strip()
|
||||
|
||||
# A. Try decoding Firebase ID token (cryptographically verified RS256 signature,
|
||||
# audience, issuer, expiry, and subject)
|
||||
try:
|
||||
from auth import decode_firebase_id_token
|
||||
claims = decode_firebase_id_token(token_val)
|
||||
if claims.get("sub"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# B. Try decoding MotorSignal access token
|
||||
try:
|
||||
from auth import decode_access_token
|
||||
payload = decode_access_token(token_val)
|
||||
if payload.get("type") in ("access", "test") or "sub" in payload:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# C. In non-production automated test environments only, accept mock test tokens
|
||||
if is_test_token_allowed() and (
|
||||
token_val.startswith("test_token_") or token_val.startswith("mock_token_")
|
||||
):
|
||||
return True
|
||||
|
||||
# Strict Fail Closed
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required. Provide a valid Bearer token or Gateway key.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def verify_admin_auth(
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
|
||||
):
|
||||
"""
|
||||
Requires admin privileges to modify operational provider switches.
|
||||
FAILS CLOSED: If admin key is missing or invalid, raises HTTP 403 Forbidden.
|
||||
Verifies roles exclusively from trusted, cryptographically validated token claims
|
||||
or server-configured admin secrets. Unverified user-supplied fields are never trusted.
|
||||
"""
|
||||
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
|
||||
if required_admin and x_admin_key == required_admin:
|
||||
return True
|
||||
|
||||
if authorization:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and token.strip():
|
||||
token_val = token.strip()
|
||||
|
||||
# 1. Check cryptographically verified Firebase ID token claims
|
||||
try:
|
||||
from auth import decode_firebase_id_token
|
||||
claims = decode_firebase_id_token(token_val)
|
||||
# Roles MUST come from cryptographically signed custom claims in the token:
|
||||
if claims.get("admin") is True or claims.get("role") in ("admin", "operator", "owner"):
|
||||
return True
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin authorization required. Token lacks required administrator claims.",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Check cryptographically verified MotorSignal JWT access token claims
|
||||
try:
|
||||
from auth import decode_access_token
|
||||
payload = decode_access_token(token_val)
|
||||
role = payload.get("role", "")
|
||||
if role in ("owner", "operator", "admin") or payload.get("is_admin") is True:
|
||||
return True
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin authorization required. Token lacks required administrator claims.",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. In non-production automated test environments only, accept mock admin test tokens
|
||||
if is_test_token_allowed() and token_val.startswith("test_admin_token_"):
|
||||
return True
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin authorization required. Access forbidden.",
|
||||
)
|
||||
|
||||
|
||||
def validate_fixture_override(
|
||||
fixture_override: Optional[str],
|
||||
x_admin_key: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Ensures fixture overrides cannot be exploited by normal clients in production.
|
||||
"""
|
||||
if not fixture_override:
|
||||
return None
|
||||
|
||||
if fixture_override not in ALLOWED_FIXTURES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid fixture_override: '{fixture_override}'. Allowed fixtures: {list(ALLOWED_FIXTURES)}",
|
||||
)
|
||||
|
||||
is_test_env = (
|
||||
os.environ.get("TESTING", "").lower() in ("true", "1")
|
||||
or os.environ.get("ENVIRONMENT", "").lower() == "test"
|
||||
or os.environ.get("ENABLE_FIXTURE_OVERRIDE", "false").lower() in ("true", "1")
|
||||
)
|
||||
if is_test_env:
|
||||
return fixture_override
|
||||
|
||||
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
|
||||
if required_admin and x_admin_key == required_admin:
|
||||
return fixture_override
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="fixture_override is restricted to test environments or authorized administrators.",
|
||||
)
|
||||
|
||||
|
||||
def validate_bounding_box(min_lat: float, min_lng: float, max_lat: float, max_lng: float):
|
||||
if min_lat > max_lat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="min_lat cannot be greater than max_lat",
|
||||
)
|
||||
if min_lng > max_lng:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="min_lng cannot be greater than max_lng",
|
||||
)
|
||||
lat_span = max_lat - min_lat
|
||||
lng_span = max_lng - min_lng
|
||||
if lat_span > MAX_GEOGRAPHIC_SPAN_DEGREES or lng_span > MAX_GEOGRAPHIC_SPAN_DEGREES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"Bounding box exceeds maximum allowed geographic span of "
|
||||
f"{MAX_GEOGRAPHIC_SPAN_DEGREES} degrees (~111 km). "
|
||||
f"Requested lat span: {lat_span:.3f}, lng span: {lng_span:.3f}."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def get_public_health():
|
||||
"""
|
||||
Lightweight unauthenticated connectivity check. Does not expose operational metrics.
|
||||
"""
|
||||
return {"status": "ok", "service": "road-alerts"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/feed",
|
||||
response_model=AlertFeedResponse,
|
||||
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
|
||||
)
|
||||
def get_aggregated_feed(
|
||||
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
|
||||
min_lng: float = Query(..., ge=-180.0, le=180.0, description="Minimum longitude (West)"),
|
||||
max_lat: float = Query(..., ge=-90.0, le=90.0, description="Maximum latitude (North)"),
|
||||
max_lng: float = Query(..., ge=-180.0, le=180.0, description="Maximum longitude (East)"),
|
||||
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
|
||||
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
|
||||
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
|
||||
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
|
||||
):
|
||||
"""
|
||||
Retrieves multi-source road alerts within the bounded geographic box.
|
||||
Enforces geographic bounds (<= 1.0 degree), rate limits, and authentication.
|
||||
"""
|
||||
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
||||
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
|
||||
|
||||
all_alerts: list[NormalizedAlert] = []
|
||||
providers_polled: list[str] = []
|
||||
|
||||
if include_zyla:
|
||||
zyla_alerts = zyla_adapter.fetch_alerts(
|
||||
min_lat=min_lat,
|
||||
min_lng=min_lng,
|
||||
max_lat=max_lat,
|
||||
max_lng=max_lng,
|
||||
fixture_override=safe_fixture,
|
||||
)
|
||||
all_alerts.extend(zyla_alerts)
|
||||
providers_polled.append(zyla_adapter.PROVIDER_ID)
|
||||
|
||||
if include_ga511:
|
||||
ga_alerts = ga511_adapter.fetch_alerts(
|
||||
min_lat=min_lat,
|
||||
min_lng=min_lng,
|
||||
max_lat=max_lat,
|
||||
max_lng=max_lng,
|
||||
)
|
||||
all_alerts.extend(ga_alerts)
|
||||
providers_polled.append(ga511_adapter.PROVIDER_ID)
|
||||
|
||||
return AlertFeedResponse(
|
||||
success=True,
|
||||
count=len(all_alerts),
|
||||
alerts=all_alerts,
|
||||
providers_polled=providers_polled,
|
||||
timestamp_utc=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/zyla",
|
||||
response_model=AlertFeedResponse,
|
||||
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
|
||||
)
|
||||
def get_zyla_alerts(
|
||||
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||
min_lng: float = Query(..., ge=-180.0, le=180.0),
|
||||
max_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||
max_lng: float = Query(..., ge=-180.0, le=180.0),
|
||||
fixture_override: Optional[str] = Query(default=None),
|
||||
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
|
||||
):
|
||||
"""
|
||||
Direct endpoint for Zyla Labs Waze Data API #4814.
|
||||
Enforces geographic box bounds and server-side budget limits.
|
||||
"""
|
||||
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
||||
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
|
||||
|
||||
alerts = zyla_adapter.fetch_alerts(
|
||||
min_lat=min_lat,
|
||||
min_lng=min_lng,
|
||||
max_lat=max_lat,
|
||||
max_lng=max_lng,
|
||||
fixture_override=safe_fixture,
|
||||
)
|
||||
return AlertFeedResponse(
|
||||
success=True,
|
||||
count=len(alerts),
|
||||
alerts=alerts,
|
||||
providers_polled=[zyla_adapter.PROVIDER_ID],
|
||||
timestamp_utc=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ga511",
|
||||
response_model=AlertFeedResponse,
|
||||
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
|
||||
)
|
||||
def get_ga511_alerts(
|
||||
min_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||
min_lng: float = Query(..., ge=-180.0, le=180.0),
|
||||
max_lat: float = Query(..., ge=-90.0, le=90.0),
|
||||
max_lng: float = Query(..., ge=-180.0, le=180.0),
|
||||
):
|
||||
"""
|
||||
Direct endpoint for official Georgia 511 incident feeds.
|
||||
Enforces geographic box bounds.
|
||||
"""
|
||||
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
|
||||
|
||||
alerts = ga511_adapter.fetch_alerts(
|
||||
min_lat=min_lat,
|
||||
min_lng=min_lng,
|
||||
max_lat=max_lat,
|
||||
max_lng=max_lng,
|
||||
)
|
||||
return AlertFeedResponse(
|
||||
success=True,
|
||||
count=len(alerts),
|
||||
alerts=alerts,
|
||||
providers_polled=[ga511_adapter.PROVIDER_ID],
|
||||
timestamp_utc=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ops/health",
|
||||
response_model=OpsHealthDashboardResponse,
|
||||
dependencies=[Depends(verify_gateway_or_user_auth)],
|
||||
)
|
||||
def get_ops_health():
|
||||
"""
|
||||
Operational health dashboard reporting uptime, failure rates, request counts,
|
||||
cache hit ratio, and usage spend per provider.
|
||||
Requires authentication to protect internal operational telemetry.
|
||||
"""
|
||||
zyla_health = zyla_adapter.get_health()
|
||||
ga511_health = ga511_adapter.get_health()
|
||||
|
||||
providers = [zyla_health, ga511_health]
|
||||
total_reqs = sum(p.request_count for p in providers)
|
||||
total_hits = sum(p.cache_hit_count for p in providers)
|
||||
total_cost = sum(p.estimated_cost_usd for p in providers)
|
||||
|
||||
return OpsHealthDashboardResponse(
|
||||
providers=providers,
|
||||
total_requests=total_reqs,
|
||||
total_cache_hits=total_hits,
|
||||
total_cost_usd=round(total_cost, 4),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/ops/switches", dependencies=[Depends(verify_admin_auth)])
|
||||
def update_provider_switch(payload: ProviderSwitchRequest):
|
||||
"""
|
||||
Runtime enable/disable switch and circuit control for external providers.
|
||||
Requires admin authorization if configured.
|
||||
"""
|
||||
if payload.provider_id == zyla_adapter.PROVIDER_ID:
|
||||
zyla_adapter.enabled = payload.enabled
|
||||
if not payload.enabled:
|
||||
zyla_adapter.circuit_breaker.trip("Disabled via Ops Switch")
|
||||
else:
|
||||
zyla_adapter.circuit_breaker.reset()
|
||||
return {"provider_id": payload.provider_id, "enabled": zyla_adapter.enabled}
|
||||
|
||||
if payload.provider_id == ga511_adapter.PROVIDER_ID:
|
||||
ga511_adapter.enabled = payload.enabled
|
||||
if not payload.enabled:
|
||||
ga511_adapter.circuit_breaker.trip("Disabled via Ops Switch")
|
||||
else:
|
||||
ga511_adapter.circuit_breaker.reset()
|
||||
return {"provider_id": payload.provider_id, "enabled": ga511_adapter.enabled}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unknown provider_id: {payload.provider_id}",
|
||||
)
|
||||
|
|
@ -1,463 +0,0 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from road_alerts.cache import CircuitBreaker, GeographicCellCache, PersistentUsageTracker
|
||||
from road_alerts.models import NormalizedAlert, ProviderHealth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Category and Subtype-aware Freshness & TTL Policies (in minutes)
|
||||
# Mobile speed traps and traffic jams dissipate quickly;
|
||||
# infrastructure repairs, potholes, and full road closures persist across days/shifts.
|
||||
CATEGORY_TTL_MINUTES = {
|
||||
"police": 45, # Mobile police presence changes within 30-45m
|
||||
"traffic": 30, # Congestion patterns shift quickly
|
||||
"debris": 60, # Debris/objects on road cleared or moved
|
||||
"hazard": 90, # General roadway hazards
|
||||
"accident": 120, # Average traffic accident duration
|
||||
"weatherHazard": 120, # Flooding, ice, or dense fog
|
||||
"pothole": 1440, # Physical pavement damage persists (24 hours)
|
||||
"construction": 1440, # Planned work zone shifts (24 hours)
|
||||
"roadClosure": 1440, # Roadway closed for civil works or major events (24 hours)
|
||||
"other": 60,
|
||||
}
|
||||
|
||||
SUBTYPE_TTL_MINUTES = {
|
||||
"police_speed_trap": 30, # Stationary radar/laser traps move frequently
|
||||
"police_visible": 45,
|
||||
"accident_minor": 60, # Minor incidents clear faster
|
||||
"accident_major": 180, # Multi-lane highway closures take longer
|
||||
"construction": 1440,
|
||||
}
|
||||
|
||||
|
||||
class ZylaWazeAdapter:
|
||||
"""
|
||||
Backend-only adapter for Zyla Labs Waze Data API #4814.
|
||||
Adheres strictly to zero-credential leakage, circuit breaker,
|
||||
geographic caching, durable request-budget tracking, and schema normalization.
|
||||
"""
|
||||
|
||||
PROVIDER_ID = "zyla_waze_4814"
|
||||
PROVIDER_NAME = "Zyla Waze Data #4814"
|
||||
BASE_URL = "https://zylalabs.com/api/4814/waze+data+api/6003/alerts+and+jams"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
monthly_budget_limit: int = 500,
|
||||
cost_per_request_usd: float = 0.005,
|
||||
engine: Optional[Any] = None,
|
||||
db_url: Optional[str] = None,
|
||||
state_file: Optional[Path] = None,
|
||||
usage_tracker: Optional[PersistentUsageTracker] = None,
|
||||
fixture_mode: Optional[bool] = None,
|
||||
):
|
||||
# Resolve config from environment or overrides
|
||||
self.api_key = api_key if api_key is not None else os.environ.get("ZYLA_API_KEY", "")
|
||||
if enabled is not None:
|
||||
self.enabled = enabled
|
||||
else:
|
||||
self.enabled = os.environ.get("ZYLA_ENABLED", "false").lower() in ("true", "1")
|
||||
|
||||
if fixture_mode is not None:
|
||||
self.fixture_mode = fixture_mode
|
||||
else:
|
||||
self.fixture_mode = os.environ.get("ZYLA_FIXTURE_MODE", "false").lower() in ("true", "1")
|
||||
self.monthly_budget_limit = monthly_budget_limit
|
||||
self.cost_per_request_usd = cost_per_request_usd
|
||||
|
||||
# Operational telemetry and durable billing safeguard
|
||||
self.cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=300.0)
|
||||
self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout_sec=300.0)
|
||||
self.usage_tracker = usage_tracker or PersistentUsageTracker(
|
||||
provider_id=self.PROVIDER_ID,
|
||||
monthly_budget_limit=monthly_budget_limit,
|
||||
cost_per_request_usd=cost_per_request_usd,
|
||||
engine=engine,
|
||||
db_url=db_url,
|
||||
state_file=state_file,
|
||||
)
|
||||
self.request_count: int = 0
|
||||
self.success_count: int = 0
|
||||
self.error_count: int = 0
|
||||
self.stale_filtered_count: int = 0
|
||||
self.invalid_filtered_count: int = 0
|
||||
self.last_success_at: Optional[str] = None
|
||||
self.last_error_at: Optional[str] = None
|
||||
self.last_error_message: Optional[str] = None
|
||||
|
||||
def get_health(self) -> ProviderHealth:
|
||||
if not self.enabled:
|
||||
status = "disabled_by_policy"
|
||||
elif not self.api_key and not self.fixture_mode:
|
||||
status = "auth_missing"
|
||||
elif not self.circuit_breaker.can_request():
|
||||
status = "circuit_open"
|
||||
else:
|
||||
status = "operational"
|
||||
|
||||
monthly_used = self.usage_tracker.requests_used
|
||||
estimated_cost = self.usage_tracker.estimated_cost_usd
|
||||
|
||||
return ProviderHealth(
|
||||
provider_id=self.PROVIDER_ID,
|
||||
name=self.PROVIDER_NAME,
|
||||
enabled=self.enabled,
|
||||
status=status,
|
||||
request_count=self.request_count,
|
||||
success_count=self.success_count,
|
||||
error_count=self.error_count,
|
||||
cache_hit_count=self.cache.hits,
|
||||
cache_miss_count=self.cache.misses,
|
||||
cache_hit_ratio=self.cache.hit_ratio,
|
||||
stale_filtered_count=self.stale_filtered_count,
|
||||
invalid_filtered_count=self.invalid_filtered_count,
|
||||
last_success_at=self.last_success_at,
|
||||
last_error_at=self.last_error_at,
|
||||
last_error_message=self.last_error_message,
|
||||
monthly_budget_limit=self.monthly_budget_limit,
|
||||
monthly_requests_used=monthly_used,
|
||||
estimated_cost_usd=estimated_cost,
|
||||
)
|
||||
|
||||
def normalize_alert(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]:
|
||||
"""
|
||||
Normalizes a raw Zyla Waze alert dictionary into a NormalizedAlert model.
|
||||
Returns None if record is invalid, out of bounds, future-dated, or malformed.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
self.invalid_filtered_count += 1
|
||||
return None
|
||||
|
||||
# 1. Coordinate extraction and validation
|
||||
loc = raw.get("location")
|
||||
lat = None
|
||||
lng = None
|
||||
if isinstance(loc, dict):
|
||||
# In Waze/Zyla format, location.x is longitude, location.y is latitude
|
||||
lng = loc.get("x")
|
||||
lat = loc.get("y")
|
||||
if lat is None or lng is None:
|
||||
lat = raw.get("latitude")
|
||||
lng = raw.get("longitude")
|
||||
|
||||
if lat is None or lng is None:
|
||||
self.invalid_filtered_count += 1
|
||||
return None
|
||||
|
||||
try:
|
||||
lat = float(lat)
|
||||
lng = float(lng)
|
||||
except (ValueError, TypeError):
|
||||
self.invalid_filtered_count += 1
|
||||
return None
|
||||
|
||||
# WGS84 range check
|
||||
if lat < -90.0 or lat > 90.0 or lng < -180.0 or lng > 180.0:
|
||||
self.invalid_filtered_count += 1
|
||||
return None
|
||||
|
||||
# 2. Timestamp extraction and validation
|
||||
reported_at = None
|
||||
pub_millis = raw.get("pubMillis")
|
||||
if isinstance(pub_millis, (int, float)) and pub_millis > 0:
|
||||
try:
|
||||
reported_at = datetime.fromtimestamp(pub_millis / 1000.0, tz=timezone.utc)
|
||||
except (ValueError, OSError, OverflowError):
|
||||
pass
|
||||
|
||||
if reported_at is None:
|
||||
pub_datetime_str = raw.get("publish_datetime_utc")
|
||||
if pub_datetime_str:
|
||||
try:
|
||||
reported_at = datetime.fromisoformat(pub_datetime_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if reported_at is None:
|
||||
self.invalid_filtered_count += 1
|
||||
return None
|
||||
|
||||
# Rejection of future-dated anomalies (> 5 mins into future)
|
||||
if reported_at > now + timedelta(minutes=5):
|
||||
self.invalid_filtered_count += 1
|
||||
return None
|
||||
|
||||
# 3. Category & Subtype normalization
|
||||
raw_type = str(raw.get("type", "")).upper()
|
||||
raw_subtype = str(raw.get("subtype", "")).upper()
|
||||
|
||||
category = "other"
|
||||
subtype = raw_subtype.lower() if raw_subtype else None
|
||||
|
||||
if "POLICE" in raw_type or "POLICE" in raw_subtype:
|
||||
category = "police"
|
||||
if "HIDDEN" in raw_subtype or "HIDING" in raw_subtype:
|
||||
subtype = "police_speed_trap"
|
||||
elif "VISIBLE" in raw_subtype:
|
||||
subtype = "police_visible"
|
||||
elif "ACCIDENT" in raw_type or "ACCIDENT" in raw_subtype:
|
||||
category = "accident"
|
||||
if "MAJOR" in raw_subtype:
|
||||
subtype = "accident_major"
|
||||
elif "MINOR" in raw_subtype:
|
||||
subtype = "accident_minor"
|
||||
elif "ROAD_CLOSED" in raw_type or "CLOSURE" in raw_subtype or "CONSTRUCTION" in raw_subtype:
|
||||
category = "roadClosure"
|
||||
if "CONSTRUCTION" in raw_subtype:
|
||||
subtype = "construction"
|
||||
elif "HAZARD" in raw_type or "HAZARD" in raw_subtype:
|
||||
if "POT_HOLE" in raw_subtype or "POTHOLE" in raw_subtype:
|
||||
category = "pothole"
|
||||
subtype = "pothole"
|
||||
elif "OBJECT" in raw_subtype or "DEBRIS" in raw_subtype:
|
||||
category = "debris"
|
||||
subtype = "debris"
|
||||
elif "WEATHER" in raw_subtype:
|
||||
category = "weatherHazard"
|
||||
subtype = "weather_hazard"
|
||||
else:
|
||||
category = "hazard"
|
||||
subtype = "hazard_on_road"
|
||||
|
||||
# 4. Freshness and TTL calculation based on category & subtype
|
||||
ttl_minutes = (
|
||||
SUBTYPE_TTL_MINUTES.get(subtype)
|
||||
or CATEGORY_TTL_MINUTES.get(category, 60)
|
||||
)
|
||||
expires_at = reported_at + timedelta(minutes=ttl_minutes)
|
||||
is_stale = now > expires_at
|
||||
|
||||
if is_stale:
|
||||
self.stale_filtered_count += 1
|
||||
return None
|
||||
|
||||
# 5. Identifier and Source Attribution
|
||||
raw_id = str(raw.get("alert_id") or raw.get("uuid") or raw.get("id") or f"{lat}_{lng}_{int(reported_at.timestamp())}")
|
||||
normalized_id = f"zyla_4814_{raw_id}"
|
||||
|
||||
# 6. Confidence normalization (Waze rating 1-6, confidence 0-5, or 0-10)
|
||||
# Truthfully preserve 0 values without inventing synthetic default confidence
|
||||
raw_confidence = raw.get("alert_confidence") if "alert_confidence" in raw else raw.get("confidence")
|
||||
if raw_confidence is not None and isinstance(raw_confidence, (int, float)):
|
||||
val = float(raw_confidence)
|
||||
if 0.0 <= val <= 5.0:
|
||||
conf_score: Optional[float] = round(max(0.0, min(1.0, val / 5.0)), 2)
|
||||
elif 0.0 <= val <= 10.0:
|
||||
conf_score = round(max(0.0, min(1.0, val / 10.0)), 2)
|
||||
else:
|
||||
conf_score = round(max(0.0, min(1.0, val)), 2)
|
||||
else:
|
||||
conf_score = None
|
||||
|
||||
# 7. Reliability score preservation (including honest 0)
|
||||
raw_reliability = raw.get("alert_reliability") if "alert_reliability" in raw else raw.get("reliability")
|
||||
reliability_score = float(raw_reliability) if isinstance(raw_reliability, (int, float)) else None
|
||||
|
||||
raw_provider = raw.get("provider") or "Waze"
|
||||
raw_provider_id = str(raw.get("provider_id") or raw.get("alert_id") or "4814")
|
||||
description_text = raw.get("description") or raw.get("reportDescription")
|
||||
|
||||
return NormalizedAlert(
|
||||
id=normalized_id,
|
||||
source=self.PROVIDER_NAME,
|
||||
source_type="experimental_external",
|
||||
category=category,
|
||||
subtype=subtype,
|
||||
latitude=round(lat, 6),
|
||||
longitude=round(lng, 6),
|
||||
road_name=raw.get("street"),
|
||||
city=raw.get("city"),
|
||||
description=description_text,
|
||||
reported_at_utc=reported_at.isoformat(),
|
||||
fetched_at_utc=now.isoformat(),
|
||||
expires_at_utc=expires_at.isoformat(),
|
||||
confidence=conf_score,
|
||||
reliability=reliability_score,
|
||||
is_stale=False,
|
||||
raw_provider_id=raw_provider_id,
|
||||
)
|
||||
|
||||
def parse_payload(self, payload: dict, now: Optional[datetime] = None) -> list[NormalizedAlert]:
|
||||
"""
|
||||
Parses upstream Zyla payload, supporting both top-level 'alerts' and wrapped
|
||||
'{status: OK, data: {alerts: [...]}}' structures.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
if isinstance(payload.get("data"), dict):
|
||||
raw_alerts = payload["data"].get("alerts", [])
|
||||
elif isinstance(payload.get("alerts"), list):
|
||||
raw_alerts = payload["alerts"]
|
||||
else:
|
||||
raw_alerts = []
|
||||
|
||||
if not isinstance(raw_alerts, list):
|
||||
return []
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
normalized_list: list[NormalizedAlert] = []
|
||||
|
||||
for item in raw_alerts:
|
||||
alert = self.normalize_alert(item, current_time)
|
||||
if alert and alert.id not in seen_ids:
|
||||
seen_ids.add(alert.id)
|
||||
normalized_list.append(alert)
|
||||
|
||||
return normalized_list
|
||||
|
||||
def load_fixture(self, fixture_name: str) -> dict:
|
||||
"""
|
||||
Loads a sanitized test fixture from tests/fixtures/.
|
||||
"""
|
||||
fixtures_dir = Path(__file__).resolve().parent.parent.parent / "tests" / "fixtures"
|
||||
path = fixtures_dir / fixture_name
|
||||
if not path.exists():
|
||||
logger.warning("Fixture not found: %s", path)
|
||||
return {"alerts": [], "jams": []}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def fetch_alerts(
|
||||
self,
|
||||
min_lat: float,
|
||||
min_lng: float,
|
||||
max_lat: float,
|
||||
max_lng: float,
|
||||
fixture_override: Optional[str] = None,
|
||||
now: Optional[datetime] = None,
|
||||
) -> list[NormalizedAlert]:
|
||||
"""
|
||||
Fetches alerts for a bounding box, enforcing cache, circuit breaker,
|
||||
durable monthly budget tracker, and zero live execution when disabled.
|
||||
"""
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
|
||||
# 1. Check cache first
|
||||
cached_results = self.cache.get(min_lat, min_lng, max_lat, max_lng)
|
||||
if cached_results is not None:
|
||||
return cached_results
|
||||
|
||||
# 2. Safety policy check: Never make live requests if disabled or missing key
|
||||
if not self.enabled and not fixture_override and not self.fixture_mode:
|
||||
logger.info("Zyla Waze API disabled by policy. Returning empty feed.")
|
||||
return []
|
||||
|
||||
if not self.api_key and not fixture_override and not self.fixture_mode:
|
||||
logger.warning("Zyla Waze API key missing. Live calls prevented.")
|
||||
return []
|
||||
|
||||
# Determine whether this is a real billable upstream request
|
||||
is_billable = not (self.fixture_mode or bool(fixture_override))
|
||||
|
||||
# 3. Circuit breaker check
|
||||
if not self.circuit_breaker.can_request() and not fixture_override:
|
||||
logger.warning("Zyla Waze circuit breaker is OPEN. Requests prevented.")
|
||||
return []
|
||||
|
||||
# 4. Durable atomic request budget safeguard
|
||||
if is_billable:
|
||||
if not self.usage_tracker.reserve_request(now=current_time):
|
||||
logger.warning(
|
||||
"Zyla Waze monthly budget limit reached (%d) or budget store unavailable.",
|
||||
self.monthly_budget_limit,
|
||||
)
|
||||
return []
|
||||
|
||||
# Increment tracked requests
|
||||
self.request_count += 1
|
||||
|
||||
upstream_attempted = False
|
||||
upstream_recorded = False
|
||||
|
||||
try:
|
||||
# 5. Fixture handling or mock fallback
|
||||
if fixture_override:
|
||||
payload = self.load_fixture(fixture_override)
|
||||
elif self.fixture_mode:
|
||||
# Select fixture based on coordinates: Rome vs Atlanta
|
||||
if min_lat >= 34.0 or min_lng <= -85.0:
|
||||
payload = self.load_fixture("zyla_rome_sample.json")
|
||||
else:
|
||||
payload = self.load_fixture("zyla_atlanta_sample.json")
|
||||
else:
|
||||
# Live mode - Only executed when explicitly configured with valid key and enabled
|
||||
if not self.api_key:
|
||||
logger.warning("Zyla live request attempted without API key. Rejecting.")
|
||||
self.invalid_filtered_count += 1
|
||||
self.error_count += 1
|
||||
if is_billable:
|
||||
self.usage_tracker.release_reservation(now=current_time)
|
||||
return []
|
||||
|
||||
# We use urllib to avoid external dependencies
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
|
||||
params = {
|
||||
"bottom_left": f"{min_lat},{min_lng}",
|
||||
"top_right": f"{max_lat},{max_lng}",
|
||||
"max_alerts": 50,
|
||||
"max_jams": 0,
|
||||
}
|
||||
query_str = urllib.parse.urlencode(params)
|
||||
url = f"{self.BASE_URL}?{query_str}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "MotorSignal-Backend/1.0",
|
||||
},
|
||||
)
|
||||
|
||||
upstream_attempted = True
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=8.0) as response:
|
||||
body = response.read()
|
||||
# Upstream HTTP attempt succeeded; record exactly once
|
||||
if is_billable and not upstream_recorded:
|
||||
self.usage_tracker.record_execution(success=True, now=current_time)
|
||||
upstream_recorded = True
|
||||
except Exception as http_exc:
|
||||
# Upstream HTTP attempt failed (429, 5xx, timeout, network); record exactly once
|
||||
if is_billable and not upstream_recorded:
|
||||
self.usage_tracker.record_execution(success=False, now=current_time)
|
||||
upstream_recorded = True
|
||||
raise http_exc
|
||||
|
||||
# Parse JSON response (may raise json.JSONDecodeError if upstream returned malformed body)
|
||||
payload = json.loads(body)
|
||||
|
||||
alerts = self.parse_payload(payload, current_time)
|
||||
self.cache.set(min_lat, min_lng, max_lat, max_lng, alerts)
|
||||
self.circuit_breaker.record_success()
|
||||
self.success_count += 1
|
||||
self.last_success_at = current_time.isoformat()
|
||||
return alerts
|
||||
|
||||
except Exception as exc:
|
||||
self.error_count += 1
|
||||
self.last_error_at = current_time.isoformat()
|
||||
self.last_error_message = str(exc)
|
||||
is_429 = "429" in str(exc) or (hasattr(exc, "code") and getattr(exc, "code", None) == 429)
|
||||
self.circuit_breaker.record_failure(str(exc), is_rate_limit=is_429)
|
||||
logger.error("Zyla Waze API request failed: %s", exc)
|
||||
if is_billable:
|
||||
if upstream_attempted and not upstream_recorded:
|
||||
self.usage_tracker.record_execution(success=False, now=current_time)
|
||||
upstream_recorded = True
|
||||
elif not upstream_attempted:
|
||||
# Request aborted before making any network call; release the reserved slot
|
||||
self.usage_tracker.release_reservation(now=current_time)
|
||||
return []
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
{
|
||||
"status": "OK",
|
||||
"data": {
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "zyla_atl_pol_01",
|
||||
"id": "zyla_atl_pol_01",
|
||||
"uuid": "zyla_atl_pol_01",
|
||||
"type": "POLICE",
|
||||
"subtype": "POLICE_HIDDEN",
|
||||
"street": "I-85 N (Downtown Connector)",
|
||||
"city": "Atlanta, GA",
|
||||
"location": {
|
||||
"x": -84.3850,
|
||||
"y": 33.7650
|
||||
},
|
||||
"pubMillis": 1791664300000,
|
||||
"reportRating": 5,
|
||||
"alert_confidence": 5,
|
||||
"alert_reliability": 9,
|
||||
"confidence": 5,
|
||||
"reliability": 9,
|
||||
"description": "Speed enforcement vehicle under Freedom Pkwy overpass",
|
||||
"reportDescription": "Speed enforcement vehicle under Freedom Pkwy overpass",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:31:40Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_atl_acc_01",
|
||||
"id": "zyla_atl_acc_01",
|
||||
"uuid": "zyla_atl_acc_01",
|
||||
"type": "ACCIDENT",
|
||||
"subtype": "ACCIDENT_MAJOR",
|
||||
"street": "I-285 W Perimeter",
|
||||
"city": "Atlanta, GA",
|
||||
"location": {
|
||||
"x": -84.4700,
|
||||
"y": 33.8900
|
||||
},
|
||||
"pubMillis": 1791664400000,
|
||||
"reportRating": 4,
|
||||
"alert_confidence": 5,
|
||||
"alert_reliability": 8,
|
||||
"confidence": 5,
|
||||
"reliability": 8,
|
||||
"description": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
|
||||
"reportDescription": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:33:20Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_atl_haz_01",
|
||||
"id": "zyla_atl_haz_01",
|
||||
"uuid": "zyla_atl_haz_01",
|
||||
"type": "HAZARD",
|
||||
"subtype": "HAZARD_ON_ROAD_POT_HOLE",
|
||||
"street": "Peachtree Rd NE",
|
||||
"city": "Atlanta, GA",
|
||||
"location": {
|
||||
"x": -84.3700,
|
||||
"y": 33.8400
|
||||
},
|
||||
"pubMillis": 1791664250000,
|
||||
"reportRating": 3,
|
||||
"alert_confidence": 4,
|
||||
"alert_reliability": 7,
|
||||
"confidence": 4,
|
||||
"reliability": 7,
|
||||
"description": "Severe pothole right wheel track near Lenox Square",
|
||||
"reportDescription": "Severe pothole right wheel track near Lenox Square",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:30:50Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_atl_clo_01",
|
||||
"id": "zyla_atl_clo_01",
|
||||
"uuid": "zyla_atl_clo_01",
|
||||
"type": "ROAD_CLOSED",
|
||||
"subtype": "ROAD_CLOSED_CONSTRUCTION",
|
||||
"street": "I-75 N at Howell Mill Rd",
|
||||
"city": "Atlanta, GA",
|
||||
"location": {
|
||||
"x": -84.4150,
|
||||
"y": 33.8100
|
||||
},
|
||||
"pubMillis": 1791663000000,
|
||||
"reportRating": 5,
|
||||
"alert_confidence": 5,
|
||||
"alert_reliability": 10,
|
||||
"confidence": 5,
|
||||
"reliability": 10,
|
||||
"description": "Overnight lane replacement construction",
|
||||
"reportDescription": "Overnight lane replacement construction",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:10:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_atl_stale_01",
|
||||
"id": "zyla_atl_stale_01",
|
||||
"uuid": "zyla_atl_stale_01",
|
||||
"type": "ACCIDENT",
|
||||
"subtype": "ACCIDENT_MINOR",
|
||||
"street": "GA-400 S",
|
||||
"city": "Atlanta, GA",
|
||||
"location": {
|
||||
"x": -84.3600,
|
||||
"y": 33.8500
|
||||
},
|
||||
"pubMillis": 1791650000000,
|
||||
"reportRating": 2,
|
||||
"alert_confidence": 2,
|
||||
"alert_reliability": 3,
|
||||
"description": "Cleared minor crash from 4 hours ago",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T16:33:20Z"
|
||||
}
|
||||
],
|
||||
"jams": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
{
|
||||
"status": "OK",
|
||||
"data": {
|
||||
"alerts": [
|
||||
{
|
||||
"alert_id": "zyla_rome_pol_01",
|
||||
"id": "zyla_rome_pol_01",
|
||||
"uuid": "zyla_rome_pol_01",
|
||||
"type": "POLICE",
|
||||
"subtype": "POLICE_VISIBLE",
|
||||
"street": "Martha Berry Hwy",
|
||||
"city": "Rome, GA",
|
||||
"location": {
|
||||
"x": -85.1700,
|
||||
"y": 34.2750
|
||||
},
|
||||
"pubMillis": 1791664000000,
|
||||
"reportRating": 4,
|
||||
"alert_confidence": 0,
|
||||
"alert_reliability": 0,
|
||||
"confidence": 0,
|
||||
"reliability": 0,
|
||||
"description": "Stationary radar trap Northbound on shoulder",
|
||||
"reportDescription": "Stationary radar trap Northbound on shoulder",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_haz_01",
|
||||
"id": "zyla_rome_haz_01",
|
||||
"uuid": "zyla_rome_haz_01",
|
||||
"type": "HAZARD",
|
||||
"subtype": "HAZARD_ON_ROAD_OBJECT",
|
||||
"street": "GA-20 / Alabama Hwy",
|
||||
"city": "Rome, GA",
|
||||
"location": {
|
||||
"x": -85.2000,
|
||||
"y": 34.2500
|
||||
},
|
||||
"pubMillis": 1791664100000,
|
||||
"reportRating": 3,
|
||||
"alert_confidence": 4,
|
||||
"alert_reliability": 7,
|
||||
"confidence": 4,
|
||||
"reliability": 7,
|
||||
"description": "Tree branch in right lane around bend",
|
||||
"reportDescription": "Tree branch in right lane around bend",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:28:20Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_acc_01",
|
||||
"id": "zyla_rome_acc_01",
|
||||
"uuid": "zyla_rome_acc_01",
|
||||
"type": "ACCIDENT",
|
||||
"subtype": "ACCIDENT_MINOR",
|
||||
"street": "Turner McCall Blvd",
|
||||
"city": "Rome, GA",
|
||||
"location": {
|
||||
"x": -85.1650,
|
||||
"y": 34.2560
|
||||
},
|
||||
"pubMillis": 1791664200000,
|
||||
"reportRating": 2,
|
||||
"alert_confidence": 3,
|
||||
"alert_reliability": 6,
|
||||
"confidence": 3,
|
||||
"reliability": 6,
|
||||
"description": "Fender bender near Oostanaula bridge",
|
||||
"reportDescription": "Fender bender near Oostanaula bridge",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:30:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_clo_01",
|
||||
"id": "zyla_rome_clo_01",
|
||||
"uuid": "zyla_rome_clo_01",
|
||||
"type": "ROAD_CLOSED",
|
||||
"subtype": "ROAD_CLOSED_CONSTRUCTION",
|
||||
"street": "Broad St",
|
||||
"city": "Rome, GA",
|
||||
"location": {
|
||||
"x": -85.1720,
|
||||
"y": 34.2530
|
||||
},
|
||||
"pubMillis": 1791663000000,
|
||||
"reportRating": 5,
|
||||
"alert_confidence": 5,
|
||||
"alert_reliability": 9,
|
||||
"confidence": 5,
|
||||
"reliability": 9,
|
||||
"description": "Utility work road closure between 3rd and 4th Ave",
|
||||
"reportDescription": "Utility work road closure between 3rd and 4th Ave",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:10:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_stale_01",
|
||||
"id": "zyla_rome_stale_01",
|
||||
"uuid": "zyla_rome_stale_01",
|
||||
"type": "POLICE",
|
||||
"subtype": "POLICE_HIDDEN",
|
||||
"street": "US-411 S",
|
||||
"city": "Rome, GA",
|
||||
"location": {
|
||||
"x": -85.1400,
|
||||
"y": 34.2200
|
||||
},
|
||||
"pubMillis": 1791600000000,
|
||||
"reportRating": 1,
|
||||
"alert_confidence": 1,
|
||||
"alert_reliability": 2,
|
||||
"description": "Old radar report from 18 hours ago",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T02:40:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_future_01",
|
||||
"id": "zyla_rome_future_01",
|
||||
"uuid": "zyla_rome_future_01",
|
||||
"type": "HAZARD",
|
||||
"subtype": "HAZARD_WEATHER",
|
||||
"street": "Redmond Rd",
|
||||
"city": "Rome, GA",
|
||||
"location": {
|
||||
"x": -85.1800,
|
||||
"y": 34.2700
|
||||
},
|
||||
"pubMillis": 1854748800000,
|
||||
"reportRating": 3,
|
||||
"alert_confidence": 3,
|
||||
"alert_reliability": 5,
|
||||
"description": "Corrupted future timestamp record",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2028-10-10T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_malformed_01",
|
||||
"id": "zyla_rome_malformed_01",
|
||||
"uuid": "zyla_rome_malformed_01",
|
||||
"type": "ACCIDENT",
|
||||
"subtype": "ACCIDENT_MAJOR",
|
||||
"street": "Shorter Ave",
|
||||
"city": "Rome, GA",
|
||||
"location": {
|
||||
"x": 999.0,
|
||||
"y": 999.0
|
||||
},
|
||||
"pubMillis": 1791664000000,
|
||||
"reportRating": 1,
|
||||
"alert_confidence": 1,
|
||||
"alert_reliability": 1,
|
||||
"description": "Invalid out-of-bounds coordinates",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
||||
},
|
||||
{
|
||||
"alert_id": "zyla_rome_pol_01",
|
||||
"id": "zyla_rome_pol_01",
|
||||
"uuid": "zyla_rome_pol_01",
|
||||
"type": "POLICE",
|
||||
"subtype": "POLICE_VISIBLE",
|
||||
"street": "Martha Berry Hwy",
|
||||
"city": "Rome, GA",
|
||||
"location": {
|
||||
"x": -85.1700,
|
||||
"y": 34.2750
|
||||
},
|
||||
"pubMillis": 1791664000000,
|
||||
"reportRating": 4,
|
||||
"alert_confidence": 0,
|
||||
"alert_reliability": 0,
|
||||
"description": "Duplicate report with identical uuid",
|
||||
"provider": "Waze",
|
||||
"provider_id": "4814",
|
||||
"publish_datetime_utc": "2026-10-10T20:26:40Z"
|
||||
}
|
||||
],
|
||||
"jams": []
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
"description": "Cloudflare Worker gateway for MotorSignal R2 telemetry bundle storage",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
|
|
@ -14,7 +13,6 @@
|
|||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240806.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.1.8",
|
||||
"wrangler": "^3.70.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const CORS_HEADERS = {
|
|||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version',
|
||||
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256, X-MotorSignal-Size',
|
||||
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256',
|
||||
};
|
||||
|
||||
const FIREBASE_JWKS = new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com');
|
||||
|
|
@ -146,79 +146,10 @@ export default {
|
|||
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
||||
const sha256 = object.customMetadata?.sha256;
|
||||
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
||||
headers.set('X-MotorSignal-Size', String(object.size));
|
||||
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
||||
return new Response(object.body, { status: 200, headers });
|
||||
}
|
||||
|
||||
if (request.method === 'HEAD' && path === '/r2/download') {
|
||||
const remoteRideId = url.searchParams.get('rideId');
|
||||
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
|
||||
|
||||
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
|
||||
if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error' }, 503);
|
||||
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403);
|
||||
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404);
|
||||
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409);
|
||||
|
||||
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
||||
const object = await env.R2_BUCKET.head(objectKey);
|
||||
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404);
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set('etag', object.httpEtag);
|
||||
headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`);
|
||||
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
||||
const sha256 = object.customMetadata?.sha256;
|
||||
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
||||
headers.set('X-MotorSignal-Size', String(object.size));
|
||||
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
|
||||
if ((request.method === 'GET' || request.method === 'HEAD') && path === '/r2/verify') {
|
||||
const remoteRideId = url.searchParams.get('rideId');
|
||||
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
|
||||
|
||||
const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
|
||||
if (ride === 'temporary-error') return jsonResponse({ error: 'Firestore temporary lookup error', code: 'TEMPORARY_ERROR' }, 503);
|
||||
if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized', code: 'UNAUTHORIZED' }, 403);
|
||||
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found', code: 'NOT_FOUND' }, 404);
|
||||
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid', code: 'INVALID_METADATA' }, 409);
|
||||
|
||||
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
||||
const object = await env.R2_BUCKET.head(objectKey);
|
||||
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2', code: 'OBJECT_NOT_FOUND' }, 404);
|
||||
|
||||
const sha256 = object.customMetadata?.sha256;
|
||||
const appVersion = object.customMetadata?.appVersion;
|
||||
const uploadedAt = object.customMetadata?.uploadedAt;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('etag', object.httpEtag);
|
||||
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
||||
if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
|
||||
headers.set('X-MotorSignal-Size', String(object.size));
|
||||
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
||||
|
||||
if (request.method === 'HEAD') {
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return new Response(JSON.stringify({
|
||||
verified: true,
|
||||
remoteRideId,
|
||||
objectKey,
|
||||
size: object.size,
|
||||
etag: object.httpEtag,
|
||||
sha256,
|
||||
appVersion,
|
||||
uploadedAt,
|
||||
}), { status: 200, headers });
|
||||
}
|
||||
|
||||
return jsonResponse({ error: 'Endpoint not found' }, 404);
|
||||
} catch (err: unknown) {
|
||||
console.error('MotorSignal Worker exception', err);
|
||||
|
|
@ -244,7 +175,7 @@ async function verifyFirebaseToken(token: string, expectedProjectId: string): Pr
|
|||
}
|
||||
|
||||
interface OwnedRide { objectKey?: string; }
|
||||
type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid' | 'temporary-error';
|
||||
type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid';
|
||||
|
||||
/** Reads the authenticated user's ride document through Firestore REST. */
|
||||
async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise<RideAuthorizationResult> {
|
||||
|
|
@ -254,7 +185,6 @@ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: stri
|
|||
|
||||
if (response.status === 401 || response.status === 403) return 'unauthorized';
|
||||
if (response.status === 404) return 'not-found';
|
||||
if (response.status === 429 || response.status >= 500) return 'temporary-error';
|
||||
if (!response.ok) {
|
||||
console.error('Firestore ownership lookup failed', response.status);
|
||||
return 'unauthorized';
|
||||
|
|
@ -262,8 +192,8 @@ async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: stri
|
|||
|
||||
const document = (await response.json()) as FirestoreDocument;
|
||||
const fields = document.fields ?? {};
|
||||
const ownerId = firestoreString(fields.ownerId) ?? firestoreString(fields.uid);
|
||||
const storedId = firestoreString(fields.id) ?? firestoreString(fields.remoteRideId);
|
||||
const ownerId = firestoreString(fields.ownerId);
|
||||
const storedId = firestoreString(fields.id);
|
||||
const objectKey = firestoreString(fields.telemetryBundlePath) ?? firestoreString(fields.telemetryBundle?.mapValue?.fields?.objectKey);
|
||||
|
||||
if (ownerId !== uid) return 'unauthorized';
|
||||
|
|
|
|||
|
|
@ -1,374 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock jose before importing worker
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => vi.fn()),
|
||||
jwtVerify: vi.fn(async (token: string) => {
|
||||
if (token === 'valid_token') {
|
||||
return {
|
||||
payload: {
|
||||
sub: 'test_user_123',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
firebase: { sign_in_provider: 'password' },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (token === 'anon_token') {
|
||||
return {
|
||||
payload: {
|
||||
sub: 'anon_user_456',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
firebase: { sign_in_provider: 'anonymous' },
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Invalid token');
|
||||
}),
|
||||
}));
|
||||
|
||||
import worker, { type Env } from './index';
|
||||
|
||||
describe('Worker /r2/verify endpoint', () => {
|
||||
let mockEnv: Env;
|
||||
let mockR2Head: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockR2Head = vi.fn();
|
||||
mockEnv = {
|
||||
R2_BUCKET: {
|
||||
head: mockR2Head,
|
||||
} as any,
|
||||
FIREBASE_PROJECT_ID: 'motoapp-test',
|
||||
ENVIRONMENT: 'test',
|
||||
GOOGLE_SERVICE_ACCOUNT_EMAIL: 'sa@test.iam.gserviceaccount.com',
|
||||
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: 'test-key',
|
||||
GOOGLE_PLAY_PACKAGE_NAME: 'app.motorsignal.motorcycle',
|
||||
};
|
||||
});
|
||||
|
||||
function mockValidFirestore(uid = 'test_user_123', rideId = 'ride_abc_123') {
|
||||
mockFirestore(200, {
|
||||
fields: {
|
||||
ownerId: { stringValue: uid },
|
||||
id: { stringValue: rideId },
|
||||
telemetryBundlePath: {
|
||||
stringValue: `users/${uid}/rides/${rideId}/telemetry.zip`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockFirestore(status: number, data?: any) {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
if (url.includes('firestore.googleapis.com')) {
|
||||
return new Response(JSON.stringify(data || {}), { status });
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}));
|
||||
}
|
||||
|
||||
it('1. returns CORS headers on OPTIONS preflight request', async () => {
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
||||
});
|
||||
|
||||
it('2. returns 404 Endpoint not found for unknown paths', async () => {
|
||||
const req = new Request('https://worker.local/unknown-path', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as any;
|
||||
expect(body.error).toBe('Endpoint not found');
|
||||
});
|
||||
|
||||
it('3. rejects /r2/verify with 401 when Authorization header is missing', async () => {
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
|
||||
method: 'GET',
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json() as any;
|
||||
expect(body.error).toContain('Missing or malformed Authorization header');
|
||||
});
|
||||
|
||||
it('4. rejects /r2/verify with 401 when Authorization header lacks Bearer prefix', async () => {
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Basic dXNlcjpwYXNz' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('5. rejects /r2/verify with 401 when Firebase token verification fails', async () => {
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_1', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer invalid_or_expired_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json() as any;
|
||||
expect(body.error).toContain('Invalid, expired, or unauthorized');
|
||||
});
|
||||
|
||||
it('6. rejects /r2/verify with 400 when rideId query parameter is missing', async () => {
|
||||
const req = new Request('https://worker.local/r2/verify', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as any;
|
||||
expect(body.error).toContain('Invalid rideId parameter');
|
||||
});
|
||||
|
||||
it('7. rejects /r2/verify with 400 when rideId contains invalid characters or path traversal', async () => {
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=../bad/traversal', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as any;
|
||||
expect(body.error).toContain('Invalid rideId parameter');
|
||||
});
|
||||
|
||||
it('8. returns 403 UNAUTHORIZED when Firestore returns 403', async () => {
|
||||
mockFirestore(403);
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json() as any;
|
||||
expect(body.code).toBe('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('9. returns 404 NOT_FOUND when Firestore ride document does not exist', async () => {
|
||||
mockFirestore(404);
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as any;
|
||||
expect(body.code).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
it('10. returns 409 INVALID_METADATA when Firestore document has invalid or missing bundle path', async () => {
|
||||
mockFirestore(200, {
|
||||
fields: {
|
||||
ownerId: { stringValue: 'test_user_123' },
|
||||
id: { stringValue: 'ride_abc_123' },
|
||||
telemetryBundlePath: { stringValue: 'invalid/bundle/path.zip' },
|
||||
},
|
||||
});
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json() as any;
|
||||
expect(body.code).toBe('INVALID_METADATA');
|
||||
});
|
||||
|
||||
it('11. returns 404 OBJECT_NOT_FOUND when bundle does not exist in R2', async () => {
|
||||
mockValidFirestore();
|
||||
mockR2Head.mockResolvedValue(null);
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as any;
|
||||
expect(body.code).toBe('OBJECT_NOT_FOUND');
|
||||
expect(mockR2Head).toHaveBeenCalledWith('users/test_user_123/rides/ride_abc_123/telemetry.zip');
|
||||
});
|
||||
|
||||
it('12. returns 200 with verification details on GET when object exists in R2', async () => {
|
||||
mockValidFirestore();
|
||||
mockR2Head.mockResolvedValue({
|
||||
size: 4096,
|
||||
httpEtag: '"etag-12345"',
|
||||
customMetadata: {
|
||||
sha256: 'abc123def456',
|
||||
appVersion: '1.1.0',
|
||||
uploadedAt: '2026-10-12T10:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as any;
|
||||
expect(body.verified).toBe(true);
|
||||
expect(body.remoteRideId).toBe('ride_abc_123');
|
||||
expect(body.objectKey).toBe('users/test_user_123/rides/ride_abc_123/telemetry.zip');
|
||||
expect(body.size).toBe(4096);
|
||||
expect(body.etag).toBe('"etag-12345"');
|
||||
expect(body.sha256).toBe('abc123def456');
|
||||
expect(body.appVersion).toBe('1.1.0');
|
||||
expect(body.uploadedAt).toBe('2026-10-12T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('13. sets verification properties in JSON body on GET and headers on HEAD', async () => {
|
||||
mockValidFirestore();
|
||||
mockR2Head.mockResolvedValue({
|
||||
size: 8192,
|
||||
httpEtag: '"etag-8192"',
|
||||
customMetadata: { sha256: 'sha_test_789' },
|
||||
});
|
||||
|
||||
const getReq = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const getRes = await worker.fetch(getReq, mockEnv);
|
||||
const getBody = await getRes.json() as any;
|
||||
expect(getBody.etag).toBe('"etag-8192"');
|
||||
expect(getBody.remoteRideId).toBe('ride_abc_123');
|
||||
expect(getBody.sha256).toBe('sha_test_789');
|
||||
expect(getBody.size).toBe(8192);
|
||||
|
||||
const headReq = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'HEAD',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const headRes = await worker.fetch(headReq, mockEnv);
|
||||
expect(headRes.headers.get('etag')).toBe('"etag-8192"');
|
||||
expect(headRes.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
|
||||
expect(headRes.headers.get('X-MotorSignal-Sha256')).toBe('sha_test_789');
|
||||
expect(headRes.headers.get('X-MotorSignal-Size')).toBe('8192');
|
||||
});
|
||||
|
||||
it('14. handles HEAD request on /r2/verify with 200 and headers but null body', async () => {
|
||||
mockValidFirestore();
|
||||
mockR2Head.mockResolvedValue({
|
||||
size: 4096,
|
||||
httpEtag: '"etag-head"',
|
||||
customMetadata: { sha256: 'sha_head' },
|
||||
});
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'HEAD',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeNull();
|
||||
expect(res.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
|
||||
expect(res.headers.get('X-MotorSignal-Size')).toBe('4096');
|
||||
});
|
||||
|
||||
it('15. handles HEAD request on /r2/verify when bundle not found in R2 with 404', async () => {
|
||||
mockValidFirestore();
|
||||
mockR2Head.mockResolvedValue(null);
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'HEAD',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as any;
|
||||
expect(body.code).toBe('OBJECT_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('16. correctly returns undefined sha256 for historical bundles without checksum metadata', async () => {
|
||||
mockValidFirestore('test_user_123', 'ride_legacy_001');
|
||||
mockR2Head.mockResolvedValue({
|
||||
size: 1024,
|
||||
httpEtag: '"etag-legacy"',
|
||||
customMetadata: undefined,
|
||||
});
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_legacy_001', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as any;
|
||||
expect(body.verified).toBe(true);
|
||||
expect(body.sha256).toBeUndefined();
|
||||
expect(res.headers.has('X-MotorSignal-Sha256')).toBe(false);
|
||||
});
|
||||
|
||||
it('17. targets user-scoped R2 object key matching authenticated user', async () => {
|
||||
mockValidFirestore('test_user_123', 'ride_custom_id');
|
||||
mockR2Head.mockResolvedValue({
|
||||
size: 512,
|
||||
httpEtag: '"etag-scoped"',
|
||||
});
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_custom_id', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
await worker.fetch(req, mockEnv);
|
||||
expect(mockR2Head).toHaveBeenCalledWith('users/test_user_123/rides/ride_custom_id/telemetry.zip');
|
||||
});
|
||||
|
||||
it('18. returns 500 when unexpected internal worker exception occurs', async () => {
|
||||
mockValidFirestore('test_user_123', 'ride_err_1');
|
||||
mockR2Head.mockRejectedValue(new Error('R2 cluster unreachable'));
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_err_1', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as any;
|
||||
expect(body.error).toBe('Internal Worker Exception');
|
||||
});
|
||||
|
||||
it('19. returns 503 TEMPORARY_ERROR when Firestore ownership lookup fails with 429 or 503', async () => {
|
||||
mockFirestore(503, { error: 'Service Unavailable' });
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(503);
|
||||
const body = await res.json() as any;
|
||||
expect(body.code).toBe('TEMPORARY_ERROR');
|
||||
expect(body.error).toContain('Firestore temporary lookup error');
|
||||
});
|
||||
|
||||
it('20. sets X-MotorSignal-* headers on GET as well as HEAD', async () => {
|
||||
mockValidFirestore();
|
||||
mockR2Head.mockResolvedValue({
|
||||
size: 2048,
|
||||
httpEtag: '"etag-2048"',
|
||||
customMetadata: { sha256: 'sha256_get_test' },
|
||||
});
|
||||
|
||||
const req = new Request('https://worker.local/r2/verify?rideId=ride_abc_123', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer valid_token' },
|
||||
});
|
||||
const res = await worker.fetch(req, mockEnv);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('X-MotorSignal-Ride-Id')).toBe('ride_abc_123');
|
||||
expect(res.headers.get('X-MotorSignal-Sha256')).toBe('sha256_get_test');
|
||||
expect(res.headers.get('X-MotorSignal-Size')).toBe('2048');
|
||||
expect(res.headers.get('etag')).toBe('"etag-2048"');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# Documentation
|
||||
|
||||
This index separates active reference material from dated planning, audit, QA, and deployment evidence. `AGENTS.md`, current code, schemas, and tests remain the authority when they conflict with documentation.
|
||||
|
||||
## Repository entrypoints
|
||||
|
||||
- [Repository README](../README.md): setup and application overview.
|
||||
- [Agent rules](../AGENTS.md): mandatory development boundaries and verification rules.
|
||||
- [Product brief](../PRODUCT.md): product intent and constraints.
|
||||
- [Changelog](../CHANGELOG.md): canonical release history.
|
||||
|
||||
## Active reference
|
||||
|
||||
- [Architecture](architecture/): telemetry, cloud, and data-model references.
|
||||
- [Design](design/): interface language and visual tokens.
|
||||
- [Product](product/): entitlement baseline and roadmap.
|
||||
- [Guides](guides/): field-testing procedure.
|
||||
- [Release operations](RELEASING.md) and [versioning](VERSIONING.md).
|
||||
- [Agent domain map](agents/domain.md): documentation authority and subsystem routing.
|
||||
|
||||
## Operational and historical material
|
||||
|
||||
- [Website and production records](#website-and-production-records): current scoped deployment and credential evidence remains at this level.
|
||||
- [Archive](archive/): preserved dated planning, audit, QA, and legacy summaries. These documents are evidence, not current implementation proof.
|
||||
|
||||
## Website and production records
|
||||
|
||||
- `website-*.md`: scoped website verification, deployment, replay, and credential records dated 2026-10-04.
|
||||
- `qa-*.md` and `QA_AUDIT_REPORT_2026-09-30.md`: dated QA outcomes and follow-up evidence.
|
||||
- `OPS4_*.md`, `PRODUCTION_DEPLOYMENT_CHECKLIST.md`, and `WEBSITE_ADMIN_CURRENT_STATE.md`: operational references that require validation against the live configuration before use.
|
||||
|
|
@ -13,13 +13,13 @@ When documents conflict, verify behavior in code and tests. Do not revive obsole
|
|||
|
||||
## Documentation Map
|
||||
|
||||
- Telemetry and field validation: `docs/architecture/telemetry-architecture.md`, `docs/architecture/telemetry-validation.md`, and `docs/guides/field-testing.md`.
|
||||
- Cloud and sync: `docs/architecture/cloud-data-model.md`, `docs/architecture/cloud-architecture.md`, and `cloudflare/motorsignal-r2-worker/README.md`.
|
||||
- Product and entitlement intent: `docs/product/free-pro-spec.md` and `docs/product/roadmap.md`. These are planning baselines, not proof that features exist.
|
||||
- Interface language and visual tokens: `docs/design/design-system.md`. Validate behavioral and timing claims against current code.
|
||||
- Telemetry and field validation: `TELEMETRY_ARCHITECTURE.md`, `TELEMETRY_VALIDATION.md`, and `FIELD_TESTING.md`.
|
||||
- Cloud and sync: `MOTORSIGNAL_FINAL_CLOUD_DATA_MODEL.md`, `MOTORSIGNAL_CLOUD_ARCHITECTURE.md`, and `cloudflare/motorsignal-r2-worker/README.md`.
|
||||
- Product and entitlement intent: `MOTORSIGNAL_FREE_PRO_SPEC.md` and `MOTORSIGNAL_PRODUCT_ROADMAP.md`. These are planning baselines, not proof that features exist.
|
||||
- Interface language and visual tokens: `DESIGN.md`. Validate behavioral and timing claims against current code.
|
||||
- Releases and versions: `docs/RELEASING.md` and `docs/VERSIONING.md`. Preserve Semantic Versioning and monotonically increasing build numbers; use current `AGENTS.md` verification gates and current schema values where old examples conflict.
|
||||
- Waze relay operation: `server/waze_relay/README.md`.
|
||||
- `CHANGELOG.md` is the canonical release record. `docs/archive/` contains dated feature histories, environment audits, phase reports, discovery documents, and gap audits. Never treat their old test counts, release status, schema numbers, or pending physical-validation claims as current proof.
|
||||
- `CHANGELOG.md`, `FEATURES_CHANGELOG.md`, `DEVELOPMENT_HISTORY.md`, `MOTORSIGNAL_AI_ENVIRONMENT_AUDIT.md`, phase reports, discovery documents, and gap audits are dated evidence. Never treat their old test counts, release status, schema numbers, or pending physical-validation claims as current proof.
|
||||
|
||||
## Stable Vocabulary
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
# Legacy summaries
|
||||
|
||||
This directory consolidates discovery for the overlapping August 2026 historical summaries while preserving each original record and its unique details. Use the root [CHANGELOG](../../../../CHANGELOG.md) for release history and current code for current behavior.
|
||||
|
||||
- [Development history](development-history.md): commit and phase chronology.
|
||||
- [Features changelog](features-changelog.md): historical feature inventory.
|
||||
- [Project summary](project-summary.md): historical architecture overview.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# Documentation Archive
|
||||
|
||||
These files preserve dated evidence and planning context. They are not current specifications. Validate present behavior against `AGENTS.md`, source, schemas, and tests.
|
||||
|
||||
- [2026-08](2026-08/): Phase 2 planning, field-test/QA reports, legacy summaries, and the environment audit.
|
||||
- [2026-09](2026-09/): dated architecture gap audit.
|
||||
|
||||
## Consolidated legacy discovery
|
||||
|
||||
The following overlapping historical summaries are retained as separate source records because each has unique detail. `CHANGELOG.md` at the repository root is the canonical release record.
|
||||
|
||||
- [Development history](2026-08/legacy/development-history.md): commit and phase chronology.
|
||||
- [Features changelog](2026-08/legacy/features-changelog.md): historical feature inventory.
|
||||
- [Project summary](2026-08/legacy/project-summary.md): historical architecture overview.
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
# DESIGN.md — MotorSignal Hybrid v1 Canonical Tokens & Components
|
||||
|
||||
## 1. Visual Language & Principles
|
||||
- **Theme Name**: MotorSignal Precision Telemetry & Community (Hybrid v1)
|
||||
- **Visual Direction**: Conventional cockpit layout clarity, high-contrast anti-glare data hierarchy, restrained cyan branding accents, and strict zero-clutter riding ergonomics.
|
||||
- **Static Recording Discipline**: Active recording indicators (`REC • LIVE`) must remain 100% static (no pulsating, flashing, or shifting animations) to eliminate rider distraction during high-speed operation.
|
||||
- **Physical Touch Enforcement**: Strict minimum `48×48 CSS px` (48×48dp) touch boundaries across all buttons, segmented toggles, navigation tabs, and modal triggers. Primary riding controls enforce `56dp` minimum height.
|
||||
- **Platform Inset Rules**: Dynamic runtime safe-area clearance (`SafeArea` / `MediaQuery.of(context).padding` in Flutter; `env(safe-area-inset-*)` in Web/PWA).
|
||||
- **Accessibility & Runtime Verification**: Accessibility scaling (200% text scale) and runtime performance claims are treated as unverified targets until proven via emulator/device test execution.
|
||||
|
||||
---
|
||||
|
||||
## 2. Canonical Token Set (Synchronized Day & Night Modes)
|
||||
|
||||
> [!NOTE]
|
||||
> Canonical Canvas Token Reconciliation: `#0B1326` is the canonical night canvas background. Hex variants like `#081326` or `#0B111E` appearing in intermediate drafts are reconciled to canonical `#0B1326`.
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "MotorSignal Hybrid v1",
|
||||
"modes": {
|
||||
"night": {
|
||||
"surface_canvas": "#0B1326",
|
||||
"surface_card_primary": "#111C38",
|
||||
"surface_card_secondary": "#17223B",
|
||||
"surface_card_lowest": "#060E20",
|
||||
"surface_border": "#1E293B",
|
||||
"surface_border_subtle": "#24324D",
|
||||
"text_primary": "#DAE2FD",
|
||||
"text_muted": "#869397",
|
||||
"brand_cyan": "#06B6D4",
|
||||
"brand_cyan_bright": "#22D3EE",
|
||||
"brand_cyan_container": "#00424F",
|
||||
"rec_static_green": "#10B981",
|
||||
"rec_static_green_bg": "#064E3B",
|
||||
"caution_amber": "#F59E0B",
|
||||
"danger_red": "#EF4444"
|
||||
},
|
||||
"day": {
|
||||
"surface_canvas": "#F8FAFC",
|
||||
"surface_card_primary": "#FFFFFF",
|
||||
"surface_card_secondary": "#F1F5F9",
|
||||
"surface_card_lowest": "#E2E8F0",
|
||||
"surface_border": "#CBD5E1",
|
||||
"surface_border_subtle": "#E2E8F0",
|
||||
"text_primary": "#0F172A",
|
||||
"text_muted": "#64748B",
|
||||
"brand_cyan": "#0284C7",
|
||||
"brand_cyan_bright": "#0EA5E9",
|
||||
"brand_cyan_container": "#E0F2FE",
|
||||
"rec_static_green": "#10B981",
|
||||
"rec_static_green_bg": "#D1FAE5",
|
||||
"caution_amber": "#D97706",
|
||||
"danger_red": "#DC2626"
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"font_primary": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font_telemetry": "JetBrains Mono, monospace",
|
||||
"scale": {
|
||||
"display_speed": "58px–72px (font-black / tabular)",
|
||||
"telemetry_primary": "24px–28px (font-bold / tabular)",
|
||||
"telemetry_unit": "10px–12px (uppercase, tracking-wider)",
|
||||
"headline_lg": "24px–28px (font-bold)",
|
||||
"headline_md": "18px–20px (font-semibold)",
|
||||
"body_regular": "14px (line-height: 20px)",
|
||||
"label_nav": "10px–11px (font-medium / bold)"
|
||||
}
|
||||
},
|
||||
"touch_targets": {
|
||||
"standard_minimum": "48px × 48px",
|
||||
"primary_riding_cta": "56px height",
|
||||
"nav_bar_height": "72px"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Reusable Component Guidelines
|
||||
|
||||
1. **Persistent 6-Destination Bottom Nav**:
|
||||
- Items: `Dash` (1), `Map` (2), `Activity` (3), `Garage` (4), `Social` (5), `Settings` (6).
|
||||
- Target bounding box: `≥48×48dp` per tab icon & label.
|
||||
|
||||
2. **Riding Dashboard Telemetry Cards**:
|
||||
- Speed Hero: High-contrast large numeral with instant unit label (`mph` or `km/h`).
|
||||
- Secondary Telemetry: Distance, Elapsed Time, Lean Dynamics (neutral max lean styling, live lean in cyan), Elevation Gain, and Braking G.
|
||||
- Pinned Action Bar: `Pause Ride` (Amber, min 56dp) and `Finish Ride` (Dark/Slate with Cyan/Green accent, min 56dp), pinned to screen base with scrollable telemetry above in accessibility text scales.
|
||||
- Non-pulsing `REC • LIVE` status badge.
|
||||
|
||||
3. **Group Ride Cards & Hub**:
|
||||
- Decoupled Logistics: Date, time, and staging location placed on a dedicated full-width row with clock icon to avoid button/text truncation.
|
||||
- Hitboxes: Segmented switcher (Feed vs Hub) and ride card buttons (`Manage Ride`, `Open Hub`, `Ride Details`, `Leave Pack`) sized to minimum `48dp` height.
|
||||
- Visibility & Join Policies: Strict preservation of existing permissions, host/organizer badges, and join policies.
|
||||
|
||||
4. **Replay HUD**:
|
||||
- Dedicated map controls locked to Top-Right quadrant, isolated from scrub timeline and lower telemetry gauges.
|
||||
- Telemetry loading/status reflects real storage retrieval (e.g. SQLite database cache or cloud bundle retrieval), replacing speculative "100% buffered in-memory" labels.
|
||||
- Scoped deletion options (*Local Device Cache Only* vs *Cloud & Local Records*). Never preselected.
|
||||
|
||||
5. **Account & System Status**:
|
||||
- Reflects actual device sensors (Internal Phone IMU at authoritative 20Hz pipeline). Discards unsupported 100Hz claims and invalid Pro restrictions on basic telemetry acquisition.
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
# HANDOFF.md — MotorSignal Hybrid v1 Architecture & Implementation Notice
|
||||
|
||||
## 1. Prototype Scope & Verification Disclaimer
|
||||
This handoff package contains **visual and interaction design prototypes** retrieved from Stitch project `5138779337547529796` ("MotorSignal UI/UX Redesign Exploration"). These specifications establish layout hierarchy, typography, touch target boundaries, and user experience flows for the MotorSignal mobile client (target viewport: `390 × 844 px`).
|
||||
|
||||
**CRITICAL IMPLEMENTATION PRINCIPLE:**
|
||||
Mockups represent target client UI. **Repository code and backend APIs are authoritative** regarding:
|
||||
1. Actual binary telemetry storage formats (authoritative SQLite Drift Schema 7, R2 telemetry bundles).
|
||||
2. Authoritative Telemetry Acquisition (frozen at 20 Hz / 50 ms via `TelemetryService`).
|
||||
3. Sensor fusion (frozen in `SensorFusionService`; no independent lean-angle calculations).
|
||||
4. Ride lifecycle and motion semantics (frozen in `RideStateMachine`; 3.0 MPH moving gate, kickstand isolation).
|
||||
5. Cloud synchronization and offline sync architecture (`SyncManager`, `SyncOutbox`, `FirebaseSyncProvider`).
|
||||
6. Authentication providers and entitlements (free vs pro capabilities).
|
||||
7. Existing group ride visibility, roles, and join policies.
|
||||
|
||||
Do **not** implement speculative backend mechanisms or invent hardware capabilities based on UI mockups.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inconsistency Resolutions & Corrections
|
||||
|
||||
1. **Replay Telemetry Retrieval Wording**:
|
||||
- Stitch Replay mockups contain the label `"100% Buffered (Local cache)"` or references to `"100% buffered in-memory"`.
|
||||
- *Resolution*: In production code and documentation, loading and status wording must reflect the actual telemetry retrieval pipeline (retrieving points from the local SQLite database or streaming/downloading the complete bundle from cloud storage/R2). Do not present speculative "100% buffered in-memory" messaging.
|
||||
|
||||
2. **Account Screen Sample Rates and Pro Restrictions**:
|
||||
- An older mockup displayed `Internal Phone IMU: Active (100Hz Gyro / Accel)` and claimed `Multi-sensor telemetry sync requires Pro subscription`.
|
||||
- *Resolution*: Discarded. The authoritative acquisition cadence is 20 Hz (50 ms). Basic telemetry acquisition and synchronization are not restricted by unsupported Pro paywalls; current entitlements and free/pro models documented in the repository are strictly preserved.
|
||||
|
||||
3. **Ride Visibility and Join Policies**:
|
||||
- Preserve all existing group ride visibility, membership, and join policies (public, unlisted, club-only, invite-only, host-approved join requests, participant lists, and role permissions). UI components adapt to these existing models without redefining policies.
|
||||
|
||||
4. **Canonical Token Reconciliation (#0B1326 vs #081326)**:
|
||||
- Stitch HTML templates occasionally used `#081326` or `#0B111E` for dark backgrounds.
|
||||
- *Resolution*: Canonical night surface canvas is strictly `#0B1326` as defined in `DESIGN.md`.
|
||||
|
||||
5. **Accessibility and Runtime Claims**:
|
||||
- Visual mockups demonstrate 200% text scaling and proclaim "Contrast Ratio: 18.2:1 (Calc sRGB)" and "Design Target: 48×48px Minimum Target Discipline".
|
||||
- *Resolution*: All accessibility, contrast, and runtime performance claims are treated as unverified until validated with unit/widget tests and on-device / emulator verification.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phased Implementation Roadmap
|
||||
|
||||
### Phase 1: Mobile App Only (Current Implementation Target)
|
||||
1. **Shared Day/Night Theme and Reusable Components**:
|
||||
- Canonical tokens (`#0B1326` night canvas, `#F8FAFC` day canvas, `#06B6D4` / `#0284C7` brand cyan, `#10B981` static green, `#F59E0B` / `#D97706` amber, `#EF4444` / `#DC2626` red).
|
||||
- Reusable cockpit telemetry card containers with 1px border.
|
||||
- Pinned riding action buttons with minimum 56dp height and 48dp touch bounds.
|
||||
- 6-destination bottom navigation bar with ≥48×48dp hitboxes.
|
||||
- Static (non-pulsing) recording status indicator.
|
||||
2. **Riding Dashboard**:
|
||||
- High-contrast Speed hero with unit pill.
|
||||
- Telemetry metrics (Trip Distance, Elapsed Time, Lean Dynamics with neutral max lean, Elevation Gain, Braking G).
|
||||
- Live binding to authoritative `telemetryStreamProvider`, `rideStateMachineProvider`, and vehicle profile.
|
||||
- Scrollable telemetry container for accessibility text scales, maintaining pinned Pause and Finish controls.
|
||||
3. **Authenticated Group Ride Hub**:
|
||||
- Decoupled meeting logistics row (Date, Time, Staging point) to prevent collision with action buttons.
|
||||
- Segmented Feed / Hub switcher with ≥48dp touch targets.
|
||||
- Ride cards supporting Host ("Manage Ride", "Open Hub") and Member ("Ride Details", "Leave Pack") states.
|
||||
- Binding to live `groupRideServiceProvider` and user state.
|
||||
|
||||
### Phase 2: Planned Client Roadmap (Future Implementation)
|
||||
- 2-step Create Group Ride wizard (staging typeahead, date/time pickers, access review, QR rendezvous code).
|
||||
- Route calculation engine loading and fallback states ("direct distance—not a road route").
|
||||
- Share ride sheet with recipient dispatch (Clubs, rider DMs, GPX export).
|
||||
- Explicit scoped deletion dialog (Local cache only vs Cloud & local deletion).
|
||||
|
||||
### Phase 3: Capabilities Requiring Further Discovery
|
||||
- External BLE sensor GATT protocol integration.
|
||||
- Two-factor authentication (2FA) setup flows.
|
||||
- Dynamic telemetry streaming throttles.
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
# SCREEN_INDEX.md — MotorSignal Hybrid v1 Final Design Handoff
|
||||
|
||||
This index maps the 14 required screen flows (and companion accessibility views) to their canonical Stitch project (`projects/5138779337547529796`) screen IDs, composite frame breakdowns, and implementation status.
|
||||
|
||||
## Screen Catalog & Resolved Stitch Identifiers
|
||||
|
||||
| # | Screen / State Name | Actual Stitch Screen ID | Composite Frame / Viewport | Status / Verification Scope | Description & Key Corrections |
|
||||
|---|---|---|---|---|---|
|
||||
| **01** | Riding Dashboard — Day | `fbbd68740d11447e8035cd0776133053` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Anti-glare daylight contrast (`#F8FAFC`), neutral max lean, static REC pill, pinned 56dp Pause/Finish controls, 6-tab nav. |
|
||||
| **01b**| Riding Dashboard — Day (200% Text Scale Companion) | `fbbd68740d11447e8035cd0776133053` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | Simulated 200% scale showing pinned controls and scrollable telemetry container. Unverified until tested. |
|
||||
| **02** | Riding Dashboard — Night | `8f336a84e92c44d8aea012b2c6703b55` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Deep slate `#0B1326` canvas, neutral 27° max lean readout, static REC pill, zero-scroll phone fit. |
|
||||
| **02b**| Riding Dashboard — Night (200% Text Scale Companion) | `8f336a84e92c44d8aea012b2c6703b55` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | 200% scale demonstration with ≥48dp physical target boundary indicators. Unverified until tested. |
|
||||
| **03** | Authenticated Group Ride Hub | `a661c1c9c82843a2bcb6b84d6d7855e1` | Left Frame (Default Scale, 390×844) | **Tier 1: Verified UI** | Dedicated logistics row, min-48dp Feed/Hub switch, 48dp action buttons. Preserves existing join/visibility policies. |
|
||||
| **03b**| Group Ride Hub (200% Text Scale Companion) | `a661c1c9c82843a2bcb6b84d6d7855e1` | Right Frame (200% Text Scale, 390×844) | **Accessibility Target** | Validates text wrapping on meeting details without action button overlap. Unverified until tested. |
|
||||
| **04** | Ride History & Archive | `934e8a33a5fa44009d40c292aacc630e` | Viewport 1 (Left Frame, 390×844) | **Tier 1 (UI) + Tier 2 (Sync UX)** | Sample data labeled, offline queued sync banner (2 rides queued), 3×2 telemetry card grid. |
|
||||
| **05** | Ride Replay HUD | `934e8a33a5fa44009d40c292aacc630e` | Viewport 2 (Right Frame, 390×844) | **Tier 1 (UI) + Tier 2 (Proposed HUD)** | Top-right map controls, telemetry retrieval status follows actual database/bundle retrieval (discards '100% buffered in-memory'), 56dp playback controls. |
|
||||
| **06** | Community Feed | `17a5177fab12466da2dea6639c8fd88f` | Frame 1 (390×844) | **Tier 1 (UI) + Tier 2 (Moderation)** | 4 feed tabs: Following, Clubs, Nearby, Discover. Generic community moderation card. Pro = subscription badge. |
|
||||
| **07** | Create Group Ride — Step 1: Details & Staging | `17a5177fab12466da2dea6639c8fd88f` | Frame 2a (390×844) | **Tier 2: Proposed Feature** | Staging location typeahead, calendar/time pickers, route preview teaser. |
|
||||
| **08** | Route Calculation Engine — Loading & Failure | `17a5177fab12466da2dea6639c8fd88f` | Frame 2b (390×844) | **Tier 2 / Tier 3 (Engine UX)** | Calculating state (spinner) vs Fallback state (GPX upload / "direct distance—not a road route"). |
|
||||
| **09** | Create Group Ride — Step 2: Review & Access | `17a5177fab12466da2dea6639c8fd88f` | Frame 2c (390×844) | **Tier 2: Proposed Feature** | Ride summary recap, Public vs Invite-Only access toggles, pace selector, safety notes. Preserves existing policies. |
|
||||
| **10** | Ride Created — Join Code & Dynamic QR | `17a5177fab12466da2dea6639c8fd88f` | Frame 2d (390×844) | **Tier 2: Proposed Feature** | High-contrast staging QR code, `#SKY92` rendezvous code, share invite triggers. |
|
||||
| **11** | Account, Security & Sensor Connections | `fd0e6370af424ff4becfdaecda7f29de` | Frame 3 (390×844) | **Tier 1 (UI) + Tier 3 (Backend/Sensors)** | Internal Phone IMU active (20Hz authoritative pipeline; discarded older 100Hz and Pro restriction assumptions); truthful BLE disconnected state. |
|
||||
| **12** | Share Ride — Recipient Selection Dialog | `17a5177fab12466da2dea6639c8fd88f` | Modal 1 (Overlay) | **Tier 2: Proposed Feature** | Scoped modal: Club channel, Rider DM, or native GPX export. |
|
||||
| **13** | Delete Ride — Explicit Scope Selection | `17a5177fab12466da2dea6639c8fd88f` | Modal 2 (Overlay) | **Tier 2: Proposed Feature** | Explicit scope required: "Delete Local Cache Only" vs "Request Cloud & Local Deletion". No preselection. |
|
||||
| **14** | Supporting System States Matrix | `17a5177fab12466da2dea6639c8fd88f` | States Grid (5 Cards) | **Tier 2: System UX** | Comprehensive previews: Loading/Downloading, Empty Filter, Sync Failure, Offline Queued, Location Denied. |
|
||||
|
||||
---
|
||||
|
||||
## Historical & Superseded Iterations
|
||||
|
||||
The following screens in project `5138779337547529796` were intermediate drafts or superseded revisions:
|
||||
- `2cd85dda96ea48ff99a03395c432bfa1`: MotorSignal A/B/C Comparative Exploration (Early 3-way layout benchmark)
|
||||
- `456a0df6c5a2486b8192c77f8f297c1c`: Hybrid Riding Dashboard (Night Mode) — Initial draft
|
||||
- `dcad02763b2b499e845aac50dc5126c2`: Revised Authenticated Group Ride Hub — Initial draft
|
||||
- `a82d7b2aced7407cb43dc0cd2501d690`: Hybrid Riding Dashboard (Day Mode) — Initial draft
|
||||
- `5e0a7784ecec46eaad2f505a43d96576`: Hybrid Riding Dashboard - Night (Default vs Enlarged Text) — Intermediate draft
|
||||
- `f9faeec4eec142349267ebb8d4c9a484`: Hybrid Riding Dashboard - Day (Default vs Enlarged Text) — Intermediate draft
|
||||
- `2f39581f42734138991a2199601038ae`: Revised Group Ride Hub (Default vs Enlarged Text) — Intermediate draft
|
||||
- `aa6aed675a3044e7a3025333cf732c2d`: MotorSignal - Ride History & Replay HUD — Redundant instance of `934e8a33a5fa44009d40c292aacc630e`
|
||||
|
||||
Uploaded Reference Screenshots from physical test runs:
|
||||
- `7166114457503287420`: `Screenshot_20261006-020215.png` (Riding Dashboard night dark state)
|
||||
- `7166114457503285870`: `Screenshot_20261006-020217.png` (Map and live cluster state)
|
||||
- `7166114457503288416`: `Screenshot_20261006-020220.png` (Activity log & telemetry cards)
|
||||
- `7166114457503286424`: `Screenshot_20261006-020224.png` (Group ride listing & cards)
|
||||
- `7166114457503288970`: `Screenshot_20261006-020229.png` (Settings and sensor connection status)
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
{
|
||||
"handoff_package": "MotorSignal Hybrid v1 — Final Design Handoff",
|
||||
"project_id": "5138779337547529796",
|
||||
"project_title": "MotorSignal UI/UX Redesign Exploration",
|
||||
"canonical_tokens": {
|
||||
"night": {
|
||||
"surface_canvas": "#0B1326",
|
||||
"surface_card_primary": "#111C38",
|
||||
"surface_card_secondary": "#17223B",
|
||||
"surface_card_lowest": "#060E20",
|
||||
"surface_border": "#1E293B",
|
||||
"surface_border_subtle": "#24324D",
|
||||
"text_primary": "#DAE2FD",
|
||||
"text_muted": "#869397",
|
||||
"brand_cyan": "#06B6D4",
|
||||
"brand_cyan_bright": "#22D3EE",
|
||||
"brand_cyan_container": "#00424F",
|
||||
"rec_static_green": "#10B981",
|
||||
"rec_static_green_bg": "#064E3B",
|
||||
"caution_amber": "#F59E0B",
|
||||
"danger_red": "#EF4444"
|
||||
},
|
||||
"day": {
|
||||
"surface_canvas": "#F8FAFC",
|
||||
"surface_card_primary": "#FFFFFF",
|
||||
"surface_card_secondary": "#F1F5F9",
|
||||
"surface_card_lowest": "#E2E8F0",
|
||||
"surface_border": "#CBD5E1",
|
||||
"surface_border_subtle": "#E2E8F0",
|
||||
"text_primary": "#0F172A",
|
||||
"text_muted": "#64748B",
|
||||
"brand_cyan": "#0284C7",
|
||||
"brand_cyan_bright": "#0EA5E9",
|
||||
"brand_cyan_container": "#E0F2FE",
|
||||
"rec_static_green": "#10B981",
|
||||
"rec_static_green_bg": "#D1FAE5",
|
||||
"caution_amber": "#D97706",
|
||||
"danger_red": "#DC2626"
|
||||
}
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"index": "01",
|
||||
"name": "Riding Dashboard — Day",
|
||||
"screen_id": "fbbd68740d11447e8035cd0776133053",
|
||||
"composite_frame": "Left Frame (Default Scale)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 1: Verified UI",
|
||||
"reference_files": [
|
||||
"references/screen_day_dashboard.png",
|
||||
"references/screen_day_dashboard.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "01b",
|
||||
"name": "Riding Dashboard — Day (200% Text Scale Companion)",
|
||||
"screen_id": "fbbd68740d11447e8035cd0776133053",
|
||||
"composite_frame": "Right Frame (200% Accessibility Scale)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Accessibility Target (Unverified)",
|
||||
"reference_files": [
|
||||
"references/screen_day_dashboard.png",
|
||||
"references/screen_day_dashboard.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "02",
|
||||
"name": "Riding Dashboard — Night",
|
||||
"screen_id": "8f336a84e92c44d8aea012b2c6703b55",
|
||||
"composite_frame": "Left Frame (Default Scale)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 1: Verified UI",
|
||||
"reference_files": [
|
||||
"references/screen_night_dashboard.png",
|
||||
"references/screen_night_dashboard.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "02b",
|
||||
"name": "Riding Dashboard — Night (200% Text Scale Companion)",
|
||||
"screen_id": "8f336a84e92c44d8aea012b2c6703b55",
|
||||
"composite_frame": "Right Frame (200% Accessibility Scale)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Accessibility Target (Unverified)",
|
||||
"reference_files": [
|
||||
"references/screen_night_dashboard.png",
|
||||
"references/screen_night_dashboard.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "03",
|
||||
"name": "Authenticated Group Ride Hub",
|
||||
"screen_id": "a661c1c9c82843a2bcb6b84d6d7855e1",
|
||||
"composite_frame": "Left Frame (Default Scale)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 1: Verified UI",
|
||||
"reference_files": [
|
||||
"references/screen_group_ride_hub.png",
|
||||
"references/screen_group_ride_hub.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "03b",
|
||||
"name": "Group Ride Hub (200% Text Scale Companion)",
|
||||
"screen_id": "a661c1c9c82843a2bcb6b84d6d7855e1",
|
||||
"composite_frame": "Right Frame (200% Accessibility Scale)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Accessibility Target (Unverified)",
|
||||
"reference_files": [
|
||||
"references/screen_group_ride_hub.png",
|
||||
"references/screen_group_ride_hub.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "04",
|
||||
"name": "Ride History & Archive",
|
||||
"screen_id": "934e8a33a5fa44009d40c292aacc630e",
|
||||
"composite_frame": "Viewport 1 (Left Frame)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 1 (UI) + Tier 2 (Sync UX)",
|
||||
"reference_files": [
|
||||
"references/screen_replay_hud.png",
|
||||
"references/screen_replay_hud.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "05",
|
||||
"name": "Ride Replay HUD",
|
||||
"screen_id": "934e8a33a5fa44009d40c292aacc630e",
|
||||
"composite_frame": "Viewport 2 (Right Frame)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 1 (UI) + Tier 2 (Proposed HUD)",
|
||||
"reference_files": [
|
||||
"references/screen_replay_hud.png",
|
||||
"references/screen_replay_hud.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "06",
|
||||
"name": "Community Feed",
|
||||
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
|
||||
"composite_frame": "Frame 1",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 1 (UI) + Tier 2 (Moderation)",
|
||||
"reference_files": [
|
||||
"references/screen_consolidated_flows.png",
|
||||
"references/screen_consolidated_flows.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "07",
|
||||
"name": "Create Group Ride — Step 1: Details & Staging",
|
||||
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
|
||||
"composite_frame": "Frame 2a",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 2: Proposed Feature",
|
||||
"reference_files": [
|
||||
"references/screen_consolidated_flows.png",
|
||||
"references/screen_consolidated_flows.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "08",
|
||||
"name": "Route Calculation Engine — Loading & Failure",
|
||||
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
|
||||
"composite_frame": "Frame 2b",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 2 / Tier 3 (Engine UX)",
|
||||
"reference_files": [
|
||||
"references/screen_consolidated_flows.png",
|
||||
"references/screen_consolidated_flows.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "09",
|
||||
"name": "Create Group Ride — Step 2: Review & Access",
|
||||
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
|
||||
"composite_frame": "Frame 2c",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 2: Proposed Feature",
|
||||
"reference_files": [
|
||||
"references/screen_consolidated_flows.png",
|
||||
"references/screen_consolidated_flows.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "10",
|
||||
"name": "Ride Created — Join Code & Dynamic QR",
|
||||
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
|
||||
"composite_frame": "Frame 2d",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 2: Proposed Feature",
|
||||
"reference_files": [
|
||||
"references/screen_consolidated_flows.png",
|
||||
"references/screen_consolidated_flows.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "11",
|
||||
"name": "Account, Security & Sensor Connections",
|
||||
"screen_id": "fd0e6370af424ff4becfdaecda7f29de",
|
||||
"composite_frame": "Frame 3",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 1 (UI) + Tier 3 (Backend/Sensors)",
|
||||
"reference_files": [
|
||||
"references/screen_remaining_flows.png",
|
||||
"references/screen_remaining_flows.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "12",
|
||||
"name": "Share Ride — Recipient Selection Dialog",
|
||||
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
|
||||
"composite_frame": "Modal 1 (Overlay)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 2: Proposed Feature",
|
||||
"reference_files": [
|
||||
"references/screen_consolidated_flows.png",
|
||||
"references/screen_consolidated_flows.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "13",
|
||||
"name": "Delete Ride — Explicit Scope Selection",
|
||||
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
|
||||
"composite_frame": "Modal 2 (Overlay)",
|
||||
"viewport": { "width": 390, "height": 844 },
|
||||
"tier": "Tier 2: Proposed Feature",
|
||||
"reference_files": [
|
||||
"references/screen_consolidated_flows.png",
|
||||
"references/screen_consolidated_flows.html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"index": "14",
|
||||
"name": "Supporting System States Matrix",
|
||||
"screen_id": "17a5177fab12466da2dea6639c8fd88f",
|
||||
"composite_frame": "System States Grid (5 Cards)",
|
||||
"viewport": { "width": 1280, "height": 800 },
|
||||
"tier": "Tier 2: System UX",
|
||||
"reference_files": [
|
||||
"references/screen_consolidated_flows.png",
|
||||
"references/screen_consolidated_flows.html"
|
||||
]
|
||||
}
|
||||
],
|
||||
"inconsistencies_resolved": {
|
||||
"replay_telemetry_retrieval_wording": "Loading/status wording must follow actual database/bundle retrieval rather than speculative '100% buffered in-memory' label.",
|
||||
"account_screen_assumptions": "Discarded unsupported 100Hz IMU claims and Pro restrictions on multi-sensor telemetry; conforms to frozen 20Hz pipeline and current free/pro model.",
|
||||
"ride_visibility_and_join_policies": "Preserved all existing membership, host approval, unlisted/public/club-only/invite-only policies.",
|
||||
"night_surface_canvas_reconciliation": "Reconciled to canonical #0B1326.",
|
||||
"accessibility_and_runtime_claims": "Treated as unverified targets until tested on device/emulator."
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
|
@ -1,994 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html class="dark" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>MotorSignal - Remaining Flows & Consolidated Architecture Handoff</title>
|
||||
<!-- Material Symbols Outlined -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<!-- JetBrains Mono & Inter Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"background": "#0b1326",
|
||||
"surface": "#0b1326",
|
||||
"surface-container-lowest": "#060e20",
|
||||
"surface-container-low": "#111c38",
|
||||
"surface-container": "#17223b",
|
||||
"surface-container-high": "#222d4a",
|
||||
"surface-container-highest": "#2d3859",
|
||||
"outline": "#869397",
|
||||
"outline-variant": "#2a3652",
|
||||
"primary": "#4cd7f6",
|
||||
"primary-container": "#06b6d4",
|
||||
"on-primary": "#003640",
|
||||
"on-primary-container": "#002028",
|
||||
"on-surface": "#dae2fd",
|
||||
"on-surface-variant": "#94a3b8",
|
||||
"tertiary": "#4edea3",
|
||||
"warning": "#f59e0b",
|
||||
"error": "#ef4444",
|
||||
"error-container": "#450a0a"
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "-apple-system", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "monospace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
.viewport-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.viewport-scroll::-webkit-scrollbar-track {
|
||||
background: #0b1326;
|
||||
}
|
||||
.viewport-scroll::-webkit-scrollbar-thumb {
|
||||
background: #2a3652;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface antialiased min-h-screen py-10 px-4 sm:px-8 selection:bg-primary-container selection:text-on-primary-container">
|
||||
|
||||
<!-- Document Header & Specification Bar -->
|
||||
<header class="max-w-[1440px] mx-auto mb-10 pb-6 border-b border-outline-variant">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-surface-container-low border border-outline-variant flex items-center justify-center text-primary-container shadow-md">
|
||||
<span class="material-symbols-outlined" style="font-size: 24px;">dataset</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs uppercase tracking-wider font-mono font-bold text-primary px-2 py-0.5 rounded bg-surface-container-high border border-outline-variant">Prototype Spec v2.8</span>
|
||||
<span class="text-xs text-on-surface-variant">MotorSignal UI Engineering Package</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-warning/10 text-warning border border-warning/30 font-medium">Design Mockups — Non-Verified Backend</span>
|
||||
</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight text-white mt-1">Consolidated Mobile Flows & Architecture Handoff</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs font-mono">
|
||||
<span class="px-3 py-1.5 rounded-lg bg-surface-container-low border border-outline-variant text-on-surface">Target Frame: 390 × 844 CSS px</span>
|
||||
<span class="px-3 py-1.5 rounded-lg bg-surface-container-low border border-outline-variant text-tertiary">Touch Target Minimum: ≥48×48dp</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-[1440px] mx-auto space-y-16">
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- SECTION 1: THREE CORE REMAINING SCREEN VIEWPORTS -->
|
||||
<!-- ======================================================== -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-primary-container"></span>
|
||||
Core Remaining Screen Flows (Default Viewports)
|
||||
</h2>
|
||||
<p class="text-sm text-on-surface-variant mt-0.5">High-fidelity 390×844 prototype viewports with persistent 6-destination navigation and explicit sample-data notation.</p>
|
||||
</div>
|
||||
<div class="text-xs font-mono text-on-surface-variant bg-surface-container-low px-3 py-1.5 rounded-lg border border-outline-variant">
|
||||
Frames 1–3 of 3
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 justify-items-center">
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- VIEWPORT 1: COMMUNITY FEED & MODERATION -->
|
||||
<!-- ========================================== -->
|
||||
<div class="flex flex-col items-center w-full max-w-[390px]">
|
||||
<div class="w-full flex items-center justify-between mb-2 px-1 text-xs">
|
||||
<span class="font-bold text-white flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-primary"></span> 1. Community Feed & Moderation
|
||||
</span>
|
||||
<span class="font-mono text-on-surface-variant">Social > Feed</span>
|
||||
</div>
|
||||
|
||||
<!-- Phone Mockup Frame (390 x 844) -->
|
||||
<div class="relative w-[390px] h-[844px] bg-background border-[5px] border-surface-container-highest rounded-[42px] shadow-2xl overflow-hidden flex flex-col justify-between ring-1 ring-outline-variant/50">
|
||||
<!-- iOS Simulated Top Safe Area / Status Bar -->
|
||||
<div class="h-11 w-full bg-background flex items-center justify-between px-6 shrink-0 select-none z-30">
|
||||
<span class="text-xs font-semibold text-white">09:41</span>
|
||||
<div class="w-24 h-4 bg-surface-container-lowest rounded-full flex items-center justify-center">
|
||||
<span class="w-2 h-2 rounded-full bg-surface-container-high mr-2"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-primary-container/40"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">signal_cellular_alt</span>
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">wifi</span>
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">battery_full</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed Top App Bar -->
|
||||
<div class="px-4 pt-2 pb-3 bg-surface-container-low/95 backdrop-blur border-b border-outline-variant shrink-0 z-20">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base font-bold text-white tracking-tight">Social Network</span>
|
||||
<span class="text-[10px] font-mono uppercase px-1.5 py-0.5 rounded bg-surface-container text-on-surface-variant border border-outline-variant">Sample Data</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Feed / Hub Switch (Min 48px target) -->
|
||||
<div class="flex h-11 p-1 bg-surface-container-lowest rounded-xl border border-outline-variant">
|
||||
<button class="px-3.5 rounded-lg bg-surface-container-high text-primary font-bold text-xs flex items-center justify-center">Feed</button>
|
||||
<button class="px-3.5 rounded-lg text-on-surface-variant font-medium text-xs flex items-center justify-center hover:text-white">Hub</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed Filter Tabs (Min 48px height) -->
|
||||
<div class="mt-2.5 flex items-center gap-2 overflow-x-auto pb-0.5 no-scrollbar text-xs">
|
||||
<button class="h-10 px-3.5 rounded-full bg-surface-container-high border border-primary text-primary font-bold flex items-center shrink-0">All Posts</button>
|
||||
<button class="h-10 px-3.5 rounded-full bg-surface-container border border-outline-variant text-on-surface-variant font-medium flex items-center shrink-0 hover:text-white">Clubs</button>
|
||||
<button class="h-10 px-3.5 rounded-full bg-surface-container border border-outline-variant text-on-surface-variant font-medium flex items-center shrink-0 hover:text-white">Nearby</button>
|
||||
<button class="h-10 px-3.5 rounded-full bg-surface-container border border-outline-variant text-on-surface-variant font-medium flex items-center shrink-0 hover:text-white">My Pack</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto viewport-scroll p-4 space-y-4 bg-background">
|
||||
|
||||
<!-- Feed Card 1: Normal Telemetry Session Post -->
|
||||
<article class="p-3.5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-3">
|
||||
<!-- Author Header with Overflow Trigger -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-full bg-primary-container/20 border border-primary/40 flex items-center justify-center text-primary font-bold text-xs">AM</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-bold text-white">@ApexMarcus</span>
|
||||
<span class="text-[10px] font-mono px-1.5 rounded bg-primary-container/20 text-primary border border-primary/30">PRO</span>
|
||||
</div>
|
||||
<span class="text-[11px] text-on-surface-variant">Bay Area Twisty Club • 2h ago</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 48×48px Touch Menu Action -->
|
||||
<button class="w-12 h-12 rounded-xl flex items-center justify-center text-on-surface-variant hover:text-white active:bg-surface-container transition-colors" title="Post Options">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-xs font-bold text-white">Sunday Skyline & Pescadero Carve</h3>
|
||||
<p class="text-[11px] text-on-surface-variant leading-snug">Clean coastal run before fog rolled into Pescadero. 2024 Ducati Streetfighter V4 feeling dialed on new rubber.</p>
|
||||
</div>
|
||||
|
||||
<!-- Compact Telemetry Strip (Sample Data) -->
|
||||
<div class="grid grid-cols-3 gap-1.5 p-2 rounded-xl bg-surface-container-lowest border border-outline-variant/60 font-mono text-center">
|
||||
<div>
|
||||
<span class="text-[9px] text-on-surface-variant uppercase">Distance</span>
|
||||
<p class="text-xs font-bold text-white">68.4 <span class="text-[9px] text-on-surface-variant">MI</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] text-on-surface-variant uppercase">Peak Lean</span>
|
||||
<p class="text-xs font-bold text-primary">51° <span class="text-[9px] text-on-surface-variant">R</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] text-on-surface-variant uppercase">Duration</span>
|
||||
<p class="text-xs font-bold text-white">1h 14m</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Bar (Min 48px hit areas) -->
|
||||
<div class="flex items-center justify-between pt-1 border-t border-outline-variant/40">
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="h-11 px-3 rounded-lg text-on-surface-variant hover:text-white flex items-center gap-1.5 text-xs">
|
||||
<span class="material-symbols-outlined text-primary" style="font-size: 18px;">favorite</span>
|
||||
<span class="font-mono text-[11px]">24</span>
|
||||
</button>
|
||||
<button class="h-11 px-3 rounded-lg text-on-surface-variant hover:text-white flex items-center gap-1.5 text-xs">
|
||||
<span class="material-symbols-outlined" style="font-size: 18px;">chat_bubble</span>
|
||||
<span class="font-mono text-[11px]">7</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="h-11 px-3 rounded-lg bg-surface-container text-primary font-bold text-xs flex items-center gap-1 border border-outline-variant">
|
||||
<span class="material-symbols-outlined" style="font-size: 16px;">visibility</span>
|
||||
Inspect Run
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Feed Card 2: Explicit Moderation & Content Warning State -->
|
||||
<article class="p-3.5 rounded-2xl bg-surface-container-low border border-warning/40 space-y-3 relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-warning"></div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-warning" style="font-size: 18px;">flag</span>
|
||||
<span class="text-xs font-bold text-warning">Flagged by Community Review</span>
|
||||
</div>
|
||||
<!-- Overflow Menu -->
|
||||
<button class="w-12 h-12 rounded-xl flex items-center justify-center text-on-surface-variant hover:text-white" title="Moderation Details">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">info</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-3 rounded-xl bg-surface-container-lowest/80 border border-outline-variant space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="material-symbols-outlined text-error mt-0.5" style="font-size: 18px;">speed</span>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">Aggressive Speed Content Hidden</p>
|
||||
<p class="text-[11px] text-on-surface-variant mt-0.5 leading-snug">Public highway speed exceeded community safety guideline (+40% above zone speed ceiling). GPS telemetry map blurred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Moderation Disclosure Actions (≥48px targets) -->
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<button class="h-11 px-4 rounded-lg bg-surface-container text-white text-xs font-bold border border-outline-variant hover:border-primary flex items-center justify-center">
|
||||
Reveal Post
|
||||
</button>
|
||||
<button class="h-11 px-4 rounded-lg text-error hover:bg-error-container/30 text-xs font-semibold flex items-center gap-1">
|
||||
<span class="material-symbols-outlined" style="font-size: 16px;">report</span>
|
||||
Report Violation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Feed Card 3: Proposed Feature Badge Annotation -->
|
||||
<div class="p-3 rounded-xl bg-surface-container border border-dashed border-outline-variant text-center space-y-1">
|
||||
<span class="text-[10px] font-mono text-tertiary uppercase font-bold tracking-wider">Proposed Flow: Pack Live Beacon</span>
|
||||
<p class="text-xs text-white">Broadcast real-time pack proximity during scheduled weekend group rides.</p>
|
||||
<span class="text-[10px] text-on-surface-variant italic">Requires field connectivity testing before production release.</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Global Bottom Navigation Bar (Persistent 6 Destinations, min 48px hit areas) -->
|
||||
<nav class="h-[68px] bg-surface-container-lowest border-t border-outline-variant px-2 flex items-center justify-around shrink-0 select-none z-30">
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">speed</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Dash</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">explore</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Map</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">analytics</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Activity</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">two_wheeler</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Garage</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-primary">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1; font-size: 20px;">groups</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Social</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">settings</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Safe Area Platform Bottom Indicator -->
|
||||
<div class="h-3 w-full bg-surface-container-lowest flex justify-center pb-1">
|
||||
<div class="w-32 h-1 bg-outline-variant rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- VIEWPORT 2: TWO-STEP CREATE GROUP RIDE -->
|
||||
<!-- ========================================== -->
|
||||
<div class="flex flex-col items-center w-full max-w-[390px]">
|
||||
<div class="w-full flex items-center justify-between mb-2 px-1 text-xs">
|
||||
<span class="font-bold text-white flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-primary-container"></span> 2. Two-Step Create Group Ride
|
||||
</span>
|
||||
<span class="font-mono text-on-surface-variant">Step 1 of 2</span>
|
||||
</div>
|
||||
|
||||
<!-- Phone Mockup Frame (390 x 844) -->
|
||||
<div class="relative w-[390px] h-[844px] bg-background border-[5px] border-surface-container-highest rounded-[42px] shadow-2xl overflow-hidden flex flex-col justify-between ring-1 ring-outline-variant/50">
|
||||
<!-- Top Safe Area Bar -->
|
||||
<div class="h-11 w-full bg-background flex items-center justify-between px-6 shrink-0 select-none z-30">
|
||||
<span class="text-xs font-semibold text-white">09:42</span>
|
||||
<div class="w-24 h-4 bg-surface-container-lowest rounded-full flex items-center justify-center">
|
||||
<span class="w-2 h-2 rounded-full bg-surface-container-high mr-2"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-primary-container/40"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">signal_cellular_alt</span>
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">wifi</span>
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">battery_full</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header / Step Indicator App Bar -->
|
||||
<div class="px-4 py-3 bg-surface-container-low/95 backdrop-blur border-b border-outline-variant shrink-0 z-20">
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="w-12 h-12 -ml-2 rounded-xl flex items-center justify-center text-on-surface-variant hover:text-white" title="Cancel Creation">
|
||||
<span class="material-symbols-outlined" style="font-size: 22px;">close</span>
|
||||
</button>
|
||||
<div class="text-center">
|
||||
<h2 class="text-sm font-bold text-white">Create Group Ride</h2>
|
||||
<span class="text-[10px] font-mono text-primary">STEP 1: LOGISTICS & ROUTE</span>
|
||||
</div>
|
||||
<!-- Step Counter Badge -->
|
||||
<span class="text-xs font-mono font-bold px-2 py-1 rounded bg-surface-container-high border border-outline-variant text-on-surface">1/2</span>
|
||||
</div>
|
||||
|
||||
<!-- Visual Stepper Progress Bar -->
|
||||
<div class="mt-2.5 flex items-center gap-1.5">
|
||||
<div class="h-1.5 flex-1 bg-primary rounded-full"></div>
|
||||
<div class="h-1.5 flex-1 bg-surface-container-highest rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 Form Content (Scrollable) -->
|
||||
<div class="flex-1 overflow-y-auto viewport-scroll p-4 space-y-4 bg-background">
|
||||
|
||||
<!-- Ride Title Input -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-xs font-bold text-white">Ride Title <span class="text-primary">*</span></label>
|
||||
<input type="text" value="Sunday Skyline & Pescadero Carve" class="w-full h-12 px-3.5 rounded-xl bg-surface-container-low border border-outline-variant text-xs text-white focus:border-primary focus:ring-0 font-medium"/>
|
||||
</div>
|
||||
|
||||
<!-- Route Selection Card with Static Vector Map Preview -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<label class="font-bold text-white">Select Route & Distance</label>
|
||||
<button class="text-[11px] font-bold text-primary hover:underline">Choose from GPX</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-outline-variant bg-surface-container-low overflow-hidden">
|
||||
<!-- Map Simulation Canvas -->
|
||||
<div class="h-28 bg-[#091426] relative flex items-center justify-center">
|
||||
<svg class="w-full h-full opacity-60" viewBox="0 0 320 112" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 90 Q 60 40, 130 65 T 250 20 T 310 50" stroke="#06b6d4" stroke-width="4" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="10" cy="90" r="5" fill="#4edea3"/>
|
||||
<circle cx="310" cy="50" r="5" fill="#ef4444"/>
|
||||
</svg>
|
||||
<div class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-surface-container-lowest/90 border border-outline-variant text-[10px] font-mono text-white">
|
||||
Skyline Blvd → Hwy 84
|
||||
</div>
|
||||
<div class="absolute top-2 right-2 px-2 py-0.5 rounded bg-surface-container-lowest/90 border border-outline-variant text-[10px] font-mono text-primary font-bold">
|
||||
68.4 MI Calculated
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<p class="font-bold text-white">Pescadero Coastal Loop</p>
|
||||
<p class="text-[11px] text-on-surface-variant font-mono">Elevation: +2,140 ft • Est. 1h 45m</p>
|
||||
</div>
|
||||
<button class="h-10 px-3 rounded-lg bg-surface-container border border-outline-variant text-xs text-white font-medium hover:border-primary">Change</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date & Staging Location (Decoupled, No Collisions) -->
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-xs font-bold text-white">Date & Kickstands Up (KSU)</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input type="text" value="Sun, Oct 12" class="h-12 px-3 rounded-xl bg-surface-container-low border border-outline-variant text-xs text-white font-mono"/>
|
||||
<input type="text" value="09:00 AM KSU" class="h-12 px-3 rounded-xl bg-surface-container-low border border-outline-variant text-xs text-white font-mono"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-xs font-bold text-white">Staging Location</label>
|
||||
<div class="relative">
|
||||
<input type="text" value="Alice's Restaurant, Woodside CA" class="w-full h-12 pl-10 pr-3 rounded-xl bg-surface-container-low border border-outline-variant text-xs text-white font-medium"/>
|
||||
<span class="material-symbols-outlined absolute left-3 top-3 text-on-surface-variant" style="font-size: 18px;">pin_drop</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pace & Safety Guidelines Segmented Selector -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-xs font-bold text-white">Target Pace Profile</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button class="h-12 rounded-xl bg-surface-container border border-outline-variant text-xs text-on-surface-variant font-medium hover:text-white">Casual</button>
|
||||
<button class="h-12 rounded-xl bg-surface-container-high border-2 border-primary text-primary text-xs font-bold">Spirited</button>
|
||||
<button class="h-12 rounded-xl bg-surface-container border border-outline-variant text-xs text-on-surface-variant font-medium hover:text-white">Track Pace</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 Preview Teaser: Join Code & Passkey Notice -->
|
||||
<div class="p-3 rounded-xl bg-surface-container-lowest border border-outline-variant flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-surface-container-high flex items-center justify-center text-primary shrink-0">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">qr_code_2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">Next Step: Join Code & Invites</p>
|
||||
<p class="text-[11px] text-on-surface-variant">Step 2 generates your pack join code (#SKY92) and shareable QR code.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Bottom Action Suite (Pinned, Min 56px height) -->
|
||||
<div class="p-4 bg-surface-container-low border-t border-outline-variant shrink-0 z-30">
|
||||
<button class="w-full h-14 rounded-2xl bg-primary-container text-on-primary-container font-bold text-sm flex items-center justify-center gap-2 shadow-lg active:scale-[0.99] transition-transform">
|
||||
Continue to Step 2: Pack Access
|
||||
<span class="material-symbols-outlined" style="font-size: 18px;">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- iOS Safe Area Indicator -->
|
||||
<div class="h-3 w-full bg-surface-container-low flex justify-center pb-1">
|
||||
<div class="w-32 h-1 bg-outline-variant rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- VIEWPORT 3: ACCOUNT, SECURITY & SENSORS -->
|
||||
<!-- ========================================== -->
|
||||
<div class="flex flex-col items-center w-full max-w-[390px]">
|
||||
<div class="w-full flex items-center justify-between mb-2 px-1 text-xs">
|
||||
<span class="font-bold text-white flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-tertiary"></span> 3. Account, Security & Sensors
|
||||
</span>
|
||||
<span class="font-mono text-on-surface-variant">Truthful States</span>
|
||||
</div>
|
||||
|
||||
<!-- Phone Mockup Frame (390 x 844) -->
|
||||
<div class="relative w-[390px] h-[844px] bg-background border-[5px] border-surface-container-highest rounded-[42px] shadow-2xl overflow-hidden flex flex-col justify-between ring-1 ring-outline-variant/50">
|
||||
<!-- Top Safe Area Bar -->
|
||||
<div class="h-11 w-full bg-background flex items-center justify-between px-6 shrink-0 select-none z-30">
|
||||
<span class="text-xs font-semibold text-white">09:43</span>
|
||||
<div class="w-24 h-4 bg-surface-container-lowest rounded-full flex items-center justify-center">
|
||||
<span class="w-2 h-2 rounded-full bg-surface-container-high mr-2"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-primary-container/40"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">signal_cellular_alt</span>
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">wifi</span>
|
||||
<span class="material-symbols-outlined" style="font-size: 14px;">battery_full</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Top App Bar -->
|
||||
<div class="px-4 py-3 bg-surface-container-low/95 backdrop-blur border-b border-outline-variant shrink-0 z-20">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base font-bold text-white tracking-tight">Account & System</span>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-on-surface-variant">Build 2.8.4</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Scrollable Content Area -->
|
||||
<div class="flex-1 overflow-y-auto viewport-scroll p-4 space-y-4 bg-background">
|
||||
|
||||
<!-- User Identity & Entitlement Card -->
|
||||
<div class="p-3.5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center text-primary font-bold text-base">B</div>
|
||||
<div>
|
||||
<h3 class="text-xs font-bold text-white">Bill Ponders</h3>
|
||||
<p class="text-[11px] text-on-surface-variant font-mono">bponders12@gmail.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="h-11 px-3.5 rounded-xl bg-surface-container border border-outline-variant text-xs text-white font-medium hover:border-primary">
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Explicit Free Tier Breakdown (No unverified claims) -->
|
||||
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant/60 space-y-1">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-bold text-white">Tier: MotorSignal Free</span>
|
||||
<span class="text-[10px] font-mono text-on-surface-variant">Active</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-on-surface-variant leading-snug">Standard GPS logging, local ride archives, and public feed access. Multi-sensor telemetry sync requires Pro subscription.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware & Sensors (Truthful States, Internal vs External) -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs px-1">
|
||||
<span class="font-bold text-white uppercase tracking-wider text-[11px]">Sensor Connections</span>
|
||||
<span class="text-[10px] font-mono text-tertiary">1 Active • 1 Disconnected</span>
|
||||
</div>
|
||||
|
||||
<!-- Internal Sensor Card: Active -->
|
||||
<div class="p-3 rounded-xl bg-surface-container-low border border-outline-variant flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-lg bg-surface-container-high flex items-center justify-center text-tertiary">
|
||||
<span class="material-symbols-outlined" style="font-size: 18px;">smartphone</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">Internal Phone IMU</p>
|
||||
<p class="text-[11px] text-tertiary font-mono">Active (100Hz Gyro / Accel)</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-tertiary font-bold">Online</span>
|
||||
</div>
|
||||
|
||||
<!-- External BLE Sensor: Truthful Disconnected State -->
|
||||
<div class="p-3 rounded-xl bg-surface-container-low border border-outline-variant flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-lg bg-surface-container-high flex items-center justify-center text-on-surface-variant">
|
||||
<span class="material-symbols-outlined" style="font-size: 18px;">bluetooth_searching</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">External Bike IMU Sensor</p>
|
||||
<p class="text-[11px] text-on-surface-variant font-mono">Disconnected • No Paired BLE</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="h-11 px-3.5 rounded-lg bg-surface-container border border-outline-variant text-primary text-xs font-bold hover:border-primary">
|
||||
Pair Sensor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security & Two-Factor Authentication (Truthfully Unconfigured) -->
|
||||
<div class="space-y-2">
|
||||
<span class="font-bold text-white uppercase tracking-wider text-[11px] px-1">Account Security</span>
|
||||
|
||||
<div class="p-3.5 rounded-xl bg-surface-container-low border border-outline-variant space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-lg bg-surface-container-high flex items-center justify-center text-warning">
|
||||
<span class="material-symbols-outlined" style="font-size: 18px;">security</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">Two-Factor Authentication (2FA)</p>
|
||||
<p class="text-[11px] text-on-surface-variant font-mono">Status: Disabled (Not Configured)</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="h-11 px-3.5 rounded-lg bg-surface-container-high text-primary border border-outline-variant text-xs font-bold hover:border-primary">
|
||||
Set Up
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-on-surface-variant leading-snug">Protect cloud session telemetry and garage specs with TOTP authenticator or hardware passkeys.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Sync & Data Management (Scope Notice) -->
|
||||
<div class="p-3 rounded-xl bg-surface-container-low border border-outline-variant space-y-2">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-bold text-white">Telemetry Sync Scope</span>
|
||||
<span class="text-[10px] font-mono text-warning">Proposed Protocol</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-on-surface-variant leading-snug">
|
||||
Automatic synchronization is a proposed background service pending device battery and bandwidth testing. Deletions distinguish between local cache vs. cloud archive purge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation Bar (Persistent 6 Destinations) -->
|
||||
<nav class="h-[68px] bg-surface-container-lowest border-t border-outline-variant px-2 flex items-center justify-around shrink-0 select-none z-30">
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">speed</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Dash</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">explore</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Map</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">analytics</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Activity</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">two_wheeler</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Garage</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-on-surface-variant hover:text-white">
|
||||
<span class="material-symbols-outlined" style="font-size: 20px;">groups</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Social</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-primary">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1; font-size: 20px;">settings</span>
|
||||
<span class="text-[10px] font-bold mt-0.5">Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Platform Inset Pill -->
|
||||
<div class="h-3 w-full bg-surface-container-lowest flex justify-center pb-1">
|
||||
<div class="w-32 h-1 bg-outline-variant rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- SECTION 2: SYSTEM DIALOGS, SHARING & MODAL FLOWS -->
|
||||
<!-- ======================================================== -->
|
||||
<div class="pt-8 border-t border-outline-variant">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-warning"></span>
|
||||
Interactive Modal Architecture & System Dialogs
|
||||
</h2>
|
||||
<p class="text-sm text-on-surface-variant mt-0.5">Specifications for Share Ride recipient selection, scoped deletion confirmation, and network fallback states.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Dialog 1: Share Ride Recipient Selection Modal -->
|
||||
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
|
||||
<div class="flex items-center justify-between border-b border-outline-variant pb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary" style="font-size: 20px;">share</span>
|
||||
<h3 class="text-sm font-bold text-white">Share Telemetry Replay</h3>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono px-2 py-0.5 rounded bg-surface-container text-primary">Recipient Selection</span>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-on-surface-variant leading-snug">Select specific riders, pack channels, or external platforms to dispatch session telemetry.</p>
|
||||
|
||||
<!-- Recipient Radio / Checkbox Options -->
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center justify-between p-2.5 rounded-xl bg-surface-container border border-primary/50 cursor-pointer">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-primary-container/20 flex items-center justify-center text-primary font-bold text-xs">BT</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">Bay Area Twisty Club</p>
|
||||
<p class="text-[10px] text-on-surface-variant font-mono">14 Active Pack Members</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="radio" checked name="share_dest" class="text-primary focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between p-2.5 rounded-xl bg-surface-container border border-outline-variant cursor-pointer hover:border-outline">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-xs">@M</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">@Marcus (Direct DM)</p>
|
||||
<p class="text-[10px] text-on-surface-variant font-mono">Rode sweep on Skyline run</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="radio" name="share_dest" class="text-primary focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between p-2.5 rounded-xl bg-surface-container border border-outline-variant cursor-pointer hover:border-outline">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-surface-container-high flex items-center justify-center text-on-surface">
|
||||
<span class="material-symbols-outlined" style="font-size: 16px;">ios_share</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">System Sheet / GPX Export</p>
|
||||
<p class="text-[10px] text-on-surface-variant font-mono">Raw .GPX file & replay web link</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="radio" name="share_dest" class="text-primary focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<button class="h-11 flex-1 rounded-xl bg-primary-container text-on-primary-container text-xs font-bold">Confirm Share</button>
|
||||
<button class="h-11 px-4 rounded-xl bg-surface-container text-white text-xs font-semibold border border-outline-variant">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog 2: Scoped Deletion Confirmation Modal -->
|
||||
<div class="p-5 rounded-2xl bg-surface-container-low border border-error/50 space-y-4">
|
||||
<div class="flex items-center justify-between border-b border-outline-variant pb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-error" style="font-size: 20px;">delete_forever</span>
|
||||
<h3 class="text-sm font-bold text-white">Delete Ride Session</h3>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono px-2 py-0.5 rounded bg-error-container text-error">Scoped Purge</span>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-on-surface-variant leading-snug">
|
||||
Specify deletion scope. Do not assume cloud records are automatically expunged when clearing phone storage.
|
||||
</p>
|
||||
|
||||
<!-- Scope Option Selector -->
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-start gap-2.5 p-2.5 rounded-xl bg-surface-container border border-outline-variant cursor-pointer">
|
||||
<input type="radio" name="del_scope" class="mt-0.5 text-error focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-white">Delete Local Device Cache Only</p>
|
||||
<p class="text-[10px] text-on-surface-variant">Frees up 14.2 MB on phone. Ride remains accessible in your cloud archive.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-2.5 p-2.5 rounded-xl bg-error-container/30 border border-error/40 cursor-pointer">
|
||||
<input type="radio" checked name="del_scope" class="mt-0.5 text-error focus:ring-0 w-4 h-4 bg-surface-container-highest"/>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-error">Delete Both Local & Cloud Records</p>
|
||||
<p class="text-[10px] text-on-surface-variant">Permanent purge. GPS track, lean dynamics, and community stats will be unrecoverable.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<button class="h-11 flex-1 rounded-xl bg-error text-white text-xs font-bold">Execute Deletion</button>
|
||||
<button class="h-11 px-4 rounded-xl bg-surface-container text-white text-xs font-semibold border border-outline-variant">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog 3: Telemetry Buffering vs. Downloading Specification -->
|
||||
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
|
||||
<div class="flex items-center justify-between border-b border-outline-variant pb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-tertiary" style="font-size: 20px;">cloud_download</span>
|
||||
<h3 class="text-sm font-bold text-white">Data Pipeline Protocol</h3>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono px-2 py-0.5 rounded bg-surface-container text-tertiary">Architecture Note</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-xs text-on-surface-variant leading-relaxed">
|
||||
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
|
||||
<span class="font-bold text-white flex items-center gap-1.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> 1. File Download Stage
|
||||
</span>
|
||||
<p class="text-[11px]">When replaying a cloud ride, client fetches the complete binary `.pb` or SQLite session record before parsing. Not live video-style streaming.</p>
|
||||
</div>
|
||||
|
||||
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
|
||||
<span class="font-bold text-white flex items-center gap-1.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-tertiary"></span> 2. Local Memory Buffering
|
||||
</span>
|
||||
<p class="text-[11px]">"100% Buffered" indicator denotes in-memory decompression of 100Hz IMU vectors to prevent scrubbing stutter.</p>
|
||||
</div>
|
||||
|
||||
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
|
||||
<span class="font-bold text-white flex items-center gap-1.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-warning"></span> 3. Proposed Auto-Sync Rule
|
||||
</span>
|
||||
<p class="text-[11px]">Automatic background sync is documented as proposed UX pending validation of operating system background execution limits.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- SECTION 3: SYSTEM STATES MATRIX -->
|
||||
<!-- ======================================================== -->
|
||||
<div class="pt-8 border-t border-outline-variant">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-primary"></span>
|
||||
Edge & Exception States Matrix
|
||||
</h2>
|
||||
<p class="text-sm text-on-surface-variant mt-0.5">Comprehensive fallback patterns for loading, empty filters, network error, offline queueing, and permission denial.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<!-- State 1: Loading / Downloading -->
|
||||
<div class="p-4 rounded-2xl bg-surface-container-low border border-outline-variant space-y-2.5 text-center flex flex-col items-center justify-between">
|
||||
<div class="w-10 h-10 rounded-full bg-primary-container/20 flex items-center justify-center text-primary mt-2">
|
||||
<span class="material-symbols-outlined animate-spin" style="font-size: 22px;">progress_activity</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-white">Loading Telemetry</h4>
|
||||
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">Downloading session binary (14.2 MB) from encrypted cloud store...</p>
|
||||
</div>
|
||||
<div class="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
|
||||
<div class="bg-primary h-full w-[65%]"></div>
|
||||
</div>
|
||||
<span class="text-[9px] font-mono text-primary">State: Downloading</span>
|
||||
</div>
|
||||
|
||||
<!-- State 2: Empty Filter -->
|
||||
<div class="p-4 rounded-2xl bg-surface-container-low border border-outline-variant space-y-2.5 text-center flex flex-col items-center justify-between">
|
||||
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center text-on-surface-variant mt-2">
|
||||
<span class="material-symbols-outlined" style="font-size: 22px;">filter_alt_off</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-white">No Rides Found</h4>
|
||||
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">No telemetry recorded for "Track Mode" under selected date filter.</p>
|
||||
</div>
|
||||
<button class="h-9 px-3 rounded-lg bg-surface-container border border-outline-variant text-[11px] text-primary font-bold">
|
||||
Reset Filter
|
||||
</button>
|
||||
<span class="text-[9px] font-mono text-on-surface-variant">State: Empty</span>
|
||||
</div>
|
||||
|
||||
<!-- State 3: Network Error -->
|
||||
<div class="p-4 rounded-2xl bg-surface-container-low border border-error/40 space-y-2.5 text-center flex flex-col items-center justify-between">
|
||||
<div class="w-10 h-10 rounded-full bg-error-container flex items-center justify-center text-error mt-2">
|
||||
<span class="material-symbols-outlined" style="font-size: 22px;">cloud_off</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-white">Sync Failure</h4>
|
||||
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">Cloud handshake timed out. Remote server unreachable.</p>
|
||||
</div>
|
||||
<button class="h-9 px-3 rounded-lg bg-surface-container border border-outline-variant text-[11px] text-white font-bold">
|
||||
Retry Sync
|
||||
</button>
|
||||
<span class="text-[9px] font-mono text-error">State: Error</span>
|
||||
</div>
|
||||
|
||||
<!-- State 4: Offline Queue -->
|
||||
<div class="p-4 rounded-2xl bg-surface-container-low border border-warning/40 space-y-2.5 text-center flex flex-col items-center justify-between">
|
||||
<div class="w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center text-warning mt-2">
|
||||
<span class="material-symbols-outlined" style="font-size: 22px;">save</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-white">Offline Queued</h4>
|
||||
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">2 rides stored locally in SQLite database cache pending uplink.</p>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-warning font-bold">Auto-sync Proposed</span>
|
||||
<span class="text-[9px] font-mono text-warning">State: Queued</span>
|
||||
</div>
|
||||
|
||||
<!-- State 5: Permission Denied -->
|
||||
<div class="p-4 rounded-2xl bg-surface-container-low border border-outline-variant space-y-2.5 text-center flex flex-col items-center justify-between">
|
||||
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center text-error mt-2">
|
||||
<span class="material-symbols-outlined" style="font-size: 22px;">location_disabled</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-white">Location Denied</h4>
|
||||
<p class="text-[10px] text-on-surface-variant mt-1 leading-snug">Precise background GPS permission required for route telemetry.</p>
|
||||
</div>
|
||||
<button class="h-9 px-3 rounded-lg bg-primary-container text-on-primary-container text-[11px] font-bold">
|
||||
Open Settings
|
||||
</button>
|
||||
<span class="text-[9px] font-mono text-error">State: Denied</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- SECTION 4: CONSOLIDATED DESIGN TOKENS & HANDOFF MATRIX -->
|
||||
<!-- ======================================================== -->
|
||||
<div class="pt-8 border-t border-outline-variant">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-tertiary"></span>
|
||||
Consolidated Design Tokens & Multi-Platform Handoff
|
||||
</h2>
|
||||
<p class="text-sm text-on-surface-variant mt-0.5">Authoritative token values, component patterns, and platform safe-area implementation contracts.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Column 1: Semantic Color Tokens -->
|
||||
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
|
||||
<h3 class="text-sm font-bold text-white flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary" style="font-size: 18px;">palette</span>
|
||||
Semantic Color Architecture
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2 text-xs font-mono">
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
|
||||
<span class="text-white">Canvas Surface</span>
|
||||
<span class="text-primary font-bold">#0B1326</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
|
||||
<span class="text-white">Card Primary</span>
|
||||
<span class="text-primary font-bold">#111C38</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
|
||||
<span class="text-white">Card Secondary</span>
|
||||
<span class="text-primary font-bold">#17223B</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
|
||||
<span class="text-white">Border / Stroke</span>
|
||||
<span class="text-primary font-bold">#2A3652</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
|
||||
<span class="text-white">Brand Cyan Accent</span>
|
||||
<span class="text-primary-container font-bold">#06B6D4</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
|
||||
<span class="text-white">Active Recording (REC)</span>
|
||||
<span class="text-tertiary font-bold">#10B981</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
|
||||
<span class="text-white">Caution / Pause Ride</span>
|
||||
<span class="text-warning font-bold">#F59E0B</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-surface-container">
|
||||
<span class="text-white">Danger / Scoped Delete</span>
|
||||
<span class="text-error font-bold">#EF4444</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Typography & Touch Boundaries -->
|
||||
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
|
||||
<h3 class="text-sm font-bold text-white flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary" style="font-size: 18px;">touch_app</span>
|
||||
Touch Targets & Type Hierarchy
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3 text-xs leading-relaxed text-on-surface-variant">
|
||||
<div>
|
||||
<span class="font-bold text-white block">Standard Interactive Minimum:</span>
|
||||
<p class="font-mono text-primary text-[11px]">48px × 48px CSS / Flutter Logical Pixels</p>
|
||||
<p class="text-[11px] mt-0.5">Enforced on all list items, segmented tabs, menu dots, and header navigation buttons.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-bold text-white block">Primary Riding Controls:</span>
|
||||
<p class="font-mono text-primary text-[11px]">56px–64px Target Height</p>
|
||||
<p class="text-[11px] mt-0.5">Pinned Pause and Finish Ride actions maximize hit-area under motorcycle vibration.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-bold text-white block">Typography Stack:</span>
|
||||
<ul class="text-[11px] space-y-1 list-disc pl-4 mt-1 font-mono">
|
||||
<li>Speed Hero: 72px JetBrains Mono bold</li>
|
||||
<li>Metrics: 16–24px JetBrains Mono bold</li>
|
||||
<li>Headings: 14–18px Inter bold</li>
|
||||
<li>Labels/Captions: 10–12px Inter medium</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 3: Platform Safe Area Implementation Contract -->
|
||||
<div class="p-5 rounded-2xl bg-surface-container-low border border-outline-variant space-y-4">
|
||||
<h3 class="text-sm font-bold text-white flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary" style="font-size: 18px;">devices</span>
|
||||
Platform Safe Areas & Implementation
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2.5 text-xs text-on-surface-variant leading-relaxed">
|
||||
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
|
||||
<span class="font-bold text-white font-mono text-[11px]">Flutter Implementation:</span>
|
||||
<p class="text-[11px]">
|
||||
Use <code class="text-primary font-mono">SafeArea(top: true, bottom: true)</code> or query <code class="text-primary font-mono">MediaQuery.of(context).padding</code> for Notch, Dynamic Island, and gesture bar clearances.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
|
||||
<span class="font-bold text-white font-mono text-[11px]">Web / PWA Implementation:</span>
|
||||
<p class="text-[11px]">
|
||||
Apply <code class="text-primary font-mono">env(safe-area-inset-top)</code> to sticky app bars and <code class="text-primary font-mono">env(safe-area-inset-bottom)</code> to fixed navigation sheets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant space-y-1">
|
||||
<span class="font-bold text-white font-mono text-[11px]">Map Overlay Separation Goal:</span>
|
||||
<p class="text-[11px]">
|
||||
Top-right map controls vs. bottom telemetry is designated as a <strong>layout goal pending physical multi-device testing</strong> across varied screen aspect ratios.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 94 KiB |
|
|
@ -1,583 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>MotorSignal Telemetry & Community - Ride History & Replay HUD</title>
|
||||
<!-- Tailwind CSS CDN with Forms & Container Plugins -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#0B1326',
|
||||
card: '#111C38',
|
||||
cardSubtle: '#171F33',
|
||||
border: '#1E293B',
|
||||
borderLight: '#334155',
|
||||
cyan: '#06B6D4',
|
||||
cyanBright: '#22D3EE',
|
||||
green: '#10B981',
|
||||
amber: '#F59E0B',
|
||||
danger: '#EF4444'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- BEGIN: Custom Device Frame & Decorative Styles -->
|
||||
<style data-purpose="device-mockup-frame">
|
||||
.mobile-screen-container {
|
||||
width: 390px;
|
||||
height: 844px;
|
||||
max-height: 844px;
|
||||
border-radius: 46px;
|
||||
box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.8), 0 0 0 10px #1E293B, 0 0 0 12px #334155;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background-color: #0B1326;
|
||||
}
|
||||
/* Dynamic Notch / Dynamic Island */
|
||||
.mobile-island {
|
||||
width: 122px;
|
||||
height: 32px;
|
||||
background-color: #000000;
|
||||
border-radius: 20px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
}
|
||||
/* Safe Area Callout Overlays */
|
||||
.safe-area-top-guide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 47px;
|
||||
pointer-events: none;
|
||||
border-bottom: 1px dashed rgba(6, 182, 212, 0.4);
|
||||
z-index: 60;
|
||||
}
|
||||
.safe-area-bottom-guide {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 34px;
|
||||
pointer-events: none;
|
||||
border-top: 1px dashed rgba(6, 182, 212, 0.4);
|
||||
z-index: 60;
|
||||
}
|
||||
/* Custom scrollbar hiding */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<style data-purpose="hud-telemetry-decorations">
|
||||
/* Subtle grid line pattern for maps */
|
||||
.topo-grid-pattern {
|
||||
background-size: 24px 24px;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
}
|
||||
.lean-gauge-arc {
|
||||
transform-origin: center;
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
<!-- END: Custom Device Frame & Decorative Styles -->
|
||||
</head>
|
||||
<body class="bg-[#070C1A] text-slate-100 min-h-screen font-sans antialiased py-10 px-4">
|
||||
<!-- BEGIN: MasterSpecificationContainer -->
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Header Documentation Banner -->
|
||||
<header class="mb-10 text-center md:text-left flex flex-col md:flex-row md:items-end md:justify-between border-b border-brand-border pb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 justify-center md:justify-start">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-brand-cyan animate-pulse"></span>
|
||||
<span class="text-xs uppercase tracking-widest text-brand-cyan font-semibold">MotorSignal Telemetry OS</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold text-white mt-1">Ride History & Replay HUD Specification</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">High-Fidelity Dual-Viewport Mobile Prototype (iOS 390x844 Frame Specification)</p>
|
||||
</div>
|
||||
<div class="mt-4 md:mt-0 flex gap-3 text-xs font-mono text-slate-300 justify-center">
|
||||
<span class="bg-brand-card border border-brand-border px-3 py-1.5 rounded-lg flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-brand-green"></span> Spec v2.6.4 Live
|
||||
</span>
|
||||
<span class="bg-brand-card border border-brand-border px-3 py-1.5 rounded-lg text-slate-400">
|
||||
Target: 390x844 @3x
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- BEGIN: DualViewportPresentationGrid -->
|
||||
<main class="flex flex-col lg:flex-row items-center justify-center gap-12 mb-16">
|
||||
<!-- ========================================== -->
|
||||
<!-- VIEWPORT 1: RIDE HISTORY ARCHIVE -->
|
||||
<!-- ========================================== -->
|
||||
<section class="flex flex-col items-center" data-purpose="viewport-ride-history">
|
||||
<!-- Viewport Label -->
|
||||
<div class="mb-3 flex items-center justify-between w-[390px] px-2 text-xs font-medium text-slate-400">
|
||||
<span class="text-brand-cyan font-semibold flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
VIEWPORT 1: Session Archive
|
||||
</span>
|
||||
<span class="text-slate-500 font-mono">Screen: Activity.History</span>
|
||||
</div>
|
||||
<div class="mobile-screen-container relative">
|
||||
<!-- Dynamic Island Notch -->
|
||||
<div class="mobile-island"></div>
|
||||
<!-- Safe Area Top Guide Callout (Visual Tag) -->
|
||||
<div class="safe-area-top-guide flex items-end justify-end pr-3 pb-1">
|
||||
<span class="text-[9px] font-mono text-brand-cyan/60 uppercase">env(safe-area-inset-top)</span>
|
||||
</div>
|
||||
<!-- Phone Status Bar -->
|
||||
<div class="h-12 pt-3 px-7 flex justify-between items-center text-xs font-semibold text-white z-40">
|
||||
<span>2:02</span>
|
||||
<div class="flex items-center gap-1.5 text-slate-200">
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewbox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9 0 2.12.74 4.07 1.97 5.61L4.35 18.25l1.41 1.41.64-.64C7.93 20.26 9.88 21 12 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7z"></path></svg>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewbox="0 0 24 24"><path d="M12 4a8 8 0 00-8 8c0 2.21.89 4.21 2.34 5.66l1.41-1.41C6.6 15.1 6 13.62 6 12a6 6 0 1112 0c0 1.62-.6 3.1-1.76 4.24l1.41 1.41A8.002 8.002 0 0012 4z"></path></svg>
|
||||
<div class="w-5 h-2.5 border border-white/80 rounded-sm p-0.5 flex items-center">
|
||||
<div class="h-full bg-white w-4/5 rounded-2xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BEGIN: Sticky Screen Header -->
|
||||
<header class="px-5 pt-1 pb-3 bg-brand-dark/95 backdrop-blur border-b border-brand-border/60 z-30">
|
||||
<h2 class="text-xl font-bold tracking-tight text-center text-white">Ride History</h2>
|
||||
<!-- Segmented Switch Rides vs Records -->
|
||||
<div class="mt-3 grid grid-cols-2 p-1 bg-brand-cardSubtle rounded-xl border border-brand-border text-xs font-semibold">
|
||||
<button class="py-2 rounded-lg bg-cyan-950/80 text-brand-cyan border border-brand-cyan/40 shadow flex items-center justify-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"></path></svg>
|
||||
Rides (13)
|
||||
</button>
|
||||
<button class="py-2 rounded-lg text-slate-400 hover:text-slate-200 flex items-center justify-center gap-1.5 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
Records (4)
|
||||
</button>
|
||||
</div>
|
||||
<!-- Motorcycle Dropdown Filter Button (Minimum 48px Target) -->
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button aria-label="Filter by bike" class="w-12 h-12 flex items-center justify-center rounded-xl bg-brand-card border border-brand-border text-brand-cyan hover:bg-slate-800 transition">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewbox="0 0 24 24"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"></path></svg>
|
||||
</button>
|
||||
<button aria-label="Select motorcycle fleet" class="flex-1 h-12 px-4 rounded-xl bg-brand-card border border-brand-border flex items-center justify-between text-xs font-semibold text-slate-200 hover:border-slate-600 transition">
|
||||
<span class="flex items-center gap-2 truncate">
|
||||
<span class="w-2 h-2 rounded-full bg-brand-cyan"></span>
|
||||
All Motorcycles
|
||||
</span>
|
||||
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M19 9l-7 7-7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- END: Sticky Screen Header -->
|
||||
<!-- Scrollable Ride Content Feed -->
|
||||
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-3 no-scrollbar pb-24">
|
||||
<!-- Offline Sync Status Banner -->
|
||||
<div class="bg-amber-950/40 border border-brand-amber/40 rounded-xl p-3 flex items-center gap-2.5" data-purpose="sync-banner">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-brand-amber shrink-0 animate-ping"></span>
|
||||
<div class="text-[11px] leading-tight text-amber-200">
|
||||
<strong class="font-semibold">Offline Mode:</strong> 2 rides queued locally (auto-sync on connection).
|
||||
</div>
|
||||
</div>
|
||||
<!-- PRIMARY CARD: Ride 12 (Targeted Detailed Session) -->
|
||||
<article class="bg-brand-card border-2 border-brand-cyan/60 rounded-2xl p-3.5 shadow-lg relative overflow-hidden" data-purpose="primary-ride-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base font-bold text-white tracking-wide">Ride 12</h3>
|
||||
<span class="text-[10px] bg-cyan-950 text-brand-cyan font-mono font-medium px-2 py-0.5 rounded border border-brand-cyan/30">
|
||||
2001 YAMAHA Y...
|
||||
</span>
|
||||
</div>
|
||||
<time class="text-[10px] text-slate-400 font-mono">2026-10-10 03:23</time>
|
||||
</div>
|
||||
<!-- Route Title Tag -->
|
||||
<p class="text-xs font-medium text-slate-300 mt-1">Skyline Blvd & Pescadero Carve</p>
|
||||
<!-- Metrics Matrix Grid -->
|
||||
<div class="grid grid-cols-3 gap-2 mt-3 pt-2.5 border-t border-brand-border/60">
|
||||
<div>
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Trip</span>
|
||||
<span class="text-sm font-bold text-white">15.7 <span class="text-[10px] font-normal text-slate-400">mi</span></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Time</span>
|
||||
<span class="text-sm font-bold text-white">16 <span class="text-[10px] font-normal text-slate-400">min</span></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Top Spd</span>
|
||||
<span class="text-sm font-bold text-white">84 <span class="text-[10px] font-normal text-slate-400">mph</span></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Max Lean</span>
|
||||
<span class="text-sm font-bold text-brand-cyanBright">27° <span class="text-[9px] font-normal text-slate-400">R</span></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Hard Brk</span>
|
||||
<span class="text-sm font-bold text-red-400">0.49 G</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Elev Gain</span>
|
||||
<span class="text-sm font-bold text-slate-200">69 m</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Minimum 48px Interactive CTA to Replay -->
|
||||
<button class="mt-3.5 w-full h-12 bg-brand-cyan hover:bg-brand-cyanBright text-slate-950 font-bold rounded-xl flex items-center justify-center gap-2 text-xs transition active:scale-[0.98] shadow-md shadow-cyan-500/20">
|
||||
<svg class="w-4 h-4 fill-current" viewbox="0 0 24 24"><path d="M8 5v14l11-7z"></path></svg>
|
||||
REPLAY TELEMETRY SESSION
|
||||
</button>
|
||||
</article>
|
||||
<!-- SECONDARY CARD: Ride 11 (Completed & Synced) -->
|
||||
<article class="bg-brand-card border border-brand-border rounded-2xl p-3.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base font-bold text-slate-200">Ride 11</h3>
|
||||
<span class="text-[10px] bg-slate-800 text-slate-300 font-mono px-2 py-0.5 rounded">
|
||||
DUCATI V4
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[9px] bg-emerald-950/80 text-brand-green border border-emerald-800/40 px-2 py-0.5 rounded-full flex items-center gap-1 font-mono">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand-green"></span> Synced
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-brand-border/40 text-xs">
|
||||
<div>
|
||||
<span class="text-[9px] text-slate-400 block font-semibold">TRIP</span>
|
||||
<span class="font-bold text-slate-100">42.1 mi</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] text-slate-400 block font-semibold">TIME</span>
|
||||
<span class="font-bold text-slate-100">54 min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[9px] text-slate-400 block font-semibold">TOP SPD</span>
|
||||
<span class="font-bold text-slate-100">72 mph</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<!-- EMPTY FILTER STATE CARD: Track Mode demonstration -->
|
||||
<div class="border border-dashed border-brand-borderLight rounded-2xl p-4 text-center bg-brand-card/40" data-purpose="empty-state-notice">
|
||||
<div class="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center mx-auto text-slate-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</div>
|
||||
<p class="text-xs font-semibold text-slate-300 mt-2">No rides found for Track Mode</p>
|
||||
<p class="text-[10px] text-slate-500 mt-0.5">Change telemetry preset or switch filter above.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BEGIN: Fixed Mobile Bottom Navigation Bar -->
|
||||
<nav aria-label="Primary mobile nav" class="absolute bottom-0 left-0 right-0 h-20 bg-brand-card/95 backdrop-blur-md border-t border-brand-border flex items-start justify-around px-2 pt-2 z-40">
|
||||
<!-- Dash -->
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
<span class="text-[9px] mt-1 font-medium">Dash</span>
|
||||
</button>
|
||||
<!-- Map -->
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
<span class="text-[9px] mt-1 font-medium">Map</span>
|
||||
</button>
|
||||
<!-- Activity (ACTIVE HIGHLIGHT) -->
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-brand-cyan">
|
||||
<div class="relative">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"></path></svg>
|
||||
<span class="absolute -top-1 -right-1.5 w-2 h-2 rounded-full bg-brand-cyan"></span>
|
||||
</div>
|
||||
<span class="text-[9px] mt-1 font-bold">Activity</span>
|
||||
</button>
|
||||
<!-- Garage -->
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
<span class="text-[9px] mt-1 font-medium">Garage</span>
|
||||
</button>
|
||||
<!-- Social -->
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
<span class="text-[9px] mt-1 font-medium">Social</span>
|
||||
</button>
|
||||
<!-- Settings -->
|
||||
<button class="flex flex-col items-center justify-center w-12 h-12 text-slate-400 hover:text-slate-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
<span class="text-[9px] mt-1 font-medium">Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- END: Fixed Mobile Bottom Navigation Bar -->
|
||||
<!-- Safe Area Bottom Guide Callout -->
|
||||
<div class="safe-area-bottom-guide flex items-start justify-center pt-0.5">
|
||||
<span class="text-[9px] font-mono text-brand-cyan/60 uppercase">env(safe-area-inset-bottom)</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- ========================================== -->
|
||||
<!-- VIEWPORT 2: RIDE REPLAY HUD -->
|
||||
<!-- ========================================== -->
|
||||
<section class="flex flex-col items-center" data-purpose="viewport-ride-replay">
|
||||
<!-- Viewport Label -->
|
||||
<div class="mb-3 flex items-center justify-between w-[390px] px-2 text-xs font-medium text-slate-400">
|
||||
<span class="text-brand-cyanBright font-semibold flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path><path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
VIEWPORT 2: Telemetry Replay HUD
|
||||
</span>
|
||||
<span class="text-slate-500 font-mono">Screen: Replay.ActiveHUD</span>
|
||||
</div>
|
||||
<div class="mobile-screen-container relative bg-slate-950">
|
||||
<!-- Dynamic Island Notch -->
|
||||
<div class="mobile-island"></div>
|
||||
<!-- Safe Area Top Guide Callout -->
|
||||
<div class="safe-area-top-guide flex items-end justify-end pr-3 pb-1">
|
||||
<span class="text-[9px] font-mono text-brand-cyan/60 uppercase">env(safe-area-inset-top)</span>
|
||||
</div>
|
||||
<!-- Phone Status Bar -->
|
||||
<div class="h-12 pt-3 px-7 flex justify-between items-center text-xs font-semibold text-white z-40">
|
||||
<span>2:02</span>
|
||||
<div class="flex items-center gap-1.5 text-slate-200">
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewbox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9 0 2.12.74 4.07 1.97 5.61L4.35 18.25l1.41 1.41.64-.64C7.93 20.26 9.88 21 12 21c4.97 0 9-4.03 9-9s-4.03-9-9-9z"></path></svg>
|
||||
<div class="w-5 h-2.5 border border-white/80 rounded-sm p-0.5 flex items-center">
|
||||
<div class="h-full bg-white w-4/5 rounded-2xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Replay Header Action Bar (All touch targets >= 48px) -->
|
||||
<header class="h-14 px-3 flex items-center justify-between border-b border-brand-border/60 bg-brand-dark/90 backdrop-blur z-30">
|
||||
<!-- Back to Archive Button -->
|
||||
<button aria-label="Exit Replay HUD" class="w-12 h-12 flex items-center justify-center text-slate-300 hover:text-white rounded-xl active:bg-slate-800">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"></path></svg>
|
||||
</button>
|
||||
<!-- Replay Track Title -->
|
||||
<div class="text-center px-1 truncate">
|
||||
<h2 class="text-xs font-bold text-white truncate">Ride 12 • Skyline & Pescadero</h2>
|
||||
<span class="text-[10px] text-brand-cyan font-mono block">GPS Telemetry Synced</span>
|
||||
</div>
|
||||
<!-- Header Action Buttons (Share & Delete) -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<!-- Share Button -->
|
||||
<button aria-label="Share Session" class="w-12 h-12 flex items-center justify-center text-slate-300 hover:text-brand-cyan rounded-xl active:bg-slate-800">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</button>
|
||||
<!-- Destructive Delete Button (Red strictly reserved for deletion) -->
|
||||
<button aria-label="Delete Session" class="w-12 h-12 flex items-center justify-center text-brand-danger hover:bg-red-950/40 rounded-xl transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- BEGIN: Map Visualizer Canvas Container -->
|
||||
<div class="relative flex-1 bg-slate-950 topo-grid-pattern overflow-hidden" data-purpose="replay-map-container">
|
||||
<!-- Telemetry Buffering / Cache State Pill -->
|
||||
<div class="absolute top-3 left-4 z-20">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-mono bg-brand-card/90 border border-brand-green/40 text-brand-green backdrop-blur shadow">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-brand-green animate-pulse"></span>
|
||||
100% Buffered (Local cache)
|
||||
</span>
|
||||
</div>
|
||||
<!-- STRICT ISOLATION: Floating Map Controls (Top-Right Quadrant, isolated from telemetry overlays) -->
|
||||
<div class="absolute top-3 right-3 flex flex-col gap-1.5 z-20" data-purpose="isolated-map-controls">
|
||||
<button aria-label="Zoom in" class="w-12 h-12 rounded-xl bg-brand-card/90 border border-brand-border text-slate-200 flex items-center justify-center hover:bg-brand-cardSubtle active:scale-95 shadow backdrop-blur">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 6v6m0 0v6m0-6h6m-6 0H6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</button>
|
||||
<button aria-label="Zoom out" class="w-12 h-12 rounded-xl bg-brand-card/90 border border-brand-border text-slate-200 flex items-center justify-center hover:bg-brand-cardSubtle active:scale-95 shadow backdrop-blur">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M18 12H6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</button>
|
||||
<button aria-label="Toggle map layer styles" class="w-12 h-12 rounded-xl bg-brand-card/90 border border-brand-border text-slate-200 flex items-center justify-center hover:bg-brand-cardSubtle active:scale-95 shadow backdrop-blur">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Stylized GPS Trace Path SVG Visualizer -->
|
||||
<svg class="absolute inset-0 w-full h-full pointer-events-none" fill="none" viewbox="0 0 390 380">
|
||||
<!-- Route Path Trajectory Glow -->
|
||||
<path d="M 40 310 C 80 290, 110 240, 130 180 C 150 120, 200 90, 240 140 C 270 180, 290 260, 330 220 C 350 200, 360 140, 370 120" opacity="0.25" stroke="#06B6D4" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"></path>
|
||||
<!-- Route Path Completed Segment -->
|
||||
<path d="M 40 310 C 80 290, 110 240, 130 180 C 150 120, 200 90, 240 140" stroke="#22D3EE" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5"></path>
|
||||
<!-- Route Path Remaining Segment -->
|
||||
<path d="M 240 140 C 270 180, 290 260, 330 220 C 350 200, 360 140, 370 120" stroke="#475569" stroke-dasharray="4 4" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"></path>
|
||||
<!-- Apex Caution Indicator (Amber semantic) -->
|
||||
<circle cx="205" cy="94" fill="#F59E0B" r="5"></circle>
|
||||
<text fill="#F59E0B" font-family="monospace" font-size="9" font-weight="bold" x="214" y="98">51° APEX</text>
|
||||
<!-- Live Vehicle Cursor Position -->
|
||||
<g transform="translate(240, 140)">
|
||||
<circle class="animate-ping" cx="0" cy="0" fill="#06B6D4" opacity="0.2" r="14"></circle>
|
||||
<circle cx="0" cy="0" fill="#06B6D4" r="7" stroke="#FFFFFF" stroke-width="2"></circle>
|
||||
<!-- Direction Pointer Arrow -->
|
||||
<polygon fill="#22D3EE" points="0,-12 4,-5 -4,-5" transform="rotate(45)"></polygon>
|
||||
</g>
|
||||
</svg>
|
||||
<!-- Bottom Elevation Profile Mini HUD Pill -->
|
||||
<div class="absolute bottom-4 left-4 z-20">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-mono bg-brand-card/90 border border-brand-border text-slate-200 backdrop-blur shadow">
|
||||
<svg class="w-3 h-3 text-brand-cyan" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
ELV +2,140 FT • GRADE 6.2%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: Map Visualizer Canvas Container -->
|
||||
<!-- BEGIN: Docked Telemetry Cluster & Playback Engine -->
|
||||
<div class="bg-brand-card border-t border-brand-border px-4 pt-3 pb-8 z-30" data-purpose="docked-telemetry-engine">
|
||||
<!-- Live Real-Time Telemetry Cluster -->
|
||||
<div class="grid grid-cols-3 gap-2 py-2 mb-3 bg-brand-dark/80 rounded-xl border border-brand-border/60 text-center">
|
||||
<!-- Live Speed -->
|
||||
<div class="px-2">
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Live Speed</span>
|
||||
<div class="text-xl font-black text-white tracking-tight">58 <span class="text-[10px] font-medium text-brand-cyan">MPH</span></div>
|
||||
</div>
|
||||
<!-- Lean Angle Gauge with Visual Arc -->
|
||||
<div class="px-2 border-x border-brand-border/50 flex flex-col items-center justify-center">
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Lean Angle</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Lean Direction Arc Indicator -->
|
||||
<svg class="w-4 h-4 text-brand-cyan" fill="none" stroke="currentColor" viewbox="0 0 24 24">
|
||||
<path d="M19 14l-7 7m0 0l-7-7m7 7V3" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</svg>
|
||||
<span class="text-xl font-black text-brand-cyanBright">21° <span class="text-[10px] font-semibold text-slate-300">R</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lateral G Force -->
|
||||
<div class="px-2">
|
||||
<span class="text-[9px] uppercase tracking-wider text-slate-400 font-semibold block">Lateral G</span>
|
||||
<div class="text-xl font-black text-white tracking-tight">0.38 <span class="text-[10px] font-medium text-slate-400">G</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scrubbable Timeline Track & Time Displays -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex justify-between items-center text-[10px] font-mono text-slate-400">
|
||||
<span class="text-brand-cyan font-bold">08:42</span>
|
||||
<span class="text-slate-500">SESSION PROGRESS</span>
|
||||
<span>16:00</span>
|
||||
</div>
|
||||
<!-- Timeline Bar with 48px touch-target area for scrubbing -->
|
||||
<div class="relative h-6 flex items-center cursor-pointer group">
|
||||
<div class="w-full h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-brand-cyan to-brand-cyanBright rounded-full" style="width: 54.3%;"></div>
|
||||
</div>
|
||||
<!-- Interactive Scrub Handle Thumb -->
|
||||
<div class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white border-2 border-brand-cyan rounded-full shadow-md pointer-events-none" style="left: calc(54.3% - 8px);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Playback Control Bar (All Minimum 48px Touch Targets) -->
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<!-- Rewind 15s -->
|
||||
<button aria-label="Skip backwards 15 seconds" class="w-12 h-12 rounded-xl flex items-center justify-center text-slate-300 hover:text-white active:bg-slate-800 transition">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
<span class="text-[8px] font-bold absolute -bottom-1 text-slate-400">15</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Primary Play/Pause Button (56dp Highlighted Button) -->
|
||||
<button aria-label="Pause replay" class="w-14 h-14 rounded-full bg-brand-cyan hover:bg-brand-cyanBright text-slate-950 flex items-center justify-center shadow-lg shadow-cyan-500/25 active:scale-95 transition">
|
||||
<svg class="w-6 h-6 fill-current" viewbox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path></svg>
|
||||
</button>
|
||||
<!-- Forward 15s -->
|
||||
<button aria-label="Skip forward 15 seconds" class="w-12 h-12 rounded-xl flex items-center justify-center text-slate-300 hover:text-white active:bg-slate-800 transition">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M11.933 12.8a1 1 0 000-1.6L6.6 7.2A1 1 0 005 8v8a1 1 0 001.6.8l5.333-4zM19.933 12.8a1 1 0 000-1.6l-5.333-4A1 1 0 0013 8v8a1 1 0 001.6.8l5.333-4z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
<span class="text-[8px] font-bold absolute -bottom-1 text-slate-400">15</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Playback Speed Multiplier Pill (Min 48px Target) -->
|
||||
<button aria-label="Replay speed toggle" class="h-12 px-3 rounded-xl bg-brand-cardSubtle border border-brand-border text-brand-cyan text-xs font-mono font-bold flex items-center justify-center hover:bg-slate-800 transition">
|
||||
2.0x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: Docked Telemetry Cluster & Playback Engine -->
|
||||
<!-- Safe Area Bottom Guide Callout -->
|
||||
<div class="safe-area-bottom-guide flex items-start justify-center pt-0.5">
|
||||
<span class="text-[9px] font-mono text-brand-cyan/60 uppercase">env(safe-area-inset-bottom)</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- END: DualViewportPresentationGrid -->
|
||||
<!-- ========================================== -->
|
||||
<!-- LOWER ARCHITECTURAL CALLOUT CARDS -->
|
||||
<!-- ========================================== -->
|
||||
<!-- BEGIN: ArchitecturalSpecSection -->
|
||||
<section class="mt-8 border-t border-brand-border pt-10" data-purpose="architectural-spec-cards">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded bg-brand-cyan"></span>
|
||||
System Architecture & Interaction Specifications
|
||||
</h3>
|
||||
<p class="text-sm text-slate-400">Engineered constraints, platform layout standards, and user experience guarantees.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- CARD 1: Map Controls vs Telemetry Overlay Isolation -->
|
||||
<article class="bg-brand-card border border-brand-border rounded-2xl p-5 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="w-10 h-10 rounded-xl bg-cyan-950/60 border border-brand-cyan/40 text-brand-cyan flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</div>
|
||||
<h4 class="text-base font-bold text-white mb-2">1. Map Controls vs Telemetry Overlay</h4>
|
||||
<p class="text-xs text-slate-300 leading-relaxed">
|
||||
Floating map controls (+, -, layers) are strictly docked to the <strong>Top-Right quadrant</strong> with isolated pointer-event boundaries. They are decoupled from lower telemetry clusters to prevent scrub mis-taps while riding or reviewing.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 pt-3 border-t border-brand-border/60">
|
||||
<span class="text-[10px] font-mono text-brand-cyan">Rule: 48dp separation zone</span>
|
||||
</div>
|
||||
</article>
|
||||
<!-- CARD 2: Platform Safe Areas -->
|
||||
<article class="bg-brand-card border border-brand-border rounded-2xl p-5 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-950/60 border border-blue-500/40 text-blue-400 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</div>
|
||||
<h4 class="text-base font-bold text-white mb-2">2. Platform Safe Areas</h4>
|
||||
<p class="text-xs text-slate-300 leading-relaxed">
|
||||
Dynamic padding utilizes <code class="text-brand-cyan font-mono text-[11px]">env(safe-area-inset-top)</code> (47px iOS island clearance) and <code class="text-brand-cyan font-mono text-[11px]">env(safe-area-inset-bottom)</code> (34px home indicator buffer) ensuring zero occlusion on gesture devices.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 pt-3 border-t border-brand-border/60">
|
||||
<span class="text-[10px] font-mono text-blue-400">Rule: Full notch & home bar immunity</span>
|
||||
</div>
|
||||
</article>
|
||||
<!-- CARD 3: Telemetry Streaming/Buffering States -->
|
||||
<article class="bg-brand-card border border-brand-border rounded-2xl p-5 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="w-10 h-10 rounded-xl bg-emerald-950/60 border border-brand-green/40 text-brand-green flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</div>
|
||||
<h4 class="text-base font-bold text-white mb-2">3. Telemetry Streaming & Cache</h4>
|
||||
<p class="text-xs text-slate-300 leading-relaxed">
|
||||
Sessions store IMU 100Hz packets in IndexedDB/SQLite. Visualizer displays green <span class="text-brand-green font-semibold">100% Buffered</span> state. When roaming without LTE, offline queue alerts notify riders of local persistence.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 pt-3 border-t border-brand-border/60">
|
||||
<span class="text-[10px] font-mono text-brand-green">State: Zero packet loss cache</span>
|
||||
</div>
|
||||
</article>
|
||||
<!-- CARD 4: Destructive Deletion Confirmation Pattern -->
|
||||
<article class="bg-brand-card border border-brand-border rounded-2xl p-5 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="w-10 h-10 rounded-xl bg-red-950/60 border border-brand-danger/40 text-brand-danger flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewbox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
</div>
|
||||
<h4 class="text-base font-bold text-white mb-2">4. Destructive Deletion Pattern</h4>
|
||||
<p class="text-xs text-slate-300 leading-relaxed">
|
||||
Red (<span class="text-brand-danger font-semibold">#EF4444</span>) is strictly reserved for destructive deletion. Tapping the header trash icon requires an intentional two-step confirmation sheet to purge irreversible GPS and lean-angle logs.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 pt-3 border-t border-brand-border/60">
|
||||
<span class="text-[10px] font-mono text-brand-danger">Security: 2-step verification modal</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END: ArchitecturalSpecSection -->
|
||||
<!-- Footer Meta -->
|
||||
<footer class="mt-12 text-center text-xs text-slate-500 pb-6 border-t border-brand-border/40 pt-6">
|
||||
MotorSignal Telemetry Design System • High-Performance Motorcycle Telemetry Platform • Specification Screen
|
||||
</footer>
|
||||
</div>
|
||||
<!-- END: MasterSpecificationContainer -->
|
||||
</body></html>
|
||||
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 35 KiB |