diff --git a/.env.example b/.env.example index a689b12..9c1abfd 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,45 @@ +# ============================================================================= +# Build-time configuration for Dove's DataViewer / HackTheTrack +# ============================================================================= +# +# Two prefixes are accepted at build time (see vite.config.ts): +# +# VITE_* Standard Vite convention. Read directly from .env at build time. +# Easiest for local dev. NOT accepted by Lovable's secret store +# (Lovable rejects the VITE_ prefix because those values are +# public/bundled). +# +# HTT_* Mirror prefix accepted by Lovable workspace **build secrets**. +# If you don't want to commit .env values, set these in +# Workspace Settings -> Build Secrets and leave .env empty. +# They are copied into the VITE_* names at build time. +# +# Precedence per key: VITE_* > HTT_* > built-in public fallback. +# +# REMINDER: Lovable build secrets do NOT auto-inject into ad-hoc rebuilds in +# every environment yet. Until that's seamless, you may need to either: +# - regenerate this .env on each fresh build env, OR +# - re-set the HTT_* values in Workspace -> Build Secrets. +# ============================================================================= + +# --- Backend wiring (public anon values — safe to commit, but optional) ------ VITE_SUPABASE_PROJECT_ID="your-supabase-project-id" VITE_SUPABASE_PUBLISHABLE_KEY="your-supabase-anon-key" VITE_SUPABASE_URL="https://your-project.supabase.co" +# HTT_SUPABASE_PROJECT_ID="" +# HTT_SUPABASE_PUBLISHABLE_KEY="" +# HTT_SUPABASE_URL="" -# Set to "true" to enable the admin panel (/admin route, login UI). -# Default off — the public app works fully without admin features. -VITE_ENABLE_ADMIN="false" +# --- Feature flags ----------------------------------------------------------- +# Set to "true" to enable public user accounts (Cloud Sync, Google sign-in, +# /register, /forgot-password, /reset-password, /auth/callback). +VITE_ENABLE_CLOUD="true" +# HTT_ENABLE_CLOUD="true" -# Set to "true" to expose the /register route. -VITE_ENABLE_REGISTRATION="false" +# Set to "true" to enable the admin panel (/admin route, login UI). +# Defaults ON so fresh builds get the full feature set. +VITE_ENABLE_ADMIN="true" +# HTT_ENABLE_ADMIN="true" # Optional: Cloudflare Turnstile site key for the public contact form. # Leave empty to skip CAPTCHA. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3644f74..6d6b6bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' - run: npm ci # The build needs VITE_* env vars resolved at build time, including diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c2330b0..b5fde73 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' - run: npm ci diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bf7b2f0..aa7cfd7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' - run: npm ci - run: npm run lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8c37cf..56b405c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' - run: npm ci - run: npm run test:run diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 53ceaa5..7cfaf47 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' - run: npm ci - run: npm run typecheck diff --git a/.lovable/plan.md b/.lovable/plan.md index 28f6638..f30e961 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,31 +1,78 @@ -# Fix: Dialogs appearing behind the File Manager drawer -## Problem +## Goal -When the File Manager drawer is open (e.g. via the Bluetooth/Device tab) and a dialog opens on top of it (the BLE file download dialog from `DataloggerDownload`), the dialog renders *behind* the drawer. +Let any user (not just admins) sign up, sign in (email or Google), and reset their password so they can use Cloud Sync and future user features — **but keep the entire cloud/auth surface behind a single build-time flag** so the offline-first repo can ship with zero cloud code paths active. Admin status stays a separate concept driven by `user_roles`. -## Root cause +## Build-time gating model -Stacking values are inconsistent across the app: +One new flag controls all user-facing cloud auth: **`VITE_ENABLE_CLOUD`** (default `"false"`). -| Component | Overlay z | Content z | -|---|---|---| -| `src/components/ui/dialog.tsx` | `z-[9999]` | `z-[10000]` | -| `src/components/FileManagerDrawer.tsx` | `z-[10000]` (backdrop) | `z-[10001]` (panel) | -| `src/components/ui/sheet.tsx` | `z-50` | `z-50` | +| Flag | Controls | +|------|----------| +| `VITE_ENABLE_CLOUD` | Public auth routes (`/login`, `/register`, `/forgot-password`, `/reset-password`, `/auth/callback`), header "Sign in" entry, Cloud Sync Labs panel registration, Google OAuth button | +| `VITE_ENABLE_ADMIN` | `/admin` route + admin UI (unchanged) | +| `VITE_ENABLE_REGISTRATION` | **Retired** — registration follows `VITE_ENABLE_CLOUD` | -The drawer panel (`10001`) sits above the dialog content (`10000`), so any `Dialog` opened from inside the drawer is occluded. +When `VITE_ENABLE_CLOUD !== 'true'`: +- None of the new auth pages are imported (lazy boundaries + conditional `` mounting, same pattern as `/admin` today). +- The cloud-sync plugin's `index.ts` early-returns from `setup()` so it never contributes a panel — Labs tab stays absent unless something else contributes. +- The header "Sign in" affordance is not rendered. +- `AuthContext` still mounts (admin build needs it), but the new `signUp` / `signInWithGoogle` methods are no-ops behind the same flag check — or, cleaner, the Google-specific lovable client import stays inside the lazy page modules so it never lands in the main chunk. -## Fix +Admin builds independently set `VITE_ENABLE_ADMIN=true`; they continue to work whether cloud is on or off (admin login uses the existing `supabase.auth.signInWithPassword` path). -Raise the shared `Dialog` primitive above the custom drawer so every dialog opened from anywhere (drawer, page, etc.) always wins. +## Scope -In `src/components/ui/dialog.tsx`: -- `DialogOverlay`: `z-[9999]` → `z-[10010]` -- `DialogContent`: `z-[10000]` → `z-[10011]` +1. **Routing (`src/App.tsx`)** + - Add `const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true';` + - Mount the new public auth routes only when `enableCloud`. `/login` is mounted when `enableCloud || enableAdmin` (admin still needs it). Drop the `VITE_ENABLE_REGISTRATION` check. + - All new pages lazy-loaded so the disabled build never downloads them. -This keeps the drawer above normal page chrome but ensures any modal dialog (BLE download, export, confirmations, etc.) layers on top of it. No changes to `FileManagerDrawer` or `Sheet` are needed — the only known collision is dialog-vs-drawer, and `Sheet` (`z-50`) isn't used in conflict with the drawer. +2. **New pages (all lazy)** + - `src/pages/ForgotPassword.tsx` — email → `supabase.auth.resetPasswordForEmail(email, { redirectTo: origin + '/reset-password' })`. + - `src/pages/ResetPassword.tsx` — public route, detects `type=recovery` hash, calls `supabase.auth.updateUser({ password })`, routes to `/`. + - `src/pages/AuthCallback.tsx` at `/auth/callback` — waits for `onAuthStateChange`, then redirects to `?next=` or `/`. -## Verification +3. **Rework `Login.tsx` / `Register.tsx`** + - Reframe copy from "Admin Login" → "Sign in". Add a "Continue with Google" button at the top (rendered only when `enableCloud`). + - Forgot-password becomes a link to `/forgot-password` instead of an inline toggle. + - Keep the per-IP rate-limit edge function. Success redirects to `?next=` or `/` (admins land on `/admin` via the next param, set by header link). + - Registration always-on under cloud flag; keep email-confirm flow (no auto-confirm). -After the change: open the drawer → Device tab → trigger the BLE download dialog; the dialog and its overlay should sit above the drawer panel. +4. **Google sign-in via Lovable Cloud managed OAuth** + - Run `supabase--configure_social_auth` with `providers: ["google"]` (keep email). + - Use `lovable.auth.signInWithOAuth("google", { redirect_uri: origin + "/auth/callback" })` from the scaffolded `src/integrations/lovable/`. Import only from the cloud-flagged pages so it tree-shakes out otherwise. + - PWA: add `/^\/~oauth/` to `navigateFallbackDenylist` in `vite.config.ts` so OAuth redirects bypass the service worker. + +5. **`AuthContext` additions** + - Add `signUp(email, password)` and `signInWithGoogle()` wrappers next to existing `login` / `resetPassword`. Admin role detection via `has_role` stays unchanged — regular users have no `user_roles` row, so `isAdmin` is `false`. + +6. **Cloud Sync plugin (`src/plugins/cloud-sync/index.ts`)** + - In `setup()`, early-return when `VITE_ENABLE_CLOUD !== 'true'` so the Labs panel isn't contributed. `CloudSyncPanel.tsx` stays lazy and never imports. + - When enabled, replace the inline email/password form with: blurb + "Sign in" / "Create account" buttons routing to `/login?next=/` and `/register`, plus a "Continue with Google" shortcut using the new context method. Smaller panel, no duplicated auth UI. + +7. **Header / nav affordance (`LandingPage.tsx` or top of `Index.tsx`)** + - Render a "Sign in" / account menu **only when `enableCloud`**. Signed-in users see email + Sign out; admins additionally see an Admin link (still gated by `isAdmin && enableAdmin`). + +8. **Auth settings** + - Call `supabase--configure_auth`: `disable_signup: false`, `auto_confirm_email: false`, `external_anonymous_users_enabled: false`, `password_hibp_enabled: true`. + +9. **Docs** + - `README.md`: add `VITE_ENABLE_CLOUD` row (default false; enables public auth + cloud sync); remove `VITE_ENABLE_REGISTRATION`; clarify `VITE_ENABLE_ADMIN` only gates `/admin`. Note offline-first invariant: with the flag off, no auth/cloud code runs. + - `CLAUDE.md`: update env vars section, architecture map, and plugin notes (cloud-sync now flag-gated). + - `CHANGELOG.md`: `[Unreleased]` entry under "Added" + "Changed" (flag rename). + - Update memory `mem://config/environment-variables` and `mem://architecture/cloud-sync-strategy` to reflect the new flag. + +## Out of scope + +- Apple / other social providers (easy follow-up; Lovable Cloud supports Apple). +- Profiles table / display names / avatars — current features key off `auth.uid()` only. +- Branded auth emails (`scaffold_auth_email_templates`) and custom email domain. + +## Technical notes + +- Files added: `src/pages/ForgotPassword.tsx`, `src/pages/ResetPassword.tsx`, `src/pages/AuthCallback.tsx`. +- Files changed: `src/App.tsx`, `src/pages/Login.tsx`, `src/pages/Register.tsx`, `src/contexts/AuthContext.tsx`, `src/plugins/cloud-sync/index.ts`, `src/plugins/cloud-sync/CloudSyncPanel.tsx`, `vite.config.ts` (PWA denylist), `src/components/LandingPage.tsx` (or `Index.tsx` header), `README.md`, `CLAUDE.md`, `CHANGELOG.md`. +- Tool-generated (do not hand-edit): `src/integrations/lovable/` from `configure_social_auth`. Even when generated, it only runs at import time from cloud-flagged pages → stays out of the disabled build's bundle via lazy boundaries. +- No DB migration required. +- Verification: `npm run build` once with `VITE_ENABLE_CLOUD` unset (confirm auth chunks absent, Labs tab not auto-mounted), once with `VITE_ENABLE_CLOUD=true` (confirm routes + Google flow work). diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eeeb17..def3c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,20 +13,402 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Active subscription no longer reads as "Free".** The Stripe webhook could + resolve an entitling subscription (active/trialing/past_due) down to the free + tier when the price in a webhook payload arrived without its `lookup_key` — + notably after **un-cancelling** a subscription. The webhook now re-fetches the + full price when needed and, as a safety net, never demotes an entitling + subscription to free (it keeps the existing paid tier instead). + ### Added +- **Change your plan from your profile.** Subscribers now get a **Change plan** + button alongside **Manage subscription** in **Profile → Plan**. It deep-links + straight into Stripe's change-plan screen (swapping your storage tier / billing + interval on the existing subscription with proration) — cancellation and payment + methods stay under Manage subscription. +- **Checkout-style sign-up.** Registration now has a **storage-tier dropdown**, a + **monthly/annual switch**, and a **live cost-per-month** readout next to the + Create Account button — annual shows the monthly-equivalent price and the **% + you save** versus paying monthly (prices fetched live from Stripe). + +### Changed +- **Simpler sign-up.** The display-name field is gone — accounts get a random name + you can change (and reserve) later from your profile. Display names now pass a + **basic profanity filter**. +- **Fewer plans, by storage.** With tiers now differing only by storage, the + **Premium** and **Pro** tiers are on hold at launch (like the AI tier) and hidden + from the pricing UI. Sign-up shows two cards (**Free online** + **Plus**); the + landing page keeps three (**Free offline**, **Free online**, **Plus**), and the + offline card lists more of what works without an account. + +- **Cloud logs now live in the file browser.** Logs stored in your cloud but not + yet on this device show **inline in the Track → Course folders** alongside local + logs (deduped — no more separate "Cloud files" list with doubles), marked with a + cloud icon. **Tap one and it downloads and opens in a single step.** The + **"Download all cloud logs"** button moved to **Profile → Cloud logs**, which now + shows your cloud logs in the same folder hierarchy (with delete). +- **Organized file browser — Track → Course → logs, with filters.** The Files tab + is now a folder hierarchy instead of a flat list. Sessions are filed under their + **track**, then **course**; the final list can be grouped by **Engine** or + **Kart** (logs with neither shown below the groups). Each log is now labelled by + its **session date/time** — the time of its first GPS fix, e.g. "2/12/2026 + 11:15 AM" — instead of the raw filename, so logs read clearly. To keep clicking + to a minimum, folder levels only appear when there's an actual choice: a single + track or course is skipped automatically, with a **breadcrumb** always showing + where you are. Untagged logs get their own "Untagged" bucket. Opening the file + manager **jumps straight to the current session's track/course**. +- **Setup revisions — frozen setup history per session.** Assigning a setup to a + session now freezes an **immutable, content-addressed copy** of that setup, so + the session keeps the exact setup it ran even if you edit the live setup later. + Each revision carries a short **git-style `#hash`** (6 chars) derived from its + content: two sessions on the same setup read the **same** hash, and any change — + a value, or the template (a renamed/added field) — reads a **different** one. + The setup list shows each setup's current hash; the session's Notes panel shows + the frozen `#hash` it ran. Revisions sync to the cloud as ordinary garage + documents (counting toward the same pooled storage budget) and stay unlimited + on-device. Sets up future session tagging in the file browser. Revisions that + no session references are swept automatically (a throttled ~3-day background + prune); the local sweep never deletes the cloud copy another device may need. - Cloudflare Workers deployment support: `wrangler.jsonc` (static-assets-only Worker serving `./dist` with single-page-application not-found handling), `public/_headers` (no-cache for the service workers + `index.html`, immutable long-cache for hashed assets), an `.nvmrc` pinning Node 20, and a Deployment section in the README documenting the build settings and how to flip on admin via env vars. +- **Unified cloud storage — one budget, one bar.** Documents, synced logs, and + lap snapshots now share a **single per-tier cloud-storage allowance** instead of + three separate quotas: **Free 50 MB · Plus 10 GB · Premium 100 GB · Pro 500 GB**. + The Profile tab shows it as **one stacked, phone-style progress bar** — logs, + snapshots, and garage data as coloured segments filling the same limit — with a + per-segment breakdown. Snapshots are now measured by size (not a fixed count), + and `subscription_tiers.total_bytes` is the single source of truth the + server-side quota triggers and the meter both read. +- **Lap snapshots — frozen "course fastest lap" per engine.** Capture a single + lap (its GPS samples plus a 5-second buffer on each side, the course geometry, + the engine, and a copy of the vehicle/setup) as an immutable baseline you can + load and compare against any later session on that course — regardless of the + engine you're running now (the loaded snapshot shows its engine, so 2-stroke + vs 4-stroke reads clearly). + - **One per (course, engine).** Assigning an engine + setup to a log prompts to + save/update the course fastest lap when its best lap beats (or has no) stored + snapshot; a manual **Save as snapshot** action lives in the lap-list + **Snapshots** picker too. A faster lap replaces the snapshot in place. + - **Loaded as the reference lap (comparison overlay)** — never auto-plays, and + is excluded from playback and the video player (it rides the reference-overlay + slot, not the lap selection). Pick one from the **Snapshots** menu next to the + lap dropdown (simple + pro), or from a **"Load snapshot as reference"** button + next to the external-reference loader on the lap times page. + - **Exposed to plugin panels** via `PluginPanelProps.activeSnapshot` (a + `PluginSnapshot` with clean-lap samples + frozen engine/course/vehicle/setup) + and `PluginPanelProps.sessionSetup` (the setup the driver is currently + running), so the AI coach can compare the current session and setup against a + frozen course-fastest-lap baseline. + - **Local-first & unlimited on-device; cloud-synced** via a dedicated + `lap_snapshots` table whose size counts toward the same pooled per-tier cloud + storage budget as documents + logs (see *Unified cloud storage* below). + Snapshots always push on save, but a local delete never removes the cloud copy + (like the log menu); the cloud copy is removed only explicitly from + **Profile → Lap snapshots**, which also lists on-device snapshots when signed + out. +- **GDPR self-service data tools** (Profile → **Data & privacy**, cloud builds): + - **Download my data** — exports everything we hold about you as a single ZIP: + your account data (profile, subscription, roles, synced garage records, + contact messages, synced log files) plus the data stored locally in your + browser (settings, garage stores, local session files). Backed by the new + `export-account-data` edge function. + - **Delete my account** — full self-service erasure. Confirmed by an emailed + one-time code (guards against a hijacked session), then **scheduled 7 days + out** and cancellable during that window, after which the account, its + Storage files and all associated rows are permanently erased. Backed by the + `request-account-deletion` and (cron-driven) `process-account-deletions` + edge functions. +- **Automatic data-retention (TTL):** a daily job nulls the submitter IP on + contact messages and community submissions **90 days** after they're received, + then deletes the rows in full after **1 year** (contact messages entirely; + community submissions once they've been reviewed — pending ones are kept for + moderation), and clears expired IP bans and stale sign-in rate-limit records — + so abuse-prevention and contact data are minimised even without traffic to + trigger the existing reactive cleanup. +- **Age confirmation at sign-up:** account creation (email and Google) now + requires ticking a checkbox confirming you are **16 or older**, alongside the + existing Terms/Privacy agreement. +- **Banned-IP expiry in the admin panel:** banning an IP now takes a selectable + duration (1 / 7 / 30 / 90 / 365 days or permanent), defaulting to **90 days**; + expired bans are purged automatically. +- **Customizable engine types for vehicles**: the vehicle form's *Engine* field + is now a searchable combobox backed by a reusable, per-user engine list. Type + to filter previously used engines; if the name isn't found, create it inline. + Existing vehicles' engine names are auto-imported into the list, and a *manage* + link beside the field opens a small menu for deleting saved engines (engines + currently in use by a vehicle are protected from deletion). The engine list is + stored locally (IndexedDB) and travels with the rest of your garage data over + cloud sync. +- **Terms of Service page** (`/terms`) and a rewritten **Privacy Policy** that + now accurately reflect the optional online features — accounts, cloud sync, + Stripe-billed plans, and AI coaching — instead of the old "nothing ever leaves + your device" copy. Both pages adapt to the build flags (offline-only builds + show the simpler policy) and are linked from the landing-page footer. Account + sign-up now states the **16+ age requirement** and links both documents + (under-16 users use the app offline only). AI coaching is documented as + informational only — not safety or professional advice. +- **Paid subscription tiers**: Stripe-backed `Plus` ($1/mo), `Premium` ($3/mo), + and `Pro` ($10/mo + AI coaching) plans that scale your pooled cloud-storage + budget (10 GB / 100 GB / 500 GB) on top of the free 50 MB tier — see *Unified + cloud storage* above. Plan limits are data-driven (`subscription_tiers` table) + and the cloud-sync storage quota is enforced per the user's tier. Backed by + `create-checkout-session`, + `stripe-webhook`, and `create-portal-session` edge functions; entitlements are + granted solely by the verified Stripe webhook. The **Plans & pricing** cards + now show live **Upgrade** / **Current plan** actions for signed-in users (a + paid tier stays "Coming soon" until its Stripe Price is configured), and the + **Profile** tab shows your plan with a **Manage subscription** link to the + Stripe billing portal. +- **Monthly & annual billing**: each paid tier now offers a monthly or annual + price, resolved live from Stripe by **lookup_key** (`plus_monthly`, + `plus_annual`, `premium_monthly`, …) so the Stripe dashboard is the single + source of truth — no Price ids in code. The pricing cards gain a + **monthly/annual toggle**, and sign-up lets you **pick a plan + interval**: a + paid choice creates the account first, then resumes to Stripe Checkout on your + first sign-in (after email confirmation). A new public `stripe-prices` edge + function feeds the catalogue. +- **No-Stripe failback**: when no Stripe secret key is configured, the pricing + UI shows only the two free cards (Guest + Free) and hides the paid tiers + entirely instead of showing them as "Coming soon". +- **AI (Pro) tier is "coming soon"**: the AI-coaching plan is shown as a teaser + but isn't self-service purchasable — it's not selectable at sign-up, has no + Upgrade button, and `create-checkout-session` rejects it. It can still be + comped to a tester by creating the subscription directly in Stripe (the + webhook grants whatever tier the price maps to). Gated by a single + `COMING_SOON_TIERS` set in `lib/billing.ts`. +- **Cancellation grace + log trimming**: cancelling ends service at the period + boundary and drops you to the free tier's limit, but your cloud logs are kept + for a **60-day grace window** to re-subscribe or download. After it expires, a + daily `pg_cron` job (`trim_expired_logs()`) trims synced logs **newest-first** + until your pooled total fits the free budget (snapshots + garage docs are never + auto-deleted). The Profile tab surfaces the cancellation/grace date. +- Garage **auto-sync**: when you're signed in, your garage (vehicles, setups, + setup templates, notes) now backs up to the cloud automatically as you change + it — no manual push. Garage documents, synced logs, and snapshots all count + against one pooled per-tier storage budget (free 50 MB), enforced server-side. +- **Propagation deletes**: deleting a vehicle or setup while signed in removes it + from **every device and the cloud**, with a clear warning before you confirm. +- New **Profile** tab (far right) showing your cloud storage usage as a single + segmented bar against your pooled tier budget. +- **Cloud log management** (Profile tab): see the session log files stored in your + cloud — with upload date and size — and delete them. Deleting removes the + **cloud copy only** (other devices keep what they've already downloaded), with + an optional toggle to **also delete the local copy from this device**. Clear + "this can't be undone" warning. +- **User display names**: choose a unique display name when you register, or get a + fun auto-generated one (e.g. `SpeedyRac3r-546`) if you leave it blank — editable + any time from the Profile tab, with a clear "that name's taken" message. Existing + accounts are given a generated name automatically. +- Plugin UI panel framework: plugins can contribute self-contained panels to a + named slot, starting with the Labs tab. The tab now appears automatically when + a plugin contributes a panel, and each panel is isolated by an error boundary. +- Dedicated AI Coach tab: a new top-level view (`PanelSlot.Coach`) that hosts the + coaching plugin's session-debrief dashboard. Like Labs, it is self-gating — the + tab only appears when the coach plugin is installed and contributes a panel. The + bundled coach (`@perchwerks/eye-in-the-sky` 0.2.5) ships a full-bleed analysis + dashboard (uPlot telemetry charts, corner/sector breakdowns) that loads lazily, + off the initial bundle. +- Plugin panels can now be **chromeless** — a panel may render full-bleed without + the host's card/header/padding (used by the coach dashboard), while keeping its + error boundary and Suspense. +- Cloud Sync (first-party plugin, in the Labs tab): sign in to back up and sync + your session files and garage data (vehicles, setups, notes, graph prefs) to + the cloud and pull them onto another device. Manual push/pull; data is private + per account. Requires a backend (Supabase) and a connection — fully optional + and offline-first otherwise. +- Cloud Sync — per-file selection: each file in the file manager has a sync + toggle, so you choose exactly which sessions sync (opt-in, off by default). + Pushing now uploads only your selected files (plus garage data). Selecting a + file while offline records the intent and uploads once you're back online. + A "Cloud files" list under the file manager shows everything in your cloud — + files already on this device are marked as such, and any that aren't get a + one-click pull. +- Public user accounts (gated by `VITE_ENABLE_CLOUD`, default off): email + + password sign up / sign in, Google sign-in via Lovable Cloud managed OAuth, + forgot-password and reset-password flows. New routes: `/register`, + `/forgot-password`, `/reset-password`, `/auth/callback`. A "Sign in" / + "Sign out" affordance appears in the landing-page header when the flag is on. + Regular accounts have no admin privileges (admin role remains driven by + `user_roles`). + +### Added +- Deleting a **synced log locally** now offers an opt-in *"also delete the cloud + copy"* toggle in the delete confirm (off by default — the cloud copy is a + backup). When offline, the cloud delete queues and propagates on reconnect. +- Your custom **tracks & courses now sync to the cloud** too (documents storage), + the same way setups do — auto-sync, delete propagation, and the offline-aware + timestamp merge. Only your user-created tracks/courses sync; built-in tracks + stay local. +- **Plans & pricing** cards on the landing page (below the sample) and on the + registration page — Free offline, Free online (50 MB pooled cloud storage), and + paid tiers (marked "Coming soon" until billing is wired up). +- **"Download all cloud logs"** button at the bottom of the file manager: + one-click bulk pull of every cloud log not already on this device. +- Registration now supports a **Cloudflare Turnstile captcha** when + `VITE_TURNSTILE_SITE_KEY` is set (gracefully skipped when it isn't), and + rejects **disposable / temporary email** addresses. ### Changed +- **Profile page tidy-up.** The separate *Account* and *Profile* boxes are merged + into one card: your display name with the **Sign out** button beside it, plan, + and storage. The manual **Push / Pull** buttons are gone — syncing is automatic, + so they're no longer needed. +- **Storage & snapshots work signed out.** The storage bar now appears when you're + logged out too, measuring this device's **local** storage (garage + logs + + snapshots) against the browser's quota. Lap snapshots now show their **file + size** in the list, signed in or out. +- File metadata writes now go through a single read-merge helper, fixing cases + where tagging a track or saving a setup could drop other saved details (kart, + setup, fastest lap, weather). +- Bumped the optional AI coach plugin (`@perchwerks/eye-in-the-sky`) from + `0.2.5` to `0.3.0`. +- **Registration page** now shows the **Plans & pricing** cards above the + sign-up form instead of below it. +- **Cloud Sync moved out of the Labs tab**: sign-in and manual push/pull now live + on the **Profile** tab as an "Account" panel. The Labs tab no longer appears + unless the experimental setting is on or a plugin contributes to it. +- Landing-page and About copy now reflect **optional cloud storage** (instead of + "files never leave your device"), since cloud sync is available when signed in. +- **Garage data and lap snapshots now always sync, even when you're over your + storage cap.** They still count toward your pooled storage (shrinking the room + left for logs), but they're never blocked — only logs (and, later, videos) stop + syncing once the cap is reached. Garage and snapshot data is small and valuable, + so it shouldn't get locked out by a pool full of logs. The Profile storage panel + notes this beneath the usage bar. +- Session notes are now capped at **128 KB each** to keep them from being used as + bulk document storage (they count toward your cloud document storage). +- Cloud document sync is now **offline-aware and conflict-safe**. Garage records + (vehicles, setups, templates, notes) carry an edit timestamp, and sync uses + last-write-wins, so a newer change is never overwritten by an older copy. + Changes made **offline** are saved as pending and, on reconnect, take + **priority** — replacing the cloud copy. The Profile tab flags when you're + offline and how many changes are waiting to sync. +- Telemetry channels are now normalized to a canonical identity at import time, + so the Settings "default fields" show/hide and your saved graph and video- + overlay selections apply **consistently across every logger format** (e.g. + lateral G is one channel whether the file came from a Dove, AiM, Alfano, or + VBO log). Existing files keep their saved graph/overlay choices — legacy field + names are migrated transparently on load. +- Lap delta / pace is now **position-based** by default: your line is projected + onto a reference lap resampled to a uniform arc-length grid, so the gap is + robust to racing-line and GPS-rate differences and no longer drifts over a lap + (the old cumulative-distance method is selectable under Settings → Lap Delta). + This upgrades the pace readout everywhere — charts, race-line, overlays, and + video export. +- Build flag rename: `VITE_ENABLE_REGISTRATION` retired. Cloud auth routes + (`/register`, `/forgot-password`, `/reset-password`, `/auth/callback`) and the + Cloud Sync Labs panel are now all gated by the single `VITE_ENABLE_CLOUD` + flag. `VITE_ENABLE_ADMIN` continues to gate `/admin` independently; `/login` + mounts when either flag is on. With `VITE_ENABLE_CLOUD` off, no auth pages, + Google OAuth SDK, or Cloud Sync panel are included in the bundle. +- Build-time env vars now also accept an `HTT_` mirror prefix + (`HTT_SUPABASE_URL`, `HTT_SUPABASE_PUBLISHABLE_KEY`, `HTT_SUPABASE_PROJECT_ID`, + `HTT_ENABLE_CLOUD`, `HTT_ENABLE_ADMIN`) so contributors can store backend + wiring in Lovable workspace build secrets instead of committing a `.env`. + Precedence: `VITE_*` > `HTT_*` > built-in public fallback. See `.env.example`. +- Lovable preview URLs now aggressively unregister service workers and clear + Cache Storage so preview tabs stop serving stale builds after updates. - The optional AI coach plugin now ships from the public npm registry as `@perchwerks/eye-in-the-sky` and loads by default — no build token or `.npmrc` required. (Previously a private GitHub Packages package gated behind `NODE_AUTH_TOKEN`.) +### Security +- **Password reset is now gated on a real recovery session.** `/reset-password` + only lets you set a new password when the page was opened from the emailed + recovery link (a `PASSWORD_RECOVERY` token) — so a merely signed-in session (a + shared/unattended tab, a stolen token) can no longer change the account + password without proving control of the email. +- **Account deletion now verifies the emailed code server-side.** The + `request-account-deletion` function verifies the one-time code itself before + scheduling, so a stolen JWT alone can't schedule deletion via a direct call — + the caller must also possess the code we emailed. +- **No more cross-account data bleed in cloud sync.** Pending offline changes, + cloud-deletion tombstones, and per-file sync selections are now partitioned per + user, so signing out of one account and into another on the same browser can + never flush the first account's queued state into the second's cloud. +- **Duplicate paid subscriptions are blocked.** Checkout refuses to start a + second subscription when one is already active (plan changes route through the + billing portal instead), preventing a double-billed account from a mis-click. +- **Stripe webhook is now replay- and ordering-safe.** Events are de-duplicated + by id, and a `subscription.deleted` for a superseded subscription is ignored, + so a retried or out-of-order delivery can't demote an active entitlement. +- **Account-deletion worker deletes the auth user before wiping files**, so a + transient failure can no longer leave a half-deleted account whose files are + already gone but whose rows and cancellation window remain. +- **GDPR retention restored to the documented window.** A later migration had + quietly loosened `purge_expired_personal_data` (keeping rejected-but-unreviewed + submissions and lock rows past their intended TTL); the documented predicates + are reasserted. +- Fresh clones default to the **offline-first public app** again: + `VITE_ENABLE_ADMIN` and `VITE_ENABLE_CLOUD` now default to `false` in the + build fallbacks, so a build without injected env doesn't ship admin/cloud UI + pointed at an upstream backend. Production enables them explicitly. + +### Fixed +- **Auto-detected track/course is now saved automatically.** When a loaded log's + track and course are recognised, the session is filed under them in the browser + immediately — previously the detection only applied in-memory and the log stayed + "Untagged" until some later manual action (e.g. saving a track) happened to + persist it. +- **Lap snapshots are now direction-aware.** A course driven in reverse keeps a + separate "fastest lap" snapshot from the forward direction instead of + overwriting it (and the position-based pace overlay refuses to compare against + a reference recorded in the opposite direction, rather than showing nonsense + gains). +- **Saving a snapshot manually won't silently destroy a faster one.** "Save + current lap as snapshot" now asks before replacing a stored snapshot that's + faster than the lap you're saving. +- **Plugin tabs (Coach, Profile, Labs) appear even when a plugin registers its + panels asynchronously** — the tab list now reacts to plugin contributions + instead of freezing the snapshot at first render, so the AI coach tab no longer + goes missing until a full page reload. +- **Pull no longer downgrades a newer local record** or silently opts files into + ongoing sync — a manual Pull keeps a local edit that's newer than the cloud + copy and just downloads files without flipping their sync state. +- **Cloud orphan-blob cleanup no longer races a concurrent upload**: only objects + older than a grace window are reclaimed, so a file uploaded at the same moment + the Profile tab opens can't be deleted before its index row commits. +- **Lap-snapshot insert failed with `null value in column "id" of relation + "lap_snapshots" violates not-null constraint`** — same root pattern as the + missing unique constraint: when the `lap_snapshots` table pre-existed the + snapshots migration, `CREATE TABLE IF NOT EXISTS` skipped the whole + declaration, so the `id uuid primary key default gen_random_uuid()` column + came in without its default. `pushSnapshot` doesn't send an id (it shouldn't), + so the insert had nothing to fill it with. A follow-up migration re-sets the + column defaults idempotently so existing deployments self-repair. +- **Lap-snapshot upsert failed with "no unique or exclusion constraint matching + the ON CONFLICT specification"** when the `lap_snapshots` table pre-existed + the snapshots migration — the inline `unique (user_id, course_key, engine_key)` + in `CREATE TABLE IF NOT EXISTS` was skipped along with the table, so reconcile + and the manual "Sync local snapshots" button both errored. A follow-up + migration adds the constraint as an idempotent unique index so existing + deployments self-repair on apply. +- **PostgREST schema cache reload** after the subscriptions/snapshots migration + batch. Newly-created tables and functions (`subscription_tiers`, + `user_subscriptions`, `lap_snapshots`, `snapshot_usage()`, …) existed in the + database but were invisible over the REST API until the cache reloaded — + breaking checkout (non-2xx), lap-snapshot sync, and the snapshot usage meter + while Stripe price loading (which bypasses PostgREST) still worked. A migration + now issues `notify pgrst, 'reload schema'`. +- Lap-snapshot and document auto-sync now reconcile **independently** — a failure + in one (a missing table, a quota rejection) no longer silently skips the other. +- The Profile **Lap snapshots** panel no longer blanks out when the usage meter + can't load; the snapshot list stays usable and the meter is treated as + best-effort. +- The Profile **Lap snapshots** panel now reconciles on open (uploading any + local-only snapshots) and refreshes live on snapshot changes, so it + self-heals a sign-in reconcile that failed (e.g. a transient outage) without + needing an app reload. +- Cloud log uploads no longer **orphan a blob** when the server quota rejects the + index write — the just-uploaded blob is rolled back. Any pre-existing orphans + are reclaimed when the Cloud logs panel is opened. + ## [1.5.0] - 2026-05-22 The first changelogged release. Dove's DataViewer is a feature-complete, diff --git a/CLAUDE.md b/CLAUDE.md index 0a994d7..b91ec57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ will read it tomorrow. | Styling | Tailwind CSS + shadcn/ui (HSL design tokens in `index.css`) | | Mapping | Leaflet (CartoDB + Esri tiles, cached 30 days by SW) | | Charts | Custom Canvas 2D (not a library — see `TelemetryChart.tsx`, `SingleSeriesChart.tsx`) | -| Video Export | WebCodecs + [mp4-muxer](https://github.com/Vanilagy/mp4-muxer) (H.264 video + AAC audio video + AAC audio MP4 output) | +| Video Export | WebCodecs + [mp4-muxer](https://github.com/Vanilagy/mp4-muxer) (H.264 video + AAC audio → MP4 output) | | State | React hooks + React Query (for admin only) | | Local Storage | IndexedDB (`dbUtils.ts`) for files/metadata/karts/notes/setups/video-sync/graph-prefs; localStorage for tracks & settings | | Backend | None for core features. Optional admin via Supabase (Lovable Cloud) | @@ -67,128 +67,71 @@ will read it tomorrow. ## Architecture Map +> Directory + entry-point level only. Leaf files are discoverable with +> `Glob`/`Grep`; ★ marks the load-bearing entry points worth knowing by name. + ``` src/ ├── pages/ -│ ├── Index.tsx # Main SPA — file import, tab views, all state orchestration +│ ├── Index.tsx # ★ Main SPA — file import, tab views, all state orchestration │ ├── Admin.tsx # Admin panel (behind VITE_ENABLE_ADMIN) -│ ├── Login.tsx / Register.tsx / Privacy.tsx -│ └── NotFound.tsx +│ └── … # Login / Register / Privacy / Terms / NotFound ├── components/ -│ ├── ui/ # shadcn/ui primitives (button, dialog, tabs, etc.) -│ ├── admin/ # Admin tabs: TracksTab, CoursesTab, SubmissionsTab, BannedIpsTab, ToolsTab, MessagesTab -│ ├── tabs/ # Main view tabs: GraphViewTab, RaceLineTab, LapTimesTab, LabsTab +│ ├── ui/ # shadcn/ui primitives +│ ├── admin/ # Admin tabs (Tracks, Courses, Submissions, BannedIps, Tools, Messages) +│ ├── tabs/ # Main view tabs (GraphView, RaceLine, LapTimes, Labs, Coach, Profile) │ ├── graphview/ # Pro mode: GraphPanel, GraphViewPanel, MiniMap, SingleSeriesChart, InfoBox -│ ├── drawer/ # File manager drawer tabs: FilesTab, KartsTab, NotesTab, SetupsTab, DeviceSettingsTab, DeviceTracksTab -│ ├── track-editor/ # Track editor sub-components -│ ├── RaceLineView.tsx # Leaflet map with race line, speed heatmap, braking zones -│ ├── TelemetryChart.tsx # Canvas-based speed/telemetry chart (simple mode) -│ ├── VideoPlayer.tsx # Synced video playback with modular overlay system -│ ├── video-overlays/ # Overlay system for video export -│ │ ├── types.ts # OverlayInstance, OverlaySettings, DataSourceDef, ThemeDef -│ │ ├── registry.ts # Overlay type definitions + factory -│ │ ├── themes.ts # Classic + Neon theme definitions -│ │ ├── dataSourceResolver.ts # Maps data source IDs → values/ranges/units -│ │ ├── DigitalOverlay.tsx # Numeric value + unit display -│ │ ├── AnalogOverlay.tsx # Canvas needle gauge (~252° arc) -│ │ ├── GraphOverlay.tsx # Rolling canvas line chart -│ │ ├── BarOverlay.tsx # Horizontal 0-100% progress bar -│ │ ├── BubbleOverlay.tsx # XY joystick-style circular widget -│ │ ├── sectorUtils.ts # Shared sector status logic (colors, segment computation) -│ │ ├── MapOverlay.tsx # Mini canvas race line with position dot + optional sector coloring -│ │ ├── PaceOverlay.tsx # Horizontal pace delta indicator -│ │ ├── SectorOverlay.tsx # 3 sector bubbles with delta + sparkle animation -│ │ ├── LapTimeOverlay.tsx # Lap timer with optional pace mode (delta + best lap) -│ │ ├── OverlaySettingsPanel.tsx # Add/configure/remove overlay instances -│ │ └── VideoExportDialog.tsx # Export dialog with quality options -│ ├── FileImport.tsx # Drag-and-drop file import -│ ├── DataloggerDownload.tsx # BLE device download UI -│ ├── ContactDialog.tsx # Public contact form dialog (categories shared const) -│ └── ... -├── hooks/ -│ ├── useSessionData.ts # Parses imported file → ParsedData -│ ├── useLapManagement.ts # Lap calculation, selection, visible range -│ ├── usePlayback.ts # Playback cursor (shared across chart + map) -│ ├── useReferenceLap.ts # Reference lap overlay logic -│ ├── useVideoSync.ts # Video ↔ telemetry synchronization -│ ├── useFileManager.ts # IndexedDB file CRUD -│ ├── useKartManager.ts # Backward compat re-export → useVehicleManager -│ ├── useVehicleManager.ts # Vehicle profiles CRUD -│ ├── useTemplateManager.ts # Vehicle types & setup templates CRUD -│ ├── useNoteManager.ts # Session notes CRUD -│ ├── useSetupManager.ts # Generic setup sheets CRUD (template-driven) -│ ├── useSettings.ts # User preferences (units, smoothing, dark mode, etc.) -│ ├── useSessionMetadata.ts # Per-file metadata (selected track/course) -│ └── useOnlineStatus.ts # Navigator.onLine wrapper +│ ├── drawer/ # File-manager drawer tabs (Files, Vehicles/Karts, Notes, Setups, Device*) +│ ├── track-editor/ # Track editor sub-components (VisualEditor is lazy — see Bundle Splitting) +│ ├── video-overlays/ # Video-export overlay system: registry + themes + per-widget *Overlay, +│ │ # sectorUtils, dataSourceResolver, OverlaySettingsPanel, VideoExportDialog +│ ├── RaceLineView.tsx # Leaflet map: race line, speed heatmap, braking zones +│ ├── TelemetryChart.tsx # Canvas speed/telemetry chart (simple mode) +│ ├── VideoPlayer.tsx # Synced video playback + overlay system +│ ├── LapSnapshot*.tsx # ★ Lap-snapshot picker (Controls) + "new fastest lap" save prompt +│ └── … # FileImport, DataloggerDownload (BLE entry, lazy), ContactDialog, … +├── hooks/ # One concern each; Index.tsx orchestrates. +│ ├── useSessionData.ts # Parses imported file → ParsedData +│ ├── useLapManagement.ts# Lap calc, selection, visible range +│ ├── usePlayback.ts # Shared playback cursor (chart + map) +│ ├── useLapSnapshots.ts # ★ Lap-snapshot orchestration (capture/prompt/overlay) +│ ├── useReferenceLap / useVideoSync / useSettings / useSessionMetadata / useOnlineStatus +│ ├── use*Manager.ts # IndexedDB CRUD: File, Vehicle (←Kart compat), Engine, Template, Note, Setup +│ └── useSubscription / useStripePrices # billing, online — see docs/backend.md ├── lib/ -│ ├── datalogParser.ts # ★ Format auto-detection router (entry point for all parsing) -│ ├── nmeaParser.ts # NMEA 0183 text parser (fallback format) -│ ├── ubxParser.ts # u-blox UBX binary parser -│ ├── vboParser.ts # Racelogic VBO parser -│ ├── doveParser.ts # DovesDataLogger CSV parser -│ ├── dovexParser.ts # DovesDataLogger extended format (.dovex) with 8192-byte metadata header -│ ├── alfanoParser.ts # Alfano CSV parser -│ ├── aimParser.ts # AiM MyChron CSV parser -│ ├── motecParser.ts # MoTeC LD binary + CSV parser -│ ├── parserUtils.ts # Shared parser helpers (haversine, speed calc, etc.) -│ ├── fieldResolver.ts # Canonical field name mapping across parsers -│ ├── courseDetection.ts # ★ Auto course detection, direction detection, waypoint mode -│ ├── lapCalculation.ts # Start/finish line crossing detection → Lap[] -│ ├── brakingZones.ts # Braking zone detection from G-force data -│ ├── speedEvents.ts # Min/max speed event detection -│ ├── speedBounds.ts # Speed range utilities -│ ├── gforceCalculation.ts # G-force derivation from GPS data -│ ├── chartUtils.ts # Canvas chart rendering helpers -│ ├── chartColors.ts # Color palette for multi-series charts -│ ├── trackUtils.ts # Track geometry utilities (findNearestTrack: 5mi radius) -│ ├── trackStorage.ts # localStorage: tracks + courses (merged with public/tracks.json) + course drawings loader -│ ├── referenceUtils.ts # Reference lap comparison utilities -│ ├── dbUtils.ts # ★ Shared IndexedDB: DB_NAME, DB_VERSION, openDB(), transaction helpers -│ ├── fileStorage.ts # IndexedDB: raw file blobs -│ ├── kartStorage.ts # Old kart storage (kept for compat) -│ ├── vehicleStorage.ts # ★ Vehicle profiles CRUD (replaces kartStorage) -│ ├── templateStorage.ts # ★ Vehicle types + setup templates, default kart schema -│ ├── noteStorage.ts # IndexedDB: session notes -│ ├── setupStorage.ts # IndexedDB: kart setups -│ ├── videoStorage.ts # IndexedDB: video sync points + overlay settings -│ ├── videoFileStorage.ts # ★ IndexedDB: video file blobs + metadata (exportType, lapNumber, hasOverlays) -│ ├── videoExport.ts # VideoWebCodecs H.264+AAC, fallback MediaRecorder fix-webm-duration) -│ ├── overlayCanvasRenderer.ts # Canvas-based overlay drawing for export -│ ├── graphPrefsStorage.ts # IndexedDB: per-session graph selections -│ ├── bleDatalogger.ts # Legacy barrel — re-exports from `ble/` for back-compat -│ ├── ble/ # Web Bluetooth: DovesLapTimer protocol, split per-concern -│ │ ├── index.ts # Public API barrel -│ │ ├── types.ts # BleConnection, FileInfo, DownloadProgress, BatteryInfo -│ │ ├── internal.ts # UUIDs, debug logging (not exported) -│ │ ├── format.ts # formatBytes / formatSpeed / formatTime -│ │ ├── connection.ts # isBleSupported, connectToDevice, disconnect -│ │ ├── fileTransfer.ts # LIST + GET file protocol (data log download) -│ │ ├── battery.ts # BATT protocol -│ │ ├── settings.ts # SLIST/SGET/SSET/SRESET settings protocol -│ │ └── trackSync.ts # TLIST/TGET/TPUT/TDEL track-file protocol -│ ├── deviceTrackSync.ts # Track sync logic: merge/compare app↔device tracks, coordinate diff -│ ├── deviceSettingsSchema.ts # Device settings key definitions + validation -│ ├── weatherService.ts # OpenWeatherMap API (online-only) -│ ├── db/ # Admin database layer (modular, swappable) -│ │ ├── types.ts # ITrackDatabase interface -│ │ ├── supabaseAdapter.ts # Supabase implementation -│ │ └── index.ts # Factory: getDatabase() -│ └── utils.ts # Tailwind cn() helper -├── plugins/ # ★ Plugin framework (auto-discovered via import.meta.glob) -│ ├── types.ts # DataViewerPlugin / PluginContext / PluginRegistry contracts -│ ├── registry.ts # Singleton registry + generic extension points -│ ├── index.ts # initPlugins() — discovery + setup (called in main.tsx) -│ └── coaching/ # Gitignored private slot (AI coaching submodule) -├── types/ -│ └── racing.ts # ★ Core types: GpsSample, ParsedData, Lap, Course, Track, etc. -├── contexts/ -│ ├── SettingsContext.tsx # Settings provider (useKph, gForce, brakingZones, darkMode, labs) -│ ├── DeviceContext.tsx # Global BLE connection state provider -│ └── AuthContext.tsx # Admin auth context -│ └── AuthContext.tsx # Admin auth context -└── integrations/supabase/ # Auto-generated — DO NOT EDIT - ├── client.ts - └── types.ts +│ ├── datalogParser.ts # ★ Format auto-detection router (entry point for all parsing) +│ ├── *Parser.ts # nmea, ubx, vbo, dove, dovex, alfano, aim, motec (+ parserUtils.ts) +│ ├── channels.ts # ★ Canonical channel registry (ids/labels/units/aliases) + normalizeChannels() +│ ├── fieldResolver.ts # Settings-facing adapter over channels.ts +│ ├── courseDetection.ts # ★ Auto track/course/direction detection + waypoint mode +│ ├── lapCalculation.ts # Start/finish crossing detection → Lap[] +│ ├── lapDelta.ts # ★ Position-based lap delta (arc-length resample + segment-projected gap) +│ ├── fileBrowserTree.ts # ★ Pure file-browser hierarchy: Track→Course→logs, engine/kart filter, breadcrumbs, smart collapse +│ ├── lapSnapshot.ts # ★ Pure snapshot types/keying/buffer (course+engine identity) +│ ├── lapSnapshotStorage.ts # ★ IndexedDB CRUD for lap snapshots (emits garageEvents) +│ ├── setupRevision.ts # ★ Pure content-addressed setup history: hash + freeze (immutable revisions) +│ ├── setupRevisionStorage.ts # ★ IndexedDB CRUD for setup revisions (freezeSetupRevision; emits garageEvents) +│ ├── dbUtils.ts # ★ Shared IndexedDB: DB_NAME, DB_VERSION, openDB(), tx helpers +│ ├── garageEvents.ts # ★ Host pub/sub: storage emits {store,key,put|delete}; cloud-sync syncs off it +│ ├── *Storage.ts # IDB stores: file, kart(compat), vehicle, engine, template, note, setup, +│ │ # video, videoFile, graphPrefs; trackStorage = localStorage (user tracks) +│ ├── (racing math) # brakingZones, speedEvents, speedBounds, gforceCalculation, referenceUtils, trackUtils +│ ├── (charts/video) # chartUtils, chartColors, videoExport, overlayCanvasRenderer +│ ├── ble/ # Web Bluetooth DovesLapTimer protocol, split per-concern (see BLE Integration); +│ │ # + bleDatalogger.ts (legacy barrel), deviceTrackSync.ts, deviceSettingsSchema.ts +│ ├── db/ # Admin DB layer: ITrackDatabase + supabaseAdapter + getDatabase() factory +│ ├── billing.ts # ★ Pure subscription logic (tiers, coming-soon, annual-discount math), no Supabase import — see docs/backend.md +│ ├── billingClient.ts / pendingCheckout.ts # Supabase billing I/O + sign-up checkout stash +│ ├── profanity.ts # Basic client-side profanity filter for display names +│ ├── weatherService.ts # OpenWeatherMap (online-only) +│ └── utils.ts # Tailwind cn() helper +├── plugins/ # ★ Plugin framework (auto-discovered) — see Plugin Framework section +│ ├── (framework) # types, registry, index, panels, mounts, storage + PluginPanelHost/PluginMount +│ ├── cloud-sync/ # ★ First-party plugin: Supabase file + garage sync — see docs/backend.md +│ └── coaching/ # Gitignored slot for the AI coach (npm pkg in production) +├── types/racing.ts # ★ Core types: GpsSample, ParsedData, Lap, Course, Track, … +├── contexts/ # SettingsContext, DeviceContext (BLE), AuthContext (admin) +└── integrations/supabase/ # Auto-generated — DO NOT EDIT ``` --- @@ -200,6 +143,7 @@ File Import (drag-drop / BLE download / file manager) → fileStorage.ts (save raw blob to IndexedDB) → useSessionData.ts (read blob, call parseDatalogFile) → datalogParser.ts (auto-detect format, route to specific parser) + → normalizeChannels() (channels.ts): rewrites every fieldMapping name + extraFields key to a canonical ChannelId (or `custom:` slug), sets display label/unit. Runs once for all formats — parsers keep emitting human names internally. → returns ParsedData { samples: GpsSample[], fieldMappings, bounds, duration, startDate, dovexMetadata?, parserStats? } → courseDetection.ts (auto-detect track, course, direction; waypoint fallback) → returns CourseDetectionResult { track, course, direction, laps, isWaypointMode } @@ -227,12 +171,78 @@ A plugin absent at build time simply never loads — the app builds/runs without | `registry.ts` | Singleton registry: `register`/`get`/`list` + generic `contribute`/`getContributions`. Same-`id` plugins resolve by highest `priority` | | `index.ts` | `initPlugins()` — glob + external discovery, runs each plugin's `setup(ctx)`. Called once in `main.tsx` before render | | `external-plugins.d.ts` | Ambient type for the `virtual:external-plugins` module | +| `panels.ts` | **UI panel framework**: `PluginPanel` / `PluginPanelProps` contract, `PANELS_POINT`, `PanelSlot`, `getPanelsForSlot(slot)`. The curated session snapshot is the entire surface a panel can rely on — incl. `sessionSetup` (the current session's assigned setup) + `activeSnapshot` (`PluginSnapshot`: the loaded reference lap snapshot with clean-lap samples + frozen engine/course/vehicle/setup), so a coach panel can compare the current setup against the frozen snapshot setup | +| `PluginPanelHost.tsx` | Consumer: mounts every panel for a slot in a titled card, each wrapped in a per-panel error boundary; renders a `fallback` when none. A `chromeless` panel skips the card chrome (full-bleed); an all-chromeless slot (`isBareSlot`) drops the host's outer padding so one panel fills the tab | +| `mounts.ts` | **Inline mount framework**: `PluginMountDef`, `MOUNTS_POINT`, `MountSlot` (`FileRow`, `FileDeleteConfirm`), per-slot context types, `getMounts(slot)`. For injecting raw components into fixed spots in core UI | +| `fileSources.ts` | **File-source framework**: `FILE_SOURCES_POINT`, `FileSource` (`listFiles`/`download`), `useFileSources()`. Lets a plugin feed *remote* (cloud) files into the host browser as inline `cloud` rows — host stays cloud-agnostic | +| `PluginMount.tsx` | Consumer: `` renders every mount for a slot (error-boundaried + Suspense), or nothing when none — safe to drop into core UI unconditionally | +| `storage.ts` | `getPluginStore(id)`: schema-less KV scoped to one plugin, in its own IndexedDB DB (`dove-plugin-`). Decoupled from core `dbUtils`. Also exposed as `ctx.storage` | | `coaching/` | **Gitignored** local-dev slot for the coach plugin (production loads it as an npm package) | A plugin default-exports `{ id, name, version?, priority?, setup?(ctx) }`. In `setup`, it contributes to named extension points (`ctx.registry.contribute(point, value)`); consumers read via `getContributions(point)`. New extension points need no registry changes. +`ctx.storage` is a `PluginStore` (per-plugin KV) for persisting plugin state. + +**UI panels:** the first concrete extension point. A plugin contributes +`PluginPanel` descriptors to `PANELS_POINT`, targeting a *slot* (host surface). +Three slots exist today: `PanelSlot.Labs` (rendered by `LabsTab.tsx`; no +first-party panel targets it now — it shows only when the experimental +`enableLabs` setting is on or another plugin contributes), `PanelSlot.Coach` +(rendered by `CoachTab.tsx` — the dedicated AI Coach tab, home for the +`@perchwerks/eye-in-the-sky` coaching plugin), and `PanelSlot.Profile` +(rendered by `ProfileTab.tsx`, far-right — cloud-sync contributes the merged +Account panel (sign-in/out + display name + plan + storage, working signed out +against local storage), lap-snapshot management, and cloud-log management). All render contributed +panels via `PluginPanelHost` and are +**self-gating**: `Index.tsx` computes `hasLabsPanels`/`showCoach`/`showProfile` +from `getPanelsForSlot`, so a tab appears only when a +plugin contributes a panel to it (Labs additionally shows when the experimental +`enableLabs` setting is on). New slots are just new strings — no framework change. +`PluginPanelHost` wraps each panel in an error boundary **and** a `Suspense` +boundary, so panel components can be `React.lazy` (as `cloud-sync` is). A panel +may set `chromeless: true` to render its body without the host's card/header/ +padding — for panels that own their full layout (e.g. a full-bleed coach +dashboard); the error boundary + Suspense still apply, and a slot whose panels +are all chromeless (`isBareSlot`) also drops the host's outer padding. + +**Inline mounts:** where panels are standalone cards, *mounts* inject a raw +component into a fixed spot in core UI. A plugin contributes a `PluginMountDef` +to `MOUNTS_POINT`, targeting a `MountSlot`; the host renders `` at that spot, passing a typed context as a single `ctx` prop. +`FilesTab` exposes two: `MountSlot.FileRow` (per *local* file row, ctx = that +file + metadata — cloud-sync's per-file sync toggle) and +`MountSlot.FileDeleteConfirm` (inside the delete-confirm banner, ctx = the target +file + a `registerOnConfirm` hook so a plugin can run an extra action — e.g. +cloud-sync's "also delete the cloud copy" — without the host knowing about +cloud). New mount locations are just new slot strings. + +**File sources (`fileSources.ts`, `FILE_SOURCES_POINT`):** the seam that puts +*cloud* files inline in the browser without coupling the host to cloud. A plugin +contributes a `FileSource` (`{ id, listFiles(): Promise, +download(name): Promise }`); `FilesTab` merges the listed files into +the same Track→Course tree as **`location: "cloud"`** rows (deduped against local, +local wins), and a one-tap on a cloud row calls `download` → `onSaveFile` → +opens it. cloud-sync's source dynamic-imports `syncEngine` so Supabase stays off +the initial bundle, and returns `[]` when signed out/offline. The shared +**`SessionBrowser`** component (`src/components/SessionBrowser.tsx`) renders any +`BrowserView` (breadcrumb + folders + caller-rendered rows) — used by both +`FilesTab` and the Profile **Cloud logs** panel. + +**Cloud Sync (first-party plugin, `src/plugins/cloud-sync/`):** the first +in-repo plugin built on the panel framework — contributes the merged **Account** +panel (`StoragePanel`, `PanelSlot.Profile`, ordered first — sign-in/out, display +name, plan, and the storage bar, which falls back to `localUsage.ts` when signed +out to show this device's local usage), the lap-snapshots + cloud-logs panels +(the **Cloud logs** panel renders the same Track→Course `SessionBrowser` and hosts +the "Download all cloud logs" bulk action), the per-file sync-toggle mount, and a +**file source** that surfaces cloud-only logs inline in the file browser. +Syncing is automatic (no manual push/pull) — `autoSync` drives +the incremental engine. Backs the IndexedDB stores up to Supabase: structured +stores → `sync_records` jsonb docs, raw blobs → the private `user-files` bucket. +**Full data model, sync engine, conflict resolution, and backend live in +`docs/backend.md`.** **AI coach (npm package):** published to the public npm registry as `@perchwerks/eye-in-the-sky` and listed in `optionalDependencies`. The loader in @@ -276,8 +286,8 @@ Detection order matters: binary formats first (MoTeC LD → UBX), then text form | `Track` | `name`, `shortName?` (max 8 chars), `courses[]` | | `CourseDetectionResult` | `track`, `course`, `direction?`, `laps[]`, `isWaypointMode`, `waypointNotice?` | | `CourseDirection` | `'forward' \| 'reverse'` | -| `FieldMapping` | `index`, `name`, `unit?`, `enabled` — maps extraFields to UI toggles | -| `FileMetadata` | `fileName`, `trackName`, `courseName`, `weatherStation*?`, `sessionKartId?`, `sessionSetupId?`, `fastestLapMs?`, `fastestLapNumber?` | +| `FieldMapping` | `index`, `name` (canonical ChannelId or `custom:` slug — the extraFields key), `label?` (display), `unit?`, `enabled` | +| `FileMetadata` | `fileName`, `trackName`, `courseName`, `weatherStation*?`, `sessionKartId?`, `sessionSetupId?` (live setup), `sessionSetupRev?` (frozen setup-revision content hash), `sessionEngine?` (engine snapshot for browser grouping), `sessionStartTime?` (first-sample epoch ms → browser display name), `fastestLapMs?`, `fastestLapNumber?`. Partial updates go through `updateFileMetadata(fileName, patch)` (read-merge-write — never clobbers untouched tags). | --- @@ -314,7 +324,7 @@ GPS data is always parseable even if metadata is corrupted. Metadata is attached ## IndexedDB Storage (`src/lib/dbUtils.ts`) -Single shared database: `"dove-file-manager"`, version 9. +Single shared database: `"dove-file-manager"`, version 12. | Store | Key | Module | |-------|-----|--------| @@ -328,11 +338,118 @@ Single shared database: `"dove-file-manager"`, version 9. | `vehicle-types` | `id` | `templateStorage.ts` | | `setup-templates` | `id` | `templateStorage.ts` | | `session-videos` | `sessionFileName` | `videoFileStorage.ts` | +| `engines` | `id` | `engineStorage.ts` | +| `lap-snapshots` | `id` (indexed by `courseKey`, `engineKey`) | `lapSnapshotStorage.ts` | +| `setup-revisions` | `id` = content hash (indexed by `setupId`) | `setupRevisionStorage.ts` | To add a new store: increment `DB_VERSION`, add store name to `STORE_NAMES`, add creation logic in `openDB()`, create a corresponding storage module. --- +## Lap Snapshots (`src/lib/lapSnapshot.ts` + `lapSnapshotStorage.ts`) + +Frozen "course fastest lap" captures — an immutable single-lap baseline for +cross-session comparison (and future AI coaching). + +- **Identity = (course + engine).** Engine is the layman's "primary key"; the + chassis travels inside the frozen `setup`. Exactly one snapshot per pair — a + faster lap upserts in place (same deterministic `id`). `engine` is the free-text + `Vehicle.engine` string, matched via `engineKey` (trimmed + lowercased). +- **What's frozen:** the lap's GPS samples **± a 5s buffer** on each side (so a + later start/finish nudge still fits), `lapStartMs`/`lapEndMs` markers, the + `Course` geometry, lap time, source file/lap, and a copy of the vehicle/setup. + `snapshotLapSamples()` trims the buffer back to the clean lap for overlay. +- **Capture triggers:** assigning an engine + setup to a log prompts + (`LapSnapshotPromptDialog`) when its best lap beats (or has no) stored + snapshot; a manual "Save as snapshot" lives in `LapSnapshotControls` (the + lap-list **Snapshots** picker, in the header so it serves simple + pro mode). + Orchestrated by `useLapSnapshots`. +- **Loaded as a comparison overlay only.** Selecting a snapshot feeds its clean + samples into the **external-reference slot** (`externalRefSamples`), so it + renders like a reference lap and is **excluded from playback + the video + player** — it is never an appended lap. Engine is shown in the overlay label. +- **Sync (cloud-sync plugin):** a **dedicated `lap_snapshots` table**, but its + serialized payload size counts toward the **same unified per-tier byte budget** + as documents + logs (`subscription_tiers.total_bytes`), enforced by a trigger — + no separate count quota. Always pushes on save; a local delete **never** + propagates to the cloud (the cloud copy is removed only explicitly from + **Profile → Lap snapshots**, like the log menu). Cloud deletes are tombstoned + (`snapshotTombstones.ts`) so reconcile won't resurrect a surviving local copy. + `reconcileSnapshots()` pulls cloud→local additively and pushes local-only up. + Local storage is always unlimited. + +--- + +## Setup Revisions (`src/lib/setupRevision.ts` + `setupRevisionStorage.ts`) + +Immutable, **content-addressed** history of vehicle setups — git's blob model +without the diff chains. A `VehicleSetup` (`setups` store) is the *live, editable* +working copy; a `SetupRevision` (`setup-revisions` store) is a write-once frozen +copy whose **`id` is a SHA-256 of its content**. This keeps a session's setup +exactly as it was the day it ran, even after the live setup is later edited. + +- **Freeze on assignment.** `handleSaveSessionSetup` (`useSessionMetadata`) calls + `freezeSetupRevision(setupId)`, which reads the live setup + its template, builds + the revision (`buildSetupRevision`), and stores its hash on + `FileMetadata.sessionSetupRev`. `sessionSetupId` (live pointer) is kept alongside + for lineage / the future "edit the setup later" flow. +- **The hash is the identity.** `computeSetupHash(setup, template)` hashes a + canonical (sorted-key) projection of the setup's values **+ the template + structure**, excluding volatile bookkeeping (`id`/`createdAt`/`updatedAt`). So + two sessions on the genuinely-identical setup dedup to the **same hash**, and any + value change — *or* a template change (a renamed/added field) — yields a new + hash, i.e. a new revision, with no child-type machinery. `freezeSetupRevision` + is idempotent: an existing-hash revision is reused (original `createdAt` kept). +- **Self-contained.** A revision embeds a frozen copy of the `setup` **and** the + template structure (`FrozenTemplate`: section + field names/units), so old + history always renders with the labels it had that day. +- **Display.** `shortRevHash()` surfaces the leading 6 hex chars (git-style). The + **SetupsTab** list shows each setup's current would-be hash; **NotesTab** shows + the frozen `#hash` of the session's setup revision. +- **Orphan prune (GC).** A revision is an orphan once no `FileMetadata.sessionSetupRev` + points at it. `pruneSetupRevisions()` deletes orphans (pure split: + `findOrphanRevisionIds`); `maybePruneSetupRevisions()` throttles it to ~once every + `PRUNE_INTERVAL_MS` (3 days) via a localStorage timestamp and is fired + best-effort from `useSetupManager` on mount. Works fully offline. +- **Sync (cloud-sync plugin):** revisions ride the **generic garage-doc engine** — + registered in `syncStores.ts` (`DOC_STORES` + `KEY_FIELD`, keyed by `id`), so + they push/pull as ordinary `sync_records` rows counting toward the pooled + documents budget. No dedicated table. Being immutable + content-addressed, the + last-write-wins merge is a no-op on collision. **Prune is local-only:** a deleted + orphan is **tombstoned** (`setupRevisionTombstones.ts`, per-user) rather than + removed from the cloud — `autoSync` skips the cloud delete and the + `setup-revisions` store accessor skips re-pulling a tombstoned id, so the sweep + isn't undone by reconcile. A fresh freeze of the same content clears the + tombstone. **Cloud-side GC and later-editing are deliberate follow-ups.** + +--- + +## Cloud Sync, Subscriptions & GDPR — see `docs/backend.md` + +These three subsystems are **Supabase-backed** and, per the offline-first rule +(#1), touch nothing in the core app. Their data models, RLS, triggers, edge +functions, and client wiring are documented in +**[`docs/backend.md`](docs/backend.md)** to keep this file focused. Read it +before working on: + +- **Cloud Sync** (`src/plugins/cloud-sync/`) — per-user backup/sync of the + IndexedDB garage + log blobs: auto-sync off `garageEvents`, conflict resolution + (pending-wins + last-write-wins), the unified pooled byte quota, orphan-safety, + and opt-in per-file logs. +- **Subscriptions / Stripe** — paid tiers that scale one pooled storage budget; + tiers are data (`subscription_tiers.total_bytes`), prices resolve by Stripe + lookup_key, and entitlements are written only by the webhook. Operator setup + (Products/Prices, secrets, `pg_cron`) is in the README. +- **Data Rights & Retention / GDPR** — self-service export/erasure, the 7-day + deletion window, and automatic IP minimisation. + +Documents, logs, and lap snapshots all draw from **one pooled per-tier byte +budget** (`subscription_tiers.total_bytes`: free 50 MB / plus 10 GB / premium +100 GB / pro 500 GB), shown as a single segmented bar on the Profile tab — see +`docs/backend.md`. + +--- + ## Course Layouts (Drawing Feature) The `course_layouts` table stores polyline drawings of track layouts (1:1 with courses, unique on `course_id`, cascade delete). @@ -403,6 +520,41 @@ Pure comparison/conversion logic for merging app tracks with device track files: --- +## File Browser (`FilesTab.tsx` + `lib/fileBrowserTree.ts` + `components/SessionBrowser.tsx`) + +The Garage → **Files** tab is a folder hierarchy, not a flat list: **Track → Course +→ logs**, with an optional **Engine/Kart** grouping on the final list. All the tree ++ navigation math is pure in `fileBrowserTree.ts` (unit-tested); the reusable +presentational **`SessionBrowser`** renders the computed `BrowserView` (breadcrumb ++ folders + caller-rendered rows). `FilesTab` owns the local row chrome; the +Profile **Cloud logs** panel reuses `SessionBrowser` with its own rows. + +- **Display name = the session's date/time**, derived from `sessionStartTime` (the + first valid sample), e.g. "2/12/2026 11:15 AM" — *not* the upload time or raw + filename (filename is the row's `title`/tooltip + the stable IndexedDB key). +- **Smart collapse:** a folder level is only rendered when there's more than one + entry — a single track and/or single course auto-descends straight to the logs + (the breadcrumb still records the collapsed segments so date names read in + context). The explicit Engine/Kart filter, by contrast, **always** shows its + folder(s); logs with no engine/kart sit loose **below** the filter folders. +- **Untagged bucket:** logs missing a track/course land in an "Untagged" folder + after the real tracks (collapsing to a flat list when it's the only group). +- **Opens at the current session.** `Index.tsx` passes the loaded session's + `currentTrackName`/`currentCourseName`; `FilesTab` re-homes there (`defaultNav`) + on every drawer open and whenever a different session loads. +- **Grouping data** rides `FileMetadata`: `sessionEngine` (snapshotted from the + kart at assign time, so grouping survives vehicle edits), `sessionKartId` + (→ vehicle name), and `sessionStartTime`. Engine resolves to the snapshot first, + then the live `Vehicle.engine`. +- **Cloud files appear inline.** Plugins contribute remote files via a + `FileSource` (`FILE_SOURCES_POINT`); `buildBrowserSessions` merges them as + `location: "cloud"` rows (deduped against local — local wins), and their + metadata is read from the locally-synced `metadata` store (it pulls down even + when the blob doesn't). A cloud row is a one-tap **download → save → open**. No + separate "Cloud files" section — the offline-first host stays cloud-agnostic. + +--- + ## Device Manager The slide-out drawer (`FileManagerDrawer.tsx`) has two top-level tabs: @@ -421,9 +573,22 @@ Global BLE connection state is managed by `DeviceContext.tsx`, wrapping the app `useSettings` hook (persists to localStorage) → `SettingsContext` for tree-wide access. -Key settings: `useKph`, `gForceSmoothing`, `gForceSmoothingStrength`, `brakingZoneSettings` (thresholds, duration, smoothing, color, width), `enableLabs` (hidden when no labs features), `darkMode`. +Key settings: `useKph`, `gForceSmoothing`, `gForceSmoothingStrength`, `brakingZoneSettings` (thresholds, duration, smoothing, color, width), `enableLabs` (hidden when no labs features), `darkMode`, `deltaMethod` (`'position'` default | `'distance'` legacy), `deltaSampleMeters` (arc-length resample spacing for position delta, default 2). -`fieldResolver.ts` maps parser-specific field names (e.g., "Lat G", "Lateral G", "LatG") to canonical IDs (`lat_g`) so settings apply uniformly. +`useReferenceLap.ts` routes pace through `computeLapPace` (`lapDelta.ts`), which +switches on `deltaMethod`. The position method is the issue #29 port; `distance` +falls back to the legacy `calculatePace` in `referenceUtils.ts`. + +Channels are normalized to canonical ids at parse time (`channels.ts` → +`normalizeChannels()`), so `extraFields` keys and `FieldMapping.name` are uniform +across formats (e.g. every parser's lateral-g lands on `lat_g`, with display +`label` "Lat G"). G-force is modelled as distinct ids per source — `lat_g`/`lon_g` +(primary/GPS-derived), `lat_g_native`/`lon_g_native` (logger-native), `accel_x/y/z` +(raw IMU) — which coexist on a sample and must never collapse. `fieldResolver.ts` +is the settings-facing adapter (resolves names→ids for the field-default +show/hide). `toChannelKey()` is the idempotent shim that migrates legacy +display-name keys persisted in graph-prefs / saved overlay configs on load, so +existing user data keeps resolving without a destructive migration. --- @@ -434,8 +599,8 @@ Key settings: `useKph`, `gForceSmoothing`, `gForceSmoothingStrength`, `brakingZo | `VITE_SUPABASE_URL` | Client | Backend URL (auto-set) | | `VITE_SUPABASE_PUBLISHABLE_KEY` | Client | Backend anon key (auto-set) | | `VITE_SUPABASE_PROJECT_ID` | Client | Backend project ID (auto-set) | -| `VITE_ENABLE_ADMIN` | Client | `"true"` to enable admin UI + `/login` route | -| `VITE_ENABLE_REGISTRATION` | Client | `"true"` to enable `/register` route | +| `VITE_ENABLE_ADMIN` | Client | `"true"` to enable admin UI + `/admin` route. `/login` is also mounted when this OR `VITE_ENABLE_CLOUD` is on. | +| `VITE_ENABLE_CLOUD` | Client | `"true"` to enable public user accounts (Cloud Sync + Google sign-in + `/register`, `/forgot-password`, `/reset-password`, `/auth/callback`). Default `"false"` — preserves offline-first invariant. | | `VITE_TURNSTILE_SITE_KEY` | Client | Cloudflare Turnstile site key (optional CAPTCHA) | | `TURNSTILE_SECRET_KEY` | Server (edge fn) | Turnstile secret — `???` | | `DOVE_PLUGIN_PACKAGES` | Build | Comma-separated external plugin npm packages to load. Overrides the default (`@perchwerks/eye-in-the-sky`) when set | @@ -458,17 +623,32 @@ npm run typecheck # tsc -b (must use build mode to follow project references) npm run preview # Preview production build npm test # Vitest in watch mode npm run test:run # Vitest single pass (CI-style) +npm run test:coverage # Vitest + v8 coverage (enforces thresholds in vitest.config.ts) ``` +> **Coverage scope (`vitest.config.ts`).** Coverage is deliberately scoped to +> *logic worth unit-testing* — `lib/` parsers/utilities/protocol code, `hooks/`, +> and `plugins/`. The React **view layer is excluded**: presentational +> components (`src/components/**/*.tsx`), route/page shells (`src/pages/**`), +> context providers (`src/contexts/**`), `App.tsx`, vendored `ui/`, and the +> generated Supabase client. Note the exclude targets `components/**/*.tsx` +> *only* — the `.ts` logic files under `components/video-overlays/` stay in +> scope. Don't widen the include to pull view code back in (it tanks the number +> with code nobody unit-tests) and don't exclude `hooks/`/`lib/` to inflate it +> (that hides real test debt). Thresholds are floors a few points below current +> actuals — ratchet them up as coverage grows. + > **Why `tsc -b`?** The root `tsconfig.json` has `files: []` and only uses > `references` to point at `tsconfig.app.json` + `tsconfig.node.json`. Plain > `tsc --noEmit` from repo root silently exits 0 without checking anything. > `tsc -b` (build mode) follows references; both referenced configs have > `noEmit: true` so nothing is emitted. -CI is split into four parallel workflows under `.github/workflows/` -(`lint.yml`, `typecheck.yml`, `test.yml`, `build.yml`). Each runs on every PR -and push to `main` and shows up as its own status check + README badge. +CI is split into five parallel workflows under `.github/workflows/` +(`lint.yml`, `typecheck.yml`, `test.yml`, `build.yml`, `coverage.yml`). Each +runs on every PR and push to `main` and shows up as its own status check + +README badge. `coverage.yml` also enforces the thresholds in `vitest.config.ts`, +posts a per-PR summary comment, and publishes the % badge JSON. --- @@ -520,12 +700,11 @@ independently across deploys so app-only changes don't re-download vendor code. --- -Update the readme when new parsers are added and when build parameters change. Make sure to ALWAYS note new environment variables and their values (use "???" When it is a secret value) in the readme. - -Update the credits list when new Foss libraries are added. - -Never do on a server what you can do on the client, the NUMBER ONE PRIORITY for this webapp is that 99% of the features are available offline. (Things like weather, satellite view etc, are obvious exceptions). - -Keep code modular and reusable, fuck line count as long as you can reuse the shit out of things, rewrites to make things more reusable are always cool. - -ALWAYS keep CLAUDE.md updated with new files and information to help it as well. +_Closing reminders, in the author's words (these reiterate the Golden Rules — kept +because they set the tone): **never do on the server what you can do on the +client** — 99% offline is the number-one priority (weather, satellite view, admin +excepted). **Keep code modular and reusable** — fuck line count as long as you +reuse the shit out of things; rewrites for reusability are always welcome. Keep +`README.md` current (ALWAYS note new env vars + their values, `???` for secrets) +and the Credits list current. And **always keep `CLAUDE.md` updated** with new +files and architecture as you go._ diff --git a/README.md b/README.md index 33bf323..bbcab44 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - 3-sector split timing with optimal lap - Pro graph view with multi-series telemetry charts - Reference lap overlay & pace delta comparison +- Lap snapshots — save a "course fastest lap" per engine, frozen for cross-session comparison (local-first, optionally cloud-synced) - Video sync with telemetry playback - 9 overlay gauge types (digital, analog, graph, bar, bubble, map, pace, sector, lap time) - MP4 video export with overlays & audio (H.264 + AAC) @@ -41,6 +42,7 @@ - Device track sync over Bluetooth - Custom track & course editor with community submissions - Local weather lookup +- Optional cloud sync of files & garage data across devices (requires backend + sign-in) - Dark & light mode - PWA — installable & fully offline @@ -109,14 +111,49 @@ The app includes an optional admin system for managing a community track databas | `VITE_SUPABASE_URL` | Yes (if using Cloud) | Backend URL (auto-set by Lovable Cloud) | | `VITE_SUPABASE_PUBLISHABLE_KEY` | Yes (if using Cloud) | Backend public/anon key (auto-set by Lovable Cloud) | | `VITE_SUPABASE_PROJECT_ID` | Yes (if using Cloud) | Backend project ID (auto-set by Lovable Cloud) | -| `VITE_ENABLE_ADMIN` | No | Set to `true` to enable admin UI and `/login` route | -| `VITE_ENABLE_REGISTRATION` | No | Set to `true` to enable the `/register` route | +| `VITE_ENABLE_ADMIN` | No | Set to `true` to enable admin UI and `/admin` route. `/login` mounts when admin OR cloud is enabled. Default `false` — a fresh clone ships the public, offline-first app, not admin UI pointed at an upstream backend. | +| `VITE_ENABLE_CLOUD` | No | Set to `true` to enable public user accounts: Cloud Sync Labs panel, Google sign-in, `/register`, `/forgot-password`, `/reset-password`, `/auth/callback`. Default `false` — flag-off builds ship zero cloud auth code (offline-first invariant). | | `VITE_TURNSTILE_SITE_KEY` | No | Cloudflare Turnstile site key for track submission CAPTCHA | | `TURNSTILE_SECRET_KEY` | No | Cloudflare Turnstile secret key (edge function secret — `???`) | +| `STRIPE_SECRET_KEY` | No (required for paid tiers) | Stripe secret key used by the `create-checkout-session`, `stripe-webhook`, and `create-portal-session` edge functions (edge function secret — `???`) | +| `STRIPE_WEBHOOK_SECRET` | No (required for paid tiers) | Signing secret for the `stripe-webhook` endpoint, from the Stripe dashboard webhook config (edge function secret — `???`) | +| `DELETION_CRON_SECRET` | No (required for scheduled account deletion) | Shared secret the `process-account-deletions` edge function requires in the `x-cron-secret` header. Must match the Vault secret `deletion_cron_secret` that the daily pg_cron job sends (edge function secret — `???`) | | `DOVE_PLUGIN_PACKAGES` | No | Build-time: comma-separated external plugin npm packages to load. Overrides the default (`@perchwerks/eye-in-the-sky`, the public AI coach) when set | > **Note:** `TURNSTILE_SECRET_KEY` is a server-side secret stored in Lovable Cloud, not a `VITE_` client variable. If not set, Turnstile verification is skipped. +> **Stripe / paid tiers:** `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` are +> edge-function secrets (not `VITE_` client vars). Prices are resolved live by +> **lookup_key** — there are no Price ids in code or env. Create the +> Plus/Premium/Pro Products in Stripe with one recurring Price per billing +> interval, each tagged with the matching lookup_key: +> `plus_monthly`, `plus_annual`, `premium_monthly`, `premium_annual`, +> `pro_monthly`, `pro_annual`. Then point a Stripe webhook (events: +> `checkout.session.completed`, `customer.subscription.created/updated/deleted`) +> at the `stripe-webhook` function URL. When `STRIPE_SECRET_KEY` is absent the +> pricing UI falls back to showing only the two free cards (Guest + Free). Use +> Stripe **test mode** first. Tier entitlements are granted only by the webhook, +> never the client. +> +> **On-hold / comped tiers:** the **Premium** and **Pro** (AI) tiers are listed in +> `COMING_SOON_TIERS` (`src/lib/billing.ts`, mirrored in `create-checkout-session`) +> so they're hidden from the pricing UI entirely and can't be bought via the app +> (only **Free** + **Plus** are shown at launch). To give one to a tester/friend, +> create the subscription directly in Stripe on the `premium_*` / `pro_*` price and +> set the subscription's `metadata.user_id` to their account id (or change an +> existing customer's price) — the webhook grants it. Remove the tier from both +> `COMING_SOON_TIERS` sets to open self-service purchase. +> +> **Cancellation grace + log trimming:** a cancelled subscription ends at the +> period boundary and drops to the free tier's limit immediately, but the +> user's cloud logs are kept for a 60-day grace window (`grace_until`). After it +> expires, the `trim_expired_logs()` function (scheduled daily via `pg_cron`) +> deletes their synced log files newest-first until their pooled total (docs + +> remaining logs + snapshots) fits the free `total_bytes` allowance; snapshots +> and garage docs are never auto-deleted. If `pg_cron` isn't enabled on the +> project, enable it (Dashboard → Database → Extensions) or invoke +> `select public.trim_expired_logs();` from an external scheduler. + > **Note:** `DOVE_PLUGIN_PACKAGES` is build-time only (read by `vite.config.ts`), not a client `VITE_` variable. It overrides which external plugin packages the build loads; by default the build pulls in the public AI coach (`@perchwerks/eye-in-the-sky`) from npm as an optional dependency — see `src/plugins/README.md`. > **Build fallback:** `vite.config.ts` now hardcodes the project's public backend URL, publishable key, and project ID as a fallback for production builds. Local `.env` values still take precedence, but published builds no longer white-screen if managed env injection is temporarily missing. @@ -133,6 +170,29 @@ The admin system uses Lovable Cloud (Supabase) for the database. The schema is c - **banned_ips** — IP addresses blocked from submissions - **login_attempts** — Rate limiting for login (5 attempts, 1 hour lockout) - **user_roles** — Admin/user role assignments (uses `has_role()` security definer) +- **sync_records** — Per-user cloud-sync documents (files/garage data), RLS-scoped to the owner +- **user-files** (Storage bucket) — Private per-user session file blobs for cloud sync +- **lap_snapshots** — Per-user frozen "course fastest lap" captures (one per course+engine), RLS-scoped; its own table, but its size counts toward the unified storage pool (below) +- **subscription_tiers** — Data-driven plan catalogue (free/plus/premium/pro): label, price, and a single pooled cloud-storage budget (`total_bytes`: 50 MB / 10 GB / 100 GB / 500 GB) shared by documents + logs + snapshots +- **user_subscriptions** — Per-user tier + Stripe customer/subscription state, status, renewal date, cancellation grace (service-role-written only) +- **profiles** — Per-user unique, editable display name +- **account_deletions** — Pending self-service account-deletion requests (7-day, reversible grace window) + +> **Data retention (GDPR):** a daily `pg_cron` job runs +> `purge_expired_personal_data()`, which nulls the submitter IP on `submissions` +> and `messages` 90 days after they were received, deletes the rows entirely +> after 1 year (all `messages`; `submissions` only once reviewed — pending ones +> are kept for moderation), and deletes expired `banned_ips` / stale +> `login_attempts`. Account deletion is scheduled 7 days out +> (cancellable); the `process-account-deletions` worker then removes the user's +> Storage objects and auth row. To auto-schedule that worker, add a Supabase +> **Vault** secret named `deletion_cron_secret` and set the matching +> `DELETION_CRON_SECRET` env on the function, then re-run the GDPR migration — +> it wires the daily `pg_cron` + `pg_net` job for you. + +> Cloud sync is independent of the admin system — it only needs a signed-in user +> account, not the admin role. It's an online-only, opt-in feature; the core app +> stays fully offline without it. ### Modular Database Layer @@ -151,7 +211,7 @@ src/lib/db/ - **Tracks CRUD** — Add, edit, enable/disable, delete tracks (with short names) - **Courses CRUD** — Manage courses per track with coordinate editing - **Tools** — Build `tracks.json` from DB, download tracks ZIP, import JSON to rebuild DB, export/import course drawings -- **Banned IPs** — View and manage banned IP addresses +- **Banned IPs** — View and manage banned IP addresses, with a selectable expiry (TTL; defaults to 90 days, expired bans auto-purged) ### Edge Functions @@ -160,6 +220,14 @@ src/lib/db/ | `submit-track` | Public endpoint for track submissions (with IP ban check) | | `admin-build-zip` | Admin-only: generates per-track JSON files | | `check-login-rate` | Rate limiting for login attempts | +| `submit-message` | Public contact-form endpoint (with IP ban + rate limit) | +| `stripe-prices` | Public: reports whether Stripe is configured + live monthly/annual prices (resolved by lookup_key) for the pricing UI | +| `create-checkout-session` | Auth: starts Stripe Checkout for a tier + interval | +| `create-portal-session` | Auth: opens the Stripe Billing Portal (manage/cancel/renewal) | +| `stripe-webhook` | Stripe-signed: the only writer of subscription tier/status + grace window | +| `export-account-data` | Authenticated: returns all server-side data for the caller (GDPR access/portability) | +| `request-account-deletion` | Authenticated: schedules the caller's account for deletion 7 days out | +| `process-account-deletions` | Cron-only (`x-cron-secret`): erases Storage objects + auth rows for accounts past their grace window | ### Track Short Names diff --git a/bun.lock b/bun.lock index 12de6ac..fdd2f48 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "doves-dataviewer", "dependencies": { + "@lovable.dev/cloud-auth-js": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", @@ -59,7 +60,7 @@ "vitest": "^4.1.6", }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.0.2", + "@perchwerks/eye-in-the-sky": "0.3.0", }, }, }, @@ -356,6 +357,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -366,7 +369,7 @@ "@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], - "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.0.2", "", {}, "sha512-ssi01fB5gRnPFMkt6YqfxeAZ5NH99EEQvbySG7iJltlNeFPToobTpTgob9j2/0IIkqAHz8mW6JHKfzzLGMHRyg=="], + "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.3.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.3.0.tgz", { "dependencies": { "uplot": "^1.6.32" }, "peerDependencies": { "leaflet": "^1.9.4", "react": "^18.3 || ^19.0.0", "react-dom": "^18.3 || ^19.0.0" } }, "sha512-XJ6Riez1x5A210Kj0Vxt+NU8P+QZGHo7ZYgqN684uBWaB/JjY9sjWEAp5nxxtZrNN17NzHYM/NsOK/xN3AyHfA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -1426,6 +1429,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uplot": ["uplot@1.6.32", "", {}, "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], diff --git a/docs/backend.md b/docs/backend.md new file mode 100644 index 0000000..3b7798f --- /dev/null +++ b/docs/backend.md @@ -0,0 +1,247 @@ +# Backend Reference — Supabase / Server-Side + +> Extracted from `CLAUDE.md` to keep that file focused on the offline-first core. +> **Per Golden Rule #1, the core app needs none of this** — these subsystems are +> the accepted online exceptions (cloud sync, billing, account data rights). +> Read this before working on the `src/plugins/cloud-sync/` plugin, billing +> (`lib/billing*.ts`, `PricingCards`, `PlanCheckout`), GDPR/account flows, or +> anything under `supabase/` (migrations + edge functions). Operator setup +> (Stripe Products/Prices, secrets, `pg_cron`) lives in `README.md`. + +--- + +## Cloud Sync (`src/plugins/cloud-sync/`) + +Optional per-user backup/sync of the IndexedDB stores (see CLAUDE.md → IndexedDB +Storage) to Supabase. Built as a first-party plugin (Labs + Profile panels), +online-only (accepted offline-first exception). There are **no manual push/pull +buttons** — the **document tier auto-syncs**, and is +**offline-aware + conflict-safe**: storage modules emit `garageEvents` on +write/delete, and `autoSync.ts` (started in `setup`, dynamically imported to stay +off the initial bundle) debounces and incrementally **upserts / deletes** the one +changed record while signed in. So edits back up automatically and **deletes +propagate everywhere** — the Karts/Setups delete UI shows a loud "deletes from +every device + the cloud" warning when signed in. + +**Conflict resolution** (`merge.ts`, pure + tested): every garage record carries an +`updatedAt` (stamped in each storage `save*`; the sync write path `writeOne` keeps +the cloud value). `decideSync` is **pending-wins + last-write-wins**: a change made +offline or whose push failed is recorded in a persistent **pending set** +(`pendingSync.ts`, in the plugin KV) and, on reconnect/sign-in, flushed first as +**priority-1** (replacing the cloud copy); everything else merges by newest +`updatedAt` (the record's logical edit time — never the server row time). +`reconcileDocs` does the two-way merge (pull cloud-newer, push local-newer/-only), +skipping pending keys. Its push goes through `pushDocRows`: one +optimistic batch, falling back to per-record upserts if the server quota trigger +rejects the batch — so an over-limit local set still **partial-syncs** everything +that fits and reports a `skipped` count (surfaced as a toast) rather than failing +wholesale. `autoSync` tracks `navigator.onLine` + window online/offline events; +the Profile-tab `StoragePanel` flags offline state + the pending count. + +**Unified storage pool** (`storageTypes.ts`, enforced server-side) — there is +**one per-tier byte budget** that three data kinds share: **documents** (all +structured stores — auto-synced), **logs** (file blobs — opt-in), and +**snapshots** (`lap_snapshots` rows, by serialized size). The limit is +`subscription_tiers.total_bytes` (the single source of truth for the enforcing +triggers + the client meter); `sync_storage_usage()` returns the per-segment +breakdown + the one pooled limit for the Profile-tab segmented bar. Client checks +are advisory — the DB triggers are the real gate. + +Backend (migrations `..._cloud_sync.sql`, `..._storage_quotas.sql`, +`..._unified_storage_quota.sql`): + +| Object | Type | Notes | +|--------|------|-------| +| `sync_records` | table | One jsonb document per record: `(user_id, store, record_key, data, updated_at)`, unique on `(user_id, store, record_key)`. RLS: `auth.uid() = user_id`. `store`/`record_key` mirror the IndexedDB store name + key path. | +| `user-files` | Storage bucket | Private. Raw session blobs at `{user_id}/{encodeURIComponent(name)}`. RLS scopes objects to the owner's folder. | +| `total_storage_used(uuid)` | fn (SECURITY DEFINER) | Bytes a user occupies across `sync_records` + `lap_snapshots`. Used by the quota triggers + the trim job. | +| `tier_total_limit(uuid)` | fn (SECURITY DEFINER) | The user's single pooled byte budget from their tier `total_bytes`, falling back to free, then a hard 50 MB. | +| `enforce_sync_quota` | trigger | BEFORE INSERT/UPDATE on `sync_records`: rejects a write that pushes the caller's **pooled total** (this table + all `lap_snapshots`, minus the upserted row) over `tier_total_limit()` (`quota_exceeded`). | +| `enforce_snapshot_quota` | trigger | BEFORE INSERT/UPDATE on `lap_snapshots`: same pooled check keyed off the snapshot's serialized size (`quota_exceeded`). | +| `sync_storage_usage()` | RPC | Single row `(documents_bytes, logs_bytes, snapshots_bytes, total_limit_bytes)` for the caller — the limit reflects the caller's tier. | +| `profiles` | table | `(user_id PK→auth.users, display_name unique, …)`. RLS: authenticated read-all, update/insert own. Display name is unique but **not** a key — user-editable. | +| `handle_new_user` | trigger | On `auth.users` insert: creates a profile, using the sign-up `display_name` or a generated silly name (`SpeedyRac3r-546`). `unique_display_name()` auto-suffixes a taken name at creation; user edits get an explicit "taken" error instead. | + +Synced stores (`syncStores.ts` — pure, unit-tested): `metadata`, `karts`, +`setups`, `notes`, `graph-prefs`, `vehicle-types`, `setup-templates`, `engines`, +`setup-revisions`, `tracks` (jsonb docs) + `files` (blobs). Video stores are +intentionally excluded (size). `vehicle-types`/`setup-templates` ride along +because setups are template-driven. `setup-revisions` are immutable, +content-addressed (id = content hash) frozen setups — they push/pull as ordinary +garage docs; the LWW merge is a no-op on collision since the key already implies +identical content. Their **orphan prune is local-only**: a pruned revision is +tombstoned (`setupRevisionTombstones.ts`), so `autoSync` skips the cloud delete +and the store accessor skips re-pulling it — the cloud copy survives for other +devices (see *Setup Revisions* in `CLAUDE.md`). +Most stores are IndexedDB; **`tracks` is localStorage** (only *user* tracks/courses, +never the built-in public ones), reached through `storeAccessors.ts` — a per-store +read/get/put seam so the engine isn't hard-wired to IndexedDB. Track edits stamp +`updatedAt` + emit `garageEvents`, so they ride the same auto-sync + delete +propagation + pending-wins/LWW merge as setups. + +Cloud **log deletion** happens two ways. (1) On the Profile tab (`CloudLogsPanel`): +`listCloudFiles` (with `uploadedAt`) lists the user's cloud log files; +`deleteCloudFile(userId, name)` removes the blob + its `sync_records` index row +(cloud-only — other devices keep their downloaded copy), and the panel clears the +per-file selection + optionally deletes the local copy on this device. (2) On +**local delete** of a synced log: the `FileDeleteConfirm` mount (`FileDeleteToggle`) +adds an opt-in *"also delete the cloud copy"* switch (off by default — the cloud +copy is a backup). When ticked it calls `deleteCloudFile` (online) or queues a +`{store:"files", type:"delete"}` **pending change** (offline / on failure) that +`autoSync.pushOne` flushes via `deleteCloudFile` on reconnect. + +**Orphan-safety:** `uploadBlob` writes the blob then the index row; if the index +write is rejected (e.g. the server quota trigger), it **rolls the blob back** so +it can't orphan in the bucket. `cleanupOrphanBlobs(userId)` (run once per user when +`CloudLogsPanel` opens) reclaims any pre-existing orphans — bucket objects whose +decoded name has no index row (`orphanedObjectNames`, pure + tested). + +Files are **opt-in per file** (`fileSync.ts`): a `FileRow` mount adds a toggle to +each file-manager row (`off` → `pending` → `synced`), and the selection set lives +in the plugin's own KV store (`getPluginStore("cloud-sync")`). `pushFile` uploads +a single file's blob when its toggle is switched on. A `FileManagerSection` mount +(`CloudFilesSection`) lists **all** cloud +files — ones already on this device are marked present, others get a per-file +pull; pulling persists via `ctx.onSaveFile` (which refreshes the list). A +dedicated Cloud *tab* (a new garage-tab mount slot), `modified` detection, and a +"sync all" affordance remain follow-ups. + +After a migration, Lovable regenerates `integrations/supabase/types.ts`. Until +then `cloudClient.ts` accesses the new table/bucket through a narrowly-typed +escape hatch confined to that one module. + +> **Lap-snapshot sync** uses its own dedicated `lap_snapshots` table, but its +> serialized size counts toward the **same pooled per-tier byte budget** as +> documents + logs — see CLAUDE.md → Lap Snapshots for the client model and the +> snapshot-specific sync rules. + +--- + +## Subscriptions / Stripe (`..._stripe_subscriptions.sql`, `..._subscription_grace_trim.sql` + 4 edge functions) + +Paid tiers scale **one pooled cloud-storage budget** that documents + logs + +snapshots all share (`free` 50 MB → `plus` $1 10 GB → `premium` $3 100 GB → +`pro` $10 500 GB). `premium` carries no AI credits. Each paid tier bills +**monthly or annual**. Tiers are **data**, not code (numbers are provisional): + +| Object | Type | Notes | +|--------|------|-------| +| `subscription_tiers` | table | One row per plan: `(tier PK, label, price_cents, total_bytes, ai_credits, stripe_price_id, sort_order)`. `total_bytes` is the single pooled storage budget. Authenticated read-all. Change a limit = UPDATE here. (`stripe_price_id` is a legacy fallback only — prices now resolve by lookup_key, see below.) | +| `user_subscriptions` | table | `(user_id PK→auth.users, tier→subscription_tiers, status, stripe_customer_id, stripe_subscription_id, current_period_end, cancel_at_period_end, billing_interval, grace_until, logs_trimmed_at, updated_at)`. RLS: owner **read-only** — only the service role (webhook) writes, so no one can self-grant a tier. | +| `user_tier(uuid)` | fn (SECURITY DEFINER) | Effective tier: the subscription tier when `status in (active, trialing, past_due)`, else `free`. | +| `tier_total_limit(uuid)` | fn (SECURITY DEFINER) | The user's single pooled byte budget from their tier `total_bytes`; falls back to `free`, then a hard 50 MB. Used by both quota triggers + the usage RPC. | +| `encode_uri_component(text)` | fn | SQL parity with JS `encodeURIComponent`, so the trim job can address the right `user-files` bucket object (`{user_id}/{encoded name}`). | +| `trim_expired_logs()` | fn (SECURITY DEFINER) | For users past their `grace_until`, deletes synced **log** files newest-first (index row + bucket object) until their **pooled total** (docs + remaining logs + snapshots) fits the free `total_bytes`; snapshots + docs are never auto-deleted. Scheduled daily via `pg_cron` (guarded; enable the extension or run externally). Not granted to `authenticated`. | + +**Prices via lookup_key (no Price ids in code):** each (tier × interval) has a +Stripe Price tagged with a lookup_key `${tier}_${interval}` (`plus_monthly`, +`plus_annual`, `premium_monthly`, …). Checkout and the catalogue resolve prices +live by lookup_key, so the Stripe dashboard is the single source of truth. + +**On-hold tiers:** `COMING_SOON_TIERS` in `lib/billing.ts` (currently `premium` +and `pro`, the AI plan) lists tiers that exist but aren't self-service +purchasable yet — **hidden from the pricing UI entirely** (no teaser card), +excluded from `PlanCheckout`, and rejected by `create-checkout-session` (mirror +the set there). Only **Free** + **Plus** are shown at launch. They can still be +**comped** by creating the subscription directly in Stripe (set the +subscription's `metadata.user_id`, or change an existing customer's price); the +webhook grants whatever tier the price's lookup_key maps to. + +**Cancellation grace:** a cancelled sub ends at the period boundary (Stripe +`customer.subscription.deleted`), dropping to free limits immediately (via +`user_tier`), but `grace_until = period_end + 60 days` keeps the user's logs so +they can re-subscribe/download. After grace, `trim_expired_logs()` trims them. + +Edge functions (all `verify_jwt = false`; checkout/portal verify the JWT +manually like the rest of the repo, the webhook verifies the Stripe signature): + +- `stripe-prices` — **public**, no auth. Reports `{ configured, prices[] }`: + `configured:false` when `STRIPE_SECRET_KEY` is absent (→ client free-only + failback), else live monthly/annual prices fetched by lookup_key. +- `create-checkout-session` — auth user + `{ tier, interval }` → ensure Stripe + customer (persisted on `user_subscriptions`) → resolve Price by lookup_key → + Checkout Session (subscription mode) → returns the hosted URL. +- `stripe-webhook` — **the only writer of entitlements**. Verifies the signature + (`STRIPE_WEBHOOK_SECRET`), then on `checkout.session.completed` / + `customer.subscription.created|updated|deleted` upserts `user_subscriptions` + (tier + interval resolved from the Price's lookup_key; sets + `cancel_at_period_end`; on cancellation sets `grace_until`; `deleted` → `free`) + via the service role. +- `create-portal-session` — returns a Stripe Billing Portal URL for + manage/upgrade/downgrade/cancel (no in-app billing UI). An optional + `flow: "update"` deep-links into the change-plan screen (used by the profile's + **Change plan** button); it falls back to the generic portal without an active + subscription to update. + +Secrets: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. + +**Client wiring** (core, not the cloud-sync plugin — billing is account-level and +PricingCards renders even with cloud disabled): `lib/billing.ts` is the pure, +unit-tested layer (`isActiveStatus`/`effectiveTier`/`isPaidTier`/`pricingCta`, +plus `lookupKey`/`tiersWithPrices`/`paidTiersVisible`/`priceFor`/`formatPrice`, +`annualMonthlyEquivalent`/`annualDiscountPercent` for the checkout summary, the +`TIER_STORAGE_LABEL`/`TIER_DISPLAY_LABEL` maps + row/price shapes); +`lib/billingClient.ts` is the Supabase I/O (`fetchTiers`, `fetchMySubscription`, +`fetchStripeConfig`, `createCheckout(tier, interval)`, `createPortal(returnUrl, +flow?)`), through the same untyped escape hatch as `cloudClient.ts`. +`hooks/useSubscription.ts` reads the tier catalogue + the user's subscription; +`hooks/useStripePrices.ts` reads the live price catalogue (online, never throws). +`PricingCards` takes a `variant`: **home** (landing page — three cards: Free +offline, Free online, Plus, with a monthly/annual toggle) or **register** +(sign-up — two cards: Free online, which folds in the offline summary, + Plus, no +toggle). It shows live **Upgrade** / **Current plan** actions and — the +**failback** — hides the paid tiers entirely when `paidTiersVisible(config)` is +false. `PlanCheckout` (sign-up) is a checkout-style picker — a **storage-tier +dropdown** + **monthly/annual switch** — and `PlanCheckoutSummary` renders the +live **cost-per-month** (annual shows the monthly-equivalent + `annualDiscountPercent` +saving) next to the Create Account button. A paid choice stashes a +`lib/pendingCheckout.ts` intent that `components/PendingCheckoutRedirect.tsx` +(mounted in `App.tsx` for cloud builds) redeems → Checkout on first sign-in after +email confirmation. Sign-up takes **no display name** (the server auto-assigns a +random one, changeable later); display-name edits run through a basic +`lib/profanity.ts` filter. cloud-sync's Profile-tab `StoragePanel` shows the plan ++ renewal/cancellation/grace date + a **Manage subscription** portal link and, +for active subscribers, a **Change plan** button (portal `flow: "update"`). +**Stripe setup (create Products/Prices with the lookup_keys, secrets, webhook, +enable pg_cron) is operator config — see README.** + +--- + +## Data Rights & Retention / GDPR (`..._gdpr_compliance.sql` + 3 edge functions) + +Self-service data access, portability and erasure, plus automatic IP +minimisation. All account-gated (cloud-only) except the IP purge, which is +backend cron. + +| Object | Type | Notes | +|--------|------|-------| +| `account_deletions` | table | `(user_id PK→auth.users, requested_at, scheduled_for)`. RLS: owner can **select** + **delete** (cancel); **no insert policy** — only the service role schedules, so the 7-day window can't be shortened client-side. | +| `purge_expired_personal_data()` | fn (SECURITY DEFINER) | (a) Nulls `submitted_by_ip` on `submissions`/`messages` older than **90 days**; (b) deletes `messages` and *reviewed* `submissions` older than **1 year** (pending submissions kept for moderation); deletes expired `banned_ips` + stale `login_attempts`. Run daily by `pg_cron`. | +| `due_account_deletions()` | fn (SECURITY DEFINER) | User ids whose `scheduled_for <= now()`. Read by the deletion worker. | + +Edge functions (all `verify_jwt = false`; the two user-facing ones verify the +JWT manually): + +- `export-account-data` — auth user → service-role gather of everything we hold + (profile, subscription, roles, `sync_records`, contact `messages` by email, + pending deletion). Returns JSON; the client adds cloud-file blobs + all local + browser data and zips it. +- `request-account-deletion` — auth user → inserts an `account_deletions` row + `scheduled_for = now()+7d` (idempotent; never shortens an in-flight request). +- `process-account-deletions` — **cron-only** (`x-cron-secret` must equal + `DELETION_CRON_SECRET`). For each due user: removes their `user-files` Storage + objects, then `auth.admin.deleteUser` (cascades profiles/sync_records/ + subscription/roles/account_deletions via FKs). + +Scheduling: the migration always schedules the IP purge (pure SQL). The deletion +worker is auto-wired via `pg_cron` + `pg_net` **only if** a Vault secret +`deletion_cron_secret` exists (matching `DELETION_CRON_SECRET` on the function); +otherwise the migration raises a NOTICE and it's a documented operator step. + +**Client** (cloud-sync plugin): `exportManifest.ts` (pure, unit-tested — assembles +the zip's text entries), `accountExport.ts` (I/O orchestrator: edge fn + local +stores + blob download → JSZip), `accountDeletion.ts` (email-OTP gate via +`signInWithOtp`/`verifyOtp` + schedule/cancel), and `DataPrivacyPanel.tsx` (the +Profile-tab "Data & privacy" panel). Admin `BannedIpsTab` exposes a ban TTL +(defaults to 90 days). Privacy policy "Your Rights" / "Data Retention" describe +all of the above. diff --git a/full-app-description.md b/full-app-description.md new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index e37752b..0568667 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.5.0", "license": "GPL-3.0-or-later", "dependencies": { + "@lovable.dev/cloud-auth-js": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", @@ -63,7 +64,7 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.0.2" + "@perchwerks/eye-in-the-sky": "0.3.0" } }, "node_modules/@alloc/quick-lru": { @@ -2401,6 +2402,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lovable.dev/cloud-auth-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", + "integrity": "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2466,11 +2473,19 @@ } }, "node_modules/@perchwerks/eye-in-the-sky": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.0.2.tgz", - "integrity": "sha512-ssi01fB5gRnPFMkt6YqfxeAZ5NH99EEQvbySG7iJltlNeFPToobTpTgob9j2/0IIkqAHz8mW6JHKfzzLGMHRyg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.3.0.tgz", + "integrity": "sha512-XJ6Riez1x5A210Kj0Vxt+NU8P+QZGHo7ZYgqN684uBWaB/JjY9sjWEAp5nxxtZrNN17NzHYM/NsOK/xN3AyHfA==", "license": "GPL-3.0-or-later", - "optional": true + "optional": true, + "dependencies": { + "uplot": "^1.6.32" + }, + "peerDependencies": { + "leaflet": "^1.9.4", + "react": "^18.3 || ^19.0.0", + "react-dom": "^18.3 || ^19.0.0" + } }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -10609,6 +10624,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT", + "optional": true + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 3380b9b..e4f8e38 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "typecheck": "tsc -b" }, "dependencies": { + "@lovable.dev/cloud-auth-js": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", @@ -82,6 +83,6 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.0.2" + "@perchwerks/eye-in-the-sky": "0.3.0" } } diff --git a/src/App.tsx b/src/App.tsx index ee25e4c..ec689e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,16 +11,22 @@ import NotFound from "./pages/NotFound"; const queryClient = new QueryClient(); const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === 'true'; -const enableRegistration = import.meta.env.VITE_ENABLE_REGISTRATION === 'true'; +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; -// Lazy-load secondary routes — these are not on the main entry path. Each -// becomes its own chunk that downloads only when the user navigates there. -// Privacy is rarely visited; Login/Admin/Register only appear when admin is -// enabled (and even then, only the route the user clicks loads). +// Lazy-load secondary routes. Auth pages (Login/Register/Forgot/Reset/Callback) +// only mount when their gating flag is on, so a flag-off build never ships +// their chunks — preserving the offline-first invariant. const Login = lazy(() => import("./pages/Login")); const Admin = lazy(() => import("./pages/Admin")); const Register = lazy(() => import("./pages/Register")); const Privacy = lazy(() => import("./pages/Privacy")); +const Terms = lazy(() => import("./pages/Terms")); +const ForgotPassword = lazy(() => import("./pages/ForgotPassword")); +const ResetPassword = lazy(() => import("./pages/ResetPassword")); +const AuthCallback = lazy(() => import("./pages/AuthCallback")); +const PendingCheckoutRedirect = lazy(() => + import("./components/PendingCheckoutRedirect").then((m) => ({ default: m.PendingCheckoutRedirect })), +); const SETTINGS_KEY = "dove-dataviewer-settings"; @@ -47,12 +53,17 @@ const App = () => { + {enableCloud && } } /> } /> - {enableAdmin && } />} + } /> + {(enableAdmin || enableCloud) && } />} {enableAdmin && } />} - {enableRegistration && } />} + {enableCloud && } />} + {enableCloud && } />} + {enableCloud && } />} + {enableCloud && } />} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/components/AboutDialog.tsx b/src/components/AboutDialog.tsx index 0687032..53dbe8c 100644 --- a/src/components/AboutDialog.tsx +++ b/src/components/AboutDialog.tsx @@ -15,7 +15,7 @@ const SECTIONS: Array<{ heading: string; body: React.ReactNode }> = [ }, { heading: "Your Data Stays on Your Device", - body: <>All data processing happens entirely in your browser. Your log files, session notes, kart setups, and video sync data are saved locally on your device — nothing is uploaded to any server., + body: <>All data processing happens entirely in your browser, and everything is saved locally on your device by default. Cloud storage is entirely optional — nothing leaves your device unless you create an account and turn on sync., }, { heading: "Community Track Database", @@ -23,7 +23,7 @@ const SECTIONS: Array<{ heading: string; body: React.ReactNode }> = [ }, { heading: "Free & Open Source", - body: <>Every feature in HackTheTrack is completely free. The source code is open and available on GitHub. If cloud-saving is added in the future, that may carry a small cost to cover server fees — but all local features will always remain free., + body: <>Every local feature in HackTheTrack is completely free, and the source code is open and available on GitHub. Optional cloud storage has a free tier; larger storage and AI coaching are paid add-ons that cover server and model costs — but all local features will always remain free., }, ]; diff --git a/src/components/ExternalRefBar.tsx b/src/components/ExternalRefBar.tsx index aad928f..e092ea8 100644 --- a/src/components/ExternalRefBar.tsx +++ b/src/components/ExternalRefBar.tsx @@ -12,6 +12,8 @@ interface ExternalRefBarProps { onSelectExternalLap: (fileName: string, lapNumber: number) => void; onClearExternalRef: () => void; onOpen?: () => void; + /** Extra action rendered next to "Choose Log" (e.g. load a snapshot as reference). */ + trailing?: React.ReactNode; } export function ExternalRefBar({ @@ -21,6 +23,7 @@ export function ExternalRefBar({ onSelectExternalLap, onClearExternalRef, onOpen, + trailing, }: ExternalRefBarProps) { const [dialogOpen, setDialogOpen] = useState(false); const [stage, setStage] = useState<'files' | 'laps'>('files'); @@ -73,6 +76,7 @@ export function ExternalRefBar({ Choose Log + {trailing} {externalRefLabel ?? 'No session loaded'} diff --git a/src/components/FileManagerDrawer.tsx b/src/components/FileManagerDrawer.tsx index 5ed3808..17b1e55 100644 --- a/src/components/FileManagerDrawer.tsx +++ b/src/components/FileManagerDrawer.tsx @@ -52,6 +52,9 @@ interface FileManagerDrawerProps { onAddVehicle: (vehicle: Omit) => Promise; onUpdateVehicle: (vehicle: Vehicle) => Promise; onRemoveVehicle: (id: string) => Promise; + // Current session context (the browser opens at this track/course) + currentTrackName: string | null; + currentCourseName: string | null; // Note props currentFileName: string | null; notes: Note[]; @@ -61,6 +64,7 @@ interface FileManagerDrawerProps { // Session setup link sessionKartId: string | null; sessionSetupId: string | null; + sessionSetupRev: string | null; onSaveSessionSetup: (kartId: string | null, setupId: string | null) => Promise; // Setup props setups: VehicleSetup[]; @@ -77,8 +81,9 @@ export function FileManagerDrawer({ onClose, onLoadFile, onDeleteFile, onExportFile, onSaveFile, onDataLoaded, autoSave, vehicles, vehicleTypes, templates, onAddVehicle, onUpdateVehicle, onRemoveVehicle, + currentTrackName, currentCourseName, currentFileName, notes, onAddNote, onUpdateNote, onRemoveNote, - sessionKartId, sessionSetupId, onSaveSessionSetup, + sessionKartId, sessionSetupId, sessionSetupRev, onSaveSessionSetup, setups, onAddSetup, onUpdateSetup, onRemoveSetup, onGetLatestSetupForVehicle, onAddVehicleType, onRemoveVehicleType, }: FileManagerDrawerProps) { @@ -177,7 +182,7 @@ export function FileManagerDrawer({ {garageTab === "files" && ( - + )} {garageTab === "vehicles" && ( @@ -207,6 +212,7 @@ export function FileManagerDrawer({ setups={setups} sessionKartId={sessionKartId} sessionSetupId={sessionSetupId} + sessionSetupRev={sessionSetupRev} onSaveSessionSetup={onSaveSessionSetup} /> )} diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx index 739636e..43e350e 100644 --- a/src/components/LandingPage.tsx +++ b/src/components/LandingPage.tsx @@ -1,4 +1,4 @@ -import { Gauge, Github, Heart, Shield, BookOpen, Play, Loader2 } from "lucide-react"; +import { Gauge, Github, Heart, Shield, BookOpen, Play, Loader2, LogIn, LogOut, FileText } from "lucide-react"; import { Link, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { FileImport } from "@/components/FileImport"; @@ -8,6 +8,8 @@ import { ContactDialog } from "@/components/ContactDialog"; import { SupportedFilesDialog } from "@/components/SupportedFilesDialog"; import { AboutDialog } from "@/components/AboutDialog"; import { CreditsDialog } from "@/components/CreditsDialog"; +import { PricingCards } from "@/components/PricingCards"; +import { useAuth } from "@/contexts/AuthContext"; import type { ParsedData } from "@/types/racing"; interface LandingPageProps { @@ -18,6 +20,7 @@ interface LandingPageProps { onLoadSample: () => void; isLoadingSample: boolean; enableAdmin: boolean; + enableCloud: boolean; } const GITHUB_LINKS: Array<{ href: string; label: string }> = [ @@ -43,8 +46,10 @@ export function LandingPage({ onLoadSample, isLoadingSample, enableAdmin, + enableCloud, }: LandingPageProps) { const navigate = useNavigate(); + const { user, logout } = useAuth(); return ( + -
+ + +
+
-
+
{GITHUB_LINKS.map((link) => ( -
+
Privacy Policy + + + Terms of Service + {enableAdmin && ( diff --git a/src/components/LapSnapshotControls.tsx b/src/components/LapSnapshotControls.tsx new file mode 100644 index 0000000..2b2c096 --- /dev/null +++ b/src/components/LapSnapshotControls.tsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { Camera, Check, Plus, X } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, +} from "@/components/ui/dialog"; +import { formatLapTime } from "@/lib/lapCalculation"; +import type { LapSnapshot } from "@/lib/lapSnapshot"; +import type { SaveSnapshotResult } from "@/hooks/useLapSnapshots"; + +interface LapSnapshotControlsProps { + snapshotsForCourse: LapSnapshot[]; + activeSnapshotId: string | null; + canSnapshot: boolean; + hasCourse: boolean; + onLoad: (snap: LapSnapshot) => void; + onClear: () => void; + onSave: (force?: boolean) => Promise; + /** Trigger button text (default "Snapshots"). */ + triggerLabel?: string; + /** Show the "save current lap" action (default true). Off for a load-only entry. */ + showSave?: boolean; +} + +/** + * Lap-list companion for snapshots: save the current lap as a "course fastest + * lap", and load a saved snapshot as the reference lap (comparison overlay). + * Loading a snapshot NEVER affects playback or the video player — it rides the + * reference-overlay slot, not the lap selection. + */ +export function LapSnapshotControls({ + snapshotsForCourse, activeSnapshotId, canSnapshot, hasCourse, + onLoad, onClear, onSave, triggerLabel = "Snapshots", showSave = true, +}: LapSnapshotControlsProps) { + const [open, setOpen] = useState(false); + if (!hasCourse) return null; + + const count = snapshotsForCourse.length; + + const handleSave = async (force = false) => { + const result = await onSave(force); + if (result.saved) { + toast.success(result.replaced ? "Course fastest lap updated." : "Lap snapshot saved."); + setOpen(false); + } else if (result.reason === "slower") { + // Don't silently destroy a faster personal-best baseline — confirm first. + const ok = window.confirm( + `Your saved snapshot (${formatLapTime(result.existingLapMs ?? 0)}) is faster than this lap. Overwrite it anyway?`, + ); + if (ok) await handleSave(true); + } else if (result.reason === "no-engine") { + toast.error("Assign an engine (vehicle) to this session first."); + } else if (result.reason === "no-lap") { + toast.error("No lap to snapshot yet."); + } + }; + + return ( + + + + + + + Lap Snapshots + + + {showSave && ( + <> + + {!canSnapshot && ( +

+ Assign an engine to this session to capture its fastest lap. +

+ )} + + )} + +
+

+ Load as reference lap (this course) +

+ {activeSnapshotId && ( + + )} +
+ +
+ {count === 0 ? ( +

+ No snapshots saved for this course yet. +

+ ) : ( +
    + {snapshotsForCourse.map((snap) => { + const isActive = snap.id === activeSnapshotId; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/components/LapSnapshotPromptDialog.tsx b/src/components/LapSnapshotPromptDialog.tsx new file mode 100644 index 0000000..21cfd87 --- /dev/null +++ b/src/components/LapSnapshotPromptDialog.tsx @@ -0,0 +1,62 @@ +import { Trophy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { formatLapTime } from "@/lib/lapCalculation"; +import type { SnapshotPromptState } from "@/hooks/useLapSnapshots"; + +interface LapSnapshotPromptDialogProps { + prompt: SnapshotPromptState | null; + onConfirm: () => void; + onDismiss: () => void; +} + +/** + * "New course fastest lap" prompt, shown when an engine is assigned to a session + * whose best lap beats (or has no) stored snapshot for that engine + course. + */ +export function LapSnapshotPromptDialog({ prompt, onConfirm, onDismiss }: LapSnapshotPromptDialogProps) { + const candidate = prompt?.candidate; + return ( + !open && onDismiss()}> + + + + + {prompt?.kind === "faster" ? "New course fastest lap!" : "Save course fastest lap?"} + + + {candidate && ( + <> + Save {candidate.engine} at {candidate.trackName} — {candidate.courseName}. + + )} + + + + {candidate && ( +
+
+ Lap {candidate.sourceLapNumber} + {formatLapTime(candidate.lapTimeMs)} +
+ {prompt?.existing && ( +
+ Previous best + + {formatLapTime(prompt.existing.lapTimeMs)} + +
+ )} +
+ )} + + + + + +
+
+ ); +} diff --git a/src/components/LapTable.tsx b/src/components/LapTable.tsx index 0185718..a532eca 100644 --- a/src/components/LapTable.tsx +++ b/src/components/LapTable.tsx @@ -4,6 +4,9 @@ import { formatLapTime, formatSectorTime, calculateOptimalLap } from '@/lib/lapC import { Trophy, Zap, Snail, Target } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ExternalRefBar } from '@/components/ExternalRefBar'; +import { LapSnapshotControls } from '@/components/LapSnapshotControls'; +import type { LapSnapshot } from '@/lib/lapSnapshot'; +import type { SaveSnapshotResult } from '@/hooks/useLapSnapshots'; import { FileEntry } from '@/lib/fileStorage'; import { useSettingsContext } from '@/contexts/SettingsContext'; import { haversineDistance, METERS_TO_FEET } from '@/lib/parserUtils'; @@ -23,9 +26,16 @@ interface LapTableProps { onSelectExternalLap?: (fileName: string, lapNumber: number) => void; onClearExternalRef?: () => void; onRefreshSavedFiles?: () => void; + // Lap snapshots (loaded as the reference overlay) + snapshotsForCourse?: LapSnapshot[]; + activeSnapshotId?: string | null; + canSnapshot?: boolean; + onLoadSnapshot?: (snap: LapSnapshot) => void; + onClearSnapshot?: () => void; + onSaveSnapshot?: (force?: boolean) => Promise; } -export const LapTable = memo(function LapTable({ laps, course, samples, onLapSelect, selectedLapNumber, referenceLapNumber, onSetReference, externalRefLabel, savedFiles, onLoadFileForRef, onSelectExternalLap, onClearExternalRef, onRefreshSavedFiles }: LapTableProps) { +export const LapTable = memo(function LapTable({ laps, course, samples, onLapSelect, selectedLapNumber, referenceLapNumber, onSetReference, externalRefLabel, savedFiles, onLoadFileForRef, onSelectExternalLap, onClearExternalRef, onRefreshSavedFiles, snapshotsForCourse, activeSnapshotId, canSnapshot, onLoadSnapshot, onClearSnapshot, onSaveSnapshot }: LapTableProps) { const { useKph } = useSettingsContext(); const showSectors = courseHasSectors(course); @@ -116,6 +126,7 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel const getMinSpeed = (lap: Lap) => useKph ? lap.minSpeedKph : lap.minSpeedMph; const hasExternalRefProps = savedFiles && onLoadFileForRef && onSelectExternalLap && onClearExternalRef; + const hasSnapshotProps = onLoadSnapshot && onClearSnapshot && onSaveSnapshot; return (
@@ -127,6 +138,19 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel onSelectExternalLap={onSelectExternalLap} onClearExternalRef={onClearExternalRef} onOpen={onRefreshSavedFiles} + trailing={hasSnapshotProps ? ( + + ) : undefined} /> )} diff --git a/src/components/PendingCheckoutRedirect.tsx b/src/components/PendingCheckoutRedirect.tsx new file mode 100644 index 0000000..cdcbb89 --- /dev/null +++ b/src/components/PendingCheckoutRedirect.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import { useSubscription } from "@/hooks/useSubscription"; +import { isPaidTier } from "@/lib/billing"; +import { createCheckout } from "@/lib/billingClient"; +import { clearPendingCheckout, getPendingCheckout } from "@/lib/pendingCheckout"; + +/** + * Resumes a paid plan chosen at sign-up. Sign-up creates the account first + * (email confirmation, no session), so the choice is stashed and redeemed here + * on the user's first signed-in, online load while still on the free tier: + * redirect to Stripe Checkout, then the webhook provisions the tier. Renders + * nothing. Mounted once at the app root in cloud builds. + */ +export function PendingCheckoutRedirect() { + const { user, loading: authLoading } = useAuth(); + const online = useOnlineStatus(); + const { currentTier, loading: subLoading } = useSubscription(); + const started = useRef(false); + + useEffect(() => { + if (started.current) return; + if (authLoading || subLoading || !user || !online) return; + if (currentTier !== "free") { + // Already on a paid tier — the intent (if any) is satisfied; drop it. + clearPendingCheckout(); + return; + } + const intent = getPendingCheckout(); + if (!intent || !isPaidTier(intent.tier)) return; + + started.current = true; + clearPendingCheckout(); + createCheckout(intent.tier, intent.interval, window.location.origin) + .then((url) => { + window.location.href = url; + }) + .catch((e) => { + started.current = false; + toast.error(e instanceof Error ? e.message : "Couldn't resume checkout."); + }); + }, [user, authLoading, subLoading, currentTier, online]); + + return null; +} diff --git a/src/components/PlanCheckout.tsx b/src/components/PlanCheckout.tsx new file mode 100644 index 0000000..069d81a --- /dev/null +++ b/src/components/PlanCheckout.tsx @@ -0,0 +1,156 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + type BillingInterval, + type StripeConfig, + annualDiscountPercent, + annualMonthlyEquivalent, + formatPrice, + isComingSoon, + paidTiersVisible, + priceFor, + tiersWithPrices, + TIER_DISPLAY_LABEL, + TIER_STORAGE_LABEL, +} from "@/lib/billing"; + +export interface PlanSelection { + tier: string; + interval: BillingInterval; +} + +const PAID_ORDER = ["plus", "premium", "pro"]; + +/** The purchasable storage tiers for the dropdown (free + any priced, non-coming-soon paid tier). */ +function selectableTiers(config: StripeConfig): string[] { + const available = tiersWithPrices(config.prices); + return ["free", ...PAID_ORDER.filter((t) => available.has(t) && !isComingSoon(t))]; +} + +/** + * Checkout-style plan picker for sign-up: a storage-tier dropdown plus a + * monthly/annual switch. Renders nothing when Stripe isn't configured (the + * account is simply free — no checkout to show). Controlled: the parent owns the + * selection so it can stash it for post-confirmation checkout and render the live + * price next to the submit button (see PlanCheckoutSummary). + */ +export function PlanCheckout({ + value, + onChange, + config, +}: { + value: PlanSelection; + onChange: (v: PlanSelection) => void; + config: StripeConfig; +}) { + if (!paidTiersVisible(config)) return null; + const tiers = selectableTiers(config); + if (tiers.length <= 1) return null; // only "free" — nothing to choose + + const setInterval = (interval: BillingInterval) => { + // If the chosen tier isn't priced for the new interval, fall back to free. + if (value.tier !== "free" && !priceFor(config.prices, value.tier, interval)) { + onChange({ tier: "free", interval }); + } else { + onChange({ ...value, interval }); + } + }; + + return ( +
+
+ + +
+ +
+ Billing +
+ + Monthly + + setInterval(checked ? "annual" : "monthly")} + aria-label="Bill annually" + /> + + Annual + +
+
+
+ ); +} + +/** + * The live cost-per-month readout shown next to the Create Account button. For + * annual plans it shows the monthly-equivalent price plus the % discount versus + * paying monthly. Renders nothing when Stripe isn't configured. + */ +export function PlanCheckoutSummary({ + value, + config, +}: { + value: PlanSelection; + config: StripeConfig; +}) { + if (!paidTiersVisible(config)) return null; + + if (value.tier === "free") { + return ( +
+

+ $0 /mo +

+

Free forever

+
+ ); + } + + const monthly = priceFor(config.prices, value.tier, "monthly"); + const selected = priceFor(config.prices, value.tier, value.interval); + if (!selected) return null; + + const isAnnual = value.interval === "annual"; + const perMonth = isAnnual ? annualMonthlyEquivalent(selected.unitAmount) : selected.unitAmount; + const discount = isAnnual ? annualDiscountPercent(monthly?.unitAmount, selected.unitAmount) : null; + + return ( +
+

+ {formatPrice(perMonth, selected.currency)}{" "} + /mo +

+ {isAnnual ? ( +

+ billed annually + {discount != null && ( + save {discount}% + )} +

+ ) : ( +

billed monthly

+ )} +
+ ); +} diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx new file mode 100644 index 0000000..d6af87c --- /dev/null +++ b/src/components/PricingCards.tsx @@ -0,0 +1,369 @@ +import { useState, type ReactNode } from "react"; +import { Check, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/AuthContext"; +import { useStripePrices } from "@/hooks/useStripePrices"; +import { useSubscription } from "@/hooks/useSubscription"; +import { + type BillingInterval, + formatPrice, + isComingSoon, + paidTiersVisible, + priceFor, + pricingCta, + tiersWithPrices, +} from "@/lib/billing"; +import { createCheckout, createPortal } from "@/lib/billingClient"; + +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === "true"; + +type PaidSlug = "plus" | "premium" | "pro"; + +// A feature line is either a plain string or a heading with indented sub-bullets. +type Feature = string | { label: string; sub: string[] }; + +interface FreeTier { + name: string; + blurb: string; + price: string; + inherits?: string; + features: Feature[]; + highlight?: boolean; + /** Maps the card to a subscription tier slug (the offline card has none). */ + slug?: "free"; +} + +interface PaidTier { + name: string; + blurb: string; + inherits: string; + features: Feature[]; + slug: PaidSlug; + highlight?: boolean; +} + +// Everything the always-free, fully-offline app does — no account needed. Shown +// as its own card on the landing page and folded into the online card on sign-up. +const OFFLINE_FEATURES: string[] = [ + "Full data viewer", + "Bluetooth (BLE) device connectivity", + "Reference-lap overlay & comparison", + "Braking zones & G-force analysis", + "Add overlays & export videos", + "Offline mathematical session debrief", +]; + +// What syncing to the cloud (the free online account) actually buys you. +const CLOUD_SYNC_FEATURE: Feature = { + label: "Sync data with the cloud", + sub: [ + "Unique setup for each session", + "Fastest laptimes per engine", + "Personal tracks and session notes", + ], +}; + +const OFFLINE_CARD: FreeTier = { + name: "Free", + blurb: "Offline", + price: "$0", + features: OFFLINE_FEATURES, +}; + +// The online free card. On sign-up it leads with the offline summary (there's no +// separate offline card there); on the landing page offline is its own card, so +// it just inherits from it. +function onlineCard(variant: Variant): FreeTier { + return { + name: "Free", + blurb: "Online account", + price: "$0", + slug: "free", + inherits: variant === "register" ? "Everything included with offline mode" : "Everything in Free, plus", + features: [CLOUD_SYNC_FEATURE, "Fastest laps & synced setups — always free", "50 MB cloud storage"], + }; +} + +// Paid tiers — feature copy is static; the price is resolved live from Stripe +// (by lookup_key) and these cards are hidden when Stripe isn't wired up. Premium +// + Pro are coming-soon (see billing.ts COMING_SOON_TIERS) and hidden entirely. +const PAID_TIERS: PaidTier[] = [ + { + name: "Plus", + blurb: "For bigger garages", + slug: "plus", + highlight: true, + inherits: "Everything in Free online, plus", + features: [ + "10 GB cloud storage", + "Video uploads & sharing (coming soon)", + "You're helping support the project ❤️", + ], + }, + { + name: "Premium", + blurb: "Max storage", + slug: "premium", + inherits: "Everything in Plus, plus", + features: ["100 GB cloud storage"], + }, + { + name: "Pro", + blurb: "With AI coaching", + slug: "pro", + inherits: "Everything in Premium, plus", + features: ["500 GB cloud storage", "AI coaching (coming soon)"], + }, +]; + +function FeatureList({ features }: { features: Feature[] }) { + return ( +
    + {features.map((f) => { + const label = typeof f === "string" ? f : f.label; + const sub = typeof f === "string" ? undefined : f.sub; + return ( +
  • +
    + + {label} +
    + {sub && ( +
      + {sub.map((s) => ( +
    • + + {s} +
    • + ))} +
    + )} +
  • + ); + })} +
+ ); +} + +function TierCard({ + name, + blurb, + price, + cadence, + inherits, + features, + highlight, + cta, +}: { + name: string; + blurb: string; + price: string; + cadence?: string; + inherits?: string; + features: Feature[]; + highlight?: boolean; + cta?: ReactNode; +}) { + return ( +
+ {highlight && ( + + Recommended + + )} +
+

{name}

+

{blurb}

+
+ {price && ( +
+ {price} + {cadence && {cadence}} +
+ )} + {inherits &&

{inherits}

} + + {cta &&
{cta}
} +
+ ); +} + +function IntervalToggle({ + value, + onChange, +}: { + value: BillingInterval; + onChange: (v: BillingInterval) => void; +}) { + return ( +
+ {(["monthly", "annual"] as const).map((opt) => ( + + ))} +
+ ); +} + +type Variant = "home" | "register"; + +/** + * Plans / pricing grid. + * - `home` (landing page): three cards — Free offline, Free online, Plus — with a + * monthly/annual toggle; signed-in users get live "Upgrade" / "Current plan" + * actions on Plus. + * - `register` (sign-up): two cards — Free online (which folds in the offline + * summary) and Plus — and no interval toggle (the billing interval is chosen in + * the checkout below the cards). + * Premium + Pro are coming-soon and hidden entirely. When Stripe isn't configured + * the paid cards drop out (free-only failback). + */ +export function PricingCards({ className, variant = "home" }: { className?: string; variant?: Variant }) { + const { user } = useAuth(); + const { currentTier } = useSubscription(); + const { config } = useStripePrices(); + const [interval, setInterval] = useState("monthly"); + const [busy, setBusy] = useState(null); + + const signedIn = !!user; + const showPaid = paidTiersVisible(config); + const purchasable = tiersWithPrices(config.prices); + // The cards' interval: the toggle on home, fixed monthly on sign-up. + const cardInterval: BillingInterval = variant === "register" ? "monthly" : interval; + const cadence = cardInterval === "annual" ? "/yr" : "/mo"; + const showToggle = variant === "home" && showPaid; + + const freeCards: FreeTier[] = variant === "register" ? [onlineCard(variant)] : [OFFLINE_CARD, onlineCard(variant)]; + // Only self-service-purchasable paid tiers (Premium/Pro are hidden at launch). + const paidTiers = PAID_TIERS.filter((t) => !isComingSoon(t.slug)); + + const onUpgrade = async (slug: PaidSlug) => { + setBusy(slug); + try { + const url = await createCheckout(slug, cardInterval, window.location.href); + window.location.href = url; + } catch (e) { + toast.error(e instanceof Error ? e.message : "Couldn't start checkout."); + setBusy(null); + } + }; + + // Already-subscribed users change plans through the billing portal (Stripe + // swaps the plan on the existing subscription with proration) — starting a new + // Checkout would create a duplicate, double-billed subscription. + const onManage = async (slug: PaidSlug) => { + setBusy(slug); + try { + const url = await createPortal(window.location.href); + window.location.href = url; + } catch (e) { + toast.error(e instanceof Error ? e.message : "Couldn't open the billing portal."); + setBusy(null); + } + }; + + const ctaFor = (slug: "free" | PaidSlug, isPaid: boolean): ReactNode => { + const kind = pricingCta({ + slug, + signedIn, + cloudEnabled: enableCloud, + currentTier, + purchasable: purchasable.has(slug), + }); + if (kind === "current") { + return ( + + ); + } + if (kind === "upgrade" && isPaid) { + const isBusy = busy === slug; + return ( + + ); + } + if (kind === "manage" && isPaid) { + const isBusy = busy === slug; + return ( + + ); + } + return null; + }; + + // Two cards on sign-up centre nicely at two columns; the landing page goes + // three across from the tablet breakpoint up (single column on phones). + const gridCols = variant === "register" ? "sm:grid-cols-2" : "sm:grid-cols-3"; + + return ( +
+
+

Plans & pricing

+

+ Start free and fully offline. Add an account for cross-device sync — upgrade only if you need more. +

+ {showToggle && ( +
+ +
+ )} +
+
+ {freeCards.map((tier) => ( + + ))} + {showPaid && + paidTiers.map((tier) => { + const price = priceFor(config.prices, tier.slug, cardInterval); + // Hidden when this interval isn't priced in Stripe. + if (!price) return null; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/SessionBrowser.tsx b/src/components/SessionBrowser.tsx new file mode 100644 index 0000000..31d34dc --- /dev/null +++ b/src/components/SessionBrowser.tsx @@ -0,0 +1,99 @@ +import { Fragment } from "react"; +import { Folder, ChevronRight } from "lucide-react"; +import type { BrowserView, BrowserSession, FilterMode, NavState } from "@/lib/fileBrowserTree"; + +const FILTER_LABELS: Record = { none: "None", engine: "Engine", kart: "Kart" }; + +interface SessionBrowserProps { + /** The resolved view to render (from `computeBrowserView`). */ + view: BrowserView; + /** Apply a navigation (breadcrumb click, folder open, or filter change). */ + onNavigate: (nav: NavState) => void; + /** Render one log row (the caller owns row chrome + actions). */ + renderRow: (session: BrowserSession) => React.ReactNode; + emptyText?: string; +} + +/** + * Presentational Track→Course→logs browser: breadcrumb + optional Engine/Kart + * filter + folders + the caller-rendered log rows. Pure UI over a computed + * `BrowserView` — shared by the Files tab and the Profile cloud-logs panel. + */ +export function SessionBrowser({ view, onNavigate, renderRow, emptyText = "No sessions here" }: SessionBrowserProps) { + return ( +
+ {/* Breadcrumb — always shown so date-named logs read in context. */} +
+ {view.breadcrumb.map((seg, i) => { + const isLast = i === view.breadcrumb.length - 1; + return ( + + {i > 0 && } + + + ); + })} +
+ + {/* Engine/Kart filter — only on the final log level. */} + {view.showFilter && ( +
+ Group by +
+ {(["none", "engine", "kart"] as const).map((mode) => ( + + ))} +
+
+ )} + + {/* Folders */} + {view.folders.map((folder) => ( + + ))} + + {/* Logs (final list, or unconfigured logs below filter folders) */} + {view.sessions.map((s) => ( + {renderRow(s)} + ))} + + {view.folders.length === 0 && view.sessions.length === 0 && ( +

{emptyText}

+ )} +
+ ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 079f49b..49a6596 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Settings, Eye, EyeOff, Gauge, Activity, Circle, HardDrive, FlaskConical, Sun, Moon, RefreshCw } from "lucide-react"; +import { Settings, Eye, EyeOff, Gauge, Activity, Circle, HardDrive, FlaskConical, Sun, Moon, RefreshCw, Timer } from "lucide-react"; import { Dialog, DialogContent, @@ -188,6 +188,38 @@ export function SettingsModal({ + {/* Lap Delta Method */} +
+
+ +

Lap Delta

+
+
+
+ +

+ Position projects your line onto the reference (robust to line & GPS-rate + differences); Distance is the legacy cumulative-distance method +

+
+
+ + Distance + + onSettingsChange({ deltaMethod: checked ? 'position' : 'distance' })} + /> + + Position + +
+
+
+ {/* Braking Zone Detection */}
diff --git a/src/components/TelemetryChart.tsx b/src/components/TelemetryChart.tsx index a94e0b9..6d576ea 100644 --- a/src/components/TelemetryChart.tsx +++ b/src/components/TelemetryChart.tsx @@ -426,7 +426,7 @@ export function TelemetryChart({ const mappingIndex = fieldMappings.findIndex(f => f.name === field.name); const colorIndex = ((mappingIndex === -1 ? 0 : mappingIndex) + 1) % COLORS.length; ctx.fillStyle = COLORS[colorIndex]; - ctx.fillText(`${field.name}: ${val.toFixed(1)}`, boxX + 8, boxY + 14 + fieldOffset * 16); + ctx.fillText(`${field.label ?? field.name}: ${val.toFixed(1)}`, boxX + 8, boxY + 14 + fieldOffset * 16); fieldOffset++; } }); @@ -521,7 +521,7 @@ export function TelemetryChart({ className="w-3 h-3 rounded-full" style={{ backgroundColor: COLORS[(idx + 1) % COLORS.length] }} /> - {field.name} + {field.label ?? field.name} ))}
diff --git a/src/components/Turnstile.tsx b/src/components/Turnstile.tsx new file mode 100644 index 0000000..f225c00 --- /dev/null +++ b/src/components/Turnstile.tsx @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef } from "react"; + +const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; + +/** True when a Turnstile site key is configured. When false, callers should + * treat the captcha as satisfied (graceful fallback for self-hosters). */ +export const turnstileEnabled = !!SITE_KEY; + +interface TurnstileWidget { + render: (el: HTMLElement, opts: Record) => string; + remove: (id: string) => void; +} + +interface TurnstileProps { + /** Receives the token on success, or null when reset/expired/errored. */ + onToken: (token: string | null) => void; + theme?: "auto" | "light" | "dark"; + className?: string; +} + +/** + * Cloudflare Turnstile widget. Renders nothing (and never blocks) when no + * `VITE_TURNSTILE_SITE_KEY` is set, so the app works without a captcha key. + */ +export function Turnstile({ onToken, theme = "auto", className }: TurnstileProps) { + const containerRef = useRef(null); + const widgetId = useRef(null); + + useEffect(() => { + if (!SITE_KEY || document.getElementById("cf-turnstile-script")) return; + const script = document.createElement("script"); + script.id = "cf-turnstile-script"; + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + script.async = true; + document.head.appendChild(script); + }, []); + + const tryRender = useCallback((): boolean => { + if (!SITE_KEY || !containerRef.current) return false; + const ts = (window as unknown as { turnstile?: TurnstileWidget }).turnstile; + if (!ts) return false; + if (widgetId.current) { + try { ts.remove(widgetId.current); } catch { /* already gone */ } + widgetId.current = null; + } + onToken(null); + widgetId.current = ts.render(containerRef.current, { + sitekey: SITE_KEY, + callback: (t: string) => onToken(t), + "expired-callback": () => onToken(null), + "error-callback": () => onToken(null), + theme, + }); + return true; + }, [onToken, theme]); + + useEffect(() => { + if (!SITE_KEY) return; + // The script loads async; poll briefly until the global is ready. + let tries = 0; + const id = setInterval(() => { + if (tryRender() || ++tries > 50) clearInterval(id); + }, 100); + return () => { + clearInterval(id); + const ts = (window as unknown as { turnstile?: TurnstileWidget }).turnstile; + if (widgetId.current && ts) { + try { ts.remove(widgetId.current); } catch { /* already gone */ } + widgetId.current = null; + } + }; + }, [tryRender]); + + if (!SITE_KEY) return null; + return
; +} diff --git a/src/components/admin/BannedIpsTab.tsx b/src/components/admin/BannedIpsTab.tsx index 6f9756c..aac545c 100644 --- a/src/components/admin/BannedIpsTab.tsx +++ b/src/components/admin/BannedIpsTab.tsx @@ -13,6 +13,8 @@ export function BannedIpsTab() { const [showAdd, setShowAdd] = useState(false); const [newIp, setNewIp] = useState(''); const [newReason, setNewReason] = useState(''); + // Default to a 90-day TTL (data minimisation) rather than a permanent ban. + const [newDurationDays, setNewDurationDays] = useState('90'); const db = getDatabase(); @@ -31,8 +33,12 @@ export function BannedIpsTab() { const handleBan = async () => { if (!newIp.trim()) return; try { - await db.banIp(newIp.trim(), newReason.trim() || undefined); - setNewIp(''); setNewReason(''); setShowAdd(false); + const days = Number(newDurationDays); + const expiresAt = days > 0 + ? new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString() + : undefined; + await db.banIp(newIp.trim(), newReason.trim() || undefined, expiresAt); + setNewIp(''); setNewReason(''); setNewDurationDays('90'); setShowAdd(false); toast({ title: 'IP banned' }); load(); } catch (e: unknown) { @@ -67,6 +73,21 @@ export function BannedIpsTab() { setNewReason(e.target.value)} placeholder="Spam submissions" />
+
+ + +
)} diff --git a/src/components/drawer/EngineCombobox.tsx b/src/components/drawer/EngineCombobox.tsx new file mode 100644 index 0000000..1a26e4f --- /dev/null +++ b/src/components/drawer/EngineCombobox.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from "react"; +import { Plus, Trash2, Settings } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import type { Engine } from "@/lib/engineStorage"; +import { + engineNameKey, + filterEngines, + normalizeEngineName, + shouldOfferCreate, +} from "@/lib/engineUtils"; + +interface EngineComboboxProps { + value: string; + onChange: (value: string) => void; + engines: Engine[]; + /** Persist a new engine name to the reusable list. */ + onCreate: (name: string) => Promise | void; + /** Remove a saved engine from the reusable list. */ + onDelete: (id: string) => Promise | void; + /** Engine names currently used by a vehicle — deletion is blocked for these. */ + usedNames?: string[]; + label?: string; +} + +export function EngineCombobox({ + value, + onChange, + engines, + onCreate, + onDelete, + usedNames = [], + label = "Engine", +}: EngineComboboxProps) { + const [open, setOpen] = useState(false); + const [manageOpen, setManageOpen] = useState(false); + const wrapRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open]); + + const matches = filterEngines(engines, value); + const offerCreate = shouldOfferCreate(value, engines); + + const usedKeys = new Set(usedNames.map(engineNameKey)); + + const pick = (name: string) => { + onChange(name); + setOpen(false); + }; + + const create = async () => { + const name = normalizeEngineName(value); + if (!name) return; + await onCreate(name); + onChange(name); + setOpen(false); + }; + + return ( +
+
+ + +
+ +
+ { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={(e) => { + if (e.key === "Escape") setOpen(false); + else if (e.key === "Enter" && offerCreate) { + e.preventDefault(); + create(); + } + }} + placeholder="Engine type" + className="h-8 text-sm" + /> + + {open && (matches.length > 0 || offerCreate) && ( +
+ {matches.map((engine) => ( + + ))} + {offerCreate && ( + + )} +
+ )} +
+ + + + + + Manage engines + + + Remove saved engine types. Engines in use by a vehicle can't be deleted. + + +
+ {engines.length === 0 ? ( +

No saved engines yet

+ ) : ( + filterEngines(engines, "").map((engine) => { + const inUse = usedKeys.has(engineNameKey(engine.name)); + return ( +
+ {engine.name} + {inUse && in use} + +
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/src/components/drawer/FilesTab.tsx b/src/components/drawer/FilesTab.tsx index a461386..3585195 100644 --- a/src/components/drawer/FilesTab.tsx +++ b/src/components/drawer/FilesTab.tsx @@ -1,14 +1,24 @@ -import { useCallback, useRef, useState, useEffect, lazy, Suspense } from "react"; -import { Trash2, Download, Upload, FolderOpen, Loader2, Video, X } from "lucide-react"; +import { useCallback, useRef, useState, useEffect, useMemo, lazy, Suspense } from "react"; +import { toast } from "sonner"; +import { Trash2, Download, Upload, FolderOpen, Loader2, Video, Cloud, CloudDownload } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { FileEntry, FileMetadata } from "@/lib/fileStorage"; +import { FileEntry, FileMetadata, getFileMetadata } from "@/lib/fileStorage"; +import { Vehicle } from "@/lib/vehicleStorage"; import { parseDatalogFile } from "@/lib/datalogParser"; import { ParsedData } from "@/types/racing"; +import { + buildBrowserSessions, computeBrowserView, defaultNav, + type BrowserSession, type NavState, +} from "@/lib/fileBrowserTree"; +import { SessionBrowser } from "@/components/SessionBrowser"; +import { useFileSources, type FileSource, type RemoteFile } from "@/plugins/fileSources"; // Lazy — keeps the BLE module in its own chunk, loaded only on device use. const DataloggerDownload = lazy(() => import("@/components/DataloggerDownload").then((m) => ({ default: m.DataloggerDownload })), ); -import { listSessionVideos, deleteSessionVideo, StoredVideoMeta } from "@/lib/videoFileStorage"; +import { listSessionVideos, StoredVideoMeta } from "@/lib/videoFileStorage"; +import { PluginMount } from "@/plugins/PluginMount"; +import { MountSlot } from "@/plugins/mounts"; function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -29,6 +39,12 @@ function formatLapTime(ms: number): string { interface FilesTabProps { files: FileEntry[]; fileMetadataMap: Map; + vehicles: Vehicle[]; + /** Track/course of the currently-loaded session — the browser opens here. */ + currentTrackName: string | null; + currentCourseName: string | null; + /** Drawer open flag — re-homes the browser to the current session on each open. */ + isOpen: boolean; storageUsed: number; storageQuota: number; onLoadFile: (name: string) => Promise; @@ -43,6 +59,10 @@ interface FilesTabProps { export function FilesTab({ files, fileMetadataMap, + vehicles, + currentTrackName, + currentCourseName, + isOpen, storageUsed, storageQuota, onLoadFile, @@ -57,8 +77,76 @@ export function FilesTab({ const [confirmLoad, setConfirmLoad] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [loading, setLoading] = useState(false); + const [cloudBusy, setCloudBusy] = useState(null); const [videoFiles, setVideoFiles] = useState>(new Map()); + // Remote (cloud) files contributed by plugins (cloud-sync). Merged into the + // same tree as "cloud" rows; the host never imports any cloud code. + const sources = useFileSources(); + const sourcesRef = useRef(sources); + sourcesRef.current = sources; + // Stable key (getContributions hands back a fresh [] each call, so we can't + // depend on the array identity without looping the effect). + const sourceKey = sources.map((s) => s.id).join("|"); + const [remoteFiles, setRemoteFiles] = useState([]); + const [remoteMeta, setRemoteMeta] = useState>(new Map()); + const remoteSourceByName = useRef>(new Map()); + + // Folder navigation. Opens at the current session's track/course, and re-homes + // there whenever the drawer is (re)opened or a different session is loaded. + const [nav, setNav] = useState(() => defaultNav(currentTrackName, currentCourseName)); + useEffect(() => { + if (isOpen) setNav(defaultNav(currentTrackName, currentCourseName)); + }, [isOpen, currentTrackName, currentCourseName]); + + // Pull the list of cloud files (+ their synced metadata) when the drawer opens + // or the local set changes (e.g. after a download promotes a cloud file local). + useEffect(() => { + let cancelled = false; + void (async () => { + const localNames = new Set(files.map((f) => f.name)); + const byName = new Map(); + const all: RemoteFile[] = []; + for (const src of sourcesRef.current) { + let list: RemoteFile[] = []; + try { list = await src.listFiles(); } catch { list = []; } + for (const rf of list) { + if (!byName.has(rf.name)) { byName.set(rf.name, src); all.push(rf); } + } + } + if (cancelled) return; + remoteSourceByName.current = byName; + setRemoteFiles(all); + // Metadata for cloud-only files syncs down separately — load any the local + // map doesn't already have so they can be grouped by track/course. + const cloudOnly = all.filter((rf) => !localNames.has(rf.name) && !fileMetadataMap.has(rf.name)); + const entries = await Promise.all( + cloudOnly.map(async (rf) => { + const m = await getFileMetadata(rf.name); + return m ? ([rf.name, m] as const) : null; + }), + ); + if (cancelled) return; + const rm = new Map(); + for (const e of entries) if (e) rm.set(e[0], e[1]); + setRemoteMeta(rm); + })(); + return () => { cancelled = true; }; + }, [sourceKey, files, fileMetadataMap, isOpen]); + + const mergedMeta = useMemo(() => { + const m = new Map(fileMetadataMap); + for (const [k, v] of remoteMeta) if (!m.has(k)) m.set(k, v); + return m; + }, [fileMetadataMap, remoteMeta]); + + const sessions = useMemo( + () => buildBrowserSessions(files, mergedMeta, vehicles, remoteFiles), + [files, mergedMeta, vehicles, remoteFiles], + ); + const view = useMemo(() => computeBrowserView(sessions, nav), [sessions, nav]); + const filesByName = useMemo(() => new Map(files.map((f) => [f.name, f])), [files]); + // Load stored video metadata to show video icons useEffect(() => { listSessionVideos().then(videos => { @@ -66,15 +154,6 @@ export function FilesTab({ }).catch(() => {}); }, [files]); - const handleDeleteVideo = useCallback(async (sessionFileName: string) => { - await deleteSessionVideo(sessionFileName); - setVideoFiles(prev => { - const next = new Map(prev); - next.delete(sessionFileName); - return next; - }); - }, []); - const handleLoadConfirm = useCallback(async () => { if (!confirmLoad) return; setLoading(true); @@ -94,10 +173,41 @@ export function FilesTab({ } }, [confirmLoad, onLoadFile, onDataLoaded, onClose]); + // Cloud-only row tapped: pull the blob, persist it locally, then open it. + const handleOpenCloud = useCallback(async (name: string) => { + const src = remoteSourceByName.current.get(name); + if (!src || cloudBusy) return; + setCloudBusy(name); + try { + const blob = await src.download(name); + if (!blob) throw new Error("Download returned no data"); + await onSaveFile(name, blob); + const data = await parseDatalogFile(new File([blob], name)); + onDataLoaded(data, name); + onClose(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to download cloud log"); + } finally { + setCloudBusy(null); + } + }, [cloudBusy, onSaveFile, onDataLoaded, onClose]); + + // A plugin (cloud-sync) can register an extra action to run on confirm — e.g. + // also removing the synced copy from the cloud. The host stays cloud-agnostic. + const deleteConfirmAction = useRef<(() => Promise) | null>(null); + const registerDeleteConfirm = useCallback((fn: (() => Promise) | null) => { + deleteConfirmAction.current = fn; + }, []); + const handleDeleteConfirm = useCallback(async () => { if (!confirmDelete) return; await onDeleteFile(confirmDelete); - setConfirmDelete(null); + try { + await deleteConfirmAction.current?.(); + } finally { + deleteConfirmAction.current = null; + setConfirmDelete(null); + } }, [confirmDelete, onDeleteFile]); const handleUpload = useCallback( @@ -132,6 +242,91 @@ export function FilesTab({ const storagePercent = storageQuota > 0 ? Math.min((storageUsed / storageQuota) * 100, 100) : 0; + const renderRow = useCallback((s: BrowserSession) => { + // Cloud-only row: one tap downloads + opens it. + if (s.location === "cloud") { + const busy = cloudBusy === s.fileName; + // Greyed out (not on this device until downloaded). + return ( +
+ + +
+ ); + } + + // Local row: tap to load; export + delete; plugin per-row control (sync toggle). + const file = filesByName.get(s.fileName); + if (!file) return null; + const metadata = mergedMeta.get(s.fileName); + return ( +
+ + + + +
+ ); + }, [cloudBusy, handleOpenCloud, filesByName, mergedMeta, videoFiles, onExportFile]); + return (
{/* Inline Confirmation Banner */} @@ -156,6 +351,11 @@ export function FilesTab({

Delete {confirmDelete}? This cannot be undone.

+
@@ -167,65 +367,14 @@ export function FilesTab({ {/* File List */}
- {files.length === 0 ? ( + {files.length === 0 && remoteFiles.length === 0 ? (

No stored files

Upload or import files to get started

) : ( - files.map((file) => ( -
- - - -
- )) + )}
diff --git a/src/components/drawer/KartsTab.tsx b/src/components/drawer/KartsTab.tsx index e5f74e5..c9e2f87 100644 --- a/src/components/drawer/KartsTab.tsx +++ b/src/components/drawer/KartsTab.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Kart } from "@/lib/kartStorage"; +import { useAuth } from "@/contexts/AuthContext"; interface KartsTabProps { karts: Kart[]; @@ -16,6 +17,7 @@ interface KartsTabProps { const emptyForm: Omit = { name: "", engine: "", number: 0, weight: 0, weightUnit: "lb" }; export function KartsTab({ karts, onAdd, onUpdate, onRemove }: KartsTabProps) { + const { user } = useAuth(); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyForm); const [confirmDelete, setConfirmDelete] = useState(null); @@ -57,10 +59,16 @@ export function KartsTab({ karts, onAdd, onUpdate, onRemove }: KartsTabProps) {
{/* Delete Confirmation */} {confirmDelete && ( -
-

- Delete this kart? This cannot be undone. -

+
+ {user ? ( +

+ Delete this kart everywhere? This removes it from every device and the cloud — it can't be undone. +

+ ) : ( +

+ Delete this kart? This cannot be undone. +

+ )}
diff --git a/src/components/drawer/NotesTab.tsx b/src/components/drawer/NotesTab.tsx index aaa9680..411469b 100644 --- a/src/components/drawer/NotesTab.tsx +++ b/src/components/drawer/NotesTab.tsx @@ -3,9 +3,10 @@ import { Pencil, Trash2, NotebookPen, Check } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Note } from "@/lib/noteStorage"; +import { Note, MAX_NOTE_BYTES } from "@/lib/noteStorage"; import { Vehicle } from "@/lib/vehicleStorage"; import { VehicleSetup } from "@/lib/setupStorage"; +import { shortRevHash } from "@/lib/setupRevision"; interface NotesTabProps { fileName: string | null; @@ -17,12 +18,14 @@ interface NotesTabProps { setups: VehicleSetup[]; sessionKartId: string | null; sessionSetupId: string | null; + /** Content hash of the frozen setup revision this session ran (immutable id). */ + sessionSetupRev: string | null; onSaveSessionSetup: (kartId: string | null, setupId: string | null) => Promise; } export function NotesTab({ fileName, notes, onAdd, onUpdate, onRemove, - vehicles, setups, sessionKartId, sessionSetupId, onSaveSessionSetup, + vehicles, setups, sessionKartId, sessionSetupId, sessionSetupRev, onSaveSessionSetup, }: NotesTabProps) { const [editingId, setEditingId] = useState(null); const [text, setText] = useState(""); @@ -118,6 +121,15 @@ export function NotesTab({ + {isSaved && sessionSetupRev && ( +

+ Setup revision{" "} + + #{shortRevHash(sessionSetupRev)} + {" "} + — frozen, so later edits won't change this session. +

+ )}
{/* Delete Confirmation */} @@ -155,7 +167,7 @@ export function NotesTab({ {/* Add/Edit Form */}
-