From ec9f228ba3adbdcfbd4c5ebfd77430736215fa8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 03:33:33 +0000 Subject: [PATCH 001/121] Add plugin UI panel framework, surfaced in the Labs tab First concrete extension point for the plugin system: plugins contribute self-contained React panels to a named slot, and the host mounts them. Built so future personal/third-party plugins plug in the same way. - panels.ts: PluginPanel/PluginPanelProps contract, PANELS_POINT, PanelSlot, getPanelsForSlot. PluginPanelProps is a curated, read-only session snapshot so plugins never depend on the host's internal session context. - PluginPanelHost: mounts every panel for a slot in a titled card, each behind a per-panel error boundary so a buggy plugin can't crash the tab. - LabsTab renders the "labs" slot; Index shows the Labs tab automatically when a plugin contributes a labs panel, even with the experimental setting off. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- CHANGELOG.md | 5 +++ CLAUDE.md | 11 ++++++ src/components/tabs/LabsTab.tsx | 23 +++++++++++- src/pages/Index.tsx | 15 +++++--- src/plugins/PluginPanelHost.tsx | 65 +++++++++++++++++++++++++++++++++ src/plugins/README.md | 54 +++++++++++++++++++++++++++ src/plugins/panels.test.ts | 34 +++++++++++++++++ src/plugins/panels.ts | 62 +++++++++++++++++++++++++++++++ 8 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 src/plugins/PluginPanelHost.tsx create mode 100644 src/plugins/panels.test.ts create mode 100644 src/plugins/panels.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1f436..7c357a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- 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. + ### Changed - 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` diff --git a/CLAUDE.md b/CLAUDE.md index 957ade9..6c71572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,8 @@ src/ │ ├── types.ts # DataViewerPlugin / PluginContext / PluginRegistry contracts │ ├── registry.ts # Singleton registry + generic extension points │ ├── index.ts # initPlugins() — discovery + setup (called in main.tsx) +│ ├── panels.ts # UI panel framework: PluginPanel/Props, PANELS_POINT, PanelSlot, getPanelsForSlot +│ ├── PluginPanelHost.tsx # Mounts plugin panels for a slot (error-boundaried, with fallback) │ └── coaching/ # Gitignored private slot (AI coaching submodule) ├── types/ │ └── racing.ts # ★ Core types: GpsSample, ParsedData, Lap, Course, Track, etc. @@ -227,6 +229,8 @@ 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 | +| `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 | | `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 @@ -234,6 +238,13 @@ A plugin default-exports `{ id, name, version?, priority?, setup?(ctx) }`. In (`ctx.registry.contribute(point, value)`); consumers read via `getContributions(point)`. New extension points need no registry changes. +**UI panels:** the first concrete extension point. A plugin contributes +`PluginPanel` descriptors to `PANELS_POINT`, targeting a *slot* (host surface). +The only slot today is `PanelSlot.Labs` — `LabsTab.tsx` renders contributed +panels via `PluginPanelHost`, and a labs-slot panel makes the Labs tab appear +automatically even when the experimental `enableLabs` setting is off (`Index.tsx` +computes `hasLabsPanels`). New slots are just new strings — no framework change. + **AI coach (npm package):** published to the public npm registry as `@perchwerks/eye-in-the-sky` and listed in `optionalDependencies`. The loader in `vite.config.ts` defaults to that package (no token or `.npmrc` needed); diff --git a/src/components/tabs/LabsTab.tsx b/src/components/tabs/LabsTab.tsx index f98137c..418ef91 100644 --- a/src/components/tabs/LabsTab.tsx +++ b/src/components/tabs/LabsTab.tsx @@ -1,7 +1,28 @@ import { memo } from "react"; import { FlaskConical } from "lucide-react"; +import { useSessionContext } from "@/contexts/SessionContext"; +import { useSettingsContext } from "@/contexts/SettingsContext"; +import { PluginPanelHost } from "@/plugins/PluginPanelHost"; +import { PanelSlot } from "@/plugins/panels"; export const LabsTab = memo(function LabsTab() { + const { data, laps, selectedLapNumber, course } = useSessionContext(); + const { useKph } = useSettingsContext(); + + return ( + } + /> + ); +}); + +function LabsEmpty() { return (
@@ -13,4 +34,4 @@ export const LabsTab = memo(function LabsTab() {
); -}); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index ec7af6f..091d2d4 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { ParsedData } from "@/types/racing"; +import { getPanelsForSlot, PanelSlot } from "@/plugins/panels"; import { TrackPromptDialog } from "@/components/TrackPromptDialog"; import { useSettings } from "@/hooks/useSettings"; import { usePlayback } from "@/hooks/usePlayback"; @@ -109,6 +110,10 @@ export default function Index() { const [topPanelView, setTopPanelView] = useState("raceline"); const [showOverlays, setShowOverlays] = useState(true); + // Plugins are registered at startup, so a labs-slot panel means the Labs tab + // has real content — surface it even when the experimental setting is off. + const hasLabsPanels = useMemo(() => getPanelsForSlot(PanelSlot.Labs).length > 0, []); + const showLabs = settings.enableLabs || hasLabsPanels; // Video sync for Labs tab const videoSync = useVideoSync({ @@ -379,7 +384,7 @@ export default function Index() {
- setShowOverlays(v => !v)} enableLabs={settings.enableLabs} /> + setShowOverlays(v => !v)} showLabs={showLabs} />
@@ -387,7 +392,7 @@ export default function Index() { {topPanelView === "laptable" && } {topPanelView === "graphview" && } - {topPanelView === "labs" && settings.enableLabs && } + {topPanelView === "labs" && showLabs && }
@@ -412,13 +417,13 @@ export default function Index() { } /** Tab navigation bar for the main data view */ -function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOverlays, enableLabs }: { +function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOverlays, showLabs }: { topPanelView: TopPanelView; setTopPanelView: (view: TopPanelView) => void; laps: { lapNumber: number }[]; showOverlays: boolean; onToggleOverlays: () => void; - enableLabs: boolean; + showLabs: boolean; }) { const tabClass = (view: TopPanelView) => `flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${ @@ -441,7 +446,7 @@ function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOve {laps.length} )} - {enableLabs && ( + {showLabs && ( diff --git a/src/plugins/PluginPanelHost.tsx b/src/plugins/PluginPanelHost.tsx new file mode 100644 index 0000000..52db255 --- /dev/null +++ b/src/plugins/PluginPanelHost.tsx @@ -0,0 +1,65 @@ +import { Component, useMemo, type ReactNode } from "react"; +import { getPanelsForSlot, type PluginPanelProps } from "./panels"; + +/** + * Isolates a single plugin panel: a throw in one panel renders a local notice + * instead of taking down the tab (or the app). Plugin UI is untrusted-ish — + * first-party today, potentially user-installed later — so each gets a boundary. + */ +class PanelErrorBoundary extends Component< + { title: string; children: ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render() { + if (this.state.failed) { + return ( +
+ The “{this.props.title}” panel hit an error and was unloaded. +
+ ); + } + return this.props.children; + } +} + +/** + * Mounts every plugin panel registered for `slot`, passing each the live + * session snapshot. Renders `fallback` when no panels target the slot. + */ +export function PluginPanelHost({ + slot, + fallback, + ...props +}: { slot: string; fallback?: ReactNode } & PluginPanelProps) { + const panels = useMemo(() => getPanelsForSlot(slot), [slot]); + + if (panels.length === 0) return <>{fallback ?? null}; + + return ( +
+ {panels.map((panel) => { + const Icon = panel.icon; + const Body = panel.component; + return ( +
+
+ {Icon && } +

{panel.title}

+
+
+ + + +
+
+ ); + })} +
+ ); +} diff --git a/src/plugins/README.md b/src/plugins/README.md index 9439acb..7c476ce 100644 --- a/src/plugins/README.md +++ b/src/plugins/README.md @@ -32,6 +32,60 @@ const plugin: DataViewerPlugin = { export default plugin; ``` +## Contributing UI panels (`panels.ts`) + +The first concrete extension point is the **panel framework**: a plugin +contributes self-contained React panels to a named *slot* (a host surface), and +the host mounts them. Today the only slot is the **Labs tab** (`PanelSlot.Labs`); +new slots are just new strings — no registry or framework changes needed. + +A panel is a `PluginPanel` contributed to the `PANELS_POINT` extension point: + +```tsx +import { FlaskConical } from "lucide-react"; +import type { DataViewerPlugin } from "@/plugins/types"; +import { PANELS_POINT, PanelSlot, type PluginPanel, type PluginPanelProps } from "@/plugins/panels"; + +function CoachPanel({ data, laps, selectedLapNumber, useKph }: PluginPanelProps) { + if (!data) return

Load a session to start coaching.

; + return

{laps.length} laps ready to analyze.

; +} + +const plugin: DataViewerPlugin = { + id: "ai-coaching", + name: "AI Coaching", + priority: 100, + setup(ctx) { + const panel: PluginPanel = { + id: "ai-coaching", + title: "AI Coaching", + slot: PanelSlot.Labs, + order: 0, + icon: FlaskConical, + component: CoachPanel, + }; + ctx.registry.contribute(PANELS_POINT, panel); + }, +}; + +export default plugin; +``` + +Contract notes: + +- **`PluginPanelProps` is the entire surface a panel can rely on** — a curated, + read-only snapshot of the active session (`data`, `laps`, `selectedLapNumber`, + `course`, `useKph`). Panels never touch the host's internal session context, so + the host can refactor internals without breaking plugins. +- The host (`PluginPanelHost`) renders each panel inside a titled card and wraps + it in an **error boundary** — a throwing panel shows a local notice instead of + crashing the tab. +- A labs-slot panel makes the **Labs tab appear automatically**, even when the + experimental Labs setting is off (`Index.tsx`). +- Panels resolve `react` from the host bundle (plugins are compiled as source by + the host's Vite), so an external package adds `react` to its `devDependencies` + for its own typecheck but does **not** bundle a second copy. + ## The AI coach as a public npm package The coach lives in its own repo and is published to the **public npm registry** diff --git a/src/plugins/panels.test.ts b/src/plugins/panels.test.ts new file mode 100644 index 0000000..1476c12 --- /dev/null +++ b/src/plugins/panels.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { pluginRegistry } from "./registry"; +import { PANELS_POINT, getPanelsForSlot, type PluginPanel } from "./panels"; + +const noopComponent: PluginPanel["component"] = () => null; + +function panel(id: string, slot: string, order?: number): PluginPanel { + return { id, title: id, slot, order, component: noopComponent }; +} + +describe("getPanelsForSlot", () => { + it("returns only panels contributed to the requested slot", () => { + pluginRegistry.contribute(PANELS_POINT, panel("a", "slot-filter")); + pluginRegistry.contribute(PANELS_POINT, panel("b", "slot-other")); + + expect(getPanelsForSlot("slot-filter").map((p) => p.id)).toEqual(["a"]); + }); + + it("sorts by order ascending, treating missing order as 0", () => { + pluginRegistry.contribute(PANELS_POINT, panel("late", "slot-order", 10)); + pluginRegistry.contribute(PANELS_POINT, panel("default", "slot-order")); + pluginRegistry.contribute(PANELS_POINT, panel("early", "slot-order", -5)); + + expect(getPanelsForSlot("slot-order").map((p) => p.id)).toEqual([ + "early", + "default", + "late", + ]); + }); + + it("returns an empty array for a slot with no contributions", () => { + expect(getPanelsForSlot("slot-empty")).toEqual([]); + }); +}); diff --git a/src/plugins/panels.ts b/src/plugins/panels.ts new file mode 100644 index 0000000..8c00347 --- /dev/null +++ b/src/plugins/panels.ts @@ -0,0 +1,62 @@ +// UI panel framework for plugins. +// +// A plugin contributes self-contained React panels to a named *slot* (a host +// surface, e.g. the Labs tab). The host mounts every panel registered for a +// slot and hands each one a curated, stable `PluginPanelProps` snapshot of the +// active session — plugins never see the host's internal session context, so +// the contract here is the entire surface a plugin can rely on. +// +// New slots need no changes here: a host surface picks a slot string, plugins +// target it, and `getPanelsForSlot` wires them together. + +import type { ComponentType } from "react"; +import type { ParsedData, Lap, Course } from "@/types/racing"; +import { pluginRegistry } from "./registry"; + +/** Registry extension point that all UI panels are contributed to. */ +export const PANELS_POINT = "ui:panels"; + +/** Known host surfaces a panel can mount into. */ +export const PanelSlot = { + /** The Labs tab in the main view. */ + Labs: "labs", +} as const; +export type PanelSlot = (typeof PanelSlot)[keyof typeof PanelSlot]; + +/** Live, read-only session snapshot handed to every panel on each render. */ +export interface PluginPanelProps { + /** Parsed telemetry for the active session, or null when none is loaded. */ + data: ParsedData | null; + /** Detected laps for the active session. */ + laps: Lap[]; + /** Currently selected lap number, or null for "all laps". */ + selectedLapNumber: number | null; + /** Selected course (start/finish + sectors), or null when undetected. */ + course: Course | null; + /** Unit preference: true = km/h, false = mph. */ + useKph: boolean; +} + +/** Descriptor a plugin contributes to `PANELS_POINT` to render a panel. */ +export interface PluginPanel { + /** Unique id (within its slot). */ + id: string; + /** Title shown in the panel header. */ + title: string; + /** Host surface to mount into — a `PanelSlot` value. */ + slot: string; + /** Sort order within the slot; lower renders first. Defaults to 0. */ + order?: number; + /** Optional lucide-style icon for the panel header. */ + icon?: ComponentType<{ className?: string }>; + /** The panel body. Re-renders with a fresh `PluginPanelProps` snapshot. */ + component: ComponentType; +} + +/** All panels contributed to `slot`, sorted by `order` then registration. */ +export function getPanelsForSlot(slot: string): PluginPanel[] { + return pluginRegistry + .getContributions(PANELS_POINT) + .filter((panel) => panel.slot === slot) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} From 44030b3042c0b3fe81abbaf5e5e710d6d776ed6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 04:06:57 +0000 Subject: [PATCH 002/121] Add Cloud Sync first-party plugin (Supabase file + garage sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First in-repo plugin built on the panel framework: a Labs panel that signs the user in and does manual, directional push/pull of local IndexedDB data to Supabase. Structured stores sync as jsonb documents in a new sync_records table; raw session blobs round-trip through a private per-user Storage bucket. Both are RLS-scoped to the owner. Sync is additive (no deletion propagation yet) and online-only — the core app stays fully offline without it. - supabase migration: sync_records table + user-files bucket, owner-scoped RLS - syncStores.ts holds the pure store/key config (unit-tested); syncEngine.ts does the IDB <-> cloud I/O; cloudClient.ts isolates the typed-client escape hatch until Supabase types regenerate - PluginPanelHost now wraps panels in Suspense so panel components can be lazy; CloudSyncPanel is lazy-loaded to keep it off the initial bundle - sign-in only for now (Google to be added via Lovable); auth UI is a thin stub https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- CHANGELOG.md | 5 + CLAUDE.md | 44 ++++++- README.md | 7 + src/plugins/PluginPanelHost.tsx | 6 +- src/plugins/README.md | 5 +- src/plugins/cloud-sync/CloudSyncPanel.tsx | 124 ++++++++++++++++++ src/plugins/cloud-sync/cloudClient.ts | 36 +++++ src/plugins/cloud-sync/index.ts | 28 ++++ src/plugins/cloud-sync/syncEngine.ts | 93 +++++++++++++ src/plugins/cloud-sync/syncStores.test.ts | 42 ++++++ src/plugins/cloud-sync/syncStores.ts | 44 +++++++ .../migrations/20260524120000_cloud_sync.sql | 74 +++++++++++ 12 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 src/plugins/cloud-sync/CloudSyncPanel.tsx create mode 100644 src/plugins/cloud-sync/cloudClient.ts create mode 100644 src/plugins/cloud-sync/index.ts create mode 100644 src/plugins/cloud-sync/syncEngine.ts create mode 100644 src/plugins/cloud-sync/syncStores.test.ts create mode 100644 src/plugins/cloud-sync/syncStores.ts create mode 100644 supabase/migrations/20260524120000_cloud_sync.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c357a5..6cceab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- 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. ### Changed - The optional AI coach plugin now ships from the public npm registry as diff --git a/CLAUDE.md b/CLAUDE.md index 6c71572..ad4d390 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,7 +179,13 @@ src/ │ ├── registry.ts # Singleton registry + generic extension points │ ├── index.ts # initPlugins() — discovery + setup (called in main.tsx) │ ├── panels.ts # UI panel framework: PluginPanel/Props, PANELS_POINT, PanelSlot, getPanelsForSlot -│ ├── PluginPanelHost.tsx # Mounts plugin panels for a slot (error-boundaried, with fallback) +│ ├── PluginPanelHost.tsx # Mounts plugin panels for a slot (error-boundaried, Suspense-wrapped, with fallback) +│ ├── cloud-sync/ # ★ First-party plugin: Supabase file + garage sync (Labs panel) +│ │ ├── index.ts # Plugin def — contributes a lazy CloudSyncPanel to the Labs slot +│ │ ├── CloudSyncPanel.tsx # Sign-in + push/pull UI (lazy-loaded) +│ │ ├── syncStores.ts # Pure config: which IDB stores sync + how they're keyed (testable) +│ │ ├── syncEngine.ts # pushAll/pullAll: IDB ↔ sync_records (jsonb) + user-files bucket (blobs) +│ │ └── cloudClient.ts # Typed access to sync_records + bucket (escape hatch until types regen) │ └── coaching/ # Gitignored private slot (AI coaching submodule) ├── types/ │ └── racing.ts # ★ Core types: GpsSample, ParsedData, Lap, Course, Track, etc. @@ -244,6 +250,15 @@ The only slot today is `PanelSlot.Labs` — `LabsTab.tsx` renders contributed panels via `PluginPanelHost`, and a labs-slot panel makes the Labs tab appear automatically even when the experimental `enableLabs` setting is off (`Index.tsx` computes `hasLabsPanels`). 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). + +**Cloud Sync (first-party plugin, `src/plugins/cloud-sync/`):** the first +in-repo plugin built on the panel framework. Contributes a lazy Labs panel that +signs the user in (`useAuth`) and does manual push/pull of local IndexedDB data +to Supabase. Structured stores go to the `sync_records` table as jsonb +documents; raw session blobs go to the private `user-files` Storage bucket. See +the Cloud Sync section below for the data model. **AI coach (npm package):** published to the public npm registry as `@perchwerks/eye-in-the-sky` and listed in `optionalDependencies`. The loader in @@ -344,6 +359,33 @@ To add a new store: increment `DB_VERSION`, add store name to `STORE_NAMES`, add --- +## Cloud Sync (`src/plugins/cloud-sync/`) + +Optional per-user backup/sync of the IndexedDB data above to Supabase. Built as +a first-party plugin (Labs panel), online-only (accepted offline-first +exception). **Manual & directional**: "push" mirrors local → cloud, "pull" +brings cloud → local; on a key collision the chosen direction wins. Sync is +**additive** — neither side deletes the other's extra records (deletion +propagation + timestamp merge are deliberate follow-ups). + +Backend (migration `..._cloud_sync.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. | + +Synced stores (`syncStores.ts` — pure, unit-tested): `metadata`, `karts`, +`setups`, `notes`, `graph-prefs`, `vehicle-types`, `setup-templates` (jsonb +docs) + `files` (blobs). Video stores are intentionally excluded (size). +`vehicle-types`/`setup-templates` ride along because setups are template-driven. + +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. + +--- + ## Course Layouts (Drawing Feature) The `course_layouts` table stores polyline drawings of track layouts (1:1 with courses, unique on `course_id`, cascade delete). diff --git a/README.md b/README.md index 74b2ad6..bc23f33 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,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 @@ -133,6 +134,12 @@ 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 + +> 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 diff --git a/src/plugins/PluginPanelHost.tsx b/src/plugins/PluginPanelHost.tsx index 52db255..1ddd3ea 100644 --- a/src/plugins/PluginPanelHost.tsx +++ b/src/plugins/PluginPanelHost.tsx @@ -1,4 +1,4 @@ -import { Component, useMemo, type ReactNode } from "react"; +import { Component, Suspense, useMemo, type ReactNode } from "react"; import { getPanelsForSlot, type PluginPanelProps } from "./panels"; /** @@ -54,7 +54,9 @@ export function PluginPanelHost({
- + Loading…

}> + +
diff --git a/src/plugins/README.md b/src/plugins/README.md index 7c476ce..e8018a1 100644 --- a/src/plugins/README.md +++ b/src/plugins/README.md @@ -3,8 +3,9 @@ Plugins are discovered at startup from two sources and merged into one registry: 1. **In-repo first-party plugins** — `src/plugins//index.ts`, found via - `import.meta.glob`. Use this for open-source plugins that ship in the repo - (e.g. cloud sync). + `import.meta.glob`. Use this for open-source plugins that ship in the repo. + `cloud-sync/` is the reference example: a Labs panel that syncs local data to + Supabase, built entirely on the panel framework below. 2. **External npm packages** — the AI coach. Surfaced via the `virtual:external-plugins` module (generated by `externalPluginsLoader` in `vite.config.ts`). A package absent at build time simply never loads, so the diff --git a/src/plugins/cloud-sync/CloudSyncPanel.tsx b/src/plugins/cloud-sync/CloudSyncPanel.tsx new file mode 100644 index 0000000..39189ed --- /dev/null +++ b/src/plugins/cloud-sync/CloudSyncPanel.tsx @@ -0,0 +1,124 @@ +import { useState, type FormEvent } from "react"; +import { toast } from "sonner"; +import { CloudUpload, CloudDownload, LogOut, WifiOff, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import { pushAll, pullAll } from "./syncEngine"; + +type Busy = "push" | "pull" | "login" | null; + +export default function CloudSyncPanel() { + const { user, loading, login, logout } = useAuth(); + const online = useOnlineStatus(); + const [busy, setBusy] = useState(null); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + if (loading) { + return ( +

+ Checking sign-in… +

+ ); + } + + if (!user) { + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + setBusy("login"); + const { error } = await login(email, password); + setBusy(null); + if (error) toast.error(error.message || "Sign-in failed"); + }; + return ( +
+

+ Sign in to sync your files and garage across devices. More sign-in options coming soon. +

+ setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + + {!online && ( +

+ You're offline — sign-in needs a connection. +

+ )} +
+ ); + } + + const runPush = async () => { + setBusy("push"); + try { + const r = await pushAll(user.id); + toast.success(`Pushed ${r.records} records and ${r.files} files to the cloud.`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Push failed"); + } finally { + setBusy(null); + } + }; + + const runPull = async () => { + if (!window.confirm("Pull merges your cloud copy into this device, overwriting any local records with the same name. Continue?")) { + return; + } + setBusy("pull"); + try { + const r = await pullAll(user.id); + toast.success(`Pulled ${r.records} records and ${r.files} files. Reloading…`); + setTimeout(() => window.location.reload(), 1200); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Pull failed"); + setBusy(null); + } + }; + + return ( +
+
+ {user.email} + +
+ + {!online && ( +

+ You're offline — syncing is paused until you reconnect. +

+ )} + +
+ + +
+ +

+ Push uploads this device's files and garage data. Pull brings your cloud copy down. Conflicts resolve in the direction you choose; nothing is deleted. +

+
+ ); +} diff --git a/src/plugins/cloud-sync/cloudClient.ts b/src/plugins/cloud-sync/cloudClient.ts new file mode 100644 index 0000000..1feb1f4 --- /dev/null +++ b/src/plugins/cloud-sync/cloudClient.ts @@ -0,0 +1,36 @@ +// Typed access to the cloud-sync backend (sync_records table + user-files +// bucket). +// +// The generated `integrations/supabase/types.ts` Database type does not yet +// include `sync_records` (Lovable regenerates it after the migration deploys), +// so `supabase.from('sync_records')` would be a compile error. We confine that +// gap to this one module: route the new table through an untyped view of the +// shared client and hand-write the row shape. When types are regenerated this +// can switch back to the typed `supabase` client with no call-site changes. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { supabase } from "@/integrations/supabase/client"; + +export const SYNC_BUCKET = "user-files"; + +/** A row in public.sync_records — one client record stored as a jsonb document. */ +export interface SyncRecordRow { + user_id: string; + store: string; + record_key: string; + data: unknown; + updated_at?: string; +} + +// Untyped view of the shared client — same auth/session, no Database generic. +const untyped = supabase as unknown as SupabaseClient; + +/** Query builder for the sync_records table. */ +export function syncRecords() { + return untyped.from("sync_records"); +} + +/** Storage API for the private per-user file bucket. */ +export function userFiles() { + return untyped.storage.from(SYNC_BUCKET); +} diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts new file mode 100644 index 0000000..c57a437 --- /dev/null +++ b/src/plugins/cloud-sync/index.ts @@ -0,0 +1,28 @@ +import { lazy } from "react"; +import { Cloud } from "lucide-react"; +import type { DataViewerPlugin } from "@/plugins/types"; +import { PANELS_POINT, PanelSlot, type PluginPanel } from "@/plugins/panels"; + +// The panel pulls in the Supabase sync engine + storage modules, so it's lazy: +// the chunk loads only when the Labs tab is opened, keeping the initial bundle +// lean (see Bundle Splitting in CLAUDE.md). +const CloudSyncPanel = lazy(() => import("./CloudSyncPanel")); + +const plugin: DataViewerPlugin = { + id: "cloud-sync", + name: "Cloud Sync", + version: "0.1.0", + setup(ctx) { + const panel: PluginPanel = { + id: "cloud-sync", + title: "Cloud Sync", + slot: PanelSlot.Labs, + order: 10, + icon: Cloud, + component: CloudSyncPanel, + }; + ctx.registry.contribute(PANELS_POINT, panel); + }, +}; + +export default plugin; diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts new file mode 100644 index 0000000..4edd581 --- /dev/null +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -0,0 +1,93 @@ +// Push/pull sync engine. +// +// Manual, directional sync (no background daemon): "push" mirrors local data up +// to the cloud, "pull" brings the cloud copy down. On a key collision the active +// direction wins (push → cloud takes local; pull → local takes cloud). Neither +// direction deletes the other side's extra records, so sync is additive — a +// missing record is never inferred as a deletion. (Deletion propagation and +// timestamp-based merge are deliberate follow-ups.) +// +// All structured stores are handled generically through IndexedDB + jsonb, so +// adding a new syncable store is a single entry in syncStores.ts. File blobs +// can't live in jsonb, so they round-trip through the Storage bucket instead. + +import { withReadTransaction, withWriteTransaction } from "@/lib/dbUtils"; +import { getFile, listFiles, saveFile } from "@/lib/fileStorage"; +import { syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; +import { DOC_STORES, FILE_STORE, extractKey, type SyncSummary } from "./syncStores"; + +export type { SyncSummary }; + +/** Storage object path for a file blob, scoped to the user's folder. */ +function blobPath(userId: string, name: string): string { + return `${userId}/${encodeURIComponent(name)}`; +} + +async function readAll(store: string): Promise[]> { + return withReadTransaction[]>(store, (s) => s.getAll()); +} + +async function writeOne(store: string, record: unknown): Promise { + await withWriteTransaction(store, (s) => s.put(record as Record)); +} + +/** Mirror all local data (structured records + file blobs) up to the cloud. */ +export async function pushAll(userId: string): Promise { + const rows: SyncRecordRow[] = []; + for (const store of DOC_STORES) { + for (const record of await readAll(store)) { + rows.push({ user_id: userId, store, record_key: extractKey(store, record), data: record }); + } + } + if (rows.length) { + const { error } = await syncRecords().upsert(rows, { onConflict: "user_id,store,record_key" }); + if (error) throw new Error(`Failed to push records: ${error.message}`); + } + + const fileRows: SyncRecordRow[] = []; + for (const file of await listFiles()) { + const blob = await getFile(file.name); + if (!blob) continue; + const { error } = await userFiles().upload(blobPath(userId, file.name), blob, { + upsert: true, + contentType: blob.type || "application/octet-stream", + }); + if (error) throw new Error(`Failed to upload ${file.name}: ${error.message}`); + fileRows.push({ + user_id: userId, + store: FILE_STORE, + record_key: file.name, + data: { size: file.size, savedAt: file.savedAt }, + }); + } + if (fileRows.length) { + const { error } = await syncRecords().upsert(fileRows, { onConflict: "user_id,store,record_key" }); + if (error) throw new Error(`Failed to push file index: ${error.message}`); + } + + return { records: rows.length, files: fileRows.length }; +} + +/** Bring the cloud copy down into local IndexedDB. */ +export async function pullAll(userId: string): Promise { + const { data, error } = await syncRecords() + .select("store,record_key,data") + .eq("user_id", userId); + if (error) throw new Error(`Failed to read cloud records: ${error.message}`); + + const rows = (data ?? []) as Pick[]; + let records = 0; + let files = 0; + for (const row of rows) { + if (row.store === FILE_STORE) { + const { data: blob, error: dlError } = await userFiles().download(blobPath(userId, row.record_key)); + if (dlError || !blob) continue; + await saveFile(row.record_key, blob); + files++; + } else if ((DOC_STORES as readonly string[]).includes(row.store)) { + await writeOne(row.store, row.data); + records++; + } + } + return { records, files }; +} diff --git a/src/plugins/cloud-sync/syncStores.test.ts b/src/plugins/cloud-sync/syncStores.test.ts new file mode 100644 index 0000000..6a9955f --- /dev/null +++ b/src/plugins/cloud-sync/syncStores.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { STORE_NAMES } from "@/lib/dbUtils"; +import { extractKey, DOC_STORES, FILE_STORE } from "./syncStores"; + +describe("extractKey", () => { + it("uses each store's IndexedDB key path", () => { + expect(extractKey(STORE_NAMES.METADATA, { fileName: "run1.dovex" })).toBe("run1.dovex"); + expect(extractKey(STORE_NAMES.KARTS, { id: "kart-7" })).toBe("kart-7"); + expect(extractKey(STORE_NAMES.GRAPH_PREFS, { sessionFileName: "run1.dovex" })).toBe("run1.dovex"); + expect(extractKey(STORE_NAMES.FILES, { name: "run1.dovex" })).toBe("run1.dovex"); + }); + + it("coerces non-string keys to string", () => { + expect(extractKey(STORE_NAMES.NOTES, { id: 42 })).toBe("42"); + }); +}); + +describe("synced store coverage", () => { + it("syncs files separately from the jsonb document stores", () => { + expect(FILE_STORE).toBe(STORE_NAMES.FILES); + expect(DOC_STORES).not.toContain(STORE_NAMES.FILES); + }); + + it("does not sync video stores (out of scope, large blobs)", () => { + expect(DOC_STORES).not.toContain(STORE_NAMES.SESSION_VIDEOS); + expect(DOC_STORES).not.toContain(STORE_NAMES.VIDEO_SYNC); + }); + + it("covers garage data: vehicles, setups, notes, graph prefs, and their templates", () => { + for (const store of [ + STORE_NAMES.KARTS, + STORE_NAMES.SETUPS, + STORE_NAMES.NOTES, + STORE_NAMES.GRAPH_PREFS, + STORE_NAMES.VEHICLE_TYPES, + STORE_NAMES.SETUP_TEMPLATES, + STORE_NAMES.METADATA, + ]) { + expect(DOC_STORES).toContain(store); + } + }); +}); diff --git a/src/plugins/cloud-sync/syncStores.ts b/src/plugins/cloud-sync/syncStores.ts new file mode 100644 index 0000000..d23c03a --- /dev/null +++ b/src/plugins/cloud-sync/syncStores.ts @@ -0,0 +1,44 @@ +// Pure config for which IndexedDB stores sync and how their records are keyed. +// Kept free of the Supabase client (and thus of browser-only globals) so it +// stays unit-testable in a node environment. + +import { STORE_NAMES } from "@/lib/dbUtils"; + +/** IndexedDB key path for each syncable store. */ +const KEY_FIELD: Record = { + [STORE_NAMES.METADATA]: "fileName", + [STORE_NAMES.KARTS]: "id", + [STORE_NAMES.NOTES]: "id", + [STORE_NAMES.SETUPS]: "id", + [STORE_NAMES.GRAPH_PREFS]: "sessionFileName", + // Setups are template-driven, so their vehicle types + templates must travel + // with them or pulled setups can't render. + [STORE_NAMES.VEHICLE_TYPES]: "id", + [STORE_NAMES.SETUP_TEMPLATES]: "id", + [STORE_NAMES.FILES]: "name", +}; + +/** Structured stores synced as jsonb documents. */ +export const DOC_STORES = [ + STORE_NAMES.METADATA, + STORE_NAMES.KARTS, + STORE_NAMES.NOTES, + STORE_NAMES.SETUPS, + STORE_NAMES.GRAPH_PREFS, + STORE_NAMES.VEHICLE_TYPES, + STORE_NAMES.SETUP_TEMPLATES, +] as const; + +/** Store whose payload is a Blob, synced through the Storage bucket. */ +export const FILE_STORE = STORE_NAMES.FILES; + +export interface SyncSummary { + records: number; + files: number; +} + +/** Extract the cloud record_key for a store's record using its IndexedDB key path. */ +export function extractKey(store: string, record: Record): string { + const field = KEY_FIELD[store]; + return String(record?.[field]); +} diff --git a/supabase/migrations/20260524120000_cloud_sync.sql b/supabase/migrations/20260524120000_cloud_sync.sql new file mode 100644 index 0000000..5cd26a6 --- /dev/null +++ b/supabase/migrations/20260524120000_cloud_sync.sql @@ -0,0 +1,74 @@ +-- Cloud sync: per-user storage of telemetry files + garage data. +-- +-- Structured records (file metadata, vehicles/karts, setups, notes, graph prefs, +-- vehicle types, setup templates) are stored one row each in sync_records as a +-- jsonb document keyed by (user_id, store, record_key) — the same keys the +-- client's IndexedDB stores use. Raw session file blobs live in the private +-- user-files Storage bucket under {user_id}/. Everything is scoped to its owner +-- via RLS; there is no cross-user or public read path. + +-- ── Structured records ────────────────────────────────────────────────────── +create table if not exists public.sync_records ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + store text not null, + record_key text not null, + data jsonb not null, + updated_at timestamptz not null default now(), + unique (user_id, store, record_key) +); + +create index if not exists sync_records_user_store_idx + on public.sync_records (user_id, store); + +alter table public.sync_records enable row level security; + +create policy "Users read own sync records" + on public.sync_records for select to authenticated + using (auth.uid() = user_id); + +create policy "Users insert own sync records" + on public.sync_records for insert to authenticated + with check (auth.uid() = user_id); + +create policy "Users update own sync records" + on public.sync_records for update to authenticated + using (auth.uid() = user_id) + with check (auth.uid() = user_id); + +create policy "Users delete own sync records" + on public.sync_records for delete to authenticated + using (auth.uid() = user_id); + +-- ── Raw file blobs ────────────────────────────────────────────────────────── +insert into storage.buckets (id, name, public) +values ('user-files', 'user-files', false) +on conflict (id) do nothing; + +create policy "Users read own files" + on storage.objects for select to authenticated + using ( + bucket_id = 'user-files' + and (storage.foldername(name))[1] = auth.uid()::text + ); + +create policy "Users upload own files" + on storage.objects for insert to authenticated + with check ( + bucket_id = 'user-files' + and (storage.foldername(name))[1] = auth.uid()::text + ); + +create policy "Users update own files" + on storage.objects for update to authenticated + using ( + bucket_id = 'user-files' + and (storage.foldername(name))[1] = auth.uid()::text + ); + +create policy "Users delete own files" + on storage.objects for delete to authenticated + using ( + bucket_id = 'user-files' + and (storage.foldername(name))[1] = auth.uid()::text + ); From 37e5646933267abef868bde9aaf54053ecaac5d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 04:37:40 +0000 Subject: [PATCH 003/121] Add position-based lap delta module (resampler + segment-projected gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the pacing rework: a standalone, unit-tested lib module porting the DovesLapTimer issue #29 design to the web tool. Not yet wired into the app — this lands the core math so it can be reviewed in isolation. - resampleByDistance(): canonical arc-length grid (one point per N meters), independent of GPS rate and lap duration — fixes the legacy distance method's cumulative-noise drift and gives uniform spatial resolution for the coach. - computePositionDelta(): projects each native current fix onto the nearest reference segment (interpolating the closest point so the gap doesn't snap), with a monotonic windowed search to defeat hairpins/self-crossings, an EMA (issue #29 convention) + optional zero-lag forward-backward smoother, and a sanity guard. Exposes matchIndex/matchFrac as a cross-lap alignment map. - 10 tests: grid uniformity, GPS-rate independence, zero gap vs self, growing gap for slower laps, segment interpolation, sanity guard, smoothing. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- CLAUDE.md | 3 +- src/lib/lapDelta.test.ts | 126 +++++++++++++++++++++ src/lib/lapDelta.ts | 238 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/lib/lapDelta.test.ts create mode 100644 src/lib/lapDelta.ts diff --git a/CLAUDE.md b/CLAUDE.md index ad4d390..ce99ea1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,8 @@ src/ │ ├── 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 +│ ├── referenceUtils.ts # Reference lap comparison (legacy distance-based pace) +│ ├── lapDelta.ts # ★ Position-based lap delta: arc-length resample + segment-projected gap (issue #29 port) │ ├── 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) diff --git a/src/lib/lapDelta.test.ts b/src/lib/lapDelta.test.ts new file mode 100644 index 0000000..0ad401c --- /dev/null +++ b/src/lib/lapDelta.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { EARTH_RADIUS_M } from "./parserUtils"; +import { resampleByDistance, computePositionDelta, smoothDelta } from "./lapDelta"; +import type { GpsSample } from "@/types/racing"; + +// Build a straight lap heading north: positions are spaced `spacingM` apart and +// timestamped every `dtMs`. Varying only latitude makes planar distance equal to +// the latitude delta in meters, so geometry is exactly known. +const BASE_LAT = 45; +const BASE_LON = 9; +const M_PER_DEG_LAT = (Math.PI / 180) * EARTH_RADIUS_M; + +function makeSample(distM: number, t: number, latShiftM = 0): GpsSample { + return { + t, + lat: BASE_LAT + (distM + latShiftM) / M_PER_DEG_LAT, + lon: BASE_LON, + speedMps: 0, + speedMph: 0, + speedKph: 0, + extraFields: {}, + }; +} + +function lineLap(points: number, spacingM: number, dtMs: number, t0 = 0): GpsSample[] { + return Array.from({ length: points }, (_, i) => makeSample(i * spacingM, t0 + i * dtMs)); +} + +describe("resampleByDistance", () => { + it("produces a uniform arc-length grid with linear time for constant speed", () => { + // 100 m lap, points every 1 m, 10 ms apart (=> 100 m/s, 1000 ms total). + const lap = lineLap(101, 1, 10); + const r = resampleByDistance(lap, 2); + + // cumDist steps of exactly 2 m, ending at 100 m. + expect(r.cumDist[0]).toBe(0); + expect(r.cumDist[1]).toBeCloseTo(2, 6); + expect(r.cumDist[r.cumDist.length - 1]).toBeCloseTo(100, 3); + expect(r.xy.length).toBe(r.cumDist.length); + + // Constant speed => elapsed time linear in distance: 10 ms/m. + for (let k = 0; k < r.cumDist.length; k++) { + expect(r.elapsedMs[k]).toBeCloseTo(r.cumDist[k] * 10, 3); + } + }); + + it("is independent of source GPS rate (same path, different sampling)", () => { + // Same 100 m path at 100 m/s, sampled coarsely (5 m) vs finely (1 m). + const coarse = resampleByDistance(lineLap(21, 5, 50), 2); + const fine = resampleByDistance(lineLap(101, 1, 10), 2); + + expect(coarse.cumDist.length).toBe(fine.cumDist.length); + for (let k = 0; k < fine.cumDist.length; k++) { + expect(coarse.cumDist[k]).toBeCloseTo(fine.cumDist[k], 3); + expect(coarse.elapsedMs[k]).toBeCloseTo(fine.elapsedMs[k], 1); + } + }); + + it("handles degenerate laps without throwing", () => { + expect(resampleByDistance([], 2).xy).toHaveLength(0); + expect(resampleByDistance(lineLap(1, 1, 10), 2).xy).toHaveLength(1); + }); +}); + +describe("computePositionDelta", () => { + it("reports ~zero gap for a lap compared against itself", () => { + const lap = lineLap(101, 1, 10); + const ref = resampleByDistance(lap, 2); + const { delta } = computePositionDelta(lap, ref); + + for (const d of delta) { + expect(d).not.toBeNull(); + expect(Math.abs(d as number)).toBeLessThan(0.02); + } + }); + + it("shows a growing positive gap for a uniformly slower lap", () => { + const ref = resampleByDistance(lineLap(101, 1, 10), 2); // 10 ms/m + const slow = lineLap(101, 1, 11); // 11 ms/m => 10% slower + const { delta, rawDelta } = computePositionDelta(slow, ref); + + // Monotonic non-decreasing gap, ending near 0.1 * 1000 ms = 0.1 s. + const last = rawDelta[rawDelta.length - 1] as number; + expect(last).toBeCloseTo(0.1, 2); + expect(delta[0] as number).toBeLessThan(delta[delta.length - 1] as number); + }); + + it("shows a negative gap for a faster lap", () => { + const ref = resampleByDistance(lineLap(101, 1, 11), 2); + const fast = lineLap(101, 1, 10); + const { rawDelta } = computePositionDelta(fast, ref); + expect(rawDelta[rawDelta.length - 1] as number).toBeLessThan(0); + }); + + it("interpolates the closest point along a segment (no grid snapping)", () => { + // Coarse 10 m reference; current fixes fall between grid points. + const ref = resampleByDistance(lineLap(101, 1, 10), 10); + const current = lineLap(101, 1, 10).map((s, i) => makeSample(i + 0.5, s.t)); // +0.5 m offset + const { matchFrac } = computePositionDelta(current, ref); + // Some matches must be fractional — proof we project onto the segment. + expect(matchFrac.some((f) => f > 0.05 && f < 0.95)).toBe(true); + }); + + it("rejects impossible gaps via the sanity guard", () => { + const ref = resampleByDistance(lineLap(101, 1, 10), 2); + const slow = lineLap(101, 1, 11); // gap grows toward ~0.1 s + const { rawDelta } = computePositionDelta(slow, ref, { sanitySeconds: 0.05 }); + // Late-lap gaps exceed the 0.05 s guard and are nulled; early ones survive. + expect(rawDelta.some((d) => d === null)).toBe(true); + expect(rawDelta.some((d) => d !== null)).toBe(true); + }); +}); + +describe("smoothDelta", () => { + it("leaves a constant signal unchanged", () => { + const out = smoothDelta([1, 1, 1, 1, 1], 0.3, true); + for (const v of out) expect(v).toBeCloseTo(1, 6); + }); + + it("holds the last value across null gaps and survives leading nulls", () => { + const out = smoothDelta([null, 2, null, 2], 0.5, false); + expect(out[0]).toBeNull(); + expect(out[1]).toBeCloseTo(2, 6); + expect(out[2]).toBeCloseTo(2, 6); // held across the gap + }); +}); diff --git a/src/lib/lapDelta.ts b/src/lib/lapDelta.ts new file mode 100644 index 0000000..25a5aa9 --- /dev/null +++ b/src/lib/lapDelta.ts @@ -0,0 +1,238 @@ +/** + * Position-based lap delta (gap to a reference lap), ported from the + * DovesLapTimer firmware design (issue #29) and adapted for offline/web use. + * + * Why this exists: the legacy `calculatePace` in referenceUtils aligns laps by + * *cumulative distance*, which accumulates GPS noise and assumes both laps trace + * the same path length — it drifts, worst at lap end where the headline gap is + * read. This module instead: + * + * 1. Resamples the reference lap to a uniform arc-length grid (one point per + * `sampleMeters` of travel) — independent of GPS rate and lap duration, + * with uniform spatial resolution. This is the canonical representation. + * 2. For each native current-lap fix, projects its position onto the nearest + * reference *segment* (interpolating the closest point, so the gap doesn't + * snap between grid points) and takes + * delta = currentElapsed - referenceElapsedAtClosestPoint. + * A monotonic windowed search keeps the match advancing, which defeats + * hairpins / self-crossings / start-finish proximity. + * + * The output `delta` is per native current sample, so it is a drop-in for the + * existing `paceData` contract. `matchIndex`/`matchFrac` expose the alignment + * map (current fix -> reference position) for cross-lap channel comparison. + */ + +import { GpsSample } from "@/types/racing"; +import { projectToPlane } from "./referenceUtils"; + +interface Point { + x: number; + y: number; +} + +/** Reference lap resampled to a uniform arc-length grid. */ +export interface ResampledLap { + /** Projection origin (shared frame for matching current fixes). */ + centerLat: number; + centerLon: number; + /** Planar coordinates, one per grid point. */ + xy: Point[]; + /** Elapsed time from lap start (ms) at each grid point. */ + elapsedMs: number[]; + /** Cumulative arc length (m) at each grid point: 0, N, 2N, … */ + cumDist: number[]; + /** Grid point -> nearest native sample index (for channel lookup). */ + nativeIdx: number[]; + /** Grid spacing (m). */ + sampleMeters: number; +} + +export interface DeltaOptions { + /** How far back along the reference the windowed search may look (m). */ + lookBackMeters?: number; + /** How far forward along the reference the windowed search may look (m). */ + lookForwardMeters?: number; + /** Reject |delta| beyond this many seconds as impossible. */ + sanitySeconds?: number; + /** EMA weight on history (issue #29 convention: s = alpha*s + (1-alpha)*raw). */ + alpha?: number; + /** Forward-backward smoothing to remove the EMA's phase lag (offline analysis). */ + zeroLag?: boolean; + /** Null out matches whose perpendicular distance exceeds this (m); off when null. */ + maxMatchMeters?: number | null; +} + +export interface DeltaResult { + /** Smoothed gap in seconds, per native current sample (+ = behind reference). */ + delta: (number | null)[]; + /** Unsmoothed gap in seconds. */ + rawDelta: (number | null)[]; + /** Matched reference grid index (segment start) per current sample. */ + matchIndex: number[]; + /** Fraction 0..1 along the matched segment. */ + matchFrac: number[]; +} + +const DEFAULTS: Required> & { maxMatchMeters: number | null } = { + lookBackMeters: 25, + lookForwardMeters: 250, + sanitySeconds: 120, + alpha: 0.3, + zeroLag: true, + maxMatchMeters: null, +}; + +function emptyResampled(sampleMeters: number): ResampledLap { + return { centerLat: 0, centerLon: 0, xy: [], elapsedMs: [], cumDist: [], nativeIdx: [], sampleMeters }; +} + +/** + * Resample a lap to a uniform arc-length grid of `sampleMeters` spacing. + * The grid is independent of the source GPS rate: the same path sampled at + * different rates yields (nearly) the same grid. + */ +export function resampleByDistance(samples: GpsSample[], sampleMeters: number): ResampledLap { + const n = samples.length; + if (n === 0 || sampleMeters <= 0) return emptyResampled(sampleMeters); + + const centerLat = samples.reduce((s, p) => s + p.lat, 0) / n; + const centerLon = samples.reduce((s, p) => s + p.lon, 0) / n; + const proj = samples.map((s) => projectToPlane(s.lat, s.lon, centerLat, centerLon)); + const t0 = samples[0].t; + + const nativeCum: number[] = [0]; + for (let i = 1; i < n; i++) { + nativeCum.push(nativeCum[i - 1] + Math.hypot(proj[i].x - proj[i - 1].x, proj[i].y - proj[i - 1].y)); + } + const total = nativeCum[n - 1]; + + const xy: Point[] = []; + const elapsedMs: number[] = []; + const cumDist: number[] = []; + const nativeIdx: number[] = []; + + // Degenerate lap (single point or no movement): one grid point. + if (n === 1 || total === 0) { + xy.push({ ...proj[0] }); + elapsedMs.push(0); + cumDist.push(0); + nativeIdx.push(0); + return { centerLat, centerLon, xy, elapsedMs, cumDist, nativeIdx, sampleMeters }; + } + + let seg = 0; + for (let d = 0; d <= total + 1e-6; d += sampleMeters) { + const target = Math.min(d, total); + while (seg < n - 2 && nativeCum[seg + 1] < target) seg++; + const segLen = nativeCum[seg + 1] - nativeCum[seg]; + const frac = segLen > 0 ? (target - nativeCum[seg]) / segLen : 0; + xy.push({ + x: proj[seg].x + frac * (proj[seg + 1].x - proj[seg].x), + y: proj[seg].y + frac * (proj[seg + 1].y - proj[seg].y), + }); + const e0 = samples[seg].t - t0; + const e1 = samples[seg + 1].t - t0; + elapsedMs.push(e0 + frac * (e1 - e0)); + cumDist.push(target); + nativeIdx.push(frac < 0.5 ? seg : seg + 1); + } + + return { centerLat, centerLon, xy, elapsedMs, cumDist, nativeIdx, sampleMeters }; +} + +/** One causal EMA pass (issue #29 convention), holding the last value across null gaps. */ +function emaPass(arr: (number | null)[], alpha: number): (number | null)[] { + let s: number | null = null; + const out: (number | null)[] = []; + for (const v of arr) { + if (v == null) { + out.push(s); + continue; + } + s = s == null ? v : alpha * s + (1 - alpha) * v; + out.push(s); + } + return out; +} + +/** + * Smooth a raw delta sequence. Causal EMA by default; with `zeroLag`, a second + * backward pass is averaged in to cancel the phase lag (preferred for offline + * charts where we have the whole lap). + */ +export function smoothDelta(raw: (number | null)[], alpha = DEFAULTS.alpha, zeroLag = DEFAULTS.zeroLag): (number | null)[] { + const fwd = emaPass(raw, alpha); + if (!zeroLag) return fwd; + const bwd = emaPass([...fwd].reverse(), alpha).reverse(); + return fwd.map((v, i) => { + const b = bwd[i]; + if (v == null) return b ?? null; + if (b == null) return v; + return (v + b) / 2; + }); +} + +/** + * Compute the position-based gap of `current` versus a resampled reference lap. + * Returns one delta per native current sample (drop-in for `paceData`). + */ +export function computePositionDelta( + current: GpsSample[], + ref: ResampledLap, + opts: DeltaOptions = {}, +): DeltaResult { + const o = { ...DEFAULTS, ...opts }; + const m = current.length; + const rawDelta: (number | null)[] = new Array(m).fill(null); + const matchIndex: number[] = new Array(m).fill(0); + const matchFrac: number[] = new Array(m).fill(0); + + const R = ref.xy.length; + if (m === 0 || R < 2) { + return { delta: rawDelta.slice(), rawDelta, matchIndex, matchFrac }; + } + + const lookBackPts = Math.max(1, Math.ceil(o.lookBackMeters / ref.sampleMeters)); + const lookForwardPts = Math.max(1, Math.ceil(o.lookForwardMeters / ref.sampleMeters)); + const t0 = current[0].t; + let lastK = 0; + + for (let i = 0; i < m; i++) { + const p = projectToPlane(current[i].lat, current[i].lon, ref.centerLat, ref.centerLon); + const lo = Math.max(0, lastK - lookBackPts); + const hi = Math.min(R - 2, lastK + lookForwardPts); + + let bestK = lo; + let bestT = 0; + let bestDist2 = Infinity; + for (let k = lo; k <= hi; k++) { + const ax = ref.xy[k].x; + const ay = ref.xy[k].y; + const vx = ref.xy[k + 1].x - ax; + const vy = ref.xy[k + 1].y - ay; + const len2 = vx * vx + vy * vy; + let tt = len2 > 0 ? ((p.x - ax) * vx + (p.y - ay) * vy) / len2 : 0; + tt = tt < 0 ? 0 : tt > 1 ? 1 : tt; + const dx = p.x - (ax + tt * vx); + const dy = p.y - (ay + tt * vy); + const d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + bestK = k; + bestT = tt; + } + } + + matchIndex[i] = bestK; + matchFrac[i] = bestT; + lastK = bestK; + + if (o.maxMatchMeters != null && Math.sqrt(bestDist2) > o.maxMatchMeters) continue; + + const refElapsed = ref.elapsedMs[bestK] + bestT * (ref.elapsedMs[bestK + 1] - ref.elapsedMs[bestK]); + const d = (current[i].t - t0 - refElapsed) / 1000; + rawDelta[i] = Math.abs(d) > o.sanitySeconds ? null : d; + } + + return { delta: smoothDelta(rawDelta, o.alpha, o.zeroLag), rawDelta, matchIndex, matchFrac }; +} From 28bec5deb781daae432db2e2aecc3bfb2453ac0f Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:14:18 +0000 Subject: [PATCH 004/121] Work in progress --- bun.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bun.lock b/bun.lock index 1171bd5..202a087 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,9 @@ "vite": "^5.4.19", "vitest": "^4.1.6", }, + "optionalDependencies": { + "@perchwerks/eye-in-the-sky": "^0.0.2", + }, }, }, "packages": { @@ -363,6 +366,8 @@ "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.0.2.tgz", {}, "sha512-ssi01fB5gRnPFMkt6YqfxeAZ5NH99EEQvbySG7iJltlNeFPToobTpTgob9j2/0IIkqAHz8mW6JHKfzzLGMHRyg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], From bca7bc02e4ab254fa66d45c36be157fc2378fa32 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:14:25 +0000 Subject: [PATCH 005/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- .lovable/plan.md | 85 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 19 deletions(-) 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). From d4fee69d661d6c6e1f0e954a7872d20cc6ecbf46 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:14:47 +0000 Subject: [PATCH 006/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- package-lock.json | 623 +++++------------------------- package.json | 1 + src/integrations/lovable/index.ts | 38 ++ 3 files changed, 144 insertions(+), 518 deletions(-) create mode 100644 src/integrations/lovable/index.ts diff --git a/package-lock.json b/package-lock.json index e37752b..6387459 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", @@ -70,6 +71,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1634,6 +1636,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1650,6 +1653,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1666,6 +1670,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1682,6 +1687,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1698,6 +1704,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1714,6 +1721,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1730,6 +1738,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1746,6 +1755,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1762,6 +1772,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1778,6 +1789,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1794,6 +1806,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1810,6 +1823,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1826,6 +1840,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1842,6 +1857,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1858,6 +1874,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1874,6 +1891,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1890,6 +1908,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1923,6 +1942,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1956,6 +1976,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1965,24 +1986,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -1990,6 +1993,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2006,6 +2010,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2022,6 +2027,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2038,6 +2044,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2401,6 +2408,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", @@ -2424,6 +2437,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2437,6 +2451,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2446,6 +2461,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2476,6 +2492,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4483,14 +4500,14 @@ "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4501,7 +4518,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -4998,12 +5015,14 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -5017,6 +5036,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5029,6 +5049,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -5270,6 +5291,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5293,6 +5315,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -5401,6 +5424,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5457,6 +5481,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -5481,6 +5506,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -5531,6 +5557,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5603,6 +5630,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -5615,7 +5643,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -5740,7 +5768,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5756,12 +5784,14 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, "license": "MIT" }, "node_modules/dunder-proto": { @@ -5952,6 +5982,7 @@ "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6247,6 +6278,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -6263,6 +6295,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6304,6 +6337,7 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6373,6 +6407,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6629,6 +6664,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -6649,6 +6685,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -6661,6 +6698,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -6670,6 +6708,7 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -6969,6 +7008,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -7057,6 +7097,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7109,6 +7150,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7151,6 +7193,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7393,6 +7436,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -7425,6 +7469,7 @@ "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7584,7 +7629,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "devOptional": true, + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -7845,6 +7890,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -7857,6 +7903,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -8368,6 +8415,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/lucide-react": { @@ -8428,6 +8476,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -8437,6 +8486,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -8450,6 +8500,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -8559,6 +8610,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -8570,6 +8622,7 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, "funding": [ { "type": "github", @@ -8601,6 +8654,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8620,6 +8674,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8629,6 +8684,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8807,6 +8863,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -8848,6 +8905,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8857,6 +8915,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8875,6 +8934,7 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8903,6 +8963,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -8920,6 +8981,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -8939,6 +9001,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8974,6 +9037,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8999,6 +9063,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -9012,6 +9077,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -9055,6 +9121,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -9211,6 +9278,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -9247,6 +9315,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -9259,6 +9328,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9405,6 +9475,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -9500,6 +9571,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -9802,6 +9874,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10087,6 +10160,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -10144,6 +10218,7 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -10241,6 +10316,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -10250,6 +10326,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -10305,6 +10382,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -10339,6 +10417,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/tslib": { @@ -10672,6 +10751,7 @@ "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -10847,456 +10927,6 @@ } } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", @@ -11324,50 +10954,6 @@ } } }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, "node_modules/vitest/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -12021,6 +11607,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 3380b9b..a7781bd 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", diff --git a/src/integrations/lovable/index.ts b/src/integrations/lovable/index.ts new file mode 100644 index 0000000..b677d42 --- /dev/null +++ b/src/integrations/lovable/index.ts @@ -0,0 +1,38 @@ +// This file is auto-generated by Lovable. Do not modify it. + +import { createLovableAuth } from "@lovable.dev/cloud-auth-js"; +import { supabase } from "../supabase/client"; +const lovableAuth = createLovableAuth(); + +type SignInOptions = { + redirect_uri?: string; + extraParams?: Record; +}; + +export const lovable = { + auth: { + signInWithOAuth: async (provider: "google" | "apple" | "microsoft" | "lovable", opts?: SignInOptions) => { + const result = await lovableAuth.signInWithOAuth(provider, { + redirect_uri: opts?.redirect_uri, + extraParams: { + ...opts?.extraParams, + }, + }); + + if (result.redirected) { + return result; + } + + if (result.error) { + return result; + } + + try { + await supabase.auth.setSession(result.tokens); + } catch (e) { + return { error: e instanceof Error ? e : new Error(String(e)) }; + } + return result; + }, + }, +}; From a94acb7790517e0f545379a8dc7f1bf857a54c45 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:14:56 +0000 Subject: [PATCH 007/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- bun.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bun.lock b/bun.lock index 202a087..0cdeab5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "vite_react_shadcn_ts", "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", @@ -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", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "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=="], From ee33297c763f2d320bb5f1b309287de7d73768df Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:15:09 +0000 Subject: [PATCH 008/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- vite.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index c4993d4..73bb923 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -40,7 +40,11 @@ const PUBLIC_BACKEND_FALLBACKS = { // cloning the repo without a .env should see the public app, not admin UI // pointing at a backend they don't control. VITE_ENABLE_ADMIN: "false", - VITE_ENABLE_REGISTRATION: "false", + // Cloud auth + sync (public user accounts, Google sign-in, Cloud Sync Labs + // panel). Default OFF — the repo's offline-first invariant means a fresh + // clone with no .env never touches the cloud. Production deploys flip this + // to "true" via Lovable Cloud env injection. + VITE_ENABLE_CLOUD: "false", } as const; // https://vitejs.dev/config/ From dbaf2bf12672e50d222f822271267741d6dd6399 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:15:16 +0000 Subject: [PATCH 009/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- vite.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 73bb923..d412ee5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -75,8 +75,8 @@ export default defineConfig(({ mode }) => { "import.meta.env.VITE_ENABLE_ADMIN": JSON.stringify( env.VITE_ENABLE_ADMIN || PUBLIC_BACKEND_FALLBACKS.VITE_ENABLE_ADMIN, ), - "import.meta.env.VITE_ENABLE_REGISTRATION": JSON.stringify( - env.VITE_ENABLE_REGISTRATION || PUBLIC_BACKEND_FALLBACKS.VITE_ENABLE_REGISTRATION, + "import.meta.env.VITE_ENABLE_CLOUD": JSON.stringify( + env.VITE_ENABLE_CLOUD || PUBLIC_BACKEND_FALLBACKS.VITE_ENABLE_CLOUD, ), }, plugins: [ From 3473b4edc6b1dc78807df43811e86a46962e0ed5 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:15:26 +0000 Subject: [PATCH 010/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/contexts/AuthContext.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index b30341a..48d6d7b 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -8,6 +8,8 @@ interface AuthContextValue { isAdmin: boolean; loading: boolean; login: (email: string, password: string) => Promise<{ error: Error | null }>; + signUp: (email: string, password: string) => Promise<{ error: Error | null }>; + signInWithGoogle: () => Promise<{ error: Error | null }>; logout: () => Promise; resetPassword: (email: string) => Promise<{ error: Error | null }>; } From 755562c282b83814138beb8d18aba5f37366e09a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:15:36 +0000 Subject: [PATCH 011/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/contexts/AuthContext.tsx | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 48d6d7b..7d996c3 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -96,13 +96,38 @@ export function AuthProvider({ children }: { children: ReactNode }) { const resetPassword = useCallback(async (email: string) => { const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: window.location.origin + '/login', + redirectTo: window.location.origin + '/reset-password', }); return { error }; }, []); + const signUp = useCallback(async (email: string, password: string) => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { emailRedirectTo: window.location.origin + '/auth/callback' }, + }); + return { error }; + }, []); + + const signInWithGoogle = useCallback(async () => { + try { + // Lazy import keeps the Lovable auth SDK out of the main chunk; this + // module is only imported in cloud-flagged builds, but the dynamic + // import doubles as belt-and-suspenders. + const { lovable } = await import('@/integrations/lovable/index'); + const result = await lovable.auth.signInWithOAuth('google', { + redirect_uri: window.location.origin + '/auth/callback', + }); + if (result.error) return { error: result.error as Error }; + return { error: null }; + } catch (e) { + return { error: e instanceof Error ? e : new Error(String(e)) }; + } + }, []); + return ( - + {children} ); From 33ffd278e31e18f2de688a9ef0d6127995fcda92 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:16:09 +0000 Subject: [PATCH 012/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/App.tsx | 19 ++++++---- src/pages/AuthCallback.tsx | 46 +++++++++++++++++++++++ src/pages/ForgotPassword.tsx | 64 ++++++++++++++++++++++++++++++++ src/pages/ResetPassword.tsx | 72 ++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 src/pages/AuthCallback.tsx create mode 100644 src/pages/ForgotPassword.tsx create mode 100644 src/pages/ResetPassword.tsx diff --git a/src/App.tsx b/src/App.tsx index ee25e4c..a7e09b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,16 +11,18 @@ 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 ForgotPassword = lazy(() => import("./pages/ForgotPassword")); +const ResetPassword = lazy(() => import("./pages/ResetPassword")); +const AuthCallback = lazy(() => import("./pages/AuthCallback")); const SETTINGS_KEY = "dove-dataviewer-settings"; @@ -50,9 +52,12 @@ const App = () => { } /> } /> - {enableAdmin && } />} + {(enableAdmin || enableCloud) && } />} {enableAdmin && } />} - {enableRegistration && } />} + {enableCloud && } />} + {enableCloud && } />} + {enableCloud && } />} + {enableCloud && } />} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/pages/AuthCallback.tsx b/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..564fa4b --- /dev/null +++ b/src/pages/AuthCallback.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { supabase } from '@/integrations/supabase/client'; +import { Loader2 } from 'lucide-react'; + +/** + * OAuth + email-confirm landing page. Lovable's managed OAuth flow has + * already set the session by the time we get here; we just wait for + * onAuthStateChange (or an existing session) and bounce to ?next= or /. + */ +export default function AuthCallback() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + + useEffect(() => { + const next = params.get('next') || '/'; + let done = false; + const finish = () => { + if (done) return; + done = true; + navigate(next, { replace: true }); + }; + + supabase.auth.getSession().then(({ data: { session } }) => { + if (session) finish(); + }); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((_e, session) => { + if (session) finish(); + }); + + const timeout = setTimeout(finish, 4000); + return () => { + clearTimeout(timeout); + subscription.unsubscribe(); + }; + }, [navigate, params]); + + return ( +
+
+ Signing you in… +
+
+ ); +} diff --git a/src/pages/ForgotPassword.tsx b/src/pages/ForgotPassword.tsx new file mode 100644 index 0000000..b309254 --- /dev/null +++ b/src/pages/ForgotPassword.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAuth } from '@/contexts/AuthContext'; +import { toast } from '@/hooks/use-toast'; +import { Gauge, ArrowLeft } from 'lucide-react'; +import { useDocumentHead } from '@/hooks/useDocumentHead'; + +export default function ForgotPassword() { + useDocumentHead({ + title: 'Forgot Password — HackTheTrack', + description: 'Reset your HackTheTrack account password.', + canonical: 'https://hackthetrack.net/forgot-password', + }); + const [email, setEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { resetPassword } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + const { error } = await resetPassword(email); + setIsLoading(false); + if (error) { + toast({ title: 'Reset failed', description: error.message, variant: 'destructive' }); + } else { + toast({ title: 'Check your email', description: 'Password reset link sent.' }); + navigate('/login'); + } + }; + + return ( +
+
+
+ +

HackTheTrack.net

+
+
+

Reset Password

+
+
+ + setEmail(e.target.value)} required /> +
+ +
+

+ Remembered it?{' '} + Back to sign in +

+
+ +
+
+ ); +} diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx new file mode 100644 index 0000000..454326d --- /dev/null +++ b/src/pages/ResetPassword.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from '@/hooks/use-toast'; +import { Gauge, ArrowLeft } from 'lucide-react'; +import { useDocumentHead } from '@/hooks/useDocumentHead'; + +export default function ResetPassword() { + useDocumentHead({ + title: 'Set New Password — HackTheTrack', + description: 'Set a new password for your HackTheTrack account.', + canonical: 'https://hackthetrack.net/reset-password', + }); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirm) { + toast({ title: 'Passwords do not match', variant: 'destructive' }); + return; + } + if (password.length < 6) { + toast({ title: 'Password must be at least 6 characters', variant: 'destructive' }); + return; + } + setIsLoading(true); + const { error } = await supabase.auth.updateUser({ password }); + setIsLoading(false); + if (error) { + toast({ title: 'Update failed', description: error.message, variant: 'destructive' }); + } else { + toast({ title: 'Password updated', description: 'You are now signed in.' }); + navigate('/'); + } + }; + + return ( +
+
+
+ +

HackTheTrack.net

+
+
+

Set New Password

+
+
+ + setPassword(e.target.value)} required /> +
+
+ + setConfirm(e.target.value)} required /> +
+ +
+
+ +
+
+ ); +} From a7978b419aad26e797e5f739c295fd974c75d6d6 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:16:54 +0000 Subject: [PATCH 013/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/pages/Login.tsx | 97 +++++++++++------------ src/pages/Register.tsx | 45 ++++++----- src/plugins/cloud-sync/CloudSyncPanel.tsx | 61 ++++++-------- src/plugins/cloud-sync/index.ts | 6 ++ 4 files changed, 103 insertions(+), 106 deletions(-) diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index cb3caf5..1209554 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -9,61 +9,53 @@ import { supabase } from '@/integrations/supabase/client'; import { Gauge, ArrowLeft } from 'lucide-react'; import { useDocumentHead } from '@/hooks/useDocumentHead'; +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; + export default function Login() { useDocumentHead({ - title: "Admin Login — HackTheTrack", - description: "Sign in to the HackTheTrack admin panel to manage tracks, courses, submissions and messages.", - canonical: "https://hackthetrack.net/login", + title: 'Sign in — HackTheTrack', + description: 'Sign in to HackTheTrack to sync your telemetry, garage and notes across devices.', + canonical: 'https://hackthetrack.net/login', }); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [isResetMode, setIsResetMode] = useState(false); - const { login, resetPassword } = useAuth(); + const { login, signInWithGoogle } = useAuth(); const navigate = useNavigate(); + const [params] = useSearchParams(); + const next = params.get('next') || '/'; const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); - try { - // Check rate limit before attempting login - const { data: rateCheck } = await supabase.functions.invoke('check-login-rate', { - body: {}, - }); - + const { data: rateCheck } = await supabase.functions.invoke('check-login-rate', { body: {} }); if (rateCheck && !rateCheck.allowed) { toast({ title: 'Too many attempts', description: rateCheck.message || 'Please try again later.', variant: 'destructive' }); setIsLoading(false); return; } - const { error } = await login(email, password); - if (error) { toast({ title: 'Login failed', description: error.message, variant: 'destructive' }); } else { - toast({ title: 'Logged in successfully' }); - navigate('/admin'); + toast({ title: 'Signed in' }); + navigate(next); } } catch { toast({ title: 'Login failed', description: 'An unexpected error occurred.', variant: 'destructive' }); } - setIsLoading(false); }; - const handleReset = async (e: React.FormEvent) => { - e.preventDefault(); + const handleGoogle = async () => { setIsLoading(true); - const { error } = await resetPassword(email); - setIsLoading(false); + const { error } = await signInWithGoogle(); if (error) { - toast({ title: 'Reset failed', description: error.message, variant: 'destructive' }); - } else { - toast({ title: 'Check your email', description: 'Password reset link sent.' }); - setIsResetMode(false); + setIsLoading(false); + toast({ title: 'Google sign-in failed', description: error.message, variant: 'destructive' }); } + // On success the browser redirects to Google; nothing else to do. }; return ( @@ -75,42 +67,45 @@ export default function Login() {
-

- {isResetMode ? 'Reset Password' : 'Admin Login'} -

+

Sign in

+ + {enableCloud && ( + <> + +
+
+ or +
+
+ + )} -
+
setEmail(e.target.value)} required />
- {!isResetMode && ( -
- - setPassword(e.target.value)} required /> -
- )} +
+ + setPassword(e.target.value)} required /> +
- - {import.meta.env.VITE_ENABLE_REGISTRATION === 'true' && ( - + {enableCloud ? ( + + Forgot password? + + ) : } + {enableCloud && ( + + Create account + )}
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 441cf3f..50778d8 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,46 +1,39 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/contexts/AuthContext'; import { toast } from '@/hooks/use-toast'; import { Gauge, ArrowLeft } from 'lucide-react'; import { useDocumentHead } from '@/hooks/useDocumentHead'; export default function Register() { useDocumentHead({ - title: "Register — HackTheTrack", - description: "Create a HackTheTrack account to access admin tools for managing tracks, courses and telemetry submissions.", - canonical: "https://hackthetrack.net/register", + title: 'Create account — HackTheTrack', + description: 'Create a HackTheTrack account to sync your telemetry, garage and notes across devices.', + canonical: 'https://hackthetrack.net/register', }); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); + const { signUp, signInWithGoogle } = useAuth(); const navigate = useNavigate(); const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); - if (password !== confirmPassword) { toast({ title: 'Passwords do not match', variant: 'destructive' }); return; } - if (password.length < 6) { toast({ title: 'Password must be at least 6 characters', variant: 'destructive' }); return; } - setIsLoading(true); - const { error } = await supabase.auth.signUp({ - email, - password, - options: { emailRedirectTo: window.location.origin + '/login' }, - }); + const { error } = await signUp(email, password); setIsLoading(false); - if (error) { toast({ title: 'Registration failed', description: error.message, variant: 'destructive' }); } else { @@ -49,6 +42,15 @@ export default function Register() { } }; + const handleGoogle = async () => { + setIsLoading(true); + const { error } = await signInWithGoogle(); + if (error) { + setIsLoading(false); + toast({ title: 'Google sign-in failed', description: error.message, variant: 'destructive' }); + } + }; + return (
@@ -58,7 +60,16 @@ export default function Register() {
-

Register

+

Create account

+ + +
+
+ or +
+
@@ -80,9 +91,7 @@ export default function Register() {

Already have an account?{' '} - + Sign in

diff --git a/src/plugins/cloud-sync/CloudSyncPanel.tsx b/src/plugins/cloud-sync/CloudSyncPanel.tsx index 39189ed..d18dfe0 100644 --- a/src/plugins/cloud-sync/CloudSyncPanel.tsx +++ b/src/plugins/cloud-sync/CloudSyncPanel.tsx @@ -1,20 +1,18 @@ -import { useState, type FormEvent } from "react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; import { toast } from "sonner"; import { CloudUpload, CloudDownload, LogOut, WifiOff, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { useAuth } from "@/contexts/AuthContext"; import { useOnlineStatus } from "@/hooks/useOnlineStatus"; import { pushAll, pullAll } from "./syncEngine"; -type Busy = "push" | "pull" | "login" | null; +type Busy = "push" | "pull" | "google" | null; export default function CloudSyncPanel() { - const { user, loading, login, logout } = useAuth(); + const { user, loading, logout, signInWithGoogle } = useAuth(); const online = useOnlineStatus(); const [busy, setBusy] = useState(null); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); if (loading) { return ( @@ -25,43 +23,34 @@ export default function CloudSyncPanel() { } if (!user) { - const onSubmit = async (e: FormEvent) => { - e.preventDefault(); - setBusy("login"); - const { error } = await login(email, password); - setBusy(null); - if (error) toast.error(error.message || "Sign-in failed"); + const handleGoogle = async () => { + setBusy("google"); + const { error } = await signInWithGoogle(); + if (error) { + setBusy(null); + toast.error(error.message || "Google sign-in failed"); + } }; return ( - +

- Sign in to sync your files and garage across devices. More sign-in options coming soon. + Sign in to back up and sync your files, garage and notes across devices. Cloud Sync is optional — the app works fully offline without it.

- setEmail(e.target.value)} - required - /> - setPassword(e.target.value)} - required - /> - +
+ +
+ + +
+
{!online && (

You're offline — sign-in needs a connection.

)} - +
); } @@ -78,9 +67,7 @@ export default function CloudSyncPanel() { }; const runPull = async () => { - if (!window.confirm("Pull merges your cloud copy into this device, overwriting any local records with the same name. Continue?")) { - return; - } + if (!window.confirm("Pull merges your cloud copy into this device, overwriting any local records with the same name. Continue?")) return; setBusy("pull"); try { const r = await pullAll(user.id); diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index c57a437..c674c6e 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -8,11 +8,17 @@ import { PANELS_POINT, PanelSlot, type PluginPanel } from "@/plugins/panels"; // lean (see Bundle Splitting in CLAUDE.md). const CloudSyncPanel = lazy(() => import("./CloudSyncPanel")); +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; + const plugin: DataViewerPlugin = { id: "cloud-sync", name: "Cloud Sync", version: "0.1.0", setup(ctx) { + // Offline-first guard: when the cloud flag is off, contribute nothing. + // The Labs panel never registers, the panel chunk never loads, and the + // Labs tab stays hidden unless another plugin contributes there. + if (!enableCloud) return; const panel: PluginPanel = { id: "cloud-sync", title: "Cloud Sync", From 8509876c073212f9a0b571a3e2c634e293e62145 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:17:09 +0000 Subject: [PATCH 014/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/components/LandingPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx index 739636e..c29af27 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 } from "lucide-react"; import { Link, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { FileImport } from "@/components/FileImport"; @@ -8,6 +8,7 @@ import { ContactDialog } from "@/components/ContactDialog"; import { SupportedFilesDialog } from "@/components/SupportedFilesDialog"; import { AboutDialog } from "@/components/AboutDialog"; import { CreditsDialog } from "@/components/CreditsDialog"; +import { useAuth } from "@/contexts/AuthContext"; import type { ParsedData } from "@/types/racing"; interface LandingPageProps { @@ -18,6 +19,7 @@ interface LandingPageProps { onLoadSample: () => void; isLoadingSample: boolean; enableAdmin: boolean; + enableCloud: boolean; } const GITHUB_LINKS: Array<{ href: string; label: string }> = [ @@ -43,8 +45,10 @@ export function LandingPage({ onLoadSample, isLoadingSample, enableAdmin, + enableCloud, }: LandingPageProps) { const navigate = useNavigate(); + const { user, logout } = useAuth(); return (
From 5007efdf3be3a602d6c8f30e58d5711d5f7c6172 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:17:23 +0000 Subject: [PATCH 015/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/components/LandingPage.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx index c29af27..3f74823 100644 --- a/src/components/LandingPage.tsx +++ b/src/components/LandingPage.tsx @@ -65,6 +65,19 @@ export function LandingPage({ + {enableCloud && ( + user ? ( + + ) : ( + + ) + )} Date: Sun, 24 May 2026 13:17:30 +0000 Subject: [PATCH 016/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/pages/Index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 091d2d4..10f7e38 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -49,6 +49,7 @@ import { SessionProvider, type SessionContextValue } from "@/contexts/SessionCon type TopPanelView = "raceline" | "laptable" | "graphview" | "labs"; const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === 'true'; +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; export default function Index() { const { settings, setSettings, toggleFieldDefault, isFieldHiddenByDefault } = useSettings(); From cde181c274b46fecd9b5ed6cfdf928baab9ffc5c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:17:38 +0000 Subject: [PATCH 017/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/pages/Index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 10f7e38..6ca02da 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -323,6 +323,7 @@ export default function Index() { onLoadSample={handleLoadSample} isLoadingSample={isLoadingSample} enableAdmin={enableAdmin} + enableCloud={enableCloud} /> From 365aa164cc91943a9c7d781726bb8c847dd2d1cb Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:17:59 +0000 Subject: [PATCH 018/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- CLAUDE.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ad4d390..733340c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -487,8 +487,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 | diff --git a/README.md b/README.md index bc23f33..806f041 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,8 @@ 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. | +| `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 — `???`) | | `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 | From 13e666a8ca3a9c5d984e8c1893e6f95f4b802c0e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 13:18:12 +0000 Subject: [PATCH 019/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cceab5..387d67f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +- 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`). ### Changed +- 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. - 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 From d4e9abe63a529cb9a501cbc15e1a6d1091ac5ba6 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 14:49:56 +0000 Subject: [PATCH 020/121] Code edited in Lovable Code Editor Edited UI in Lovable Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index a689b12..d1075bb 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ VITE_SUPABASE_PROJECT_ID="your-supabase-project-id" VITE_SUPABASE_PUBLISHABLE_KEY="your-supabase-anon-key" VITE_SUPABASE_URL="https://your-project.supabase.co" +# Set to "true" tonenable user accounts +VITE_ENABLE_CLOUD="false" + # 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" @@ -12,3 +15,4 @@ VITE_ENABLE_REGISTRATION="false" # Optional: Cloudflare Turnstile site key for the public contact form. # Leave empty to skip CAPTCHA. VITE_TURNSTILE_SITE_KEY="" + From 68ad49898ff2f46815f9d1def1dd6eeecaf382c6 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 14:55:21 +0000 Subject: [PATCH 021/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/main.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 3f52caf..a3d388a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -27,17 +27,26 @@ const isInIframe = (() => { } })(); +const hostname = window.location.hostname.toLowerCase(); const isPreviewHost = - window.location.hostname.includes("id-preview--") || - window.location.hostname.includes("lovableproject.com") || - window.location.hostname.includes("lovable.app"); + hostname.includes("id-preview--") || + hostname.includes("lovableproject.com") || + window.location.search.includes("nosw=1"); + +const cleanupPreviewServiceWorkers = async () => { + const registrations = await navigator.serviceWorker?.getRegistrations(); + await Promise.all( + (registrations ?? []).map((registration) => registration.unregister()), + ); + + if (typeof caches === "undefined") return; + + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName))); +}; if (isInIframe || isPreviewHost) { - navigator.serviceWorker?.getRegistrations().then((registrations) => { - registrations.forEach((registration) => { - void registration.unregister(); - }); - }); + void cleanupPreviewServiceWorkers(); } else { const updateSW = registerSW({ immediate: true, From 18b46466696459c5f304685bf8c861f8f759583f Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 14:55:35 +0000 Subject: [PATCH 022/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 387d67f..ed7d8c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +- 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 From e5835f2c689814b66789b8267a2a096df4d27031 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 15:05:20 +0000 Subject: [PATCH 023/121] Phase 2: make position-based lap delta the default pace method Routes the app's pace computation through the new position delta behind a user setting. paceData keeps its exact shape, so every consumer (charts, race-line, overlays, video export, headline gap) upgrades transparently. - useReferenceLap now calls computeLapPace() for both the reference and best-lap-fallback paths, selecting position (default, zero-lag) vs the legacy distance method. - New settings: deltaMethod ('position' | 'distance', default position) and deltaSampleMeters (default 2), with a Settings -> Lap Delta toggle. - computeLapPace selector added to lapDelta.ts with 2 tests (distance delegates to the legacy path; position resamples + projects). https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- CHANGELOG.md | 6 ++++++ CLAUDE.md | 6 +++++- src/components/SettingsModal.tsx | 34 +++++++++++++++++++++++++++++++- src/hooks/useReferenceLap.ts | 15 ++++++++------ src/hooks/useSettings.ts | 4 ++++ src/lib/lapDelta.test.ts | 20 ++++++++++++++++++- src/lib/lapDelta.ts | 29 ++++++++++++++++++++++++++- src/pages/Index.tsx | 3 ++- 8 files changed, 106 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cceab5..821e132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and offline-first otherwise. ### Changed +- 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. - 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 diff --git a/CLAUDE.md b/CLAUDE.md index ce99ea1..27366a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -475,7 +475,11 @@ 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). + +`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`. `fieldResolver.ts` maps parser-specific field names (e.g., "Lat G", "Lateral G", "LatG") to canonical IDs (`lat_g`) so settings apply uniformly. 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/hooks/useReferenceLap.ts b/src/hooks/useReferenceLap.ts index 5277eb6..220ec62 100644 --- a/src/hooks/useReferenceLap.ts +++ b/src/hooks/useReferenceLap.ts @@ -3,7 +3,8 @@ import { ParsedData, Lap, GpsSample, Course } from "@/types/racing"; import { FileEntry, listFiles, getFile } from "@/lib/fileStorage"; import { parseDatalogContent } from "@/lib/datalogParser"; import { calculateLaps, formatLapTime } from "@/lib/lapCalculation"; -import { calculatePace, calculateReferenceSpeed } from "@/lib/referenceUtils"; +import { calculateReferenceSpeed } from "@/lib/referenceUtils"; +import { computeLapPace, type DeltaMethod } from "@/lib/lapDelta"; import { findSpeedEvents } from "@/lib/speedEvents"; /** @@ -18,7 +19,9 @@ export function useReferenceLap( selectedLapNumber: number | null, referenceLapNumber: number | null, externalRefSamples: GpsSample[] | null, - useKph: boolean + useKph: boolean, + deltaMethod: DeltaMethod = "position", + deltaSampleMeters = 2 ) { // Get reference lap samples (external takes priority) const referenceSamples = useMemo((): GpsSample[] => { @@ -45,10 +48,10 @@ export function useReferenceLap( return { paceData: [] as (number | null)[], referenceSpeedData: [] as (number | null)[] }; } return { - paceData: calculatePace(filteredSamples, referenceSamples), + paceData: computeLapPace(filteredSamples, referenceSamples, { method: deltaMethod, sampleMeters: deltaSampleMeters }), referenceSpeedData: calculateReferenceSpeed(filteredSamples, referenceSamples, useKph), }; - }, [filteredSamples, referenceSamples, useKph]); + }, [filteredSamples, referenceSamples, useKph, deltaMethod, deltaSampleMeters]); // Calculate lap to fastest delta (direct lap time difference) const lapToFastestDelta = useMemo((): number | null => { @@ -123,7 +126,7 @@ export function useReferenceLap( // Otherwise, compare to fastest lap if (fastestLapSamples.length > 0) { - const bestPaceData = calculatePace(filteredSamples, fastestLapSamples); + const bestPaceData = computeLapPace(filteredSamples, fastestLapSamples, { method: deltaMethod, sampleMeters: deltaSampleMeters }); const lastPace = bestPaceData.filter((p) => p !== null).pop() ?? null; const { deltaTop, deltaMin, refTop, refMin } = calculateDeltas(fastestLapSamples); return { @@ -137,7 +140,7 @@ export function useReferenceLap( } return defaultResult; - }, [filteredSamples, referenceSamples, fastestLapSamples, paceData, selectedLapNumber]); + }, [filteredSamples, referenceSamples, fastestLapSamples, paceData, selectedLapNumber, deltaMethod, deltaSampleMeters]); return { referenceSamples, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 273411d..12e80d6 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -19,6 +19,8 @@ export interface AppSettings { enableLabs: boolean; // Enable experimental Labs tab (default: false) darkMode: boolean; // Dark mode enabled (default: true) gForceSource: 'gps' | 'hw'; // Which G-force source to show in simple mode (default: 'hw') + deltaMethod: 'position' | 'distance'; // Lap delta algorithm (default: 'position') + deltaSampleMeters: number; // Arc-length resample spacing for position delta (default: 2) } const SETTINGS_KEY = "dove-dataviewer-settings"; @@ -41,6 +43,8 @@ const defaultSettings: AppSettings = { enableLabs: false, darkMode: false, gForceSource: 'hw', + deltaMethod: 'position', + deltaSampleMeters: 2, }; export function useSettings() { diff --git a/src/lib/lapDelta.test.ts b/src/lib/lapDelta.test.ts index 0ad401c..b59435b 100644 --- a/src/lib/lapDelta.test.ts +++ b/src/lib/lapDelta.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { EARTH_RADIUS_M } from "./parserUtils"; -import { resampleByDistance, computePositionDelta, smoothDelta } from "./lapDelta"; +import { resampleByDistance, computePositionDelta, smoothDelta, computeLapPace } from "./lapDelta"; +import { calculatePace } from "./referenceUtils"; import type { GpsSample } from "@/types/racing"; // Build a straight lap heading north: positions are spaced `spacingM` apart and @@ -111,6 +112,23 @@ describe("computePositionDelta", () => { }); }); +describe("computeLapPace", () => { + it("delegates to the legacy distance method when selected", () => { + const current = lineLap(101, 1, 11); + const reference = lineLap(101, 1, 10); + const viaSelector = computeLapPace(current, reference, { method: "distance", sampleMeters: 2 }); + expect(viaSelector).toEqual(calculatePace(current, reference)); + }); + + it("uses the position method by resampling + projecting", () => { + const current = lineLap(101, 1, 10); + const reference = lineLap(101, 1, 10); + const pace = computeLapPace(current, reference, { method: "position", sampleMeters: 2 }); + expect(pace).toHaveLength(current.length); + for (const d of pace) expect(Math.abs((d as number) ?? 0)).toBeLessThan(0.02); + }); +}); + describe("smoothDelta", () => { it("leaves a constant signal unchanged", () => { const out = smoothDelta([1, 1, 1, 1, 1], 0.3, true); diff --git a/src/lib/lapDelta.ts b/src/lib/lapDelta.ts index 25a5aa9..f2c2779 100644 --- a/src/lib/lapDelta.ts +++ b/src/lib/lapDelta.ts @@ -23,7 +23,7 @@ */ import { GpsSample } from "@/types/racing"; -import { projectToPlane } from "./referenceUtils"; +import { projectToPlane, calculatePace } from "./referenceUtils"; interface Point { x: number; @@ -236,3 +236,30 @@ export function computePositionDelta( return { delta: smoothDelta(rawDelta, o.alpha, o.zeroLag), rawDelta, matchIndex, matchFrac }; } + +export type DeltaMethod = "position" | "distance"; + +export interface PaceOptions { + method: DeltaMethod; + /** Arc-length grid spacing for the position method (m). */ + sampleMeters: number; + zeroLag?: boolean; + alpha?: number; +} + +/** + * Compute pace (gap-to-reference per native current sample) using the configured + * method. "position" resamples the reference to an arc-length grid and projects + * each current fix onto it (issue #29); "distance" is the legacy cumulative- + * distance interpolation. Output shape is identical, so it's a drop-in for the + * existing `paceData` contract regardless of method. + */ +export function computeLapPace( + current: GpsSample[], + reference: GpsSample[], + opts: PaceOptions, +): (number | null)[] { + if (opts.method === "distance") return calculatePace(current, reference); + const ref = resampleByDistance(reference, opts.sampleMeters); + return computePositionDelta(current, ref, { zeroLag: opts.zeroLag ?? true, alpha: opts.alpha }).delta; +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 091d2d4..f22912f 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -89,7 +89,8 @@ export default function Index() { // Reference lap comparison const refLap = useReferenceLap( data, laps, selectedCourse, filteredSamples, selectedLapNumber, - referenceLapNumber, externalRefSamples, useKph + referenceLapNumber, externalRefSamples, useKph, + settings.deltaMethod, settings.deltaSampleMeters ); const { referenceSamples, paceData, referenceSpeedData, lapToFastestDelta, From 0d115e003f95eccb92583cbd895eb522a579874d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:05:26 +0000 Subject: [PATCH 024/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- vite.config.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index d412ee5..89c0962 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,6 +51,18 @@ const PUBLIC_BACKEND_FALLBACKS = { export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); + // Lovable's secret store rejects the `VITE_` prefix (those are public, + // build-time values by Vite convention). To let contributors stash the + // backend wiring in Lovable workspace build secrets *without* committing a + // `.env`, we also accept a parallel `HTT_` prefix and copy it into the + // VITE_* names at build time. Precedence: VITE_* > HTT_* > public fallback. + // + // REMINDER: until Lovable injects these automatically, you may need to + // regenerate `.env` (or re-set the HTT_* build secrets) on each fresh + // build environment. See `.env.example` for the full list. + const pick = (viteKey: string, httKey: string, fallback: string) => + env[viteKey] || env[httKey] || fallback; + const DEFAULT_PLUGIN_PACKAGES = "@perchwerks/eye-in-the-sky"; const pluginPackages = (env.DOVE_PLUGIN_PACKAGES || DEFAULT_PLUGIN_PACKAGES) .split(",") From 45baeb96f1c44b64a9ce858468c6e33b7703a429 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:05:39 +0000 Subject: [PATCH 025/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- vite.config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 89c0962..4a03db8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -76,19 +76,19 @@ export default defineConfig(({ mode }) => { }, define: { "import.meta.env.VITE_SUPABASE_PROJECT_ID": JSON.stringify( - env.VITE_SUPABASE_PROJECT_ID || PUBLIC_BACKEND_FALLBACKS.VITE_SUPABASE_PROJECT_ID, + pick("VITE_SUPABASE_PROJECT_ID", "HTT_SUPABASE_PROJECT_ID", PUBLIC_BACKEND_FALLBACKS.VITE_SUPABASE_PROJECT_ID), ), "import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY": JSON.stringify( - env.VITE_SUPABASE_PUBLISHABLE_KEY || PUBLIC_BACKEND_FALLBACKS.VITE_SUPABASE_PUBLISHABLE_KEY, + pick("VITE_SUPABASE_PUBLISHABLE_KEY", "HTT_SUPABASE_PUBLISHABLE_KEY", PUBLIC_BACKEND_FALLBACKS.VITE_SUPABASE_PUBLISHABLE_KEY), ), "import.meta.env.VITE_SUPABASE_URL": JSON.stringify( - env.VITE_SUPABASE_URL || PUBLIC_BACKEND_FALLBACKS.VITE_SUPABASE_URL, + pick("VITE_SUPABASE_URL", "HTT_SUPABASE_URL", PUBLIC_BACKEND_FALLBACKS.VITE_SUPABASE_URL), ), "import.meta.env.VITE_ENABLE_ADMIN": JSON.stringify( - env.VITE_ENABLE_ADMIN || PUBLIC_BACKEND_FALLBACKS.VITE_ENABLE_ADMIN, + pick("VITE_ENABLE_ADMIN", "HTT_ENABLE_ADMIN", PUBLIC_BACKEND_FALLBACKS.VITE_ENABLE_ADMIN), ), "import.meta.env.VITE_ENABLE_CLOUD": JSON.stringify( - env.VITE_ENABLE_CLOUD || PUBLIC_BACKEND_FALLBACKS.VITE_ENABLE_CLOUD, + pick("VITE_ENABLE_CLOUD", "HTT_ENABLE_CLOUD", PUBLIC_BACKEND_FALLBACKS.VITE_ENABLE_CLOUD), ), }, plugins: [ From f0dcb21216bf483d8685d59578411b06ee92136f Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:05:53 +0000 Subject: [PATCH 026/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- .env.example | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index d1075bb..d1fb913 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,46 @@ +# ============================================================================= +# 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" tonenable user accounts +# --- 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="false" +# HTT_ENABLE_CLOUD="false" # 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" - -# Set to "true" to expose the /register route. -VITE_ENABLE_REGISTRATION="false" +# HTT_ENABLE_ADMIN="false" # Optional: Cloudflare Turnstile site key for the public contact form. # Leave empty to skip CAPTCHA. VITE_TURNSTILE_SITE_KEY="" - From bc23d293ccbd5c7e245d27ad8f064cbc994ee80d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:06:00 +0000 Subject: [PATCH 027/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7d8c4..200076c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 From b22f0fcaf3a2df018ed6a4f530fb7a7e6cfb4f7d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:25:40 +0000 Subject: [PATCH 028/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- vite.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 4a03db8..e88a1ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -60,8 +60,12 @@ export default defineConfig(({ mode }) => { // REMINDER: until Lovable injects these automatically, you may need to // regenerate `.env` (or re-set the HTT_* build secrets) on each fresh // build environment. See `.env.example` for the full list. + // NOTE: Vite's loadEnv() only reads .env files — it does NOT include + // process.env. Lovable injects build secrets as real env vars, so we must + // check process.env explicitly for the HTT_* (and VITE_*) fallbacks to work + // when there's no committed .env file. const pick = (viteKey: string, httKey: string, fallback: string) => - env[viteKey] || env[httKey] || fallback; + env[viteKey] || process.env[viteKey] || env[httKey] || process.env[httKey] || fallback; const DEFAULT_PLUGIN_PACKAGES = "@perchwerks/eye-in-the-sky"; const pluginPackages = (env.DOVE_PLUGIN_PACKAGES || DEFAULT_PLUGIN_PACKAGES) From c15ee5459743a9bc6baef194e8966f7a4d09a0d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 15:37:18 +0000 Subject: [PATCH 029/121] ci: run workflows on Node 22 to match the lockfile's npm CI failed at `npm ci` with "Missing: @esbuild/@0.28.0 from lock file" across all workflows. Root cause: vitest 4 pulls a nested vite 8 that resolves esbuild 0.28.0, and the lockfile (written by npm 10.9+) records its nested optional platform packages in a form the runner's Node 20 npm (10.8) won't validate. `npm ci` passes cleanly under npm 10.9.7. Bump all five workflows from Node 20 to Node 22 (current LTS, ships npm 10.9+), which both reads the lockfile correctly and clears GitHub's Node 20 runner deprecation warning. Lockfile is unchanged. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- .github/workflows/build.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- .github/workflows/typecheck.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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 From c0d70bd0919b8b8964356a5b1cd6b92c439e7f91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 15:40:18 +0000 Subject: [PATCH 030/121] fix(ci): regenerate package-lock to sync with package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm ci` was failing with "Missing: esbuild@0.28.0 from lock file" — the lockfile drifted out of sync with package.json after the recent merges, so the esbuild 0.28.0 tree (pulled by vitest 4 / vite 8) wasn't fully recorded. Ran `npm install` to reconcile; npm ci now passes and the full gate is green (lint, typecheck, 295 tests, build). https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- package-lock.json | 616 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 518 insertions(+), 98 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6387459..9f5b344 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1636,7 +1635,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1653,7 +1651,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1670,7 +1667,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1687,7 +1683,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1704,7 +1699,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1721,7 +1715,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1738,7 +1731,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1755,7 +1747,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1772,7 +1763,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1789,7 +1779,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1806,7 +1795,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1823,7 +1811,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1840,7 +1827,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1857,7 +1843,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1874,7 +1859,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1891,7 +1875,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1908,7 +1891,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1942,7 +1924,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1976,7 +1957,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1986,6 +1966,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -1993,7 +1991,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2010,7 +2007,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2027,7 +2023,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2044,7 +2039,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2437,7 +2431,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2451,7 +2444,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2461,7 +2453,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2492,7 +2483,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4500,14 +4490,14 @@ "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4518,7 +4508,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -5015,14 +5005,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -5036,7 +5024,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5049,7 +5036,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -5291,7 +5277,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5315,7 +5300,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -5424,7 +5408,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5481,7 +5464,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -5506,7 +5488,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -5557,7 +5538,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5630,7 +5610,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -5643,7 +5622,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -5768,7 +5747,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5784,14 +5763,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/dunder-proto": { @@ -5982,7 +5959,6 @@ "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6278,7 +6254,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -6295,7 +6270,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6337,7 +6311,6 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6407,7 +6380,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6664,7 +6636,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -6685,7 +6656,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -6698,7 +6668,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -6708,7 +6677,6 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -7008,7 +6976,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -7097,7 +7064,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7150,7 +7116,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7193,7 +7158,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7436,7 +7400,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -7469,7 +7432,6 @@ "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7629,7 +7591,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -7890,7 +7852,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -7903,7 +7864,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -8415,7 +8375,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/lucide-react": { @@ -8476,7 +8435,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -8486,7 +8444,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -8500,7 +8457,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -8610,7 +8566,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -8622,7 +8577,6 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -8654,7 +8608,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8674,7 +8627,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8684,7 +8636,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8863,7 +8814,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -8905,7 +8855,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8915,7 +8864,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8934,7 +8882,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -8963,7 +8910,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -8981,7 +8927,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -9001,7 +8946,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -9037,7 +8981,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -9063,7 +9006,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -9077,7 +9019,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -9121,7 +9062,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -9278,7 +9218,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -9315,7 +9254,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -9328,7 +9266,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9475,7 +9412,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -9571,7 +9507,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -9874,7 +9809,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10160,7 +10094,6 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -10218,7 +10151,6 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -10316,7 +10248,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -10326,7 +10257,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -10382,7 +10312,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -10417,7 +10346,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/tslib": { @@ -10751,7 +10679,6 @@ "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -10927,6 +10854,456 @@ } } }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", @@ -10954,6 +11331,50 @@ } } }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/vitest/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -11607,7 +12028,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" From 8c9bc679abae783481fb491285c1c05bf2472ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:19:53 +0000 Subject: [PATCH 031/121] Bump AI coach to 0.0.4 (fixes its panel packaging) 0.0.3 broke the build by importing its own panel via the host `@/` alias (`@/panel/CoachPanel`), which resolves to the host's src at bundle time. 0.0.4 uses relative internal imports (`./panel/CoachPanel`, `../analysis/insights`) while keeping host contracts on `@/`, so the coach's Labs panel now bundles and loads. Build + full gate green. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f5b344..2cd4383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.0.2" + "@perchwerks/eye-in-the-sky": "^0.0.4" } }, "node_modules/@alloc/quick-lru": { @@ -2473,9 +2473,9 @@ } }, "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.0.4", + "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.0.4.tgz", + "integrity": "sha512-YefiNWXJ/syA1ADI38VUWtFqpGAxBigJMsKT0nuO8NNQN1AbvHZ9Oy9BBorl+4l+QWWO8pCir0OGG2Mi2HMsDA==", "license": "GPL-3.0-or-later", "optional": true }, diff --git a/package.json b/package.json index a7781bd..3959107 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,6 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.0.2" + "@perchwerks/eye-in-the-sky": "^0.0.4" } } From 9b5a5dc3656d5d1ae0aa46b4c59117354a7e66a5 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 17:06:21 +0000 Subject: [PATCH 032/121] Work in progress --- bun.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 0cdeab5..5e64e8b 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,7 @@ "vitest": "^4.1.6", }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.0.2", + "@perchwerks/eye-in-the-sky": "^0.0.4", }, }, }, @@ -369,7 +369,7 @@ "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], - "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.0.2.tgz", {}, "sha512-ssi01fB5gRnPFMkt6YqfxeAZ5NH99EEQvbySG7iJltlNeFPToobTpTgob9j2/0IIkqAHz8mW6JHKfzzLGMHRyg=="], + "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.0.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.0.4.tgz", {}, "sha512-YefiNWXJ/syA1ADI38VUWtFqpGAxBigJMsKT0nuO8NNQN1AbvHZ9Oy9BBorl+4l+QWWO8pCir0OGG2Mi2HMsDA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], From 0e83c387c80e2ee3bc237917449f3370737de323 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 17:06:33 +0000 Subject: [PATCH 033/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- .env.example | 10 +++++----- vite.config.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index d1fb913..9c1abfd 100644 --- a/.env.example +++ b/.env.example @@ -33,13 +33,13 @@ VITE_SUPABASE_URL="https://your-project.supabase.co" # --- 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="false" -# HTT_ENABLE_CLOUD="false" +VITE_ENABLE_CLOUD="true" +# HTT_ENABLE_CLOUD="true" # 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" -# HTT_ENABLE_ADMIN="false" +# 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/vite.config.ts b/vite.config.ts index e88a1ca..4a8b31b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,12 +39,12 @@ const PUBLIC_BACKEND_FALLBACKS = { // enables them via Lovable Cloud env injection ("true"). A new contributor // cloning the repo without a .env should see the public app, not admin UI // pointing at a backend they don't control. - VITE_ENABLE_ADMIN: "false", + VITE_ENABLE_ADMIN: "true", // Cloud auth + sync (public user accounts, Google sign-in, Cloud Sync Labs - // panel). Default OFF — the repo's offline-first invariant means a fresh - // clone with no .env never touches the cloud. Production deploys flip this - // to "true" via Lovable Cloud env injection. - VITE_ENABLE_CLOUD: "false", + // panel). Defaulted ON so fresh builds (including Lovable preview rebuilds + // without injected env) ship with the full feature set. Set to "false" + // explicitly via VITE_*/HTT_* if you want an offline-only build. + VITE_ENABLE_CLOUD: "true", } as const; // https://vitejs.dev/config/ From b0bfef280bfb9798250aef06617bb90a937822c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 17:36:19 +0000 Subject: [PATCH 034/121] Phase A: plugin storage hook + inline mount primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundational framework work for per-file cloud sync (and the plugin ecosystem generally). No behavior change yet — the mount points render nothing until a plugin contributes. - storage.ts: getPluginStore(id) — schema-less KV scoped to a plugin in its own IndexedDB DB (dove-plugin-), fully decoupled from core dbUtils/DB_VERSION. Also exposed as ctx.storage in PluginContext. - mounts.ts + PluginMount.tsx: inline mount framework. Plugins contribute components to a MountSlot; renders them error- boundaried + Suspense-wrapped, or nothing when none. Sibling to the panel framework (cards) for injecting raw components into core UI. - FilesTab wires two mount points: MountSlot.FileRow (per file) and MountSlot.FileManagerSection (under the list). - Tests for the getMounts selector; registry test mock updated for ctx.storage. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- CLAUDE.md | 15 +++++++ src/components/drawer/FilesTab.tsx | 11 +++++ src/plugins/PluginMount.tsx | 38 ++++++++++++++++ src/plugins/index.ts | 3 +- src/plugins/mounts.test.ts | 28 ++++++++++++ src/plugins/mounts.ts | 56 ++++++++++++++++++++++++ src/plugins/registry.test.ts | 9 +++- src/plugins/storage.ts | 70 ++++++++++++++++++++++++++++++ src/plugins/types.ts | 15 +++++++ 9 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/plugins/PluginMount.tsx create mode 100644 src/plugins/mounts.test.ts create mode 100644 src/plugins/mounts.ts create mode 100644 src/plugins/storage.ts diff --git a/CLAUDE.md b/CLAUDE.md index 594e73e..625c0b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,6 +181,9 @@ src/ │ ├── index.ts # initPlugins() — discovery + setup (called in main.tsx) │ ├── panels.ts # UI panel framework: PluginPanel/Props, PANELS_POINT, PanelSlot, getPanelsForSlot │ ├── PluginPanelHost.tsx # Mounts plugin panels for a slot (error-boundaried, Suspense-wrapped, with fallback) +│ ├── mounts.ts # Inline mounts: MOUNTS_POINT, MountSlot (FileRow/FileManagerSection), contexts, getMounts +│ ├── PluginMount.tsx # Renders inline mounts for a slot (error-boundaried, Suspense; renders null when none) +│ ├── storage.ts # getPluginStore(id): per-plugin KV in its own IndexedDB DB (dove-plugin-) │ ├── cloud-sync/ # ★ First-party plugin: Supabase file + garage sync (Labs panel) │ │ ├── index.ts # Plugin def — contributes a lazy CloudSyncPanel to the Labs slot │ │ ├── CloudSyncPanel.tsx # Sign-in + push/pull UI (lazy-loaded) @@ -238,12 +241,16 @@ A plugin absent at build time simply never loads — the app builds/runs without | `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 | | `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 | +| `mounts.ts` | **Inline mount framework**: `PluginMountDef`, `MOUNTS_POINT`, `MountSlot` (`FileRow`, `FileManagerSection`), per-slot context types, `getMounts(slot)`. For injecting raw components into fixed spots in core UI | +| `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). @@ -254,6 +261,14 @@ computes `hasLabsPanels`). New slots are just new strings — no framework chang `PluginPanelHost` wraps each panel in an error boundary **and** a `Suspense` boundary, so panel components can be `React.lazy` (as `cloud-sync` is). +**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 file row, ctx = that file) and +`MountSlot.FileManagerSection` (once under the list, ctx = the whole list). New +mount locations are just new slot strings. + **Cloud Sync (first-party plugin, `src/plugins/cloud-sync/`):** the first in-repo plugin built on the panel framework. Contributes a lazy Labs panel that signs the user in (`useAuth`) and does manual push/pull of local IndexedDB data diff --git a/src/components/drawer/FilesTab.tsx b/src/components/drawer/FilesTab.tsx index a461386..50b3b55 100644 --- a/src/components/drawer/FilesTab.tsx +++ b/src/components/drawer/FilesTab.tsx @@ -9,6 +9,8 @@ const DataloggerDownload = lazy(() => import("@/components/DataloggerDownload").then((m) => ({ default: m.DataloggerDownload })), ); import { listSessionVideos, deleteSessionVideo, 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`; @@ -206,6 +208,10 @@ export function FilesTab({ )}
+
{/* Storage Usage */} diff --git a/src/plugins/PluginMount.tsx b/src/plugins/PluginMount.tsx new file mode 100644 index 0000000..680a164 --- /dev/null +++ b/src/plugins/PluginMount.tsx @@ -0,0 +1,38 @@ +import { Component, Suspense, useMemo, type ReactNode } from "react"; +import { getMounts } from "./mounts"; + +/** Isolates a single mounted component so a plugin throw can't break core UI. */ +class MountErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + render() { + return this.state.failed ? null : this.props.children; + } +} + +/** + * Renders every plugin component mounted at `slot`, passing each the given + * context. Renders nothing when no plugin targets the slot, so it's safe to + * drop into core UI unconditionally. Each component is error-boundaried and + * Suspense-wrapped, so mounts may be `React.lazy`. + */ +export function PluginMount({ slot, ctx }: { slot: string; ctx: C }) { + const mounts = useMemo(() => getMounts(slot), [slot]); + if (mounts.length === 0) return null; + return ( + <> + {mounts.map((m) => { + const Body = m.component; + return ( + + + + + + ); + })} + + ); +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts index cf94224..dc93c81 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,5 +1,6 @@ import externalPlugins from "virtual:external-plugins"; import { pluginRegistry } from "./registry"; +import { getPluginStore } from "./storage"; import type { DataViewerPlugin } from "./types"; let initialized = false; @@ -29,7 +30,7 @@ export function initPlugins(): void { } for (const plugin of pluginRegistry.list()) { - void plugin.setup?.({ registry: pluginRegistry }); + void plugin.setup?.({ registry: pluginRegistry, storage: getPluginStore(plugin.id) }); } if (import.meta.env.DEV) { diff --git a/src/plugins/mounts.test.ts b/src/plugins/mounts.test.ts new file mode 100644 index 0000000..b2fd3ac --- /dev/null +++ b/src/plugins/mounts.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { pluginRegistry } from "./registry"; +import { MOUNTS_POINT, getMounts, type PluginMountDef } from "./mounts"; + +const noop: PluginMountDef["component"] = () => null; + +function mount(id: string, slot: string, order?: number): PluginMountDef { + return { id, slot, order, component: noop }; +} + +describe("getMounts", () => { + it("returns only mounts contributed to the requested slot", () => { + pluginRegistry.contribute(MOUNTS_POINT, mount("a", "slot-x")); + pluginRegistry.contribute(MOUNTS_POINT, mount("b", "slot-y")); + expect(getMounts("slot-x").map((m) => m.id)).toEqual(["a"]); + }); + + it("sorts by order ascending, missing order treated as 0", () => { + pluginRegistry.contribute(MOUNTS_POINT, mount("late", "slot-ord", 5)); + pluginRegistry.contribute(MOUNTS_POINT, mount("mid", "slot-ord")); + pluginRegistry.contribute(MOUNTS_POINT, mount("early", "slot-ord", -1)); + expect(getMounts("slot-ord").map((m) => m.id)).toEqual(["early", "mid", "late"]); + }); + + it("returns an empty array for a slot with no mounts", () => { + expect(getMounts("slot-empty")).toEqual([]); + }); +}); diff --git a/src/plugins/mounts.ts b/src/plugins/mounts.ts new file mode 100644 index 0000000..0a1e9cb --- /dev/null +++ b/src/plugins/mounts.ts @@ -0,0 +1,56 @@ +// Inline UI mount points. +// +// Where `panels.ts` lets a plugin contribute a standalone titled card to a slot +// (the Labs tab), a *mount* lets a plugin inject a raw component into a fixed +// spot in core UI — e.g. a control on every file row, or a section under the +// file list. Each mount targets a named slot and receives a typed context. +// +// All mounts share one registry point (`MOUNTS_POINT`), discriminated by `slot`, +// so adding a new mount location is just a new string — no registry change. + +import type { ComponentType } from "react"; +import type { FileEntry, FileMetadata } from "@/lib/fileStorage"; +import { pluginRegistry } from "./registry"; + +export const MOUNTS_POINT = "ui:mounts"; + +/** Known inline mount locations in core UI. */ +export const MountSlot = { + /** Rendered once per file row in the file manager. Context: that file. */ + FileRow: "file-row", + /** Rendered once below the file list. Context: the whole list. */ + FileManagerSection: "file-manager-section", +} as const; +export type MountSlot = (typeof MountSlot)[keyof typeof MountSlot]; + +/** Context handed to a `MountSlot.FileRow` component. */ +export interface FileRowContext { + file: FileEntry; + metadata?: FileMetadata; +} + +/** Context handed to a `MountSlot.FileManagerSection` component. */ +export interface FileManagerSectionContext { + files: FileEntry[]; + /** Persist a (e.g. cloud-pulled) blob into local storage. */ + onSaveFile: (name: string, blob: Blob) => Promise; +} + +/** + * A mount descriptor. The component receives its slot's context as a single + * `ctx` prop (avoids generic prop-spreading; keeps the contract explicit). + */ +export interface PluginMountDef { + id: string; + slot: string; + order?: number; + component: ComponentType<{ ctx: C }>; +} + +/** All mounts contributed to `slot`, sorted by `order` then registration. */ +export function getMounts(slot: string): PluginMountDef[] { + return pluginRegistry + .getContributions>(MOUNTS_POINT) + .filter((m) => m.slot === slot) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} diff --git a/src/plugins/registry.test.ts b/src/plugins/registry.test.ts index 2faa626..efc5e15 100644 --- a/src/plugins/registry.test.ts +++ b/src/plugins/registry.test.ts @@ -52,7 +52,14 @@ describe("pluginRegistry", () => { received = ctx.registry; }); pluginRegistry.register(p); - void pluginRegistry.get("with-setup")?.setup?.({ registry: pluginRegistry }); + const storage = { + get: async () => undefined, + set: async () => undefined, + delete: async () => undefined, + getAll: async () => [], + keys: async () => [], + }; + void pluginRegistry.get("with-setup")?.setup?.({ registry: pluginRegistry, storage }); expect(received).toBe(pluginRegistry); }); }); diff --git a/src/plugins/storage.ts b/src/plugins/storage.ts new file mode 100644 index 0000000..5f6f81f --- /dev/null +++ b/src/plugins/storage.ts @@ -0,0 +1,70 @@ +// Per-plugin persistent storage. +// +// Each plugin gets its own IndexedDB database (`dove-plugin-`) with a single +// key-value object store. This keeps plugin data fully decoupled from the core +// schema in `dbUtils.ts` — plugins never bump the app's DB_VERSION or register +// stores there, so a new plugin's storage needs is zero core changes. + +import type { PluginStore } from "./types"; + +const DB_PREFIX = "dove-plugin-"; +const KV_STORE = "kv"; + +// Plugin ids become part of an IndexedDB database name; keep them tame. +function assertSafeId(pluginId: string): void { + if (!/^[a-z0-9][a-z0-9_-]*$/i.test(pluginId)) { + throw new Error(`Invalid plugin id for storage: "${pluginId}"`); + } +} + +function openDb(pluginId: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(`${DB_PREFIX}${pluginId}`, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(KV_STORE)) db.createObjectStore(KV_STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function withStore( + pluginId: string, + mode: IDBTransactionMode, + op: (store: IDBObjectStore) => IDBRequest, +): Promise { + const db = await openDb(pluginId); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(KV_STORE, mode); + const req = op(tx.objectStore(KV_STORE)); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } finally { + db.close(); + } +} + +const stores = new Map(); + +/** Get the key-value store for a plugin (memoized per id). */ +export function getPluginStore(pluginId: string): PluginStore { + assertSafeId(pluginId); + const cached = stores.get(pluginId); + if (cached) return cached; + + const store: PluginStore = { + get: (key: string) => withStore(pluginId, "readonly", (s) => s.get(key) as IDBRequest), + set: (key, value) => + withStore(pluginId, "readwrite", (s) => s.put(value, key)).then(() => undefined), + delete: (key) => + withStore(pluginId, "readwrite", (s) => s.delete(key) as IDBRequest).then(() => undefined), + getAll: () => withStore(pluginId, "readonly", (s) => s.getAll() as IDBRequest), + keys: () => + withStore(pluginId, "readonly", (s) => s.getAllKeys()).then((ks) => ks.map(String)), + }; + stores.set(pluginId, store); + return store; +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 78e20b5..c6fe9f2 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -9,6 +9,21 @@ export interface PluginContext { /** The shared registry. Plugins read/write extension points through this. */ registry: PluginRegistry; + /** Persistent key-value storage private to this plugin (own IndexedDB DB). */ + storage: PluginStore; +} + +/** + * Schema-less key-value storage scoped to one plugin. Backed by the plugin's + * own IndexedDB database, so plugins never touch the core schema/version. + * Values must be structured-cloneable. + */ +export interface PluginStore { + get(key: string): Promise; + set(key: string, value: T): Promise; + delete(key: string): Promise; + getAll(): Promise; + keys(): Promise; } export interface DataViewerPlugin { From 9ad847bcd30a71c1c09e7b391d4f56d571487a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 17:55:19 +0000 Subject: [PATCH 035/121] Phase B: per-file cloud sync selection (opt-in toggle on each file row) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the Phase A primitives. Files are now individually selectable for sync, modeled on the device track-sync UX; opt-in, off by default. - fileSync.ts: selection state in the plugin's own KV store (getPluginStore), with a pure, tested fileSyncStatus (off/pending/synced). - FileSyncToggle.tsx: a FileRow mount — per-file toggle that selects + pushes (or records intent when offline/signed-out). Lazy-loaded (its own chunk). - syncEngine: pushAll now uploads garage docs + only *selected* files; new pushFile for a single toggle; pulled files are marked synced. - index.ts contributes the FileRow mount (lazy, cloud-gated) alongside the panel. - Docs: plugins/README gains storage + inline-mount authoring sections; CLAUDE.md + CHANGELOG updated. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- CHANGELOG.md | 4 ++ CLAUDE.md | 14 ++++- src/plugins/README.md | 42 ++++++++++++++ src/plugins/cloud-sync/FileSyncToggle.tsx | 70 +++++++++++++++++++++++ src/plugins/cloud-sync/fileSync.test.ts | 16 ++++++ src/plugins/cloud-sync/fileSync.ts | 50 ++++++++++++++++ src/plugins/cloud-sync/index.ts | 12 ++++ src/plugins/cloud-sync/syncEngine.ts | 59 ++++++++++++------- 8 files changed, 242 insertions(+), 25 deletions(-) create mode 100644 src/plugins/cloud-sync/FileSyncToggle.tsx create mode 100644 src/plugins/cloud-sync/fileSync.test.ts create mode 100644 src/plugins/cloud-sync/fileSync.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 987a3c6..0bc5ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. - 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`, diff --git a/CLAUDE.md b/CLAUDE.md index 625c0b9..e4e99f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,11 +184,13 @@ src/ │ ├── mounts.ts # Inline mounts: MOUNTS_POINT, MountSlot (FileRow/FileManagerSection), contexts, getMounts │ ├── PluginMount.tsx # Renders inline mounts for a slot (error-boundaried, Suspense; renders null when none) │ ├── storage.ts # getPluginStore(id): per-plugin KV in its own IndexedDB DB (dove-plugin-) -│ ├── cloud-sync/ # ★ First-party plugin: Supabase file + garage sync (Labs panel) -│ │ ├── index.ts # Plugin def — contributes a lazy CloudSyncPanel to the Labs slot +│ ├── cloud-sync/ # ★ First-party plugin: Supabase file + garage sync (Labs panel + per-file toggle) +│ │ ├── index.ts # Plugin def — contributes the Labs panel + a FileRow mount (both lazy, cloud-gated) │ │ ├── CloudSyncPanel.tsx # Sign-in + push/pull UI (lazy-loaded) +│ │ ├── FileSyncToggle.tsx # Per-file sync toggle, mounted on each file row (off/pending/synced) +│ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus (pure, tested) │ │ ├── syncStores.ts # Pure config: which IDB stores sync + how they're keyed (testable) -│ │ ├── syncEngine.ts # pushAll/pullAll: IDB ↔ sync_records (jsonb) + user-files bucket (blobs) +│ │ ├── syncEngine.ts # pushAll (garage + selected files) / pushFile / pullAll: IDB ↔ sync_records + bucket │ │ └── cloudClient.ts # Typed access to sync_records + bucket (escape hatch until types regen) │ └── coaching/ # Gitignored private slot (AI coaching submodule) ├── types/ @@ -396,6 +398,12 @@ Synced stores (`syncStores.ts` — pure, unit-tested): `metadata`, `karts`, docs) + `files` (blobs). Video stores are intentionally excluded (size). `vehicle-types`/`setup-templates` ride along because setups are template-driven. +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")`). `pushAll` uploads +all garage docs but only the *selected* files; `pushFile` handles a single +toggle. Cloud-only files (pull-per-file) + a section mount are a follow-up. + 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. diff --git a/src/plugins/README.md b/src/plugins/README.md index e8018a1..73bf4e6 100644 --- a/src/plugins/README.md +++ b/src/plugins/README.md @@ -87,6 +87,48 @@ Contract notes: the host's Vite), so an external package adds `react` to its `devDependencies` for its own typecheck but does **not** bundle a second copy. +## Inline mounts (`mounts.ts`) + +Where a panel is a standalone card, a **mount** injects a raw component into a +fixed spot in core UI. A plugin contributes a `PluginMountDef` to `MOUNTS_POINT`, +targeting a `MountSlot`; the host renders it via ``, +passing a typed context as a single `ctx` prop. The component is error-boundaried +and `Suspense`-wrapped, so it can be `React.lazy`. + +```tsx +import { lazy } from "react"; +import { MOUNTS_POINT, MountSlot, type PluginMountDef, type FileRowContext } from "@/plugins/mounts"; + +const FileBadge = lazy(() => import("./FileBadge")); // ({ ctx }: { ctx: FileRowContext }) => … + +ctx.registry.contribute(MOUNTS_POINT, { + id: "my-file-badge", + slot: MountSlot.FileRow, // rendered once per file row + component: FileBadge, +} satisfies PluginMountDef); +``` + +Today's slots: `MountSlot.FileRow` (ctx = a file + its metadata) and +`MountSlot.FileManagerSection` (ctx = the whole file list). `cloud-sync`'s +per-file toggle is a `FileRow` mount. A slot with no contributions renders +nothing, so hosts add `` unconditionally. New slots are just new +strings — no framework change. + +## Plugin storage (`storage.ts`) + +Each plugin gets a private key-value store in its **own** IndexedDB database +(`dove-plugin-`) — decoupled from the core schema, so persisting plugin +state never touches `dbUtils`/`DB_VERSION`. Available as `ctx.storage` in +`setup`, or `getPluginStore(id)` from anywhere (e.g. a panel/mount component): + +```ts +const store = getPluginStore("my-plugin"); +await store.set("prefs", { theme: "neon" }); +const prefs = await store.get<{ theme: string }>("prefs"); +``` + +`cloud-sync` uses it to hold the opt-in set of files selected for sync. + ## The AI coach as a public npm package The coach lives in its own repo and is published to the **public npm registry** diff --git a/src/plugins/cloud-sync/FileSyncToggle.tsx b/src/plugins/cloud-sync/FileSyncToggle.tsx new file mode 100644 index 0000000..d664a37 --- /dev/null +++ b/src/plugins/cloud-sync/FileSyncToggle.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { Cloud, CloudOff, CloudUpload, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import type { FileRowContext } from "@/plugins/mounts"; +import { fileSyncStatus, getFileRecord, selectFile, unselectFile, type FileSyncState } from "./fileSync"; +import { pushFile } from "./syncEngine"; + +const TITLES: Record = { + off: "Sync this file to the cloud", + pending: "Selected — uploads once you're signed in and online", + synced: "Synced — click to stop syncing (cloud copy is kept)", +}; + +export default function FileSyncToggle({ ctx }: { ctx: FileRowContext }) { + const { user } = useAuth(); + const online = useOnlineStatus(); + const name = ctx.file.name; + const [state, setState] = useState("off"); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let active = true; + getFileRecord(name).then((r) => active && setState(fileSyncStatus(r))); + return () => { + active = false; + }; + }, [name]); + + const toggle = async () => { + if (busy) return; + setBusy(true); + try { + if (state === "off") { + await selectFile(name); + if (user && online) { + await pushFile(user.id, name); + setState("synced"); + } else { + setState("pending"); // intent recorded; uploads later + } + } else { + await unselectFile(name); + setState("off"); + } + } catch { + setState("pending"); // selected, but the upload failed — retry later + } finally { + setBusy(false); + } + }; + + const Icon = busy ? Loader2 : state === "synced" ? Cloud : state === "pending" ? CloudUpload : CloudOff; + const color = state === "synced" ? "text-primary" : "text-muted-foreground"; + + return ( + + ); +} diff --git a/src/plugins/cloud-sync/fileSync.test.ts b/src/plugins/cloud-sync/fileSync.test.ts new file mode 100644 index 0000000..a34fd59 --- /dev/null +++ b/src/plugins/cloud-sync/fileSync.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { fileSyncStatus } from "./fileSync"; + +describe("fileSyncStatus", () => { + it("is 'off' when there is no record (not selected)", () => { + expect(fileSyncStatus(undefined)).toBe("off"); + }); + + it("is 'pending' when selected but not yet uploaded", () => { + expect(fileSyncStatus({})).toBe("pending"); + }); + + it("is 'synced' once a push timestamp is recorded", () => { + expect(fileSyncStatus({ pushedAt: Date.now() })).toBe("synced"); + }); +}); diff --git a/src/plugins/cloud-sync/fileSync.ts b/src/plugins/cloud-sync/fileSync.ts new file mode 100644 index 0000000..9fcb3ff --- /dev/null +++ b/src/plugins/cloud-sync/fileSync.ts @@ -0,0 +1,50 @@ +// Per-file sync selection state (opt-in, default off). +// +// Which files the user has chosen to sync lives in this plugin's own KV store +// (getPluginStore), keyed `file:`. A record's presence means "selected"; +// `pushedAt` means it has been uploaded at least once. Absence means "not +// synced". File blobs are immutable (same name = same bytes), so once pushed a +// file stays synced — no "modified" state needed for the blob itself. + +import { getPluginStore } from "@/plugins/storage"; + +export type FileSyncState = "off" | "pending" | "synced"; + +export interface FileSyncRecord { + pushedAt?: number; +} + +const store = getPluginStore("cloud-sync"); +const PREFIX = "file:"; +const recordKey = (name: string) => `${PREFIX}${name}`; + +/** Derive the UI state from a stored record. Pure — unit-tested. */ +export function fileSyncStatus(rec: FileSyncRecord | undefined): FileSyncState { + if (!rec) return "off"; + return rec.pushedAt ? "synced" : "pending"; +} + +export function getFileRecord(name: string): Promise { + return store.get(recordKey(name)); +} + +/** Mark a file as selected for sync (not yet uploaded). */ +export function selectFile(name: string): Promise { + return store.set(recordKey(name), {}); +} + +/** Record that a file has been uploaded to the cloud. */ +export function markPushed(name: string): Promise { + return store.set(recordKey(name), { pushedAt: Date.now() }); +} + +/** Stop syncing a file. Additive: the cloud copy is left in place. */ +export function unselectFile(name: string): Promise { + return store.delete(recordKey(name)); +} + +/** File names currently selected for sync. */ +export async function listSelectedFiles(): Promise { + const keys = await store.keys(); + return keys.filter((k) => k.startsWith(PREFIX)).map((k) => k.slice(PREFIX.length)); +} diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index c674c6e..a4f7a05 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -2,11 +2,15 @@ import { lazy } from "react"; import { Cloud } from "lucide-react"; import type { DataViewerPlugin } from "@/plugins/types"; import { PANELS_POINT, PanelSlot, type PluginPanel } from "@/plugins/panels"; +import { MOUNTS_POINT, MountSlot, type PluginMountDef, type FileRowContext } from "@/plugins/mounts"; // The panel pulls in the Supabase sync engine + storage modules, so it's lazy: // the chunk loads only when the Labs tab is opened, keeping the initial bundle // lean (see Bundle Splitting in CLAUDE.md). const CloudSyncPanel = lazy(() => import("./CloudSyncPanel")); +// Likewise the per-file toggle: lazy so the file-manager drawer doesn't pull the +// sync engine onto its chunk until a row actually renders the control. +const FileSyncToggle = lazy(() => import("./FileSyncToggle")); const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; @@ -28,6 +32,14 @@ const plugin: DataViewerPlugin = { component: CloudSyncPanel, }; ctx.registry.contribute(PANELS_POINT, panel); + + // Per-file sync toggle injected into each file-manager row. + ctx.registry.contribute(MOUNTS_POINT, { + id: "cloud-sync-file-toggle", + slot: MountSlot.FileRow, + order: 0, + component: FileSyncToggle, + } satisfies PluginMountDef); }, }; diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index 4edd581..83a00c5 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -12,9 +12,10 @@ // can't live in jsonb, so they round-trip through the Storage bucket instead. import { withReadTransaction, withWriteTransaction } from "@/lib/dbUtils"; -import { getFile, listFiles, saveFile } from "@/lib/fileStorage"; +import { getFile, saveFile } from "@/lib/fileStorage"; import { syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; import { DOC_STORES, FILE_STORE, extractKey, type SyncSummary } from "./syncStores"; +import { listSelectedFiles, markPushed } from "./fileSync"; export type { SyncSummary }; @@ -31,7 +32,33 @@ async function writeOne(store: string, record: unknown): Promise { await withWriteTransaction(store, (s) => s.put(record as Record)); } -/** Mirror all local data (structured records + file blobs) up to the cloud. */ +/** Upload one local file blob + its index row. Returns false if not stored locally. */ +async function uploadBlob(userId: string, name: string): Promise { + const blob = await getFile(name); + if (!blob) return false; + const { error: upErr } = await userFiles().upload(blobPath(userId, name), blob, { + upsert: true, + contentType: blob.type || "application/octet-stream", + }); + if (upErr) throw new Error(`Failed to upload ${name}: ${upErr.message}`); + const { error } = await syncRecords().upsert( + [{ user_id: userId, store: FILE_STORE, record_key: name, data: { size: blob.size } }], + { onConflict: "user_id,store,record_key" }, + ); + if (error) throw new Error(`Failed to index ${name}: ${error.message}`); + return true; +} + +/** Push a single selected file and mark it synced. Throws if not stored locally. */ +export async function pushFile(userId: string, name: string): Promise { + if (!(await uploadBlob(userId, name))) throw new Error(`File not found locally: ${name}`); + await markPushed(name); +} + +/** + * Mirror local data up to the cloud: all structured (garage) records, plus only + * the files the user has selected for sync. + */ export async function pushAll(userId: string): Promise { const rows: SyncRecordRow[] = []; for (const store of DOC_STORES) { @@ -44,28 +71,15 @@ export async function pushAll(userId: string): Promise { if (error) throw new Error(`Failed to push records: ${error.message}`); } - const fileRows: SyncRecordRow[] = []; - for (const file of await listFiles()) { - const blob = await getFile(file.name); - if (!blob) continue; - const { error } = await userFiles().upload(blobPath(userId, file.name), blob, { - upsert: true, - contentType: blob.type || "application/octet-stream", - }); - if (error) throw new Error(`Failed to upload ${file.name}: ${error.message}`); - fileRows.push({ - user_id: userId, - store: FILE_STORE, - record_key: file.name, - data: { size: file.size, savedAt: file.savedAt }, - }); - } - if (fileRows.length) { - const { error } = await syncRecords().upsert(fileRows, { onConflict: "user_id,store,record_key" }); - if (error) throw new Error(`Failed to push file index: ${error.message}`); + let files = 0; + for (const name of await listSelectedFiles()) { + if (await uploadBlob(userId, name)) { + await markPushed(name); + files++; + } } - return { records: rows.length, files: fileRows.length }; + return { records: rows.length, files }; } /** Bring the cloud copy down into local IndexedDB. */ @@ -83,6 +97,7 @@ export async function pullAll(userId: string): Promise { const { data: blob, error: dlError } = await userFiles().download(blobPath(userId, row.record_key)); if (dlError || !blob) continue; await saveFile(row.record_key, blob); + await markPushed(row.record_key); // pulled files are now synced locally files++; } else if ((DOC_STORES as readonly string[]).includes(row.store)) { await writeOne(row.store, row.data); From 4c227afea47abde5ef9355439701ba58e997a983 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 18:02:50 +0000 Subject: [PATCH 036/121] Phase C: cloud-only files inline with per-file pull Completes the per-file sync UX. Files that exist in the cloud but not on this device are listed under the file manager (via the FileManagerSection mount), each with a one-click pull. Pulling persists through ctx.onSaveFile, which refreshes the list, so the file moves out of the cloud-only section into the list automatically. - syncEngine: listCloudFiles + downloadCloudFile. - fileSync: cloudOnlyNames pure helper (+ tests). - CloudFilesSection.tsx: the section mount (lazy, cloud-gated, signed-in only). - CloudSyncPanel copy updated to reflect selective push. - modified detection + "sync all" remain follow-ups. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- CHANGELOG.md | 2 + CLAUDE.md | 8 +- src/plugins/cloud-sync/CloudFilesSection.tsx | 79 ++++++++++++++++++++ src/plugins/cloud-sync/CloudSyncPanel.tsx | 2 +- src/plugins/cloud-sync/fileSync.test.ts | 12 ++- src/plugins/cloud-sync/fileSync.ts | 6 ++ src/plugins/cloud-sync/index.ts | 19 ++++- src/plugins/cloud-sync/syncEngine.ts | 25 +++++++ 8 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 src/plugins/cloud-sync/CloudFilesSection.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc5ee8..622dc94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. + Files that live in your cloud but not on this device are listed under the file + manager with 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`, diff --git a/CLAUDE.md b/CLAUDE.md index e4e99f9..e6bc496 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,7 +188,8 @@ src/ │ │ ├── index.ts # Plugin def — contributes the Labs panel + a FileRow mount (both lazy, cloud-gated) │ │ ├── CloudSyncPanel.tsx # Sign-in + push/pull UI (lazy-loaded) │ │ ├── FileSyncToggle.tsx # Per-file sync toggle, mounted on each file row (off/pending/synced) -│ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus (pure, tested) +│ │ ├── CloudFilesSection.tsx # FileManagerSection mount: cloud-only files with per-file pull +│ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus/cloudOnlyNames (pure, tested) │ │ ├── syncStores.ts # Pure config: which IDB stores sync + how they're keyed (testable) │ │ ├── syncEngine.ts # pushAll (garage + selected files) / pushFile / pullAll: IDB ↔ sync_records + bucket │ │ └── cloudClient.ts # Typed access to sync_records + bucket (escape hatch until types regen) @@ -402,7 +403,10 @@ Files are **opt-in per file** (`fileSync.ts`): a `FileRow` mount adds a toggle t each file-manager row (`off` → `pending` → `synced`), and the selection set lives in the plugin's own KV store (`getPluginStore("cloud-sync")`). `pushAll` uploads all garage docs but only the *selected* files; `pushFile` handles a single -toggle. Cloud-only files (pull-per-file) + a section mount are a follow-up. +toggle. Cloud-only files (in the cloud, not on this device) are listed by a +`FileManagerSection` mount (`CloudFilesSection`) with a per-file pull; pulling +persists via `ctx.onSaveFile` (which refreshes the list). `modified` detection + +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 diff --git a/src/plugins/cloud-sync/CloudFilesSection.tsx b/src/plugins/cloud-sync/CloudFilesSection.tsx new file mode 100644 index 0000000..cc43420 --- /dev/null +++ b/src/plugins/cloud-sync/CloudFilesSection.tsx @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { CloudDownload, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import type { FileManagerSectionContext } from "@/plugins/mounts"; +import { cloudOnlyNames, markPushed } from "./fileSync"; +import { listCloudFiles, downloadCloudFile, type CloudFile } from "./syncEngine"; + +/** + * Lists files that exist in the user's cloud but not on this device, each with a + * per-file pull. Pulled files persist via `ctx.onSaveFile` (which refreshes the + * file list), so they move out of this section and into the list automatically. + */ +export default function CloudFilesSection({ ctx }: { ctx: FileManagerSectionContext }) { + const { user } = useAuth(); + const online = useOnlineStatus(); + const [cloud, setCloud] = useState(null); + const [pulling, setPulling] = useState(null); + + useEffect(() => { + if (!user || !online) { + setCloud(null); + return; + } + let active = true; + listCloudFiles(user.id) + .then((c) => active && setCloud(c)) + .catch(() => active && setCloud([])); + return () => { + active = false; + }; + }, [user, online]); + + const localNames = useMemo(() => ctx.files.map((f) => f.name), [ctx.files]); + const onlyInCloud = useMemo( + () => cloudOnlyNames((cloud ?? []).map((c) => c.name), localNames), + [cloud, localNames], + ); + + if (!user || onlyInCloud.length === 0) return null; + + const pull = async (name: string) => { + if (pulling) return; + setPulling(name); + try { + const blob = await downloadCloudFile(user.id, name); + if (!blob) throw new Error("Download returned no data"); + await ctx.onSaveFile(name, blob); + await markPushed(name); + } catch (e) { + toast.error(e instanceof Error ? e.message : `Failed to pull ${name}`); + } finally { + setPulling(null); + } + }; + + return ( +
+

Available in cloud

+ {onlyInCloud.map((name) => ( +
+ {name} + +
+ ))} +
+ ); +} diff --git a/src/plugins/cloud-sync/CloudSyncPanel.tsx b/src/plugins/cloud-sync/CloudSyncPanel.tsx index d18dfe0..9f449b1 100644 --- a/src/plugins/cloud-sync/CloudSyncPanel.tsx +++ b/src/plugins/cloud-sync/CloudSyncPanel.tsx @@ -104,7 +104,7 @@ export default function CloudSyncPanel() {

- Push uploads this device's files and garage data. Pull brings your cloud copy down. Conflicts resolve in the direction you choose; nothing is deleted. + Push uploads your selected files (toggle them in the file manager) and your garage data. Pull brings your cloud copy down. Conflicts resolve in the direction you choose; nothing is deleted.

); diff --git a/src/plugins/cloud-sync/fileSync.test.ts b/src/plugins/cloud-sync/fileSync.test.ts index a34fd59..3bb3383 100644 --- a/src/plugins/cloud-sync/fileSync.test.ts +++ b/src/plugins/cloud-sync/fileSync.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { fileSyncStatus } from "./fileSync"; +import { fileSyncStatus, cloudOnlyNames } from "./fileSync"; describe("fileSyncStatus", () => { it("is 'off' when there is no record (not selected)", () => { @@ -14,3 +14,13 @@ describe("fileSyncStatus", () => { expect(fileSyncStatus({ pushedAt: Date.now() })).toBe("synced"); }); }); + +describe("cloudOnlyNames", () => { + it("returns cloud files not present locally", () => { + expect(cloudOnlyNames(["a", "b", "c"], ["b"])).toEqual(["a", "c"]); + }); + + it("returns nothing when every cloud file is already local", () => { + expect(cloudOnlyNames(["a", "b"], ["a", "b", "x"])).toEqual([]); + }); +}); diff --git a/src/plugins/cloud-sync/fileSync.ts b/src/plugins/cloud-sync/fileSync.ts index 9fcb3ff..f89ace6 100644 --- a/src/plugins/cloud-sync/fileSync.ts +++ b/src/plugins/cloud-sync/fileSync.ts @@ -48,3 +48,9 @@ export async function listSelectedFiles(): Promise { const keys = await store.keys(); return keys.filter((k) => k.startsWith(PREFIX)).map((k) => k.slice(PREFIX.length)); } + +/** Cloud file names that aren't present locally (i.e. pullable). Pure. */ +export function cloudOnlyNames(cloudNames: string[], localNames: Iterable): string[] { + const local = new Set(localNames); + return cloudNames.filter((n) => !local.has(n)); +} diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index a4f7a05..7758c4f 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -2,15 +2,19 @@ import { lazy } from "react"; import { Cloud } from "lucide-react"; import type { DataViewerPlugin } from "@/plugins/types"; import { PANELS_POINT, PanelSlot, type PluginPanel } from "@/plugins/panels"; -import { MOUNTS_POINT, MountSlot, type PluginMountDef, type FileRowContext } from "@/plugins/mounts"; +import { + MOUNTS_POINT, MountSlot, + type PluginMountDef, type FileRowContext, type FileManagerSectionContext, +} from "@/plugins/mounts"; // The panel pulls in the Supabase sync engine + storage modules, so it's lazy: // the chunk loads only when the Labs tab is opened, keeping the initial bundle // lean (see Bundle Splitting in CLAUDE.md). const CloudSyncPanel = lazy(() => import("./CloudSyncPanel")); -// Likewise the per-file toggle: lazy so the file-manager drawer doesn't pull the -// sync engine onto its chunk until a row actually renders the control. +// Likewise the per-file toggle + cloud-only list: lazy so the file-manager +// drawer doesn't pull the sync engine onto its chunk until they render. const FileSyncToggle = lazy(() => import("./FileSyncToggle")); +const CloudFilesSection = lazy(() => import("./CloudFilesSection")); const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; @@ -40,6 +44,15 @@ const plugin: DataViewerPlugin = { order: 0, component: FileSyncToggle, } satisfies PluginMountDef); + + // Cloud-only files (in the cloud, not on this device) listed under the file + // list, each with a per-file pull. + ctx.registry.contribute(MOUNTS_POINT, { + id: "cloud-sync-cloud-files", + slot: MountSlot.FileManagerSection, + order: 0, + component: CloudFilesSection, + } satisfies PluginMountDef); }, }; diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index 83a00c5..a5257d5 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -55,6 +55,31 @@ export async function pushFile(userId: string, name: string): Promise { await markPushed(name); } +export interface CloudFile { + name: string; + size?: number; +} + +/** List the files this user has in the cloud (the file index rows). */ +export async function listCloudFiles(userId: string): Promise { + const { data, error } = await syncRecords() + .select("record_key,data") + .eq("user_id", userId) + .eq("store", FILE_STORE); + if (error) throw new Error(`Failed to list cloud files: ${error.message}`); + return ((data ?? []) as { record_key: string; data: { size?: number } | null }[]).map((r) => ({ + name: r.record_key, + size: r.data?.size, + })); +} + +/** Download a single file blob from the cloud (does not persist it locally). */ +export async function downloadCloudFile(userId: string, name: string): Promise { + const { data, error } = await userFiles().download(blobPath(userId, name)); + if (error || !data) return null; + return data; +} + /** * Mirror local data up to the cloud: all structured (garage) records, plus only * the files the user has selected for sync. From e9fe4d0b9de52b09223766c3091ea7499ba0014d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 18:53:47 +0000 Subject: [PATCH 037/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/integrations/supabase/types.ts | 27 ++++++++ ...4_7111be94-4e12-476e-abfb-5f3716a856cb.sql | 65 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 supabase/migrations/20260524185344_7111be94-4e12-476e-abfb-5f3716a856cb.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 22ab1ef..71cd9df 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -253,6 +253,33 @@ export type Database = { } Relationships: [] } + sync_records: { + Row: { + data: Json + id: string + record_key: string + store: string + updated_at: string + user_id: string + } + Insert: { + data: Json + id?: string + record_key: string + store: string + updated_at?: string + user_id: string + } + Update: { + data?: Json + id?: string + record_key?: string + store?: string + updated_at?: string + user_id?: string + } + Relationships: [] + } tracks: { Row: { created_at: string | null diff --git a/supabase/migrations/20260524185344_7111be94-4e12-476e-abfb-5f3716a856cb.sql b/supabase/migrations/20260524185344_7111be94-4e12-476e-abfb-5f3716a856cb.sql new file mode 100644 index 0000000..93b88f4 --- /dev/null +++ b/supabase/migrations/20260524185344_7111be94-4e12-476e-abfb-5f3716a856cb.sql @@ -0,0 +1,65 @@ +-- Cloud sync: per-user storage of telemetry files + garage data. +create table if not exists public.sync_records ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + store text not null, + record_key text not null, + data jsonb not null, + updated_at timestamptz not null default now(), + unique (user_id, store, record_key) +); + +create index if not exists sync_records_user_store_idx + on public.sync_records (user_id, store); + +alter table public.sync_records enable row level security; + +create policy "Users read own sync records" + on public.sync_records for select to authenticated + using (auth.uid() = user_id); + +create policy "Users insert own sync records" + on public.sync_records for insert to authenticated + with check (auth.uid() = user_id); + +create policy "Users update own sync records" + on public.sync_records for update to authenticated + using (auth.uid() = user_id) + with check (auth.uid() = user_id); + +create policy "Users delete own sync records" + on public.sync_records for delete to authenticated + using (auth.uid() = user_id); + +-- Raw file blobs +insert into storage.buckets (id, name, public) +values ('user-files', 'user-files', false) +on conflict (id) do nothing; + +create policy "Users read own files" + on storage.objects for select to authenticated + using ( + bucket_id = 'user-files' + and (storage.foldername(name))[1] = auth.uid()::text + ); + +create policy "Users upload own files" + on storage.objects for insert to authenticated + with check ( + bucket_id = 'user-files' + and (storage.foldername(name))[1] = auth.uid()::text + ); + +create policy "Users update own files" + on storage.objects for update to authenticated + using ( + bucket_id = 'user-files' + and (storage.foldername(name))[1] = auth.uid()::text + ); + +create policy "Users delete own files" + on storage.objects for delete to authenticated + using ( + bucket_id = 'user-files' + and (storage.foldername(name))[1] = auth.uid()::text + ); \ No newline at end of file From df2dc4eb672a7a3318f3e07148d4c2b0e69964b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 19:10:12 +0000 Subject: [PATCH 038/121] Cloud files inventory: list all cloud files, not just cloud-only The "Available in cloud" section only showed files missing locally, so on the device that uploaded them it looked empty. Rework it into a "Cloud files" list that shows everything in your cloud: files already on this device are marked present, and any not local get the per-file pull. Gives visibility that sync worked from any device. A dedicated Cloud tab (via a new garage-tab mount slot) is the natural next step. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- CHANGELOG.md | 5 +- CLAUDE.md | 11 ++-- src/plugins/cloud-sync/CloudFilesSection.tsx | 61 ++++++++++++-------- 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 622dc94..6c7b9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. - Files that live in your cloud but not on this device are listed under the file - manager with a one-click pull. + 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`, diff --git a/CLAUDE.md b/CLAUDE.md index e6bc496..2f4bf57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,7 +188,7 @@ src/ │ │ ├── index.ts # Plugin def — contributes the Labs panel + a FileRow mount (both lazy, cloud-gated) │ │ ├── CloudSyncPanel.tsx # Sign-in + push/pull UI (lazy-loaded) │ │ ├── FileSyncToggle.tsx # Per-file sync toggle, mounted on each file row (off/pending/synced) -│ │ ├── CloudFilesSection.tsx # FileManagerSection mount: cloud-only files with per-file pull +│ │ ├── CloudFilesSection.tsx # FileManagerSection mount: lists all cloud files (on-device marked, others pullable) │ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus/cloudOnlyNames (pure, tested) │ │ ├── syncStores.ts # Pure config: which IDB stores sync + how they're keyed (testable) │ │ ├── syncEngine.ts # pushAll (garage + selected files) / pushFile / pullAll: IDB ↔ sync_records + bucket @@ -403,10 +403,11 @@ Files are **opt-in per file** (`fileSync.ts`): a `FileRow` mount adds a toggle t each file-manager row (`off` → `pending` → `synced`), and the selection set lives in the plugin's own KV store (`getPluginStore("cloud-sync")`). `pushAll` uploads all garage docs but only the *selected* files; `pushFile` handles a single -toggle. Cloud-only files (in the cloud, not on this device) are listed by a -`FileManagerSection` mount (`CloudFilesSection`) with a per-file pull; pulling -persists via `ctx.onSaveFile` (which refreshes the list). `modified` detection + -a "sync all" affordance remain follow-ups. +toggle. 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 diff --git a/src/plugins/cloud-sync/CloudFilesSection.tsx b/src/plugins/cloud-sync/CloudFilesSection.tsx index cc43420..0b0abcc 100644 --- a/src/plugins/cloud-sync/CloudFilesSection.tsx +++ b/src/plugins/cloud-sync/CloudFilesSection.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { CloudDownload, Loader2 } from "lucide-react"; +import { Cloud, CloudDownload, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { useOnlineStatus } from "@/hooks/useOnlineStatus"; @@ -9,9 +9,10 @@ import { cloudOnlyNames, markPushed } from "./fileSync"; import { listCloudFiles, downloadCloudFile, type CloudFile } from "./syncEngine"; /** - * Lists files that exist in the user's cloud but not on this device, each with a - * per-file pull. Pulled files persist via `ctx.onSaveFile` (which refreshes the - * file list), so they move out of this section and into the list automatically. + * Inventory of every file in the user's cloud. Files already on this device are + * shown as present; files that aren't local get a per-file pull. Pulled files + * persist via `ctx.onSaveFile` (which refreshes the list), so they flip to + * "on this device" automatically. */ export default function CloudFilesSection({ ctx }: { ctx: FileManagerSectionContext }) { const { user } = useAuth(); @@ -34,12 +35,13 @@ export default function CloudFilesSection({ ctx }: { ctx: FileManagerSectionCont }, [user, online]); const localNames = useMemo(() => ctx.files.map((f) => f.name), [ctx.files]); - const onlyInCloud = useMemo( - () => cloudOnlyNames((cloud ?? []).map((c) => c.name), localNames), + const files = cloud ?? []; + const pullable = useMemo( + () => new Set(cloudOnlyNames((cloud ?? []).map((c) => c.name), localNames)), [cloud, localNames], ); - if (!user || onlyInCloud.length === 0) return null; + if (!user || files.length === 0) return null; const pull = async (name: string) => { if (pulling) return; @@ -58,22 +60,35 @@ export default function CloudFilesSection({ ctx }: { ctx: FileManagerSectionCont return (
-

Available in cloud

- {onlyInCloud.map((name) => ( -
- {name} - -
- ))} +

Cloud files ({files.length})

+ {files.map((c) => { + const canPull = pullable.has(c.name); + return ( +
+ {c.name} + {canPull ? ( + + ) : ( + + + + )} +
+ ); + })}
); } From c3381c67329fe74a96dbd32a0501fcbfc564d03a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 19:12:50 +0000 Subject: [PATCH 039/121] Pin the Cloud files list to the top of the Files tab It was below the local list and easy to miss under the fold. Move the FileManagerSection mount above the file list and flip its divider to the bottom so it reads as a header block. https://claude.ai/code/session_01QF56Xjp5ZMgXrqfTWD14Le --- src/components/drawer/FilesTab.tsx | 10 +++++----- src/plugins/cloud-sync/CloudFilesSection.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/drawer/FilesTab.tsx b/src/components/drawer/FilesTab.tsx index 50b3b55..5e31036 100644 --- a/src/components/drawer/FilesTab.tsx +++ b/src/components/drawer/FilesTab.tsx @@ -169,6 +169,11 @@ export function FilesTab({ {/* File List */}
+ {/* Plugin-contributed file-manager section (e.g. cloud files), pinned on top. */} + {files.length === 0 ? (
@@ -233,11 +238,6 @@ export function FilesTab({
)) )} - {/* Plugin-contributed file-manager section (e.g. cloud-only files). */} -
{/* Storage Usage */} diff --git a/src/plugins/cloud-sync/CloudFilesSection.tsx b/src/plugins/cloud-sync/CloudFilesSection.tsx index 0b0abcc..04e1a8c 100644 --- a/src/plugins/cloud-sync/CloudFilesSection.tsx +++ b/src/plugins/cloud-sync/CloudFilesSection.tsx @@ -59,7 +59,7 @@ export default function CloudFilesSection({ ctx }: { ctx: FileManagerSectionCont }; return ( -
+

Cloud files ({files.length})

{files.map((c) => { const canPull = pullable.has(c.name); From 58bacc8f78ed22f5e3f68ddaab2560f3540e79fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 20:50:53 +0000 Subject: [PATCH 040/121] Add dedicated AI Coach tab (PanelSlot.Coach) Adds a new top-level Coach view that hosts coaching-plugin panels via a new PanelSlot.Coach, rendered by CoachTab through the existing PluginPanelHost. The tab is self-gating like Labs: it appears only when a plugin contributes a Coach-slot panel, so builds without the coach are unaffected and the coach keeps working in its current slot until it migrates. CoachTab is lazy-loaded to stay off the initial bundle. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 3 +++ CLAUDE.md | 13 ++++++----- src/components/tabs/CoachTab.tsx | 37 ++++++++++++++++++++++++++++++++ src/pages/Index.tsx | 19 +++++++++++++--- src/plugins/panels.test.ts | 10 ++++++++- src/plugins/panels.ts | 2 ++ 6 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 src/components/tabs/CoachTab.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7b9ec..89e4a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 panels. Like Labs, it is self-gating — the + tab only appears when the coach plugin is installed and contributes a panel. - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 2f4bf57..751d8d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ src/ ├── 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 +│ ├── tabs/ # Main view tabs: GraphViewTab, RaceLineTab, LapTimesTab, LabsTab, CoachTab │ ├── 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 @@ -257,10 +257,13 @@ A plugin default-exports `{ id, name, version?, priority?, setup?(ctx) }`. In **UI panels:** the first concrete extension point. A plugin contributes `PluginPanel` descriptors to `PANELS_POINT`, targeting a *slot* (host surface). -The only slot today is `PanelSlot.Labs` — `LabsTab.tsx` renders contributed -panels via `PluginPanelHost`, and a labs-slot panel makes the Labs tab appear -automatically even when the experimental `enableLabs` setting is off (`Index.tsx` -computes `hasLabsPanels`). New slots are just new strings — no framework change. +Two slots exist today: `PanelSlot.Labs` (rendered by `LabsTab.tsx`) and +`PanelSlot.Coach` (rendered by `CoachTab.tsx` — the dedicated AI Coach tab, home +for the `@perchwerks/eye-in-the-sky` coaching plugin). Both render contributed +panels via `PluginPanelHost` and are **self-gating**: `Index.tsx` computes +`hasLabsPanels`/`showCoach` 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). diff --git a/src/components/tabs/CoachTab.tsx b/src/components/tabs/CoachTab.tsx new file mode 100644 index 0000000..d9ceae1 --- /dev/null +++ b/src/components/tabs/CoachTab.tsx @@ -0,0 +1,37 @@ +import { memo } from "react"; +import { Gauge } from "lucide-react"; +import { useSessionContext } from "@/contexts/SessionContext"; +import { useSettingsContext } from "@/contexts/SettingsContext"; +import { PluginPanelHost } from "@/plugins/PluginPanelHost"; +import { PanelSlot } from "@/plugins/panels"; + +export const CoachTab = memo(function CoachTab() { + const { data, laps, selectedLapNumber, course } = useSessionContext(); + const { useKph } = useSettingsContext(); + + return ( + } + /> + ); +}); + +function CoachEmpty() { + return ( +
+
+ +

AI Coach

+

+ The coaching plugin isn’t loaded in this build. +

+
+
+ ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index a244ed8..1bbc403 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -14,6 +14,9 @@ const GraphViewTab = lazy(() => const LabsTab = lazy(() => import("@/components/tabs/LabsTab").then((m) => ({ default: m.LabsTab })), ); +const CoachTab = lazy(() => + import("@/components/tabs/CoachTab").then((m) => ({ default: m.CoachTab })), +); import { InstallPrompt } from "@/components/InstallPrompt"; import { SettingsModal } from "@/components/SettingsModal"; // FileManagerDrawer is a slide-out that only opens on user click. Lazy-loading @@ -46,7 +49,7 @@ import { DeviceProvider } from "@/contexts/DeviceContext"; import { SessionProvider, type SessionContextValue } from "@/contexts/SessionContext"; -type TopPanelView = "raceline" | "laptable" | "graphview" | "labs"; +type TopPanelView = "raceline" | "laptable" | "graphview" | "labs" | "coach"; const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === 'true'; const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; @@ -116,6 +119,9 @@ export default function Index() { // has real content — surface it even when the experimental setting is off. const hasLabsPanels = useMemo(() => getPanelsForSlot(PanelSlot.Labs).length > 0, []); const showLabs = settings.enableLabs || hasLabsPanels; + // The Coach tab is self-gating: it appears only when a plugin contributes a + // panel to the Coach slot (i.e. the coach package is installed). + const showCoach = useMemo(() => getPanelsForSlot(PanelSlot.Coach).length > 0, []); // Video sync for Labs tab const videoSync = useVideoSync({ @@ -387,7 +393,7 @@ export default function Index() {
- setShowOverlays(v => !v)} showLabs={showLabs} /> + setShowOverlays(v => !v)} showLabs={showLabs} showCoach={showCoach} />
@@ -396,6 +402,7 @@ export default function Index() { {topPanelView === "graphview" && } {topPanelView === "labs" && showLabs && } + {topPanelView === "coach" && showCoach && }
@@ -420,13 +427,14 @@ export default function Index() { } /** Tab navigation bar for the main data view */ -function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOverlays, showLabs }: { +function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOverlays, showLabs, showCoach }: { topPanelView: TopPanelView; setTopPanelView: (view: TopPanelView) => void; laps: { lapNumber: number }[]; showOverlays: boolean; onToggleOverlays: () => void; showLabs: boolean; + showCoach: boolean; }) { const tabClass = (view: TopPanelView) => `flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${ @@ -454,6 +462,11 @@ function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOve Labs )} + {showCoach && ( + + )} {topPanelView === "raceline" && (
))}
diff --git a/src/components/graphview/GraphPanel.tsx b/src/components/graphview/GraphPanel.tsx index beafb20..b0bbc69 100644 --- a/src/components/graphview/GraphPanel.tsx +++ b/src/components/graphview/GraphPanel.tsx @@ -129,11 +129,11 @@ export function GraphPanel({ // Check if both GPS and HW G-force data are available const hasHwAccel = useMemo(() => { - return filteredSamples.some(s => s.extraFields['Accel X'] !== undefined); + return filteredSamples.some(s => s.extraFields['accel_x'] !== undefined); }, [filteredSamples]); const hasGpsG = useMemo(() => { - return filteredSamples.some(s => s.extraFields['Lat G'] !== undefined); + return filteredSamples.some(s => s.extraFields['lat_g'] !== undefined); }, [filteredSamples]); const hasBothSources = hasHwAccel && hasGpsG; @@ -148,14 +148,15 @@ export function GraphPanel({ } sources.push({ key: '__braking_g__', label: hasBothSources ? 'Brake % (GPS)' : 'Brake % (computed)' }); fieldMappings.forEach(f => { - let label = f.name + (f.unit ? ` (${f.unit})` : ''); + const display = f.label ?? f.name; + let label = display + (f.unit ? ` (${f.unit})` : ''); // Add source indicator when both GPS and HW G-force data exist if (hasBothSources) { - if (f.name === 'Lat G') label = 'Lat G (GPS)'; - else if (f.name === 'Lon G') label = 'Lon G (GPS)'; - else if (f.name === 'Accel X') label = 'Accel X (HW)'; - else if (f.name === 'Accel Y') label = 'Accel Y (HW)'; - else if (f.name === 'Accel Z') label = 'Accel Z (HW)'; + if (f.name === 'lat_g') label = 'Lat G (GPS)'; + else if (f.name === 'lon_g') label = 'Lon G (GPS)'; + else if (f.name === 'accel_x') label = 'Accel X (HW)'; + else if (f.name === 'accel_y') label = 'Accel Y (HW)'; + else if (f.name === 'accel_z') label = 'Accel Z (HW)'; } sources.push({ key: f.name, label }); }); diff --git a/src/components/video-overlays/dataSourceResolver.ts b/src/components/video-overlays/dataSourceResolver.ts index 445330b..c28748c 100644 --- a/src/components/video-overlays/dataSourceResolver.ts +++ b/src/components/video-overlays/dataSourceResolver.ts @@ -1,4 +1,18 @@ import type { GpsSample, FieldMapping } from "@/types/racing"; +import { toChannelKey } from "@/lib/channels"; + +/** + * Find a data source by id, falling back to the canonical key for legacy + * sourceIds saved before channel normalization (e.g. a stored "Lat G" now lives + * under "lat_g"). Special ids ("speed", "__pace__"…) match exactly on the first + * lookup. + */ +function findSource(dataSources: DataSourceDef[], sourceId: string): DataSourceDef | undefined { + return ( + dataSources.find((d) => d.id === sourceId) ?? + dataSources.find((d) => d.id === toChannelKey(sourceId)) + ); +} import type { DataSourceDef } from "./types"; /** @@ -65,7 +79,7 @@ export function buildDataSources( for (const f of fieldMappings) { sources.push({ id: f.name, - label: f.name + (f.unit ? ` (${f.unit})` : ""), + label: (f.label ?? f.name) + (f.unit ? ` (${f.unit})` : ""), unit: f.unit ?? "", getValue: (s) => s.extraFields[f.name] ?? null, getMin: (samples) => { @@ -108,7 +122,7 @@ export function resolveValue( if (sourceId === "__braking_g__") { return brakingGData?.[currentIndex] ?? null; } - const src = dataSources.find((d) => d.id === sourceId); + const src = findSource(dataSources, sourceId); if (!src) return null; return src.getValue(sample); } @@ -135,19 +149,19 @@ export function resolveRange( const absMax = Math.max(Math.abs(min), Math.abs(max), 0.5); return { min: -absMax, max: absMax }; } - const src = dataSources.find((d) => d.id === sourceId); + const src = findSource(dataSources, sourceId); if (!src) return { min: 0, max: 100 }; return { min: src.getMin(samples), max: src.getMax(samples) }; } /** Get the unit string for a source */ export function resolveUnit(sourceId: string, dataSources: DataSourceDef[]): string { - const src = dataSources.find((d) => d.id === sourceId); + const src = findSource(dataSources, sourceId); return src?.unit ?? ""; } /** Get the label for a source */ export function resolveLabel(sourceId: string, dataSources: DataSourceDef[]): string { - const src = dataSources.find((d) => d.id === sourceId); + const src = findSource(dataSources, sourceId); return src?.label ?? sourceId; } diff --git a/src/lib/channels.test.ts b/src/lib/channels.test.ts index 7e98207..29d5746 100644 --- a/src/lib/channels.test.ts +++ b/src/lib/channels.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import type { ParsedData, FieldMapping, GpsSample } from "@/types/racing"; import { CHANNELS, channelKeyFor, @@ -6,7 +7,9 @@ import { channelUnit, customChannelId, isKnownChannel, + normalizeChannels, resolveChannelId, + toChannelKey, } from "./channels"; describe("channel registry", () => { @@ -64,3 +67,99 @@ describe("channel registry", () => { expect(channelKeyFor("Gizmo Voltage")).toBe("custom:gizmo_voltage"); }); }); + +describe("toChannelKey (idempotent migration)", () => { + it("resolves a legacy display name to its canonical id", () => { + expect(toChannelKey("Lat G")).toBe("lat_g"); + expect(toChannelKey("Gizmo Voltage")).toBe("custom:gizmo_voltage"); + }); + + it("leaves already-migrated keys untouched (idempotent)", () => { + expect(toChannelKey("lat_g")).toBe("lat_g"); + expect(toChannelKey("custom:gizmo_voltage")).toBe("custom:gizmo_voltage"); + expect(toChannelKey(toChannelKey("Lat G"))).toBe("lat_g"); + expect(toChannelKey(toChannelKey("Gizmo Voltage"))).toBe("custom:gizmo_voltage"); + }); +}); + +describe("normalizeChannels", () => { + function sample(extra: Record): GpsSample { + return { + t: 0, + lat: 0, + lon: 0, + speedMps: 0, + speedMph: 0, + speedKph: 0, + extraFields: extra, + }; + } + + function data(mappings: FieldMapping[], samples: GpsSample[]): ParsedData { + return { + samples, + fieldMappings: mappings, + bounds: { minLat: 0, maxLat: 0, minLon: 0, maxLon: 0 }, + duration: 0, + }; + } + + it("renames mappings and sample keys to canonical ids and sets labels", () => { + const out = normalizeChannels( + data( + [ + { index: -10, name: "Lat G", enabled: true }, + { index: -20, name: "RPM", enabled: true }, + ], + [sample({ "Lat G": 0.5, RPM: 9000 })], + ), + ); + expect(out.fieldMappings.map((m) => m.name)).toEqual(["lat_g", "rpm"]); + expect(out.fieldMappings[0].label).toBe("Lat G"); + expect(out.samples[0].extraFields).toEqual({ lat_g: 0.5, rpm: 9000 }); + }); + + it("keeps native, derived, and raw-IMU g as separate keys on one sample", () => { + const out = normalizeChannels( + data( + [ + { index: -10, name: "Lat G", enabled: true }, + { index: -12, name: "Lat G (Native)", enabled: true }, + { index: -30, name: "Accel X", unit: "G", enabled: true }, + ], + [sample({ "Lat G": 0.5, "Lat G (Native)": 0.48, "Accel X": 0.51 })], + ), + ); + expect(out.samples[0].extraFields).toEqual({ + lat_g: 0.5, + lat_g_native: 0.48, + accel_x: 0.51, + }); + // A pre-set unit is preserved over the registry default. + expect(out.fieldMappings.find((m) => m.name === "accel_x")!.unit).toBe("G"); + }); + + it("preserves unmapped custom columns under a stable custom key", () => { + const out = normalizeChannels( + data( + [{ index: 5, name: "Gizmo Voltage", enabled: true }], + [sample({ "Gizmo Voltage": 12.6 })], + ), + ); + expect(out.fieldMappings[0].name).toBe("custom:gizmo_voltage"); + expect(out.fieldMappings[0].label).toBe("Gizmo Voltage"); + expect(out.samples[0].extraFields).toEqual({ "custom:gizmo_voltage": 12.6 }); + }); + + it("is idempotent — re-normalizing already-canonical data is a no-op", () => { + const once = normalizeChannels( + data( + [{ index: -10, name: "Lat G", enabled: true }], + [sample({ "Lat G": 0.5 })], + ), + ); + const twice = normalizeChannels(once); + expect(twice.fieldMappings.map((m) => m.name)).toEqual(["lat_g"]); + expect(twice.samples[0].extraFields).toEqual({ lat_g: 0.5 }); + }); +}); diff --git a/src/lib/channels.ts b/src/lib/channels.ts index 2427a26..210b112 100644 --- a/src/lib/channels.ts +++ b/src/lib/channels.ts @@ -18,12 +18,16 @@ // IMU). These legitimately coexist on one sample (e.g. Alfano reports native g // while we also derive g from GPS), so they must never collapse to one key. +import type { ParsedData } from "@/types/racing"; + export type ChannelId = // GPS / quality | "altitude" | "satellites" | "hdop" | "h_acc" + | "v_acc" + | "speed_acc" // G-force / IMU | "lat_g" | "lon_g" @@ -64,7 +68,9 @@ export const CHANNELS: readonly ChannelDef[] = [ { id: "altitude", label: "Altitude", unit: "m", aliases: ["Altitude (m)", "Alt", "Altitude M"] }, { id: "satellites", label: "Satellites", aliases: ["Sats", "NumSats"] }, { id: "hdop", label: "HDOP", aliases: ["Hdop"] }, - { id: "h_acc", label: "H Accuracy", unit: "m", aliases: ["H Acc M", "Horizontal Accuracy"] }, + { id: "h_acc", label: "H Accuracy", unit: "m", aliases: ["H Accuracy (m)", "H Acc M", "Horizontal Accuracy"] }, + { id: "v_acc", label: "V Accuracy", unit: "m", aliases: ["V Accuracy (m)", "V Acc M", "Vertical Accuracy"] }, + { id: "speed_acc", label: "Speed Accuracy", unit: "m/s", aliases: ["Speed Acc (m/s)", "Speed Acc"] }, { id: "lat_g", label: "Lat G", unit: "g", aliases: ["Lateral G", "LatG"] }, { id: "lon_g", label: "Lon G", unit: "g", aliases: ["Longitudinal G", "LonG"] }, @@ -147,3 +153,58 @@ export function customChannelId(rawName: string): string { export function channelKeyFor(rawName: string): string { return resolveChannelId(rawName) ?? customChannelId(rawName); } + +/** + * Idempotent migration of any field identity to its canonical storage key. + * Handles three inputs: an already-canonical id (kept), an already-migrated + * `custom:` slug (kept), or a legacy display name (resolved). Use this for + * persisted identities (stored graph-prefs, saved overlay sourceIds) so old data + * keeps resolving without a destructive migration. + */ +export function toChannelKey(name: string): string { + if (isKnownChannel(name) || name.startsWith("custom:")) return name; + return channelKeyFor(name); +} + +/** + * Rewrite a freshly-parsed `ParsedData` so every channel identity is canonical: + * `fieldMappings` get a stable `name` (id/slug) plus a display `label` and unit, + * and every sample's `extraFields` keys are renamed to match. Run once per parse + * (at the format router) so all parsers can keep emitting human display names + * internally while the rest of the app sees uniform keys regardless of format. + * + * Mutates the passed samples' `extraFields` in place (they're owned by the + * just-parsed result) and returns `data` with rebuilt `fieldMappings`. + */ +export function normalizeChannels(data: ParsedData): ParsedData { + const rename = new Map(); + const usedKeys = new Set(); + + const fieldMappings = data.fieldMappings.map((m) => { + const known = resolveChannelId(m.name); + let key = toChannelKey(m.name); + // Two source columns must never collapse onto one channel id — that would + // clobber a column's samples. Keep the later duplicate under a custom key. + if (usedKeys.has(key)) key = customChannelId(m.name); + usedKeys.add(key); + rename.set(m.name, key); + return { + ...m, + name: key, + label: m.label ?? (known ? channelLabel(known) : m.name), + unit: m.unit ?? (known ? channelUnit(known) : undefined), + }; + }); + + for (const s of data.samples) { + const next: Record = {}; + for (const k in s.extraFields) { + const v = s.extraFields[k]; + if (v === undefined) continue; + next[rename.get(k) ?? toChannelKey(k)] = v; + } + s.extraFields = next; + } + + return { ...data, fieldMappings }; +} diff --git a/src/lib/chartUtils.ts b/src/lib/chartUtils.ts index 2f9b5fa..23c4eff 100644 --- a/src/lib/chartUtils.ts +++ b/src/lib/chartUtils.ts @@ -2,11 +2,11 @@ * Shared chart utilities used by TelemetryChart and SingleSeriesChart. */ -/** Field names that should have optional smoothing applied in charts (GPS-derived). */ -export const G_FORCE_FIELDS_GPS = ['Lat G', 'Lon G']; +/** Canonical channel ids for GPS-derived G-force (optional smoothing applied). */ +export const G_FORCE_FIELDS_GPS = ['lat_g', 'lon_g']; -/** Field names for hardware accelerometer G-force fields. */ -export const G_FORCE_FIELDS_HW = ['Accel X', 'Accel Y']; +/** Canonical channel ids for hardware accelerometer G-force fields. */ +export const G_FORCE_FIELDS_HW = ['accel_x', 'accel_y']; /** All G-force field names (for smoothing detection). */ export const G_FORCE_FIELDS = [...G_FORCE_FIELDS_GPS, ...G_FORCE_FIELDS_HW]; diff --git a/src/lib/datalogParser.test.ts b/src/lib/datalogParser.test.ts index da8942b..02bf60e 100644 --- a/src/lib/datalogParser.test.ts +++ b/src/lib/datalogParser.test.ts @@ -108,10 +108,12 @@ describe("regression: okc-tillotson-data.dovex", () => { expect(parsed.dovexMetadata!.bestLapMs).toBe(minLap); }); - it("fieldMappings includes the GPS-derived G-forces", () => { + it("fieldMappings includes the GPS-derived G-forces (canonical ids + labels)", () => { const names = parsed.fieldMappings.map((m) => m.name); - expect(names).toContain("Lat G"); - expect(names).toContain("Lon G"); + expect(names).toContain("lat_g"); + expect(names).toContain("lon_g"); + const latG = parsed.fieldMappings.find((m) => m.name === "lat_g"); + expect(latG!.label).toBe("Lat G"); }); it("parserStats reports the row breakdown", () => { @@ -195,18 +197,19 @@ describe("regression: okc-tillotson-plain.nmea", () => { it("fieldMappings includes GPS-derived G-forces (added by parser)", () => { const names = parsed.fieldMappings.map((m) => m.name); - expect(names).toContain("Lat G"); - expect(names).toContain("Lon G"); + expect(names).toContain("lat_g"); + expect(names).toContain("lon_g"); }); it("populates Satellites/HDOP/Altitude from GGA sentences in extraFields", () => { - // The NMEA fixture has interleaved $GPGGA sentences which provide these - const anyWithSats = parsed.samples.find((s) => s.extraFields["Satellites"] !== undefined); + // The NMEA fixture has interleaved $GPGGA sentences which provide these; + // extraFields are keyed by canonical channel id after normalization. + const anyWithSats = parsed.samples.find((s) => s.extraFields["satellites"] !== undefined); expect(anyWithSats).toBeDefined(); - expect(anyWithSats!.extraFields["Satellites"]).toBeGreaterThan(0); - expect(anyWithSats!.extraFields["Satellites"]).toBeLessThan(50); + expect(anyWithSats!.extraFields["satellites"]).toBeGreaterThan(0); + expect(anyWithSats!.extraFields["satellites"]).toBeLessThan(50); - const anyWithAlt = parsed.samples.find((s) => s.extraFields["Altitude (m)"] !== undefined); + const anyWithAlt = parsed.samples.find((s) => s.extraFields["altitude"] !== undefined); expect(anyWithAlt).toBeDefined(); }); diff --git a/src/lib/datalogParser.ts b/src/lib/datalogParser.ts index a62265c..da69685 100644 --- a/src/lib/datalogParser.ts +++ b/src/lib/datalogParser.ts @@ -1,4 +1,5 @@ import { ParsedData } from '@/types/racing'; +import { normalizeChannels } from './channels'; import { parseDatalog } from './nmeaParser'; import { parseUbxFile, isUbxFormat } from './ubxParser'; import { parseVboFile, isVboFormat } from './vboParser'; @@ -22,6 +23,17 @@ import { isMotecLdFormat, parseMotecLdFile, isMotecCsvFormat, parseMotecCsvFile * - NMEA text format (CSV with NMEA sentences, .nmea files) */ export async function parseDatalogFile(file: File): Promise { + return normalizeChannels(await routeDatalogFile(file)); +} + +/** + * Parse from raw content (for when you already have the data loaded). + */ +export function parseDatalogContent(content: string | ArrayBuffer): ParsedData { + return normalizeChannels(routeDatalogContent(content)); +} + +async function routeDatalogFile(file: File): Promise { const buffer = await file.arrayBuffer(); // Check MoTeC LD binary format first (different magic bytes from UBX) @@ -71,10 +83,7 @@ export async function parseDatalogFile(file: File): Promise { return parseDatalog(text); } -/** - * Parse from raw content (for when you already have the data loaded) - */ -export function parseDatalogContent(content: string | ArrayBuffer): ParsedData { +function routeDatalogContent(content: string | ArrayBuffer): ParsedData { if (content instanceof ArrayBuffer) { if (isMotecLdFormat(content)) { return parseMotecLdFile(content); diff --git a/src/lib/fieldResolver.ts b/src/lib/fieldResolver.ts index 0e217ab..31e1e9c 100644 --- a/src/lib/fieldResolver.ts +++ b/src/lib/fieldResolver.ts @@ -6,7 +6,7 @@ // (`getCanonicalFieldId`, `isFieldHiddenByCanonical`, `FIELD_CATEGORIES`) stay // stable. -import { type ChannelId, getChannelDef, resolveChannelId } from "./channels"; +import { type ChannelId, getChannelDef, isKnownChannel, resolveChannelId } from "./channels"; /** A canonical field id is a registry channel id. */ export type CanonicalFieldId = ChannelId; @@ -16,6 +16,9 @@ export type CanonicalFieldId = ChannelId; * known alias). Returns undefined if the field has no canonical mapping. */ export function getCanonicalFieldId(fieldName: string): CanonicalFieldId | undefined { + // Accept an already-canonical id (post-normalization field name) or a raw + // display name / alias (legacy callers, settings UI). + if (isKnownChannel(fieldName)) return fieldName; return resolveChannelId(fieldName); } diff --git a/src/lib/graphPrefsStorage.ts b/src/lib/graphPrefsStorage.ts index 41006d1..9ae69f5 100644 --- a/src/lib/graphPrefsStorage.ts +++ b/src/lib/graphPrefsStorage.ts @@ -2,6 +2,7 @@ * Persist active graph selections per session file in IndexedDB. */ import { STORE_NAMES, withReadTransaction, withWriteTransaction } from './dbUtils'; +import { toChannelKey } from './channels'; interface GraphPrefsRecord { sessionFileName: string; @@ -10,6 +11,13 @@ interface GraphPrefsRecord { const STORE = STORE_NAMES.GRAPH_PREFS; +// Synthetic graph keys that are not telemetry channels and must pass through +// channel migration untouched. +function migrateGraphKey(key: string): string { + if (key === 'speed' || key.startsWith('__')) return key; + return toChannelKey(key); +} + export async function saveGraphPrefs(sessionFileName: string, activeGraphs: string[]): Promise { await withWriteTransaction(STORE, (store) => { store.put({ sessionFileName, activeGraphs } satisfies GraphPrefsRecord); @@ -21,7 +29,7 @@ export async function loadGraphPrefs(sessionFileName: string): Promise STORE, (store) => store.get(sessionFileName), ); - return record?.activeGraphs ?? []; + return (record?.activeGraphs ?? []).map(migrateGraphKey); } export async function deleteGraphPrefs(sessionFileName: string): Promise { diff --git a/src/types/racing.ts b/src/types/racing.ts index 5a4bab0..d265b6f 100644 --- a/src/types/racing.ts +++ b/src/types/racing.ts @@ -77,7 +77,10 @@ export interface Lap { export interface FieldMapping { index: number; + /** Stable channel identity (canonical ChannelId or a `custom:` slug). */ name: string; + /** Human-readable display name; falls back to `name` when absent. */ + label?: string; unit?: string; enabled: boolean; } From f4237575e9d0b240941249a65e93ae20ee6a0660 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 23:40:18 +0000 Subject: [PATCH 045/121] Add chromeless plugin panels (full-bleed dashboards) A PluginPanel 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 coach dashboard). The per-panel error boundary and Suspense still apply. When a slot's panels are all chromeless (isBareSlot), PluginPanelHost also drops its outer padding so one panel can fill the tab; mixed/chromed slots keep the existing padded, stacked card layout unchanged. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CLAUDE.md | 8 ++++++-- src/plugins/PluginPanelHost.tsx | 28 +++++++++++++++++++--------- src/plugins/panels.test.ts | 20 +++++++++++++++++++- src/plugins/panels.ts | 17 +++++++++++++++++ 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8752c65..8826ae0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,7 +245,7 @@ A plugin absent at build time simply never loads — the app builds/runs without | `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 | -| `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 | +| `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`, `FileManagerSection`), per-slot context types, `getMounts(slot)`. For injecting raw components into fixed spots in core UI | | `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` | @@ -267,7 +267,11 @@ panels via `PluginPanelHost` and are **self-gating**: `Index.tsx` computes 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). +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` diff --git a/src/plugins/PluginPanelHost.tsx b/src/plugins/PluginPanelHost.tsx index 1ddd3ea..5f7661e 100644 --- a/src/plugins/PluginPanelHost.tsx +++ b/src/plugins/PluginPanelHost.tsx @@ -1,5 +1,5 @@ import { Component, Suspense, useMemo, type ReactNode } from "react"; -import { getPanelsForSlot, type PluginPanelProps } from "./panels"; +import { getPanelsForSlot, isBareSlot, type PluginPanelProps } from "./panels"; /** * Isolates a single plugin panel: a throw in one panel renders a local notice @@ -41,24 +41,34 @@ export function PluginPanelHost({ if (panels.length === 0) return <>{fallback ?? null}; + // A fully-chromeless slot drops the host's outer padding/spacing so the panel + // can fill the tab; a mixed/chromed slot keeps the padded, stacked layout. + const bare = isBareSlot(panels); + return ( -
+
{panels.map((panel) => { const Icon = panel.icon; const Body = panel.component; + + const body = ( + + Loading…

}> + +
+
+ ); + + // Chromeless: render the body directly, letting the panel own its layout. + if (panel.chromeless) return
{body}
; + return (
{Icon && }

{panel.title}

-
- - Loading…

}> - -
-
-
+
{body}
); })} diff --git a/src/plugins/panels.test.ts b/src/plugins/panels.test.ts index 1e1be1e..2328bd6 100644 --- a/src/plugins/panels.test.ts +++ b/src/plugins/panels.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { pluginRegistry } from "./registry"; -import { PANELS_POINT, PanelSlot, getPanelsForSlot, type PluginPanel } from "./panels"; +import { PANELS_POINT, PanelSlot, getPanelsForSlot, isBareSlot, type PluginPanel } from "./panels"; const noopComponent: PluginPanel["component"] = () => null; @@ -8,6 +8,10 @@ function panel(id: string, slot: string, order?: number): PluginPanel { return { id, title: id, slot, order, component: noopComponent }; } +function chromelessPanel(id: string, slot: string): PluginPanel { + return { id, title: id, slot, chromeless: true, component: noopComponent }; +} + describe("getPanelsForSlot", () => { it("returns only panels contributed to the requested slot", () => { pluginRegistry.contribute(PANELS_POINT, panel("a", "slot-filter")); @@ -40,3 +44,17 @@ describe("getPanelsForSlot", () => { expect(getPanelsForSlot(PanelSlot.Labs).map((p) => p.id)).toEqual(["labs-panel"]); }); }); + +describe("isBareSlot", () => { + it("is true only when there are panels and all are chromeless", () => { + expect(isBareSlot([chromelessPanel("a", "s"), chromelessPanel("b", "s")])).toBe(true); + }); + + it("is false for an empty slot", () => { + expect(isBareSlot([])).toBe(false); + }); + + it("is false when any panel keeps its chrome", () => { + expect(isBareSlot([chromelessPanel("a", "s"), panel("b", "s")])).toBe(false); + }); +}); diff --git a/src/plugins/panels.ts b/src/plugins/panels.ts index 2d8d6ea..7abef0b 100644 --- a/src/plugins/panels.ts +++ b/src/plugins/panels.ts @@ -53,6 +53,14 @@ export interface PluginPanel { icon?: ComponentType<{ className?: string }>; /** The panel body. Re-renders with a fresh `PluginPanelProps` snapshot. */ component: ComponentType; + /** + * Render the body without the host's card chrome (no bordered section, + * header, or padding) — for panels that own their full layout, e.g. a + * full-bleed dashboard. The error boundary and Suspense still apply. When a + * slot's panels are all chromeless, the host also drops its outer padding so + * the panel can fill the tab. + */ + chromeless?: boolean; } /** All panels contributed to `slot`, sorted by `order` then registration. */ @@ -62,3 +70,12 @@ export function getPanelsForSlot(slot: string): PluginPanel[] { .filter((panel) => panel.slot === slot) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } + +/** + * A slot is "bare" when it has panels and every one is chromeless — the host + * then renders without its outer padding/spacing so a single dashboard panel + * can fill the tab. A mixed slot keeps the padded, stacked layout. + */ +export function isBareSlot(panels: PluginPanel[]): boolean { + return panels.length > 0 && panels.every((p) => p.chromeless); +} From 7527bb411af181726c98375e5dc825f558338700 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 00:27:21 +0000 Subject: [PATCH 046/121] Pull in coach plugin v0.2.0 (analysis dashboard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps @perchwerks/eye-in-the-sky 0.1.0 → 0.2.0 and refreshes the lockfile. v0.2.0 ships a full-bleed Coach dashboard (uPlot charts + corner/sector analysis) that uses the new chromeless panel flag and lazy-loads its body, so uPlot lands in its own chunk (~70KB) off the initial bundle. Coupled with the chromeless host change in this PR — v0.2.0's `chromeless: true` requires the PluginPanel flag to typecheck. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 10 ++++++++-- package-lock.json | 20 +++++++++++++++----- package.json | 2 +- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac1d6a..76da0a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 panels. Like Labs, it is self-gating — the - tab only appears when the coach plugin is installed and contributes a panel. + 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.0) 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 diff --git a/package-lock.json b/package-lock.json index c004e4e..7b84c86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.1.0" + "@perchwerks/eye-in-the-sky": "^0.2.0" } }, "node_modules/@alloc/quick-lru": { @@ -2473,11 +2473,14 @@ } }, "node_modules/@perchwerks/eye-in-the-sky": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.1.0.tgz", - "integrity": "sha512-HFsl3BEhCQXuf9WUTJVRPEr50HIMNNj4+CJ16OwAAdXjDG/Wntz4aEYP8sxKvScozzswA6MZrAsvIrjMHMcAVg==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.0.tgz", + "integrity": "sha512-yzaAz04EE3VSd4G+hdqvdfHTyod25Qr/O1PLUVqKG6pyhun2HajD0bl8q5Ke6YGcexQ0lMaHnZUoMtaaJJmr6w==", "license": "GPL-3.0-or-later", - "optional": true + "optional": true, + "dependencies": { + "uplot": "^1.6.32" + } }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -10616,6 +10619,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 5ac50e3..aadd4dd 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,6 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.1.0" + "@perchwerks/eye-in-the-sky": "^0.2.0" } } From ecb85416d284017b9afb617ce9b12303649591a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 01:24:19 +0000 Subject: [PATCH 047/121] Refresh coach plugin to v0.2.2 (React 18 peer fix + Leaflet map) 0.2.2 widens its React peer range to ^18.3 || ^19 (0.2.1 required ^19 and wouldn't resolve against the host's React 18.3.1) and adds a Leaflet race-line map panel. Lockfile-only refresh; the ^0.2.0 pin already covers it. Verified: typecheck/tests/build green, uPlot stays off the initial bundle, and the map reuses the host's shared vendor-leaflet chunk (leaflet is a plugin peer dep). https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- package-lock.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b84c86..062bb97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2473,13 +2473,18 @@ } }, "node_modules/@perchwerks/eye-in-the-sky": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.0.tgz", - "integrity": "sha512-yzaAz04EE3VSd4G+hdqvdfHTyod25Qr/O1PLUVqKG6pyhun2HajD0bl8q5Ke6YGcexQ0lMaHnZUoMtaaJJmr6w==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.2.tgz", + "integrity": "sha512-oZe/Pp/loFLyknibRTroxSZGp3PVjCSDaWSigLMYck+yKYIrMgfvHbZlKRrgANzoeRbOpWICy58KYdnGzVZmig==", "license": "GPL-3.0-or-later", "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": { From 8ffa2813b706fe7de5b096daa77e06ccb1ba75e2 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 01:43:05 +0000 Subject: [PATCH 048/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- bun.lock | 4 +--- package.json | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 14a6c90..11e1e34 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,7 @@ "vitest": "^4.1.6", }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.1.0", + "@perchwerks/eye-in-the-sky": "0.2.3", }, }, }, @@ -369,8 +369,6 @@ "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], - "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.1.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.1.0.tgz", {}, "sha512-HFsl3BEhCQXuf9WUTJVRPEr50HIMNNj4+CJ16OwAAdXjDG/Wntz4aEYP8sxKvScozzswA6MZrAsvIrjMHMcAVg=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], diff --git a/package.json b/package.json index aadd4dd..bd93559 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,6 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.2.0" + "@perchwerks/eye-in-the-sky": "0.2.3" } } From 5d214165a55d1c49028c6b69efa08dc728e265bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 01:48:41 +0000 Subject: [PATCH 049/121] Refresh coach plugin to v0.2.3 Lockfile-only refresh within the existing ^0.2.0 pin. Peer ranges unchanged (React ^18.3 || ^19). Verified green: typecheck, 322 tests, build; uPlot stays off the initial bundle. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 062bb97..f4fbb45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2473,9 +2473,9 @@ } }, "node_modules/@perchwerks/eye-in-the-sky": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.2.tgz", - "integrity": "sha512-oZe/Pp/loFLyknibRTroxSZGp3PVjCSDaWSigLMYck+yKYIrMgfvHbZlKRrgANzoeRbOpWICy58KYdnGzVZmig==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.3.tgz", + "integrity": "sha512-9qc5fMtbrS9ELiCB2RAw+O2bTh/3hmQ1C83VPwCKcT/VpyIDB+R0OoXhuChrtrrnJJzOF+vQdOQDK0TA9dSmxg==", "license": "GPL-3.0-or-later", "optional": true, "dependencies": { From dcdf293c0ae10ddfca10ef1c60b96c9eccad4cfe Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 01:51:07 +0000 Subject: [PATCH 050/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- bun.lock | 96 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/bun.lock b/bun.lock index 11e1e34..5cb90d8 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 0, "workspaces": { "": { - "name": "vite_react_shadcn_ts", + "name": "doves-dataviewer", "dependencies": { "@lovable.dev/cloud-auth-js": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.11", @@ -249,7 +249,7 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -357,7 +357,7 @@ "@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", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="], + "@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=="], @@ -367,7 +367,9 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + "@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], + + "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.2.3", "", { "dependencies": { "uplot": "^1.6.32" }, "peerDependencies": { "leaflet": "^1.9.4", "react": "^18.3 || ^19.0.0", "react-dom": "^18.3 || ^19.0.0" } }, "sha512-9qc5fMtbrS9ELiCB2RAw+O2bTh/3hmQ1C83VPwCKcT/VpyIDB+R0OoXhuChrtrrnJJzOF+vQdOQDK0TA9dSmxg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -447,35 +449,35 @@ "@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -645,21 +647,21 @@ "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.7", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.7", "vitest": "4.1.7" }, "optionalPeers": ["@vitest/browser"] }, "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.7", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.7", "vitest": "4.1.7" }, "optionalPeers": ["@vitest/browser"] }, "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ=="], - "@vitest/expect": ["@vitest/expect@4.1.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg=="], + "@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], - "@vitest/mocker": ["@vitest/mocker@4.1.6", "", { "dependencies": { "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw"] }, "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ=="], + "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.6", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.7", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw=="], - "@vitest/runner": ["@vitest/runner@4.1.6", "", { "dependencies": { "@vitest/utils": "4.1.6", "pathe": "^2.0.3" } }, "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA=="], + "@vitest/runner": ["@vitest/runner@4.1.7", "", { "dependencies": { "@vitest/utils": "4.1.7", "pathe": "^2.0.3" } }, "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw=="], - "@vitest/spy": ["@vitest/spy@4.1.6", "", {}, "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg=="], + "@vitest/spy": ["@vitest/spy@4.1.7", "", {}, "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q=="], - "@vitest/utils": ["@vitest/utils@4.1.7", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@vitest/utils/-/utils-4.1.7.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw=="], + "@vitest/utils": ["@vitest/utils@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw=="], "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -687,7 +689,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -931,7 +933,7 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "html-escaper": ["html-escaper@2.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/html-escaper/-/html-escaper-2.0.2.tgz", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], @@ -1017,11 +1019,11 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], - "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], - "istanbul-reports": ["istanbul-reports@3.2.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/istanbul-reports/-/istanbul-reports-3.2.0.tgz", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], @@ -1029,7 +1031,7 @@ "jiti": ["jiti@1.21.6", "", { "bin": "bin/jiti.js" }, "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w=="], - "js-tokens": ["js-tokens@10.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1111,9 +1113,9 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "magicast": ["magicast@0.5.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/magicast/-/magicast-0.5.3.tgz", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], - "make-dir": ["make-dir@4.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/make-dir/-/make-dir-4.0.0.tgz", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1267,7 +1269,7 @@ "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="], - "rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": "bin/cli.mjs" }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], + "rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": "bin/cli.mjs" }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="], "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], @@ -1427,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=="], @@ -1439,7 +1443,7 @@ "vite-plugin-pwa": ["vite-plugin-pwa@1.2.0", "", { "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", "workbox-build": "^7.4.0", "workbox-window": "^7.4.0" }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "workbox-build": "^7.4.0", "workbox-window": "^7.4.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw=="], - "vitest": ["vitest@4.1.6", "", { "dependencies": { "@vitest/expect": "4.1.6", "@vitest/mocker": "4.1.6", "@vitest/pretty-format": "4.1.6", "@vitest/runner": "4.1.6", "@vitest/snapshot": "4.1.6", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.6", "@vitest/browser-preview": "4.1.6", "@vitest/browser-webdriverio": "4.1.6", "@vitest/coverage-istanbul": "4.1.6", "@vitest/coverage-v8": "4.1.6", "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ=="], + "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], @@ -1521,6 +1525,8 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/eslintrc/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], @@ -1531,13 +1537,7 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="], - - "@vitest/runner/@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="], - - "@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="], - - "@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.7", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw=="], + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1571,6 +1571,8 @@ "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], @@ -1579,9 +1581,7 @@ "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - "vitest/@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="], - - "vitest/vite": ["vite@8.0.13", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "less", "sass", "sass-embedded", "stylus", "sugarss", "tsx"], "bin": "bin/vite.js" }, "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw=="], + "vitest/vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@vitejs/devtools", "less", "sass", "sass-embedded", "stylus", "sugarss", "tsx"], "bin": "bin/vite.js" }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], "which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], @@ -1599,6 +1599,8 @@ "@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], From 0f37fbeaf2ae58eec77720cd284983ff8f6248f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 03:22:35 +0000 Subject: [PATCH 051/121] Document storage tier: auto-sync, propagation deletes, quotas + Profile tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Garage data (vehicles/setups/templates/types/notes) now auto-syncs to the cloud while signed in, as its own free "documents" tier (5 MB) separate from log blobs (20 MB). Limits are enforced server-side. - garageEvents.ts: host pub/sub; the doc-store modules emit put/delete after each write. cloud-sync's autoSync.ts subscribes (lazy, started in setup), debounces, and incrementally upserts/deletes the one changed cloud record; reconciles (pull→push docs) on sign-in. Stays off the initial bundle. - Propagation deletes: removing a vehicle/setup while signed in deletes the cloud record too; the Karts/Setups delete UI shows a loud "deletes from every device + the cloud" warning when signed in. - Tiers + server enforcement (migration): quota_limits table (single source of truth), enforce_sync_quota BEFORE trigger on sync_records, and a sync_storage_usage() RPC for the meter. tiers.ts holds the advisory client mirror + usage math (unit-tested). - Profile tab (new PanelSlot.Profile, far right): cloud-sync contributes a StoragePanel with per-tier usage meters + an account scratch pad. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 9 ++ CLAUDE.md | 49 ++++++--- src/components/drawer/KartsTab.tsx | 16 ++- src/components/drawer/SetupsTab.tsx | 6 +- src/components/tabs/ProfileTab.tsx | 37 +++++++ src/lib/garageEvents.ts | 38 +++++++ src/lib/noteStorage.ts | 3 + src/lib/setupStorage.ts | 3 + src/lib/templateStorage.ts | 9 ++ src/lib/vehicleStorage.ts | 3 + src/pages/Index.tsx | 21 +++- src/plugins/cloud-sync/StoragePanel.tsx | 96 ++++++++++++++++++ src/plugins/cloud-sync/autoSync.ts | 99 +++++++++++++++++++ src/plugins/cloud-sync/cloudClient.ts | 19 ++++ src/plugins/cloud-sync/index.ts | 23 ++++- src/plugins/cloud-sync/syncEngine.ts | 81 ++++++++++++++- src/plugins/cloud-sync/tiers.test.ts | 49 +++++++++ src/plugins/cloud-sync/tiers.ts | 56 +++++++++++ src/plugins/panels.ts | 2 + .../20260525020000_storage_quotas.sql | 96 ++++++++++++++++++ 20 files changed, 690 insertions(+), 25 deletions(-) create mode 100644 src/components/tabs/ProfileTab.tsx create mode 100644 src/lib/garageEvents.ts create mode 100644 src/plugins/cloud-sync/StoragePanel.tsx create mode 100644 src/plugins/cloud-sync/autoSync.ts create mode 100644 src/plugins/cloud-sync/tiers.test.ts create mode 100644 src/plugins/cloud-sync/tiers.ts create mode 100644 supabase/migrations/20260525020000_storage_quotas.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 76da0a2..a747c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Document storage tier + **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. This "documents" tier is free + with a **5 MB** limit; raw session **logs** are a separate **20 MB** tier. + Limits are 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 against the + document and log limits (account display name/avatar are placeholders for now). - 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. diff --git a/CLAUDE.md b/CLAUDE.md index 8826ae0..a719b41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ src/ ├── 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, CoachTab +│ ├── tabs/ # Main view tabs: GraphViewTab, RaceLineTab, LapTimesTab, LabsTab, CoachTab, ProfileTab │ ├── 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 @@ -146,6 +146,7 @@ src/ │ ├── referenceUtils.ts # Reference lap comparison (legacy distance-based pace) │ ├── lapDelta.ts # ★ Position-based lap delta: arc-length resample + segment-projected gap (issue #29 port) │ ├── dbUtils.ts # ★ Shared IndexedDB: DB_NAME, DB_VERSION, openDB(), transaction helpers +│ ├── garageEvents.ts # ★ Host pub/sub: storage modules emit {store,key,put|delete}; cloud-sync auto-syncs off it │ ├── fileStorage.ts # IndexedDB: raw file blobs │ ├── kartStorage.ts # Old kart storage (kept for compat) │ ├── vehicleStorage.ts # ★ Vehicle profiles CRUD (replaces kartStorage) @@ -192,8 +193,11 @@ src/ │ │ ├── CloudFilesSection.tsx # FileManagerSection mount: lists all cloud files (on-device marked, others pullable) │ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus/cloudOnlyNames (pure, tested) │ │ ├── syncStores.ts # Pure config: which IDB stores sync + how they're keyed (testable) -│ │ ├── syncEngine.ts # pushAll (garage + selected files) / pushFile / pullAll: IDB ↔ sync_records + bucket -│ │ └── cloudClient.ts # Typed access to sync_records + bucket (escape hatch until types regen) +│ │ ├── tiers.ts # Pure: storage tiers (documents 5MB / logs 20MB) + usage math (tested) +│ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord/pushDocs/pullDocs + getStorageUsage +│ │ ├── autoSync.ts # Background doc auto-sync: subscribes to garageEvents, debounced upsert/delete + reconcile on sign-in +│ │ ├── StoragePanel.tsx # Profile-tab panel: storage usage meters + account scratch pad (lazy) +│ │ └── cloudClient.ts # Typed access to sync_records + bucket + sync_storage_usage RPC (escape hatch until types regen) │ └── coaching/ # Gitignored private slot (AI coaching submodule) ├── types/ │ └── racing.ts # ★ Core types: GpsSample, ParsedData, Lap, Course, Track, etc. @@ -259,11 +263,13 @@ A plugin default-exports `{ id, name, version?, priority?, setup?(ctx) }`. In **UI panels:** the first concrete extension point. A plugin contributes `PluginPanel` descriptors to `PANELS_POINT`, targeting a *slot* (host surface). -Two slots exist today: `PanelSlot.Labs` (rendered by `LabsTab.tsx`) and +Three slots exist today: `PanelSlot.Labs` (rendered by `LabsTab.tsx`), `PanelSlot.Coach` (rendered by `CoachTab.tsx` — the dedicated AI Coach tab, home -for the `@perchwerks/eye-in-the-sky` coaching plugin). Both render contributed -panels via `PluginPanelHost` and are **self-gating**: `Index.tsx` computes -`hasLabsPanels`/`showCoach` from `getPanelsForSlot`, so a tab appears only when a +for the `@perchwerks/eye-in-the-sky` coaching plugin), and `PanelSlot.Profile` +(rendered by `ProfileTab.tsx`, far-right — cloud-sync contributes the storage +meters). 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` @@ -390,18 +396,33 @@ To add a new store: increment `DB_VERSION`, add store name to `STORE_NAMES`, add ## Cloud Sync (`src/plugins/cloud-sync/`) Optional per-user backup/sync of the IndexedDB data above to Supabase. Built as -a first-party plugin (Labs panel), online-only (accepted offline-first -exception). **Manual & directional**: "push" mirrors local → cloud, "pull" -brings cloud → local; on a key collision the chosen direction wins. Sync is -**additive** — neither side deletes the other's extra records (deletion -propagation + timestamp merge are deliberate follow-ups). - -Backend (migration `..._cloud_sync.sql`): +a first-party plugin (Labs + Profile panels), online-only (accepted offline-first +exception). Manual push/pull remains (`CloudSyncPanel`), but the **document tier +now auto-syncs**: 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 (put) / deletes (delete)** the one +changed record while signed in, and **reconciles** (pull docs → push docs) on +sign-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. (Log-blob deletion propagation + timestamp merge are +still follow-ups.) + +**Storage tiers** (`tiers.ts`, enforced server-side): **documents** = all +structured stores (5 MB, free, auto-synced) and **logs** = file blobs (20 MB, +opt-in). Limits live in the `quota_limits` table (one source of truth for the +enforcing trigger + the client meter); `sync_storage_usage()` returns per-tier +usage for the Profile-tab meters. Client checks are advisory — the DB trigger is +the real gate. + +Backend (migrations `..._cloud_sync.sql`, `..._storage_quotas.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. | +| `quota_limits` | table | `(tier, max_bytes)` seeded `documents`=5 MB, `logs`=20 MB. Read by client + trigger. | +| `enforce_sync_quota` | trigger | BEFORE INSERT/UPDATE on `sync_records`: rejects writes that push a tier over its limit (`quota_exceeded`). | +| `sync_storage_usage()` | RPC | Per-tier `(used_bytes, limit_bytes)` for the caller. | Synced stores (`syncStores.ts` — pure, unit-tested): `metadata`, `karts`, `setups`, `notes`, `graph-prefs`, `vehicle-types`, `setup-templates` (jsonb 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/SetupsTab.tsx b/src/components/drawer/SetupsTab.tsx index f7b2422..31e3927 100644 --- a/src/components/drawer/SetupsTab.tsx +++ b/src/components/drawer/SetupsTab.tsx @@ -10,6 +10,7 @@ import { Vehicle } from "@/lib/vehicleStorage"; import { VehicleSetup } from "@/lib/setupStorage"; import { VehicleType, SetupTemplate, TemplateSection, TemplateFieldDef } from "@/lib/templateStorage"; import { TemplateCreator } from "@/components/drawer/TemplateCreator"; +import { useAuth } from "@/contexts/AuthContext"; interface SetupsTabProps { vehicles: Vehicle[]; @@ -62,6 +63,7 @@ export function SetupsTab({ const [preloaded, setPreloaded] = useState(false); const preloadSnapshot = useRef | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const { user } = useAuth(); // PSI display helpers const [psiSingle, setPsiSingle] = useState(null); @@ -293,7 +295,9 @@ export function SetupsTab({
{isDeleting && (
- Delete this setup? + + {user ? "Delete everywhere — removes this setup from every device and the cloud." : "Delete this setup?"} +
diff --git a/src/components/tabs/ProfileTab.tsx b/src/components/tabs/ProfileTab.tsx new file mode 100644 index 0000000..6fc0214 --- /dev/null +++ b/src/components/tabs/ProfileTab.tsx @@ -0,0 +1,37 @@ +import { memo } from "react"; +import { User } from "lucide-react"; +import { useSessionContext } from "@/contexts/SessionContext"; +import { useSettingsContext } from "@/contexts/SettingsContext"; +import { PluginPanelHost } from "@/plugins/PluginPanelHost"; +import { PanelSlot } from "@/plugins/panels"; + +export const ProfileTab = memo(function ProfileTab() { + const { data, laps, selectedLapNumber, course } = useSessionContext(); + const { useKph } = useSettingsContext(); + + return ( + } + /> + ); +}); + +function ProfileEmpty() { + return ( +
+
+ +

Profile

+

+ Profile & storage are unavailable in this build. +

+
+
+ ); +} diff --git a/src/lib/garageEvents.ts b/src/lib/garageEvents.ts new file mode 100644 index 0000000..f415471 --- /dev/null +++ b/src/lib/garageEvents.ts @@ -0,0 +1,38 @@ +// Lightweight host pub/sub for garage mutations (vehicles, setups, templates, +// vehicle types, notes). Storage modules emit a change after each write/delete; +// the cloud-sync plugin subscribes to drive incremental auto-sync (upsert on +// put, delete on delete). Host-owned and generic — no plugin or network deps — +// so the core works the same whether or not a sync plugin is listening. + +export type GarageChangeType = "put" | "delete"; + +export interface GarageChange { + /** IndexedDB store name (matches STORE_NAMES + the cloud record `store`). */ + store: string; + /** Record key (the store's key path value). */ + key: string; + type: GarageChangeType; +} + +type Listener = (change: GarageChange) => void; + +const listeners = new Set(); + +/** Subscribe to garage changes. Returns an unsubscribe function. */ +export function onGarageChange(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Notify subscribers of a garage mutation. Listener errors are isolated. */ +export function emitGarageChange(change: GarageChange): void { + for (const listener of listeners) { + try { + listener(change); + } catch (err) { + console.error("garage change listener failed", err); + } + } +} diff --git a/src/lib/noteStorage.ts b/src/lib/noteStorage.ts index 1179939..52bf31d 100644 --- a/src/lib/noteStorage.ts +++ b/src/lib/noteStorage.ts @@ -3,6 +3,7 @@ */ import { openDB, STORE_NAMES } from './dbUtils'; +import { emitGarageChange } from './garageEvents'; export interface Note { id: string; @@ -36,6 +37,7 @@ export async function saveNote(note: Note): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: NOTES_STORE, key: note.id, type: "put" }); } export async function deleteNote(id: string): Promise { @@ -47,4 +49,5 @@ export async function deleteNote(id: string): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: NOTES_STORE, key: id, type: "delete" }); } diff --git a/src/lib/setupStorage.ts b/src/lib/setupStorage.ts index 5e92f96..7902725 100644 --- a/src/lib/setupStorage.ts +++ b/src/lib/setupStorage.ts @@ -4,6 +4,7 @@ */ import { openDB, STORE_NAMES } from './dbUtils'; +import { emitGarageChange } from './garageEvents'; export interface VehicleSetup { id: string; @@ -63,6 +64,7 @@ export async function saveSetup(setup: VehicleSetup): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: SETUPS_STORE, key: setup.id, type: "put" }); } export async function deleteSetup(id: string): Promise { @@ -74,6 +76,7 @@ export async function deleteSetup(id: string): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: SETUPS_STORE, key: id, type: "delete" }); } export async function getLatestSetupForVehicle(vehicleId: string): Promise { diff --git a/src/lib/templateStorage.ts b/src/lib/templateStorage.ts index adfd263..2a5065d 100644 --- a/src/lib/templateStorage.ts +++ b/src/lib/templateStorage.ts @@ -4,6 +4,7 @@ */ import { openDB, STORE_NAMES } from './dbUtils'; +import { emitGarageChange } from './garageEvents'; // ── Types ── @@ -169,6 +170,7 @@ export async function saveVehicleType(vt: VehicleType): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: STORE_NAMES.VEHICLE_TYPES, key: vt.id, type: "put" }); } export async function deleteVehicleType(id: string): Promise { @@ -180,6 +182,7 @@ export async function deleteVehicleType(id: string): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: STORE_NAMES.VEHICLE_TYPES, key: id, type: "delete" }); } // ── Setup Template CRUD ── @@ -217,6 +220,7 @@ export async function saveTemplate(template: SetupTemplate): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: STORE_NAMES.SETUP_TEMPLATES, key: template.id, type: "put" }); } export async function deleteTemplate(id: string): Promise { @@ -228,6 +232,7 @@ export async function deleteTemplate(id: string): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: STORE_NAMES.SETUP_TEMPLATES, key: id, type: "delete" }); } /** @@ -274,6 +279,8 @@ export async function createVehicleTypeWithTemplate( tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: STORE_NAMES.VEHICLE_TYPES, key: vehicleType.id, type: "put" }); + emitGarageChange({ store: STORE_NAMES.SETUP_TEMPLATES, key: template.id, type: "put" }); return { vehicleType, template }; } @@ -291,4 +298,6 @@ export async function deleteVehicleTypeWithTemplate(vehicleTypeId: string, templ tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: STORE_NAMES.VEHICLE_TYPES, key: vehicleTypeId, type: "delete" }); + emitGarageChange({ store: STORE_NAMES.SETUP_TEMPLATES, key: templateId, type: "delete" }); } diff --git a/src/lib/vehicleStorage.ts b/src/lib/vehicleStorage.ts index 1a59d9c..e192587 100644 --- a/src/lib/vehicleStorage.ts +++ b/src/lib/vehicleStorage.ts @@ -4,6 +4,7 @@ */ import { openDB, STORE_NAMES } from './dbUtils'; +import { emitGarageChange } from './garageEvents'; export interface Vehicle { id: string; @@ -26,6 +27,7 @@ export async function saveVehicle(vehicle: Vehicle): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: VEHICLES_STORE, key: vehicle.id, type: "put" }); } export async function listVehicles(): Promise { @@ -61,4 +63,5 @@ export async function deleteVehicle(id: string): Promise { tx.onerror = () => reject(tx.error); }); db.close(); + emitGarageChange({ store: VEHICLES_STORE, key: id, type: "delete" }); } diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 1bbc403..819054d 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, lazy, Suspense } from "react"; -import { Gauge, Map, ListOrdered, BarChart3, FolderOpen, Play, Pause, Eye, EyeOff, FlaskConical } from "lucide-react"; +import { Gauge, Map, ListOrdered, BarChart3, FolderOpen, Play, Pause, Eye, EyeOff, FlaskConical, User } from "lucide-react"; import { LandingPage } from "@/components/LandingPage"; import { TrackEditor } from "@/components/TrackEditor"; // still used in compact header import { RaceLineTab } from "@/components/tabs/RaceLineTab"; @@ -17,6 +17,9 @@ const LabsTab = lazy(() => const CoachTab = lazy(() => import("@/components/tabs/CoachTab").then((m) => ({ default: m.CoachTab })), ); +const ProfileTab = lazy(() => + import("@/components/tabs/ProfileTab").then((m) => ({ default: m.ProfileTab })), +); import { InstallPrompt } from "@/components/InstallPrompt"; import { SettingsModal } from "@/components/SettingsModal"; // FileManagerDrawer is a slide-out that only opens on user click. Lazy-loading @@ -49,7 +52,7 @@ import { DeviceProvider } from "@/contexts/DeviceContext"; import { SessionProvider, type SessionContextValue } from "@/contexts/SessionContext"; -type TopPanelView = "raceline" | "laptable" | "graphview" | "labs" | "coach"; +type TopPanelView = "raceline" | "laptable" | "graphview" | "labs" | "coach" | "profile"; const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === 'true'; const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; @@ -122,6 +125,9 @@ export default function Index() { // The Coach tab is self-gating: it appears only when a plugin contributes a // panel to the Coach slot (i.e. the coach package is installed). const showCoach = useMemo(() => getPanelsForSlot(PanelSlot.Coach).length > 0, []); + // Profile tab is self-gating too: appears only when a plugin (cloud-sync) + // contributes a Profile panel (i.e. the cloud build flag is on). + const showProfile = useMemo(() => getPanelsForSlot(PanelSlot.Profile).length > 0, []); // Video sync for Labs tab const videoSync = useVideoSync({ @@ -393,7 +399,7 @@ export default function Index() {
- setShowOverlays(v => !v)} showLabs={showLabs} showCoach={showCoach} /> + setShowOverlays(v => !v)} showLabs={showLabs} showCoach={showCoach} showProfile={showProfile} />
@@ -403,6 +409,7 @@ export default function Index() { {topPanelView === "graphview" && } {topPanelView === "labs" && showLabs && } {topPanelView === "coach" && showCoach && } + {topPanelView === "profile" && showProfile && }
@@ -427,7 +434,7 @@ export default function Index() { } /** Tab navigation bar for the main data view */ -function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOverlays, showLabs, showCoach }: { +function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOverlays, showLabs, showCoach, showProfile }: { topPanelView: TopPanelView; setTopPanelView: (view: TopPanelView) => void; laps: { lapNumber: number }[]; @@ -435,6 +442,7 @@ function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOve onToggleOverlays: () => void; showLabs: boolean; showCoach: boolean; + showProfile: boolean; }) { const tabClass = (view: TopPanelView) => `flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${ @@ -475,6 +483,11 @@ function TabBar({ topPanelView, setTopPanelView, laps, showOverlays, onToggleOve
)} + {showProfile && ( + + )}
); } diff --git a/src/plugins/cloud-sync/StoragePanel.tsx b/src/plugins/cloud-sync/StoragePanel.tsx new file mode 100644 index 0000000..6c8f2a0 --- /dev/null +++ b/src/plugins/cloud-sync/StoragePanel.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useState } from "react"; +import { User as UserIcon } from "lucide-react"; +import type { PluginPanelProps } from "@/plugins/panels"; +import { useAuth } from "@/contexts/AuthContext"; +import { getStorageUsage } from "./syncEngine"; +import { formatBytes, usageFraction, type TierUsage } from "./tiers"; + +const TIER_LABEL: Record = { documents: "Documents", logs: "Logs" }; +const TIER_HINT: Record = { + documents: "Vehicles, setups, templates & notes — free, auto-synced.", + logs: "Session log files you've chosen to sync.", +}; + +// Scratch-pad profile panel: who you're signed in as + your cloud storage usage +// against the document/log tier limits. (Display name / avatar are placeholders +// until profiles land.) +export default function StoragePanel(_props: PluginPanelProps) { + const { user, loading } = useAuth(); + const [usage, setUsage] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!user) return; + try { + setUsage(await getStorageUsage()); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load storage usage"); + } + }, [user]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + if (loading) return

Loading…

; + + if (!user) { + return ( +
+

Not signed in

+

+ Sign in under Labs → Cloud Sync to back up your garage and see your storage usage. +

+
+ ); + } + + const displayName = + (user.user_metadata?.display_name as string | undefined) || user.email || "Driver"; + + return ( +
+
+
+ +
+
+

{displayName}

+

{user.email}

+
+
+ +
+

Storage

+ {error &&

{error}

} + {!usage && !error &&

Loading usage…

} + {usage?.map((u) => ( + + ))} +
+
+ ); +} + +function Meter({ usage }: { usage: TierUsage }) { + const pct = Math.round(usageFraction(usage) * 100); + const over = usage.usedBytes > usage.limitBytes; + return ( +
+
+ {TIER_LABEL[usage.tier] ?? usage.tier} + + {formatBytes(usage.usedBytes)} / {formatBytes(usage.limitBytes)} + +
+
+
+
+

{TIER_HINT[usage.tier]}

+
+ ); +} diff --git a/src/plugins/cloud-sync/autoSync.ts b/src/plugins/cloud-sync/autoSync.ts new file mode 100644 index 0000000..14275a2 --- /dev/null +++ b/src/plugins/cloud-sync/autoSync.ts @@ -0,0 +1,99 @@ +// Background document auto-sync. +// +// Runs outside React: tracks the signed-in user via the Supabase auth session +// and listens to host garage-change events (vehicles/setups/templates/types/ +// notes). On a change it debounces, then upserts (put) or deletes (delete) the +// single cloud record — so edits propagate up and deletes propagate everywhere. +// On sign-in it reconciles (pull cloud docs down, push local docs up). Only the +// free "documents" tier auto-syncs here; log blobs stay manual/opt-in. + +import { supabase } from "@/integrations/supabase/client"; +import { onGarageChange, type GarageChange } from "@/lib/garageEvents"; +import { isQuotaError } from "./cloudClient"; +import { deleteRecord, pullDocs, pushDocs, pushRecord } from "./syncEngine"; +import { tierForStore } from "./tiers"; + +const DEBOUNCE_MS = 800; + +let currentUserId: string | null = null; +let started = false; +const pending = new Map>(); + +/** Injected so this module stays free of any toast/UI dependency. */ +type Notifier = (message: string, kind: "error" | "info") => void; +let notify: Notifier = () => {}; +export function setAutoSyncNotifier(fn: Notifier): void { + notify = fn; +} + +function recordKey(change: GarageChange): string { + return `${change.store}:${change.key}`; +} + +async function flush(change: GarageChange): Promise { + const userId = currentUserId; + if (!userId) return; + try { + if (change.type === "delete") { + await deleteRecord(userId, change.store, change.key); + } else { + await pushRecord(userId, change.store, change.key); + } + } catch (err) { + if (isQuotaError(err)) { + notify( + `Cloud ${tierForStore(change.store)} storage is full — saved locally, not synced.`, + "error", + ); + } else { + console.error("auto-sync failed", err); + } + } +} + +function schedule(change: GarageChange): void { + if (!currentUserId) return; // only sync while signed in + const key = recordKey(change); + const existing = pending.get(key); + if (existing) clearTimeout(existing); + pending.set( + key, + setTimeout(() => { + pending.delete(key); + void flush(change); + }, DEBOUNCE_MS), + ); +} + +async function reconcile(userId: string): Promise { + try { + await pullDocs(userId); // cloud → local + await pushDocs(userId); // local-only → cloud (additive) + } catch (err) { + if (isQuotaError(err)) { + notify("Cloud document storage is full — some items didn't sync.", "error"); + } else { + console.error("auto-sync reconcile failed", err); + } + } +} + +/** Start the background document auto-sync. Idempotent. */ +export function startAutoSync(): void { + if (started) return; + started = true; + + void supabase.auth.getSession().then(({ data }) => { + currentUserId = data.session?.user?.id ?? null; + if (currentUserId) void reconcile(currentUserId); + }); + + supabase.auth.onAuthStateChange((_event, session) => { + const next = session?.user?.id ?? null; + const newlySignedIn = next !== null && next !== currentUserId; + currentUserId = next; + if (newlySignedIn) void reconcile(next); + }); + + onGarageChange(schedule); +} diff --git a/src/plugins/cloud-sync/cloudClient.ts b/src/plugins/cloud-sync/cloudClient.ts index 1feb1f4..e0b32a1 100644 --- a/src/plugins/cloud-sync/cloudClient.ts +++ b/src/plugins/cloud-sync/cloudClient.ts @@ -34,3 +34,22 @@ export function syncRecords() { export function userFiles() { return untyped.storage.from(SYNC_BUCKET); } + +/** One tier's usage as returned by the server's sync_storage_usage() RPC. */ +export interface StorageUsageRow { + tier: string; + used_bytes: number; + limit_bytes: number; +} + +/** Per-tier storage usage for the current user (authoritative, server-computed). */ +export async function fetchStorageUsage(): Promise { + const { data, error } = await untyped.rpc("sync_storage_usage"); + if (error) throw new Error(`Failed to read storage usage: ${error.message}`); + return (data ?? []) as StorageUsageRow[]; +} + +/** True when an error from a sync_records write is the server quota rejection. */ +export function isQuotaError(err: unknown): boolean { + return err instanceof Error && /quota_exceeded/i.test(err.message); +} diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index 7758c4f..d7125ca 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -1,5 +1,6 @@ import { lazy } from "react"; -import { Cloud } from "lucide-react"; +import { Cloud, User } from "lucide-react"; +import { toast } from "sonner"; import type { DataViewerPlugin } from "@/plugins/types"; import { PANELS_POINT, PanelSlot, type PluginPanel } from "@/plugins/panels"; import { @@ -15,6 +16,8 @@ const CloudSyncPanel = lazy(() => import("./CloudSyncPanel")); // drawer doesn't pull the sync engine onto its chunk until they render. const FileSyncToggle = lazy(() => import("./FileSyncToggle")); const CloudFilesSection = lazy(() => import("./CloudFilesSection")); +// Profile tab panel: storage usage meters + account scratch pad. +const StoragePanel = lazy(() => import("./StoragePanel")); const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; @@ -53,6 +56,24 @@ const plugin: DataViewerPlugin = { order: 0, component: CloudFilesSection, } satisfies PluginMountDef); + + // Profile tab: storage usage meters (document + log tiers) + account. + ctx.registry.contribute(PANELS_POINT, { + id: "cloud-sync-storage", + title: "Profile", + slot: PanelSlot.Profile, + order: 0, + icon: User, + component: StoragePanel, + } satisfies PluginPanel); + + // Background document auto-sync. Dynamically imported so the sync engine + // stays off the initial bundle; the notifier routes quota warnings to a + // toast (keeping autoSync itself free of any UI dependency). + void import("./autoSync").then((m) => { + m.setAutoSyncNotifier((msg, kind) => (kind === "error" ? toast.error(msg) : toast(msg))); + m.startAutoSync(); + }); }, }; diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index a5257d5..a07b908 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -13,9 +13,10 @@ import { withReadTransaction, withWriteTransaction } from "@/lib/dbUtils"; import { getFile, saveFile } from "@/lib/fileStorage"; -import { syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; +import { fetchStorageUsage, syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; import { DOC_STORES, FILE_STORE, extractKey, type SyncSummary } from "./syncStores"; import { listSelectedFiles, markPushed } from "./fileSync"; +import { DEFAULT_LIMITS, type Tier, type TierUsage } from "./tiers"; export type { SyncSummary }; @@ -131,3 +132,81 @@ export async function pullAll(userId: string): Promise { } return { records, files }; } + +// ── Incremental (auto) sync ────────────────────────────────────────────────── + +/** + * Upsert one document record to the cloud by reading it from its local store. + * No-op if the record is already gone locally. Throws on a backend error + * (including the server quota rejection — see `isQuotaError`). + */ +export async function pushRecord(userId: string, store: string, key: string): Promise { + const record = await withReadTransaction | undefined>( + store, + (s) => s.get(key), + ); + if (record == null) return; + const { error } = await syncRecords().upsert( + [{ user_id: userId, store, record_key: key, data: record }], + { onConflict: "user_id,store,record_key" }, + ); + if (error) throw new Error(error.message); +} + +/** Delete one document record from the cloud (deletion propagation). */ +export async function deleteRecord(userId: string, store: string, key: string): Promise { + const { error } = await syncRecords() + .delete() + .eq("user_id", userId) + .eq("store", store) + .eq("record_key", key); + if (error) throw new Error(error.message); +} + +/** Mirror only the structured (free document-tier) stores up — no file blobs. */ +export async function pushDocs(userId: string): Promise { + const rows: SyncRecordRow[] = []; + for (const store of DOC_STORES) { + for (const record of await readAll(store)) { + rows.push({ user_id: userId, store, record_key: extractKey(store, record), data: record }); + } + } + if (rows.length) { + const { error } = await syncRecords().upsert(rows, { onConflict: "user_id,store,record_key" }); + if (error) throw new Error(`Failed to push documents: ${error.message}`); + } + return rows.length; +} + +/** Bring only the document-tier records down into local IndexedDB (no files). */ +export async function pullDocs(userId: string): Promise { + const { data, error } = await syncRecords() + .select("store,record_key,data") + .eq("user_id", userId); + if (error) throw new Error(`Failed to read cloud documents: ${error.message}`); + + const rows = (data ?? []) as Pick[]; + let records = 0; + for (const row of rows) { + if ((DOC_STORES as readonly string[]).includes(row.store)) { + await writeOne(row.store, row.data); + records++; + } + } + return records; +} + +/** Per-tier storage usage from the server, with the advisory limits as fallback. */ +export async function getStorageUsage(): Promise { + const rows = await fetchStorageUsage(); + const byTier = new Map(rows.map((r) => [r.tier, r])); + const tiers: Tier[] = ["documents", "logs"]; + return tiers.map((tier) => { + const row = byTier.get(tier); + return { + tier, + usedBytes: row?.used_bytes ?? 0, + limitBytes: row?.limit_bytes ?? DEFAULT_LIMITS[tier], + }; + }); +} diff --git a/src/plugins/cloud-sync/tiers.test.ts b/src/plugins/cloud-sync/tiers.test.ts new file mode 100644 index 0000000..92d5530 --- /dev/null +++ b/src/plugins/cloud-sync/tiers.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_LIMITS, + docByteSize, + formatBytes, + isOverLimit, + tierForStore, + usageFraction, + wouldExceed, +} from "./tiers"; + +describe("storage tiers", () => { + it("classifies the files store as logs, everything else as documents", () => { + expect(tierForStore("files")).toBe("logs"); + expect(tierForStore("setups")).toBe("documents"); + expect(tierForStore("karts")).toBe("documents"); + expect(tierForStore("graph-prefs")).toBe("documents"); + }); + + it("has the agreed default limits (5 MB docs / 20 MB logs)", () => { + expect(DEFAULT_LIMITS.documents).toBe(5 * 1024 * 1024); + expect(DEFAULT_LIMITS.logs).toBe(20 * 1024 * 1024); + }); + + it("measures document byte size from serialized JSON", () => { + expect(docByteSize({ a: 1 })).toBe(new TextEncoder().encode('{"a":1}').length); + expect(docByteSize(null)).toBe(4); // "null" + }); + + it("computes a clamped usage fraction", () => { + expect(usageFraction({ usedBytes: 0, limitBytes: 100 })).toBe(0); + expect(usageFraction({ usedBytes: 50, limitBytes: 100 })).toBe(0.5); + expect(usageFraction({ usedBytes: 200, limitBytes: 100 })).toBe(1); + expect(usageFraction({ usedBytes: 5, limitBytes: 0 })).toBe(0); + }); + + it("detects over-limit and projected overflow", () => { + expect(isOverLimit(101, 100)).toBe(true); + expect(isOverLimit(100, 100)).toBe(false); + expect(wouldExceed(90, 20, 100)).toBe(true); + expect(wouldExceed(90, 10, 100)).toBe(false); + }); + + it("formats byte sizes", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(2048)).toBe("2 KB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB"); + }); +}); diff --git a/src/plugins/cloud-sync/tiers.ts b/src/plugins/cloud-sync/tiers.ts new file mode 100644 index 0000000..0add109 --- /dev/null +++ b/src/plugins/cloud-sync/tiers.ts @@ -0,0 +1,56 @@ +// Storage tiers + limits for cloud sync. +// +// Two tiers: "documents" (garage data — vehicles, setups, templates, types, +// notes, metadata, graph prefs) and "logs" (raw session file blobs). The real +// limits + enforcement live server-side (the quota_limits table + trigger in +// the storage-quotas migration); these client values are the offline/advisory +// fallback for the meter and the pre-push check. sync_storage_usage() on the +// server is the source of truth the UI reads when online. + +import { FILE_STORE } from "./syncStores"; + +export type Tier = "documents" | "logs"; + +/** Advisory fallback limits (bytes). Mirror of the server `quota_limits` seed. */ +export const DEFAULT_LIMITS: Record = { + documents: 5 * 1024 * 1024, // 5 MB + logs: 20 * 1024 * 1024, // 20 MB +}; + +/** Which tier a sync store belongs to. */ +export function tierForStore(store: string): Tier { + return store === FILE_STORE ? "logs" : "documents"; +} + +/** Approximate serialized byte size of a structured (document) record. */ +export function docByteSize(record: unknown): number { + return new TextEncoder().encode(JSON.stringify(record ?? null)).length; +} + +export interface TierUsage { + tier: Tier; + usedBytes: number; + limitBytes: number; +} + +/** Fraction of the limit used, clamped to [0, 1]. */ +export function usageFraction(u: Pick): number { + if (u.limitBytes <= 0) return 0; + return Math.min(1, u.usedBytes / u.limitBytes); +} + +export function isOverLimit(usedBytes: number, limitBytes: number): boolean { + return usedBytes > limitBytes; +} + +/** Would adding `addBytes` to the current usage exceed the limit? */ +export function wouldExceed(usedBytes: number, addBytes: number, limitBytes: number): boolean { + return usedBytes + addBytes > limitBytes; +} + +/** Human-readable byte size (KB/MB), 1 decimal for MB. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/plugins/panels.ts b/src/plugins/panels.ts index 7abef0b..f5c16d0 100644 --- a/src/plugins/panels.ts +++ b/src/plugins/panels.ts @@ -22,6 +22,8 @@ export const PanelSlot = { Labs: "labs", /** The dedicated AI Coach tab in the main view. */ Coach: "coach", + /** The user profile tab (storage usage, account) in the main view. */ + Profile: "profile", } as const; export type PanelSlot = (typeof PanelSlot)[keyof typeof PanelSlot]; diff --git a/supabase/migrations/20260525020000_storage_quotas.sql b/supabase/migrations/20260525020000_storage_quotas.sql new file mode 100644 index 0000000..2561ac5 --- /dev/null +++ b/supabase/migrations/20260525020000_storage_quotas.sql @@ -0,0 +1,96 @@ +-- Storage quotas for cloud sync. +-- +-- Two tiers, enforced server-side (client caps are advisory only): +-- • documents — all structured sync_records except file blobs (vehicles, +-- setups, templates, vehicle types, notes, metadata, graph prefs) +-- • logs — raw session file blobs, tracked by their index row's size +-- +-- Limits live in a single table (quota_limits) read by both the enforcing +-- trigger and the client meter, so there's one source of truth. A BEFORE +-- INSERT/UPDATE trigger on sync_records rejects writes that would push a tier +-- over its limit; sync_storage_usage() returns per-tier usage for the UI. + +-- ── Limits (single source of truth) ───────────────────────────────────────── +create table if not exists public.quota_limits ( + tier text primary key, + max_bytes bigint not null +); + +insert into public.quota_limits (tier, max_bytes) values + ('documents', 5242880), -- 5 MB + ('logs', 20971520) -- 20 MB +on conflict (tier) do update set max_bytes = excluded.max_bytes; + +alter table public.quota_limits enable row level security; + +drop policy if exists "Anyone authenticated reads limits" on public.quota_limits; +create policy "Anyone authenticated reads limits" + on public.quota_limits for select to authenticated + using (true); + +-- ── Per-record byte size ──────────────────────────────────────────────────── +-- Files are counted by the size recorded on their index row; structured docs by +-- their serialized jsonb length. +create or replace function public.sync_record_size(p_store text, p_data jsonb) +returns bigint language sql immutable as $$ + select case + when p_store = 'files' then coalesce((p_data->>'size')::bigint, 0) + else octet_length(p_data::text)::bigint + end; +$$; + +-- ── Quota enforcement trigger ─────────────────────────────────────────────── +create or replace function public.enforce_sync_quota() +returns trigger language plpgsql as $$ +declare + v_is_log boolean := (NEW.store = 'files'); + v_tier text := case when v_is_log then 'logs' else 'documents' end; + v_limit bigint; + v_used bigint; + v_new bigint := public.sync_record_size(NEW.store, NEW.data); +begin + select max_bytes into v_limit from public.quota_limits where tier = v_tier; + if v_limit is null then + return NEW; -- no limit configured for this tier + end if; + + -- Current usage for this tier, excluding the row being upserted. + select coalesce(sum(public.sync_record_size(store, data)), 0) + into v_used + from public.sync_records + where user_id = NEW.user_id + and (store = 'files') = v_is_log + and not (store = NEW.store and record_key = NEW.record_key); + + if v_used + v_new > v_limit then + raise exception + 'quota_exceeded: % tier over limit (% bytes used + % new > % limit)', + v_tier, v_used, v_new, v_limit + using errcode = 'check_violation'; + end if; + + return NEW; +end; +$$; + +drop trigger if exists sync_records_quota on public.sync_records; +create trigger sync_records_quota + before insert or update on public.sync_records + for each row execute function public.enforce_sync_quota(); + +-- ── Usage readout for the client meter ────────────────────────────────────── +-- Returns one row per tier with used + limit bytes, scoped to the caller. +create or replace function public.sync_storage_usage() +returns table(tier text, used_bytes bigint, limit_bytes bigint) +language sql stable as $$ + select q.tier, + coalesce(sum(public.sync_record_size(r.store, r.data)), 0)::bigint, + q.max_bytes + from public.quota_limits q + left join public.sync_records r + on r.user_id = auth.uid() + and (case when r.store = 'files' then 'logs' else 'documents' end) = q.tier + group by q.tier, q.max_bytes; +$$; + +grant execute on function public.sync_storage_usage() to authenticated; From d6bd9aad70865673f2556978418472f5fbf7c8a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 03:39:31 +0000 Subject: [PATCH 052/121] =?UTF-8?q?Rename=20storage=20"tiers"=20=E2=86=92?= =?UTF-8?q?=20"storage=20types"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve "tier" for future subscription tiers. The storage-class concept (documents vs logs) is now "storage type" throughout: storageTypes.ts (StorageType/storageTypeForStore/StorageTypeUsage), the quota_limits.storage_type column + sync_storage_type() helper, and the sync_storage_usage() RPC column. No behavioural change. Migration is unmerged, so the column rename is safe. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 8 +-- CLAUDE.md | 20 ++++---- src/plugins/cloud-sync/StoragePanel.tsx | 18 +++---- src/plugins/cloud-sync/autoSync.ts | 6 +-- src/plugins/cloud-sync/cloudClient.ts | 6 +-- src/plugins/cloud-sync/index.ts | 2 +- .../{tiers.test.ts => storageTypes.test.ts} | 14 +++--- .../cloud-sync/{tiers.ts => storageTypes.ts} | 29 +++++------ src/plugins/cloud-sync/syncEngine.ts | 22 ++++---- .../20260525020000_storage_quotas.sql | 50 +++++++++++-------- 10 files changed, 91 insertions(+), 84 deletions(-) rename src/plugins/cloud-sync/{tiers.test.ts => storageTypes.test.ts} (81%) rename src/plugins/cloud-sync/{tiers.ts => storageTypes.ts} (56%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a747c86..58e4b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Document storage tier + **auto-sync**: when you're signed in, your garage +- Document storage + **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. This "documents" tier is free - with a **5 MB** limit; raw session **logs** are a separate **20 MB** tier. - Limits are enforced server-side. + automatically as you change it — no manual push. The "documents" storage type + is free with a **5 MB** limit; raw session **logs** are a separate **20 MB** + storage type. Limits are 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 against the diff --git a/CLAUDE.md b/CLAUDE.md index a719b41..c24ef9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,7 +193,7 @@ src/ │ │ ├── CloudFilesSection.tsx # FileManagerSection mount: lists all cloud files (on-device marked, others pullable) │ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus/cloudOnlyNames (pure, tested) │ │ ├── syncStores.ts # Pure config: which IDB stores sync + how they're keyed (testable) -│ │ ├── tiers.ts # Pure: storage tiers (documents 5MB / logs 20MB) + usage math (tested) +│ │ ├── storageTypes.ts # Pure: storage types (documents 5MB / logs 20MB) + usage math (tested) │ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord/pushDocs/pullDocs + getStorageUsage │ │ ├── autoSync.ts # Background doc auto-sync: subscribes to garageEvents, debounced upsert/delete + reconcile on sign-in │ │ ├── StoragePanel.tsx # Profile-tab panel: storage usage meters + account scratch pad (lazy) @@ -407,12 +407,12 @@ the Karts/Setups delete UI shows a loud "deletes from every device + the cloud" warning when signed in. (Log-blob deletion propagation + timestamp merge are still follow-ups.) -**Storage tiers** (`tiers.ts`, enforced server-side): **documents** = all -structured stores (5 MB, free, auto-synced) and **logs** = file blobs (20 MB, -opt-in). Limits live in the `quota_limits` table (one source of truth for the -enforcing trigger + the client meter); `sync_storage_usage()` returns per-tier -usage for the Profile-tab meters. Client checks are advisory — the DB trigger is -the real gate. +**Storage types** (`storageTypes.ts`, enforced server-side) — distinct from +future *subscription tiers*: **documents** = all structured stores (5 MB, free, +auto-synced) and **logs** = file blobs (20 MB, opt-in). Limits live in the +`quota_limits` table (one source of truth for the enforcing trigger + the client +meter); `sync_storage_usage()` returns per-type usage for the Profile-tab meters. +Client checks are advisory — the DB trigger is the real gate. Backend (migrations `..._cloud_sync.sql`, `..._storage_quotas.sql`): @@ -420,9 +420,9 @@ Backend (migrations `..._cloud_sync.sql`, `..._storage_quotas.sql`): |--------|------|-------| | `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. | -| `quota_limits` | table | `(tier, max_bytes)` seeded `documents`=5 MB, `logs`=20 MB. Read by client + trigger. | -| `enforce_sync_quota` | trigger | BEFORE INSERT/UPDATE on `sync_records`: rejects writes that push a tier over its limit (`quota_exceeded`). | -| `sync_storage_usage()` | RPC | Per-tier `(used_bytes, limit_bytes)` for the caller. | +| `quota_limits` | table | `(storage_type, max_bytes)` seeded `documents`=5 MB, `logs`=20 MB. Read by client + trigger. | +| `enforce_sync_quota` | trigger | BEFORE INSERT/UPDATE on `sync_records`: rejects writes that push a storage type over its limit (`quota_exceeded`). | +| `sync_storage_usage()` | RPC | Per-type `(used_bytes, limit_bytes)` for the caller. | Synced stores (`syncStores.ts` — pure, unit-tested): `metadata`, `karts`, `setups`, `notes`, `graph-prefs`, `vehicle-types`, `setup-templates` (jsonb diff --git a/src/plugins/cloud-sync/StoragePanel.tsx b/src/plugins/cloud-sync/StoragePanel.tsx index 6c8f2a0..4a41384 100644 --- a/src/plugins/cloud-sync/StoragePanel.tsx +++ b/src/plugins/cloud-sync/StoragePanel.tsx @@ -3,20 +3,20 @@ import { User as UserIcon } from "lucide-react"; import type { PluginPanelProps } from "@/plugins/panels"; import { useAuth } from "@/contexts/AuthContext"; import { getStorageUsage } from "./syncEngine"; -import { formatBytes, usageFraction, type TierUsage } from "./tiers"; +import { formatBytes, usageFraction, type StorageTypeUsage } from "./storageTypes"; -const TIER_LABEL: Record = { documents: "Documents", logs: "Logs" }; -const TIER_HINT: Record = { +const TYPE_LABEL: Record = { documents: "Documents", logs: "Logs" }; +const TYPE_HINT: Record = { documents: "Vehicles, setups, templates & notes — free, auto-synced.", logs: "Session log files you've chosen to sync.", }; // Scratch-pad profile panel: who you're signed in as + your cloud storage usage -// against the document/log tier limits. (Display name / avatar are placeholders +// against the document/log storage limits. (Display name / avatar are placeholders // until profiles land.) export default function StoragePanel(_props: PluginPanelProps) { const { user, loading } = useAuth(); - const [usage, setUsage] = useState(null); + const [usage, setUsage] = useState(null); const [error, setError] = useState(null); const refresh = useCallback(async () => { @@ -66,20 +66,20 @@ export default function StoragePanel(_props: PluginPanelProps) { {error &&

{error}

} {!usage && !error &&

Loading usage…

} {usage?.map((u) => ( - + ))}
); } -function Meter({ usage }: { usage: TierUsage }) { +function Meter({ usage }: { usage: StorageTypeUsage }) { const pct = Math.round(usageFraction(usage) * 100); const over = usage.usedBytes > usage.limitBytes; return (
- {TIER_LABEL[usage.tier] ?? usage.tier} + {TYPE_LABEL[usage.storageType] ?? usage.storageType} {formatBytes(usage.usedBytes)} / {formatBytes(usage.limitBytes)} @@ -90,7 +90,7 @@ function Meter({ usage }: { usage: TierUsage }) { style={{ width: `${pct}%` }} />
-

{TIER_HINT[usage.tier]}

+

{TYPE_HINT[usage.storageType]}

); } diff --git a/src/plugins/cloud-sync/autoSync.ts b/src/plugins/cloud-sync/autoSync.ts index 14275a2..019decf 100644 --- a/src/plugins/cloud-sync/autoSync.ts +++ b/src/plugins/cloud-sync/autoSync.ts @@ -5,13 +5,13 @@ // notes). On a change it debounces, then upserts (put) or deletes (delete) the // single cloud record — so edits propagate up and deletes propagate everywhere. // On sign-in it reconciles (pull cloud docs down, push local docs up). Only the -// free "documents" tier auto-syncs here; log blobs stay manual/opt-in. +// free "documents" storage type auto-syncs here; log blobs stay manual/opt-in. import { supabase } from "@/integrations/supabase/client"; import { onGarageChange, type GarageChange } from "@/lib/garageEvents"; import { isQuotaError } from "./cloudClient"; import { deleteRecord, pullDocs, pushDocs, pushRecord } from "./syncEngine"; -import { tierForStore } from "./tiers"; +import { storageTypeForStore } from "./storageTypes"; const DEBOUNCE_MS = 800; @@ -42,7 +42,7 @@ async function flush(change: GarageChange): Promise { } catch (err) { if (isQuotaError(err)) { notify( - `Cloud ${tierForStore(change.store)} storage is full — saved locally, not synced.`, + `Cloud ${storageTypeForStore(change.store)} storage is full — saved locally, not synced.`, "error", ); } else { diff --git a/src/plugins/cloud-sync/cloudClient.ts b/src/plugins/cloud-sync/cloudClient.ts index e0b32a1..f2fec8a 100644 --- a/src/plugins/cloud-sync/cloudClient.ts +++ b/src/plugins/cloud-sync/cloudClient.ts @@ -35,14 +35,14 @@ export function userFiles() { return untyped.storage.from(SYNC_BUCKET); } -/** One tier's usage as returned by the server's sync_storage_usage() RPC. */ +/** One storage type's usage as returned by the server's sync_storage_usage() RPC. */ export interface StorageUsageRow { - tier: string; + storage_type: string; used_bytes: number; limit_bytes: number; } -/** Per-tier storage usage for the current user (authoritative, server-computed). */ +/** Per-type storage usage for the current user (authoritative, server-computed). */ export async function fetchStorageUsage(): Promise { const { data, error } = await untyped.rpc("sync_storage_usage"); if (error) throw new Error(`Failed to read storage usage: ${error.message}`); diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index d7125ca..232f9da 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -57,7 +57,7 @@ const plugin: DataViewerPlugin = { component: CloudFilesSection, } satisfies PluginMountDef); - // Profile tab: storage usage meters (document + log tiers) + account. + // Profile tab: storage usage meters (document + log storage types) + account. ctx.registry.contribute(PANELS_POINT, { id: "cloud-sync-storage", title: "Profile", diff --git a/src/plugins/cloud-sync/tiers.test.ts b/src/plugins/cloud-sync/storageTypes.test.ts similarity index 81% rename from src/plugins/cloud-sync/tiers.test.ts rename to src/plugins/cloud-sync/storageTypes.test.ts index 92d5530..be20a8b 100644 --- a/src/plugins/cloud-sync/tiers.test.ts +++ b/src/plugins/cloud-sync/storageTypes.test.ts @@ -4,17 +4,17 @@ import { docByteSize, formatBytes, isOverLimit, - tierForStore, + storageTypeForStore, usageFraction, wouldExceed, -} from "./tiers"; +} from "./storageTypes"; -describe("storage tiers", () => { +describe("storage types", () => { it("classifies the files store as logs, everything else as documents", () => { - expect(tierForStore("files")).toBe("logs"); - expect(tierForStore("setups")).toBe("documents"); - expect(tierForStore("karts")).toBe("documents"); - expect(tierForStore("graph-prefs")).toBe("documents"); + expect(storageTypeForStore("files")).toBe("logs"); + expect(storageTypeForStore("setups")).toBe("documents"); + expect(storageTypeForStore("karts")).toBe("documents"); + expect(storageTypeForStore("graph-prefs")).toBe("documents"); }); it("has the agreed default limits (5 MB docs / 20 MB logs)", () => { diff --git a/src/plugins/cloud-sync/tiers.ts b/src/plugins/cloud-sync/storageTypes.ts similarity index 56% rename from src/plugins/cloud-sync/tiers.ts rename to src/plugins/cloud-sync/storageTypes.ts index 0add109..47415e0 100644 --- a/src/plugins/cloud-sync/tiers.ts +++ b/src/plugins/cloud-sync/storageTypes.ts @@ -1,24 +1,25 @@ -// Storage tiers + limits for cloud sync. +// Storage types + limits for cloud sync. // -// Two tiers: "documents" (garage data — vehicles, setups, templates, types, -// notes, metadata, graph prefs) and "logs" (raw session file blobs). The real -// limits + enforcement live server-side (the quota_limits table + trigger in -// the storage-quotas migration); these client values are the offline/advisory -// fallback for the meter and the pre-push check. sync_storage_usage() on the -// server is the source of truth the UI reads when online. +// Two storage *types* (not to be confused with subscription tiers, which will +// scale these limits later): "documents" (garage data — vehicles, setups, +// templates, types, notes, metadata, graph prefs) and "logs" (raw session file +// blobs). The real limits + enforcement live server-side (the quota_limits +// table + trigger in the storage-quotas migration); these client values are the +// offline/advisory fallback for the meter and the pre-push check. +// sync_storage_usage() on the server is the source of truth the UI reads online. import { FILE_STORE } from "./syncStores"; -export type Tier = "documents" | "logs"; +export type StorageType = "documents" | "logs"; /** Advisory fallback limits (bytes). Mirror of the server `quota_limits` seed. */ -export const DEFAULT_LIMITS: Record = { +export const DEFAULT_LIMITS: Record = { documents: 5 * 1024 * 1024, // 5 MB logs: 20 * 1024 * 1024, // 20 MB }; -/** Which tier a sync store belongs to. */ -export function tierForStore(store: string): Tier { +/** Which storage type a sync store belongs to. */ +export function storageTypeForStore(store: string): StorageType { return store === FILE_STORE ? "logs" : "documents"; } @@ -27,14 +28,14 @@ export function docByteSize(record: unknown): number { return new TextEncoder().encode(JSON.stringify(record ?? null)).length; } -export interface TierUsage { - tier: Tier; +export interface StorageTypeUsage { + storageType: StorageType; usedBytes: number; limitBytes: number; } /** Fraction of the limit used, clamped to [0, 1]. */ -export function usageFraction(u: Pick): number { +export function usageFraction(u: Pick): number { if (u.limitBytes <= 0) return 0; return Math.min(1, u.usedBytes / u.limitBytes); } diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index a07b908..85d33af 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -16,7 +16,7 @@ import { getFile, saveFile } from "@/lib/fileStorage"; import { fetchStorageUsage, syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; import { DOC_STORES, FILE_STORE, extractKey, type SyncSummary } from "./syncStores"; import { listSelectedFiles, markPushed } from "./fileSync"; -import { DEFAULT_LIMITS, type Tier, type TierUsage } from "./tiers"; +import { DEFAULT_LIMITS, type StorageType, type StorageTypeUsage } from "./storageTypes"; export type { SyncSummary }; @@ -163,7 +163,7 @@ export async function deleteRecord(userId: string, store: string, key: string): if (error) throw new Error(error.message); } -/** Mirror only the structured (free document-tier) stores up — no file blobs. */ +/** Mirror only the structured (free documents storage type) stores up — no file blobs. */ export async function pushDocs(userId: string): Promise { const rows: SyncRecordRow[] = []; for (const store of DOC_STORES) { @@ -178,7 +178,7 @@ export async function pushDocs(userId: string): Promise { return rows.length; } -/** Bring only the document-tier records down into local IndexedDB (no files). */ +/** Bring only the documents-type records down into local IndexedDB (no files). */ export async function pullDocs(userId: string): Promise { const { data, error } = await syncRecords() .select("store,record_key,data") @@ -196,17 +196,17 @@ export async function pullDocs(userId: string): Promise { return records; } -/** Per-tier storage usage from the server, with the advisory limits as fallback. */ -export async function getStorageUsage(): Promise { +/** Per-type storage usage from the server, with the advisory limits as fallback. */ +export async function getStorageUsage(): Promise { const rows = await fetchStorageUsage(); - const byTier = new Map(rows.map((r) => [r.tier, r])); - const tiers: Tier[] = ["documents", "logs"]; - return tiers.map((tier) => { - const row = byTier.get(tier); + const byType = new Map(rows.map((r) => [r.storage_type, r])); + const types: StorageType[] = ["documents", "logs"]; + return types.map((storageType) => { + const row = byType.get(storageType); return { - tier, + storageType, usedBytes: row?.used_bytes ?? 0, - limitBytes: row?.limit_bytes ?? DEFAULT_LIMITS[tier], + limitBytes: row?.limit_bytes ?? DEFAULT_LIMITS[storageType], }; }); } diff --git a/supabase/migrations/20260525020000_storage_quotas.sql b/supabase/migrations/20260525020000_storage_quotas.sql index 2561ac5..c018007 100644 --- a/supabase/migrations/20260525020000_storage_quotas.sql +++ b/supabase/migrations/20260525020000_storage_quotas.sql @@ -1,25 +1,26 @@ -- Storage quotas for cloud sync. -- --- Two tiers, enforced server-side (client caps are advisory only): +-- Two storage *types*, enforced server-side (client caps are advisory only): -- • documents — all structured sync_records except file blobs (vehicles, -- setups, templates, vehicle types, notes, metadata, graph prefs) -- • logs — raw session file blobs, tracked by their index row's size -- +-- ("type", not "tier" — subscription tiers will scale these limits later.) -- Limits live in a single table (quota_limits) read by both the enforcing -- trigger and the client meter, so there's one source of truth. A BEFORE --- INSERT/UPDATE trigger on sync_records rejects writes that would push a tier --- over its limit; sync_storage_usage() returns per-tier usage for the UI. +-- INSERT/UPDATE trigger on sync_records rejects writes that would push a type +-- over its limit; sync_storage_usage() returns per-type usage for the UI. -- ── Limits (single source of truth) ───────────────────────────────────────── create table if not exists public.quota_limits ( - tier text primary key, + storage_type text primary key, max_bytes bigint not null ); -insert into public.quota_limits (tier, max_bytes) values +insert into public.quota_limits (storage_type, max_bytes) values ('documents', 5242880), -- 5 MB ('logs', 20971520) -- 20 MB -on conflict (tier) do update set max_bytes = excluded.max_bytes; +on conflict (storage_type) do update set max_bytes = excluded.max_bytes; alter table public.quota_limits enable row level security; @@ -39,33 +40,38 @@ returns bigint language sql immutable as $$ end; $$; +-- Which storage type a sync store belongs to. +create or replace function public.sync_storage_type(p_store text) +returns text language sql immutable as $$ + select case when p_store = 'files' then 'logs' else 'documents' end; +$$; + -- ── Quota enforcement trigger ─────────────────────────────────────────────── create or replace function public.enforce_sync_quota() returns trigger language plpgsql as $$ declare - v_is_log boolean := (NEW.store = 'files'); - v_tier text := case when v_is_log then 'logs' else 'documents' end; - v_limit bigint; - v_used bigint; - v_new bigint := public.sync_record_size(NEW.store, NEW.data); + v_type text := public.sync_storage_type(NEW.store); + v_limit bigint; + v_used bigint; + v_new bigint := public.sync_record_size(NEW.store, NEW.data); begin - select max_bytes into v_limit from public.quota_limits where tier = v_tier; + select max_bytes into v_limit from public.quota_limits where storage_type = v_type; if v_limit is null then - return NEW; -- no limit configured for this tier + return NEW; -- no limit configured for this type end if; - -- Current usage for this tier, excluding the row being upserted. + -- Current usage for this type, excluding the row being upserted. select coalesce(sum(public.sync_record_size(store, data)), 0) into v_used from public.sync_records where user_id = NEW.user_id - and (store = 'files') = v_is_log + and public.sync_storage_type(store) = v_type and not (store = NEW.store and record_key = NEW.record_key); if v_used + v_new > v_limit then raise exception - 'quota_exceeded: % tier over limit (% bytes used + % new > % limit)', - v_tier, v_used, v_new, v_limit + 'quota_exceeded: % storage over limit (% bytes used + % new > % limit)', + v_type, v_used, v_new, v_limit using errcode = 'check_violation'; end if; @@ -79,18 +85,18 @@ create trigger sync_records_quota for each row execute function public.enforce_sync_quota(); -- ── Usage readout for the client meter ────────────────────────────────────── --- Returns one row per tier with used + limit bytes, scoped to the caller. +-- Returns one row per storage type with used + limit bytes, scoped to the caller. create or replace function public.sync_storage_usage() -returns table(tier text, used_bytes bigint, limit_bytes bigint) +returns table(storage_type text, used_bytes bigint, limit_bytes bigint) language sql stable as $$ - select q.tier, + select q.storage_type, coalesce(sum(public.sync_record_size(r.store, r.data)), 0)::bigint, q.max_bytes from public.quota_limits q left join public.sync_records r on r.user_id = auth.uid() - and (case when r.store = 'files' then 'logs' else 'documents' end) = q.tier - group by q.tier, q.max_bytes; + and public.sync_storage_type(r.store) = q.storage_type + group by q.storage_type, q.max_bytes; $$; grant execute on function public.sync_storage_usage() to authenticated; From ff8ff00c24c7788225a69c7db50b4d1d5de2810e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 03:55:19 +0000 Subject: [PATCH 053/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/integrations/supabase/types.ts | 28 +++++++ ...8_2b26ff8d-c793-4112-9423-1b84cef1a4e1.sql | 81 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 supabase/migrations/20260525035518_2b26ff8d-c793-4112-9423-1b84cef1a4e1.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 71cd9df..4c72ad7 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -202,6 +202,21 @@ export type Database = { } Relationships: [] } + quota_limits: { + Row: { + max_bytes: number + storage_type: string + } + Insert: { + max_bytes: number + storage_type: string + } + Update: { + max_bytes?: number + storage_type?: string + } + Relationships: [] + } submissions: { Row: { course_data: Json @@ -348,6 +363,19 @@ export type Database = { } Returns: boolean } + sync_record_size: { + Args: { p_data: Json; p_store: string } + Returns: number + } + sync_storage_type: { Args: { p_store: string }; Returns: string } + sync_storage_usage: { + Args: never + Returns: { + limit_bytes: number + storage_type: string + used_bytes: number + }[] + } } Enums: { app_role: "admin" | "user" diff --git a/supabase/migrations/20260525035518_2b26ff8d-c793-4112-9423-1b84cef1a4e1.sql b/supabase/migrations/20260525035518_2b26ff8d-c793-4112-9423-1b84cef1a4e1.sql new file mode 100644 index 0000000..76bc70a --- /dev/null +++ b/supabase/migrations/20260525035518_2b26ff8d-c793-4112-9423-1b84cef1a4e1.sql @@ -0,0 +1,81 @@ +-- Storage quotas for cloud sync. +create table if not exists public.quota_limits ( + storage_type text primary key, + max_bytes bigint not null +); + +insert into public.quota_limits (storage_type, max_bytes) values + ('documents', 5242880), + ('logs', 20971520) +on conflict (storage_type) do update set max_bytes = excluded.max_bytes; + +alter table public.quota_limits enable row level security; + +drop policy if exists "Anyone authenticated reads limits" on public.quota_limits; +create policy "Anyone authenticated reads limits" + on public.quota_limits for select to authenticated + using (true); + +create or replace function public.sync_record_size(p_store text, p_data jsonb) +returns bigint language sql immutable as $$ + select case + when p_store = 'files' then coalesce((p_data->>'size')::bigint, 0) + else octet_length(p_data::text)::bigint + end; +$$; + +create or replace function public.sync_storage_type(p_store text) +returns text language sql immutable as $$ + select case when p_store = 'files' then 'logs' else 'documents' end; +$$; + +create or replace function public.enforce_sync_quota() +returns trigger language plpgsql as $$ +declare + v_type text := public.sync_storage_type(NEW.store); + v_limit bigint; + v_used bigint; + v_new bigint := public.sync_record_size(NEW.store, NEW.data); +begin + select max_bytes into v_limit from public.quota_limits where storage_type = v_type; + if v_limit is null then + return NEW; + end if; + + select coalesce(sum(public.sync_record_size(store, data)), 0) + into v_used + from public.sync_records + where user_id = NEW.user_id + and public.sync_storage_type(store) = v_type + and not (store = NEW.store and record_key = NEW.record_key); + + if v_used + v_new > v_limit then + raise exception + 'quota_exceeded: % storage over limit (% bytes used + % new > % limit)', + v_type, v_used, v_new, v_limit + using errcode = 'check_violation'; + end if; + + return NEW; +end; +$$; + +drop trigger if exists sync_records_quota on public.sync_records; +create trigger sync_records_quota + before insert or update on public.sync_records + for each row execute function public.enforce_sync_quota(); + +create or replace function public.sync_storage_usage() +returns table(storage_type text, used_bytes bigint, limit_bytes bigint) +language sql stable as $$ + select q.storage_type, + coalesce(sum(public.sync_record_size(r.store, r.data)), 0)::bigint, + q.max_bytes + from public.quota_limits q + left join public.sync_records r + on r.user_id = auth.uid() + and public.sync_storage_type(r.store) = q.storage_type + group by q.storage_type, q.max_bytes; +$$; + +grant execute on function public.sync_storage_usage() to authenticated; \ No newline at end of file From 8221303fc9a72578eac1e832db693bfc80c6d70d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 03:55:30 +0000 Subject: [PATCH 054/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- .../20260525035529_c0fa2e06-61a3-43e4-b353-834c99565f03.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 supabase/migrations/20260525035529_c0fa2e06-61a3-43e4-b353-834c99565f03.sql diff --git a/supabase/migrations/20260525035529_c0fa2e06-61a3-43e4-b353-834c99565f03.sql b/supabase/migrations/20260525035529_c0fa2e06-61a3-43e4-b353-834c99565f03.sql new file mode 100644 index 0000000..71f4755 --- /dev/null +++ b/supabase/migrations/20260525035529_c0fa2e06-61a3-43e4-b353-834c99565f03.sql @@ -0,0 +1,4 @@ +alter function public.sync_record_size(text, jsonb) set search_path = public; +alter function public.sync_storage_type(text) set search_path = public; +alter function public.enforce_sync_quota() set search_path = public; +alter function public.sync_storage_usage() set search_path = public; \ No newline at end of file From c4bb5444427737d3a0098af0649e9260050339d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 15:27:54 +0000 Subject: [PATCH 055/121] Add unique, editable user display names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each account gets a unique display name (not a key — user-editable any time): - Migration: profiles table (display_name unique) + handle_new_user trigger that creates a profile on sign-up from the provided name, or a generated silly name (SpeedyRac3r-546) when blank. unique_display_name() auto-suffixes a taken name at creation; existing users are backfilled with generated names. - signUp(email, password, displayName?) threads the name into user metadata; the Register form adds an optional display-name field. - Profile tab (StoragePanel) shows the name with inline edit; updateDisplayName reports a taken name distinctly so the UI shows "that name's taken" rather than a raw error. profile.ts + cloudClient profiles()/isUniqueViolation back it. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 6 +- CLAUDE.md | 5 +- src/contexts/AuthContext.tsx | 12 +- src/pages/Register.tsx | 7 +- src/plugins/cloud-sync/StoragePanel.tsx | 107 ++++++++++++++--- src/plugins/cloud-sync/cloudClient.ts | 18 +++ src/plugins/cloud-sync/profile.ts | 35 ++++++ .../20260525030000_user_profiles.sql | 112 ++++++++++++++++++ 8 files changed, 280 insertions(+), 22 deletions(-) create mode 100644 src/plugins/cloud-sync/profile.ts create mode 100644 supabase/migrations/20260525030000_user_profiles.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e4b5c..773cca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 against the - document and log limits (account display name/avatar are placeholders for now). + document and log limits. +- **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. diff --git a/CLAUDE.md b/CLAUDE.md index c24ef9f..396f0bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,7 +196,8 @@ src/ │ │ ├── storageTypes.ts # Pure: storage types (documents 5MB / logs 20MB) + usage math (tested) │ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord/pushDocs/pullDocs + getStorageUsage │ │ ├── autoSync.ts # Background doc auto-sync: subscribes to garageEvents, debounced upsert/delete + reconcile on sign-in -│ │ ├── StoragePanel.tsx # Profile-tab panel: storage usage meters + account scratch pad (lazy) +│ │ ├── StoragePanel.tsx # Profile-tab panel: display-name editor + storage usage meters (lazy) +│ │ ├── profile.ts # getMyProfile / updateDisplayName (unique display names; taken-name handling) │ │ └── cloudClient.ts # Typed access to sync_records + bucket + sync_storage_usage RPC (escape hatch until types regen) │ └── coaching/ # Gitignored private slot (AI coaching submodule) ├── types/ @@ -423,6 +424,8 @@ Backend (migrations `..._cloud_sync.sql`, `..._storage_quotas.sql`): | `quota_limits` | table | `(storage_type, max_bytes)` seeded `documents`=5 MB, `logs`=20 MB. Read by client + trigger. | | `enforce_sync_quota` | trigger | BEFORE INSERT/UPDATE on `sync_records`: rejects writes that push a storage type over its limit (`quota_exceeded`). | | `sync_storage_usage()` | RPC | Per-type `(used_bytes, limit_bytes)` for the caller. | +| `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` (jsonb diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 7d996c3..b327bb8 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -8,7 +8,7 @@ interface AuthContextValue { isAdmin: boolean; loading: boolean; login: (email: string, password: string) => Promise<{ error: Error | null }>; - signUp: (email: string, password: string) => Promise<{ error: Error | null }>; + signUp: (email: string, password: string, displayName?: string) => Promise<{ error: Error | null }>; signInWithGoogle: () => Promise<{ error: Error | null }>; logout: () => Promise; resetPassword: (email: string) => Promise<{ error: Error | null }>; @@ -101,11 +101,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { error }; }, []); - const signUp = useCallback(async (email: string, password: string) => { + const signUp = useCallback(async (email: string, password: string, displayName?: string) => { + const trimmed = displayName?.trim(); const { error } = await supabase.auth.signUp({ email, password, - options: { emailRedirectTo: window.location.origin + '/auth/callback' }, + options: { + emailRedirectTo: window.location.origin + '/auth/callback', + // Picked up by the handle_new_user trigger; blank → a random name is + // generated server-side. A taken name is auto-suffixed there too. + data: trimmed ? { display_name: trimmed } : {}, + }, }); return { error }; }, []); diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 50778d8..37bbda5 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -15,6 +15,7 @@ export default function Register() { canonical: 'https://hackthetrack.net/register', }); const [email, setEmail] = useState(''); + const [displayName, setDisplayName] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -32,7 +33,7 @@ export default function Register() { return; } setIsLoading(true); - const { error } = await signUp(email, password); + const { error } = await signUp(email, password, displayName); setIsLoading(false); if (error) { toast({ title: 'Registration failed', description: error.message, variant: 'destructive' }); @@ -76,6 +77,10 @@ export default function Register() { setEmail(e.target.value)} required />
+
+ + setDisplayName(e.target.value)} /> +
setPassword(e.target.value)} required /> diff --git a/src/plugins/cloud-sync/StoragePanel.tsx b/src/plugins/cloud-sync/StoragePanel.tsx index 4a41384..6e93b8a 100644 --- a/src/plugins/cloud-sync/StoragePanel.tsx +++ b/src/plugins/cloud-sync/StoragePanel.tsx @@ -1,8 +1,12 @@ import { useCallback, useEffect, useState } from "react"; -import { User as UserIcon } from "lucide-react"; +import { Check, Pencil, User as UserIcon, X } from "lucide-react"; +import { toast } from "sonner"; import type { PluginPanelProps } from "@/plugins/panels"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { useAuth } from "@/contexts/AuthContext"; import { getStorageUsage } from "./syncEngine"; +import { getMyProfile, updateDisplayName } from "./profile"; import { formatBytes, usageFraction, type StorageTypeUsage } from "./storageTypes"; const TYPE_LABEL: Record = { documents: "Documents", logs: "Logs" }; @@ -11,9 +15,8 @@ const TYPE_HINT: Record = { logs: "Session log files you've chosen to sync.", }; -// Scratch-pad profile panel: who you're signed in as + your cloud storage usage -// against the document/log storage limits. (Display name / avatar are placeholders -// until profiles land.) +// Scratch-pad profile panel: your (editable, unique) display name + cloud storage +// usage against the document/log storage limits. export default function StoragePanel(_props: PluginPanelProps) { const { user, loading } = useAuth(); const [usage, setUsage] = useState(null); @@ -46,20 +49,9 @@ export default function StoragePanel(_props: PluginPanelProps) { ); } - const displayName = - (user.user_metadata?.display_name as string | undefined) || user.email || "Driver"; - return (
-
-
- -
-
-

{displayName}

-

{user.email}

-
-
+

Storage

@@ -73,6 +65,89 @@ export default function StoragePanel(_props: PluginPanelProps) { ); } +function DisplayName({ userId, email }: { userId: string; email: string }) { + const [name, setName] = useState(null); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + void getMyProfile(userId) + .then((p) => { + if (!cancelled) setName(p?.display_name ?? null); + }) + .catch(() => { + if (!cancelled) setName(null); + }); + return () => { + cancelled = true; + }; + }, [userId]); + + const startEdit = () => { + setDraft(name ?? ""); + setEditing(true); + }; + + const save = async () => { + setSaving(true); + const result = await updateDisplayName(userId, draft); + setSaving(false); + if (result.ok) { + setName(draft.trim()); + setEditing(false); + toast.success("Display name updated."); + } else if (result.reason === "taken") { + toast.error("That name's taken — try another."); + } else if (result.reason === "empty") { + toast.error("Display name can't be empty."); + } else { + toast.error(result.message ?? "Couldn't update display name."); + } + }; + + return ( +
+
+ +
+
+ {editing ? ( +
+ setDraft(e.target.value)} + maxLength={40} + autoFocus + disabled={saving} + className="h-8" + onKeyDown={(e) => { + if (e.key === "Enter") void save(); + if (e.key === "Escape") setEditing(false); + }} + /> + + +
+ ) : ( +
+

{name ?? "…"}

+ +
+ )} +

{email}

+
+
+ ); +} + function Meter({ usage }: { usage: StorageTypeUsage }) { const pct = Math.round(usageFraction(usage) * 100); const over = usage.usedBytes > usage.limitBytes; diff --git a/src/plugins/cloud-sync/cloudClient.ts b/src/plugins/cloud-sync/cloudClient.ts index f2fec8a..64e57d5 100644 --- a/src/plugins/cloud-sync/cloudClient.ts +++ b/src/plugins/cloud-sync/cloudClient.ts @@ -53,3 +53,21 @@ export async function fetchStorageUsage(): Promise { export function isQuotaError(err: unknown): boolean { return err instanceof Error && /quota_exceeded/i.test(err.message); } + +/** A row in public.profiles — the user's unique, editable display name. */ +export interface ProfileRow { + user_id: string; + display_name: string; +} + +/** Query builder for the profiles table. */ +export function profiles() { + return untyped.from("profiles"); +} + +/** True when a Postgres error is a unique-constraint violation (e.g. taken name). */ +export function isUniqueViolation(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const e = err as { code?: string; message?: string }; + return e.code === "23505" || /duplicate key|unique constraint/i.test(e.message ?? ""); +} diff --git a/src/plugins/cloud-sync/profile.ts b/src/plugins/cloud-sync/profile.ts new file mode 100644 index 0000000..9bb9603 --- /dev/null +++ b/src/plugins/cloud-sync/profile.ts @@ -0,0 +1,35 @@ +// Display-name profile access. The name is unique (DB constraint) but not a key +// and is user-editable: account creation auto-resolves a free name server-side, +// while an explicit edit surfaces a "taken" result so the user can pick another. + +import { isUniqueViolation, profiles, type ProfileRow } from "./cloudClient"; + +/** The signed-in user's profile, or null if it doesn't exist yet. */ +export async function getMyProfile(userId: string): Promise { + const { data, error } = await profiles() + .select("user_id,display_name") + .eq("user_id", userId) + .maybeSingle(); + if (error) throw new Error(error.message); + return (data as ProfileRow | null) ?? null; +} + +export type UpdateNameResult = + | { ok: true } + | { ok: false; reason: "taken" | "empty" | "error"; message?: string }; + +/** Change the display name, reporting a taken name distinctly so the UI can prompt. */ +export async function updateDisplayName(userId: string, name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) return { ok: false, reason: "empty" }; + + const { error } = await profiles() + .update({ display_name: trimmed, updated_at: new Date().toISOString() }) + .eq("user_id", userId); + + if (error) { + if (isUniqueViolation(error)) return { ok: false, reason: "taken" }; + return { ok: false, reason: "error", message: error.message }; + } + return { ok: true }; +} diff --git a/supabase/migrations/20260525030000_user_profiles.sql b/supabase/migrations/20260525030000_user_profiles.sql new file mode 100644 index 0000000..16ea681 --- /dev/null +++ b/supabase/migrations/20260525030000_user_profiles.sql @@ -0,0 +1,112 @@ +-- User profiles: a unique, user-editable display name per account. +-- +-- Display name is NOT a key (the user id is) — it's a human label that must be +-- unique and can be changed any time. If none is provided at sign-up (or for +-- existing users when this migration runs), a silly random name is generated, +-- e.g. "SpeedyRac3r-546". Avatars / richer profiles are intentionally deferred. + +-- ── Table ──────────────────────────────────────────────────────────────────── +create table if not exists public.profiles ( + user_id uuid primary key references auth.users (id) on delete cascade, + display_name text not null unique, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table public.profiles enable row level security; + +-- Display names aren't sensitive (and are needed for future social/team +-- features + name-availability), so any authenticated user may read them. +drop policy if exists "Profiles readable by authenticated" on public.profiles; +create policy "Profiles readable by authenticated" + on public.profiles for select to authenticated using (true); + +drop policy if exists "Users insert own profile" on public.profiles; +create policy "Users insert own profile" + on public.profiles for insert to authenticated with check (auth.uid() = user_id); + +drop policy if exists "Users update own profile" on public.profiles; +create policy "Users update own profile" + on public.profiles for update to authenticated + using (auth.uid() = user_id) with check (auth.uid() = user_id); + +-- ── Random silly name generation ───────────────────────────────────────────── +-- Adjective + (lightly leetified) noun + "-" + 3 digits, e.g. "SpeedyRac3r-546". +-- Retries until the generated name is free. +create or replace function public.random_display_name() +returns text language plpgsql as $$ +declare + adjs text[] := array[ + 'Speedy','Turbo','Drifty','Nitro','Reckless','Smooth','Apex','Sideways', + 'Greasy','Loose','Sketchy','Mighty','Sneaky','Wobbly','Blazing','Rowdy', + 'Janky','Cosmic','Feral','Zippy']; + nouns text[] := array[ + 'Racer','Driver','Pilot','Hooligan','Throttle','Slider','Charger','Rocket', + 'Gremlin','Goblin','Wrench','Piston','Sender','Drifter','Maniac','Comet', + 'Bandit','Cheetah','Noodle','Menace']; + candidate text; +begin + loop + candidate := + adjs[1 + floor(random() * array_length(adjs, 1))::int] + || replace(nouns[1 + floor(random() * array_length(nouns, 1))::int], 'e', '3') + || '-' || (100 + floor(random() * 900))::int::text; + exit when not exists (select 1 from public.profiles where display_name = candidate); + end loop; + return candidate; +end; +$$; + +-- Resolve a desired name to a free one: blank → a random silly name; a taken +-- name → suffixed with digits until free. Used at account creation only (user +-- edits get an explicit "taken" error instead — see the client). +create or replace function public.unique_display_name(desired text) +returns text language plpgsql as $$ +declare + d text := nullif(btrim(coalesce(desired, '')), ''); + candidate text; + tries int := 0; +begin + if d is null then + return public.random_display_name(); + end if; + candidate := d; + while exists (select 1 from public.profiles where display_name = candidate) loop + tries := tries + 1; + candidate := d || '-' || (100 + floor(random() * 9900))::int::text; + if tries > 50 then + candidate := d || '-' || replace(gen_random_uuid()::text, '-', ''); + exit; + end if; + end loop; + return candidate; +end; +$$; + +-- ── Auto-create a profile on sign-up ───────────────────────────────────────── +create or replace function public.handle_new_user() +returns trigger language plpgsql security definer set search_path = public as $$ +begin + insert into public.profiles (user_id, display_name) + values (new.id, public.unique_display_name(new.raw_user_meta_data->>'display_name')); + return new; +end; +$$; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + +-- ── Backfill existing users (one at a time so names stay unique) ────────────── +do $$ +declare u record; +begin + for u in + select id, raw_user_meta_data from auth.users + where id not in (select user_id from public.profiles) + loop + insert into public.profiles (user_id, display_name) + values (u.id, public.unique_display_name(u.raw_user_meta_data->>'display_name')); + end loop; +end $$; From 28147fc858be49167bd8005fc4239eebb105e037 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 15:43:28 +0000 Subject: [PATCH 056/121] Bump coach plugin to v0.2.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin + lockfile refresh (0.2.3 → 0.2.4). Peer ranges unchanged (React ^18.3 || ^19, leaflet ^1.9.4). Verified green: typecheck, 328 tests, build; uPlot/Leaflet stay off the initial bundle. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f4fbb45..71db8ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "^0.2.0" + "@perchwerks/eye-in-the-sky": "0.2.4" } }, "node_modules/@alloc/quick-lru": { @@ -2473,9 +2473,9 @@ } }, "node_modules/@perchwerks/eye-in-the-sky": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.3.tgz", - "integrity": "sha512-9qc5fMtbrS9ELiCB2RAw+O2bTh/3hmQ1C83VPwCKcT/VpyIDB+R0OoXhuChrtrrnJJzOF+vQdOQDK0TA9dSmxg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.4.tgz", + "integrity": "sha512-KLITvw1kwA/kR5kJbn7Vhz2UxAxqDYoPi6vD4SXtsyuPUpAr4L4N1fSRea7CyiQoorhlsGGCs9pN6Bg8RG12Cg==", "license": "GPL-3.0-or-later", "optional": true, "dependencies": { diff --git a/package.json b/package.json index bd93559..7d5a229 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,6 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "0.2.3" + "@perchwerks/eye-in-the-sky": "0.2.4" } } From 667e3ad555979b62a5ed795e9a4b33ed7b258bb4 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 15:49:41 +0000 Subject: [PATCH 057/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/integrations/supabase/types.ts | 23 +++++ ...0_6ef31fa9-f46f-458f-b55e-d2fbed5dee49.sql | 99 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 supabase/migrations/20260525154940_6ef31fa9-f46f-458f-b55e-d2fbed5dee49.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 4c72ad7..f69474d 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -202,6 +202,27 @@ export type Database = { } Relationships: [] } + profiles: { + Row: { + created_at: string + display_name: string + updated_at: string + user_id: string + } + Insert: { + created_at?: string + display_name: string + updated_at?: string + user_id: string + } + Update: { + created_at?: string + display_name?: string + updated_at?: string + user_id?: string + } + Relationships: [] + } quota_limits: { Row: { max_bytes: number @@ -363,6 +384,7 @@ export type Database = { } Returns: boolean } + random_display_name: { Args: never; Returns: string } sync_record_size: { Args: { p_data: Json; p_store: string } Returns: number @@ -376,6 +398,7 @@ export type Database = { used_bytes: number }[] } + unique_display_name: { Args: { desired: string }; Returns: string } } Enums: { app_role: "admin" | "user" diff --git a/supabase/migrations/20260525154940_6ef31fa9-f46f-458f-b55e-d2fbed5dee49.sql b/supabase/migrations/20260525154940_6ef31fa9-f46f-458f-b55e-d2fbed5dee49.sql new file mode 100644 index 0000000..2bd3eb0 --- /dev/null +++ b/supabase/migrations/20260525154940_6ef31fa9-f46f-458f-b55e-d2fbed5dee49.sql @@ -0,0 +1,99 @@ +-- User profiles: a unique, user-editable display name per account. +create table if not exists public.profiles ( + user_id uuid primary key references auth.users (id) on delete cascade, + display_name text not null unique, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table public.profiles enable row level security; + +drop policy if exists "Profiles readable by authenticated" on public.profiles; +create policy "Profiles readable by authenticated" + on public.profiles for select to authenticated using (true); + +drop policy if exists "Users insert own profile" on public.profiles; +create policy "Users insert own profile" + on public.profiles for insert to authenticated with check (auth.uid() = user_id); + +drop policy if exists "Users update own profile" on public.profiles; +create policy "Users update own profile" + on public.profiles for update to authenticated + using (auth.uid() = user_id) with check (auth.uid() = user_id); + +create or replace function public.random_display_name() +returns text language plpgsql +set search_path = public +as $$ +declare + adjs text[] := array[ + 'Speedy','Turbo','Drifty','Nitro','Reckless','Smooth','Apex','Sideways', + 'Greasy','Loose','Sketchy','Mighty','Sneaky','Wobbly','Blazing','Rowdy', + 'Janky','Cosmic','Feral','Zippy']; + nouns text[] := array[ + 'Racer','Driver','Pilot','Hooligan','Throttle','Slider','Charger','Rocket', + 'Gremlin','Goblin','Wrench','Piston','Sender','Drifter','Maniac','Comet', + 'Bandit','Cheetah','Noodle','Menace']; + candidate text; +begin + loop + candidate := + adjs[1 + floor(random() * array_length(adjs, 1))::int] + || replace(nouns[1 + floor(random() * array_length(nouns, 1))::int], 'e', '3') + || '-' || (100 + floor(random() * 900))::int::text; + exit when not exists (select 1 from public.profiles where display_name = candidate); + end loop; + return candidate; +end; +$$; + +create or replace function public.unique_display_name(desired text) +returns text language plpgsql +set search_path = public +as $$ +declare + d text := nullif(btrim(coalesce(desired, '')), ''); + candidate text; + tries int := 0; +begin + if d is null then + return public.random_display_name(); + end if; + candidate := d; + while exists (select 1 from public.profiles where display_name = candidate) loop + tries := tries + 1; + candidate := d || '-' || (100 + floor(random() * 9900))::int::text; + if tries > 50 then + candidate := d || '-' || replace(gen_random_uuid()::text, '-', ''); + exit; + end if; + end loop; + return candidate; +end; +$$; + +create or replace function public.handle_new_user() +returns trigger language plpgsql security definer set search_path = public as $$ +begin + insert into public.profiles (user_id, display_name) + values (new.id, public.unique_display_name(new.raw_user_meta_data->>'display_name')); + return new; +end; +$$; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + +do $$ +declare u record; +begin + for u in + select id, raw_user_meta_data from auth.users + where id not in (select user_id from public.profiles) + loop + insert into public.profiles (user_id, display_name) + values (u.id, public.unique_display_name(u.raw_user_meta_data->>'display_name')); + end loop; +end $$; \ No newline at end of file From 81a6b0aa621a3e099b5eb529867eb9166ad3ee55 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 15:49:53 +0000 Subject: [PATCH 058/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- .../20260525154952_b16239ef-0e8a-4c99-9a37-74f226bcd69a.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 supabase/migrations/20260525154952_b16239ef-0e8a-4c99-9a37-74f226bcd69a.sql diff --git a/supabase/migrations/20260525154952_b16239ef-0e8a-4c99-9a37-74f226bcd69a.sql b/supabase/migrations/20260525154952_b16239ef-0e8a-4c99-9a37-74f226bcd69a.sql new file mode 100644 index 0000000..3d56b38 --- /dev/null +++ b/supabase/migrations/20260525154952_b16239ef-0e8a-4c99-9a37-74f226bcd69a.sql @@ -0,0 +1 @@ +revoke execute on function public.handle_new_user() from public, anon, authenticated; \ No newline at end of file From 830c5037895f794820f6f73ea52503567654cc64 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 16:12:11 +0000 Subject: [PATCH 059/121] Work in progress --- bun.lock | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 5cb90d8..3d4a0e4 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,7 @@ "vitest": "^4.1.6", }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "0.2.3", + "@perchwerks/eye-in-the-sky": "0.2.4", }, }, }, @@ -369,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.2.3", "", { "dependencies": { "uplot": "^1.6.32" }, "peerDependencies": { "leaflet": "^1.9.4", "react": "^18.3 || ^19.0.0", "react-dom": "^18.3 || ^19.0.0" } }, "sha512-9qc5fMtbrS9ELiCB2RAw+O2bTh/3hmQ1C83VPwCKcT/VpyIDB+R0OoXhuChrtrrnJJzOF+vQdOQDK0TA9dSmxg=="], + "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.2.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.4.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-KLITvw1kwA/kR5kJbn7Vhz2UxAxqDYoPi6vD4SXtsyuPUpAr4L4N1fSRea7CyiQoorhlsGGCs9pN6Bg8RG12Cg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -1525,8 +1525,6 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@eslint/eslintrc/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], @@ -1537,8 +1535,6 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1571,8 +1567,6 @@ "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], @@ -1599,8 +1593,6 @@ "@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], From da67f517b9cbc3ebb7b5bdb8a725ef5702cafd6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 16:46:48 +0000 Subject: [PATCH 060/121] Add cloud log deletion to the Profile tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Profile-tab panel (CloudLogsPanel) lists the session log files in the user's cloud — name, upload date, size, on-device marker — and deletes them. - deleteCloudFile(userId, name): removes the bucket blob + its sync_records index row (cloud-only) and the per-file sync selection. Other devices keep their downloaded copy. - Per-file confirm with a "can't be undone" warning; when the file is also on this device, an opt-in toggle additionally deletes the local copy here. - listCloudFiles now returns uploadedAt (from the index row's updated_at). https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 5 + CLAUDE.md | 8 ++ src/plugins/cloud-sync/CloudLogsPanel.tsx | 151 ++++++++++++++++++++++ src/plugins/cloud-sync/index.ts | 13 +- src/plugins/cloud-sync/syncEngine.ts | 25 +++- 5 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 src/plugins/cloud-sync/CloudLogsPanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 773cca5..44bfef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 from **every device and the cloud**, with a clear warning before you confirm. - New **Profile** tab (far right) showing your cloud storage usage against the document and log limits. +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index 396f0bf..9cd4386 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,6 +197,7 @@ src/ │ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord/pushDocs/pullDocs + getStorageUsage │ │ ├── autoSync.ts # Background doc auto-sync: subscribes to garageEvents, debounced upsert/delete + reconcile on sign-in │ │ ├── StoragePanel.tsx # Profile-tab panel: display-name editor + storage usage meters (lazy) +│ │ ├── CloudLogsPanel.tsx # Profile-tab panel: list + delete cloud log files (cloud-only; opt-in local delete) (lazy) │ │ ├── profile.ts # getMyProfile / updateDisplayName (unique display names; taken-name handling) │ │ └── cloudClient.ts # Typed access to sync_records + bucket + sync_storage_usage RPC (escape hatch until types regen) │ └── coaching/ # Gitignored private slot (AI coaching submodule) @@ -432,6 +433,13 @@ Synced stores (`syncStores.ts` — pure, unit-tested): `metadata`, `karts`, docs) + `files` (blobs). Video stores are intentionally excluded (size). `vehicle-types`/`setup-templates` ride along because setups are template-driven. +Cloud **log deletion** is managed on the Profile tab (`CloudLogsPanel`): +`listCloudFiles` (now 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), clears the per-file +selection, and optionally deletes the local copy on this device. (Auto-propagation +of log deletes on local delete is still a separate follow-up.) + 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")`). `pushAll` uploads diff --git a/src/plugins/cloud-sync/CloudLogsPanel.tsx b/src/plugins/cloud-sync/CloudLogsPanel.tsx new file mode 100644 index 0000000..0782282 --- /dev/null +++ b/src/plugins/cloud-sync/CloudLogsPanel.tsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useState } from "react"; +import { FileText, Trash2, AlertTriangle } from "lucide-react"; +import { toast } from "sonner"; +import type { PluginPanelProps } from "@/plugins/panels"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/contexts/AuthContext"; +import { deleteFile, listFiles } from "@/lib/fileStorage"; +import { deleteCloudFile, listCloudFiles, type CloudFile } from "./syncEngine"; +import { unselectFile } from "./fileSync"; +import { formatBytes } from "./storageTypes"; + +function formatDate(iso?: string): string { + if (!iso) return "—"; + const d = new Date(iso); + return isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +// Profile-tab panel: manage the log files stored in YOUR cloud. Deleting removes +// the cloud copy only (other devices keep what they've downloaded); an opt-in +// toggle also removes the local copy from this device. +export default function CloudLogsPanel(_props: PluginPanelProps) { + const { user, loading } = useAuth(); + const [files, setFiles] = useState(null); + const [localNames, setLocalNames] = useState>(new Set()); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(null); + const [alsoLocal, setAlsoLocal] = useState(false); + const [busy, setBusy] = useState(null); + + const refresh = useCallback(async () => { + if (!user) return; + try { + const [cloud, local] = await Promise.all([listCloudFiles(user.id), listFiles()]); + cloud.sort((a, b) => (b.uploadedAt ?? "").localeCompare(a.uploadedAt ?? "")); + setFiles(cloud); + setLocalNames(new Set(local.map((f) => f.name))); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load cloud files"); + } + }, [user]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + if (loading) return

Loading…

; + if (!user) { + return ( +

+ Sign in to manage the log files stored in your cloud. +

+ ); + } + + const startConfirm = (name: string) => { + setConfirming(name); + setAlsoLocal(false); + }; + + const handleDelete = async (file: CloudFile) => { + const removeLocal = alsoLocal && localNames.has(file.name); + setBusy(file.name); + try { + await deleteCloudFile(user.id, file.name); + await unselectFile(file.name); + if (removeLocal) await deleteFile(file.name); + toast.success( + removeLocal + ? `Deleted "${file.name}" from the cloud and this device.` + : `Deleted "${file.name}" from the cloud.`, + ); + setConfirming(null); + setAlsoLocal(false); + await refresh(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Delete failed"); + } finally { + setBusy(null); + } + }; + + if (error) return

{error}

; + if (!files) return

Loading cloud files…

; + if (files.length === 0) { + return

No log files in your cloud yet.

; + } + + return ( +
+ {files.map((file) => { + const isConfirming = confirming === file.name; + const onDevice = localNames.has(file.name); + return ( +
+
+ +
+

{file.name}

+

+ {formatDate(file.uploadedAt)} + {file.size != null && ` · ${formatBytes(file.size)}`} + {onDevice && " · on this device"} +

+
+ +
+ + {isConfirming && ( +
+

+ + Permanently delete the cloud copy? This can't be undone. +

+ {onDevice && ( +
+ + +
+ )} +
+ + +
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index 232f9da..b23469f 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -16,8 +16,9 @@ const CloudSyncPanel = lazy(() => import("./CloudSyncPanel")); // drawer doesn't pull the sync engine onto its chunk until they render. const FileSyncToggle = lazy(() => import("./FileSyncToggle")); const CloudFilesSection = lazy(() => import("./CloudFilesSection")); -// Profile tab panel: storage usage meters + account scratch pad. +// Profile tab panels: storage usage meters + account, and cloud-log management. const StoragePanel = lazy(() => import("./StoragePanel")); +const CloudLogsPanel = lazy(() => import("./CloudLogsPanel")); const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; @@ -67,6 +68,16 @@ const plugin: DataViewerPlugin = { component: StoragePanel, } satisfies PluginPanel); + // Profile tab: manage (delete) the log files stored in the cloud. + ctx.registry.contribute(PANELS_POINT, { + id: "cloud-sync-logs", + title: "Cloud logs", + slot: PanelSlot.Profile, + order: 10, + icon: Cloud, + component: CloudLogsPanel, + } satisfies PluginPanel); + // Background document auto-sync. Dynamically imported so the sync engine // stays off the initial bundle; the notifier routes quota warnings to a // toast (keeping autoSync itself free of any UI dependency). diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index 85d33af..aec3e08 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -59,21 +59,42 @@ export async function pushFile(userId: string, name: string): Promise { export interface CloudFile { name: string; size?: number; + /** When the file was last uploaded (ISO string from the index row). */ + uploadedAt?: string; } /** List the files this user has in the cloud (the file index rows). */ export async function listCloudFiles(userId: string): Promise { const { data, error } = await syncRecords() - .select("record_key,data") + .select("record_key,data,updated_at") .eq("user_id", userId) .eq("store", FILE_STORE); if (error) throw new Error(`Failed to list cloud files: ${error.message}`); - return ((data ?? []) as { record_key: string; data: { size?: number } | null }[]).map((r) => ({ + return ( + (data ?? []) as { record_key: string; data: { size?: number } | null; updated_at?: string }[] + ).map((r) => ({ name: r.record_key, size: r.data?.size, + uploadedAt: r.updated_at, })); } +/** + * Delete one log file from the cloud: the blob in the bucket + its index row. + * Does NOT touch any device's local copy — callers handle local deletion + * separately (and only for the current device). + */ +export async function deleteCloudFile(userId: string, name: string): Promise { + const { error: rmErr } = await userFiles().remove([blobPath(userId, name)]); + if (rmErr) throw new Error(`Failed to delete cloud file: ${rmErr.message}`); + const { error } = await syncRecords() + .delete() + .eq("user_id", userId) + .eq("store", FILE_STORE) + .eq("record_key", name); + if (error) throw new Error(`Failed to remove cloud file index: ${error.message}`); +} + /** Download a single file blob from the cloud (does not persist it locally). */ export async function downloadCloudFile(userId: string, name: string): Promise { const { data, error } = await userFiles().download(blobPath(userId, name)); From ff0253d9ed19709fe4e44268d7a388eb34429a0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 17:28:15 +0000 Subject: [PATCH 061/121] Offline-aware, conflict-safe document sync (timestamp merge + pending set) Stops auto-sync from stomping edits and makes offline changes safe. - Every garage record carries updatedAt: added to Vehicle/VehicleType; stamped in each storage save* (the sync write path keeps the cloud value). - merge.ts (pure, tested): decideSync = pending-wins + last-write-wins by the record's own updatedAt (not the server row time). - pendingSync.ts: persistent offline "pending changes" set in the plugin KV. - syncEngine.reconcileDocs: timestamp-aware two-way merge (pull cloud-newer, push local-newer/-only), skipping pending keys; replaces pushDocs/pullDocs. - autoSync: tracks navigator.onLine + window online/offline; records changes as pending when offline or on failed push; on reconnect/sign-in flushes pending first (priority-1, replacing cloud) then reconciles the rest. - StoragePanel flags offline state + the pending-change count. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 6 ++ CLAUDE.md | 28 +++++-- src/lib/noteStorage.ts | 3 +- src/lib/setupStorage.ts | 3 +- src/lib/templateStorage.ts | 9 ++- src/lib/vehicleStorage.ts | 5 +- src/plugins/cloud-sync/StoragePanel.tsx | 28 ++++++- src/plugins/cloud-sync/autoSync.ts | 98 +++++++++++++++++------ src/plugins/cloud-sync/merge.test.ts | 42 ++++++++++ src/plugins/cloud-sync/merge.ts | 48 ++++++++++++ src/plugins/cloud-sync/pendingSync.ts | 54 +++++++++++++ src/plugins/cloud-sync/syncEngine.ts | 100 +++++++++++++++++++----- 12 files changed, 362 insertions(+), 62 deletions(-) create mode 100644 src/plugins/cloud-sync/merge.test.ts create mode 100644 src/plugins/cloud-sync/merge.ts create mode 100644 src/plugins/cloud-sync/pendingSync.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 44bfef6..c632c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `user_roles`). ### Changed +- 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. diff --git a/CLAUDE.md b/CLAUDE.md index 9cd4386..cf04df2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,6 +193,8 @@ src/ │ │ ├── CloudFilesSection.tsx # FileManagerSection mount: lists all cloud files (on-device marked, others pullable) │ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus/cloudOnlyNames (pure, tested) │ │ ├── syncStores.ts # Pure config: which IDB stores sync + how they're keyed (testable) +│ │ ├── merge.ts # ★ Pure conflict resolution: decideSync (pending-wins + updatedAt LWW), pendingId (tested) +│ │ ├── pendingSync.ts # Persistent offline "pending changes" set (plugin KV); flushed priority-1 on reconnect │ │ ├── storageTypes.ts # Pure: storage types (documents 5MB / logs 20MB) + usage math (tested) │ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord/pushDocs/pullDocs + getStorageUsage │ │ ├── autoSync.ts # Background doc auto-sync: subscribes to garageEvents, debounced upsert/delete + reconcile on sign-in @@ -400,14 +402,24 @@ To add a new store: increment `DB_VERSION`, add store name to `STORE_NAMES`, add Optional per-user backup/sync of the IndexedDB data above to Supabase. Built as a first-party plugin (Labs + Profile panels), online-only (accepted offline-first exception). Manual push/pull remains (`CloudSyncPanel`), but the **document tier -now auto-syncs**: 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 (put) / deletes (delete)** the one -changed record while signed in, and **reconciles** (pull docs → push docs) on -sign-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. (Log-blob deletion propagation + timestamp merge are -still follow-ups.) +now 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. `autoSync` tracks `navigator.onLine` + window online/offline +events; the Profile-tab `StoragePanel` flags offline state + the pending count. +(Log-blob deletion propagation remains a follow-up.) **Storage types** (`storageTypes.ts`, enforced server-side) — distinct from future *subscription tiers*: **documents** = all structured stores (5 MB, free, diff --git a/src/lib/noteStorage.ts b/src/lib/noteStorage.ts index 52bf31d..c9700d1 100644 --- a/src/lib/noteStorage.ts +++ b/src/lib/noteStorage.ts @@ -29,9 +29,10 @@ export async function listNotes(fileName: string): Promise { } export async function saveNote(note: Note): Promise { + const stamped: Note = { ...note, updatedAt: Date.now() }; const db = await openDB(); const tx = db.transaction(NOTES_STORE, "readwrite"); - tx.objectStore(NOTES_STORE).put(note); + tx.objectStore(NOTES_STORE).put(stamped); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); diff --git a/src/lib/setupStorage.ts b/src/lib/setupStorage.ts index 7902725..188a39f 100644 --- a/src/lib/setupStorage.ts +++ b/src/lib/setupStorage.ts @@ -56,9 +56,10 @@ export async function listSetups(): Promise { } export async function saveSetup(setup: VehicleSetup): Promise { + const stamped: VehicleSetup = { ...setup, updatedAt: Date.now() }; const db = await openDB(); const tx = db.transaction(SETUPS_STORE, "readwrite"); - tx.objectStore(SETUPS_STORE).put(setup); + tx.objectStore(SETUPS_STORE).put(stamped); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); diff --git a/src/lib/templateStorage.ts b/src/lib/templateStorage.ts index 2a5065d..8ac60b1 100644 --- a/src/lib/templateStorage.ts +++ b/src/lib/templateStorage.ts @@ -43,6 +43,8 @@ export interface VehicleType { wheelCount: 2 | 4; isDefault: boolean; createdAt: number; + /** Last local edit time (ms) — set by saveVehicleType; used for sync merge. */ + updatedAt?: number; } // ── Default Kart Template ── @@ -162,9 +164,10 @@ export async function getVehicleType(id: string): Promise { } export async function saveVehicleType(vt: VehicleType): Promise { + const stamped: VehicleType = { ...vt, updatedAt: Date.now() }; const db = await openDB(); const tx = db.transaction(STORE_NAMES.VEHICLE_TYPES, "readwrite"); - tx.objectStore(STORE_NAMES.VEHICLE_TYPES).put(vt); + tx.objectStore(STORE_NAMES.VEHICLE_TYPES).put(stamped); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); @@ -212,9 +215,10 @@ export async function getTemplate(id: string): Promise { } export async function saveTemplate(template: SetupTemplate): Promise { + const stamped: SetupTemplate = { ...template, updatedAt: Date.now() }; const db = await openDB(); const tx = db.transaction(STORE_NAMES.SETUP_TEMPLATES, "readwrite"); - tx.objectStore(STORE_NAMES.SETUP_TEMPLATES).put(template); + tx.objectStore(STORE_NAMES.SETUP_TEMPLATES).put(stamped); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); @@ -256,6 +260,7 @@ export async function createVehicleTypeWithTemplate( wheelCount, isDefault: false, createdAt: now, + updatedAt: now, }; const template: SetupTemplate = { diff --git a/src/lib/vehicleStorage.ts b/src/lib/vehicleStorage.ts index e192587..6b9abff 100644 --- a/src/lib/vehicleStorage.ts +++ b/src/lib/vehicleStorage.ts @@ -14,14 +14,17 @@ export interface Vehicle { number: number; weight: number; weightUnit: "lb" | "kg"; + /** Last local edit time (ms) — set by saveVehicle; used for sync merge. */ + updatedAt?: number; } const VEHICLES_STORE = STORE_NAMES.KARTS; // store name unchanged in IDB export async function saveVehicle(vehicle: Vehicle): Promise { + const stamped: Vehicle = { ...vehicle, updatedAt: Date.now() }; const db = await openDB(); const tx = db.transaction(VEHICLES_STORE, "readwrite"); - tx.objectStore(VEHICLES_STORE).put(vehicle); + tx.objectStore(VEHICLES_STORE).put(stamped); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); diff --git a/src/plugins/cloud-sync/StoragePanel.tsx b/src/plugins/cloud-sync/StoragePanel.tsx index 6e93b8a..c7931f6 100644 --- a/src/plugins/cloud-sync/StoragePanel.tsx +++ b/src/plugins/cloud-sync/StoragePanel.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useState } from "react"; -import { Check, Pencil, User as UserIcon, X } from "lucide-react"; +import { Check, CloudOff, Pencil, User as UserIcon, X } from "lucide-react"; import { toast } from "sonner"; import type { PluginPanelProps } from "@/plugins/panels"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; import { getStorageUsage } from "./syncEngine"; import { getMyProfile, updateDisplayName } from "./profile"; +import { pendingCount } from "./pendingSync"; import { formatBytes, usageFraction, type StorageTypeUsage } from "./storageTypes"; const TYPE_LABEL: Record = { documents: "Documents", logs: "Logs" }; @@ -19,11 +21,14 @@ const TYPE_HINT: Record = { // usage against the document/log storage limits. export default function StoragePanel(_props: PluginPanelProps) { const { user, loading } = useAuth(); + const online = useOnlineStatus(); const [usage, setUsage] = useState(null); const [error, setError] = useState(null); + const [pending, setPending] = useState(0); const refresh = useCallback(async () => { if (!user) return; + setPending(await pendingCount()); try { setUsage(await getStorageUsage()); setError(null); @@ -32,9 +37,11 @@ export default function StoragePanel(_props: PluginPanelProps) { } }, [user]); + // Re-read on mount and whenever connectivity flips (pending changes flush on + // reconnect, so the count + usage should refresh then). useEffect(() => { void refresh(); - }, [refresh]); + }, [refresh, online]); if (loading) return

Loading…

; @@ -53,6 +60,23 @@ export default function StoragePanel(_props: PluginPanelProps) {
+ {!online && ( +
+ + + You're offline.{" "} + {pending > 0 + ? `${pending} change${pending === 1 ? "" : "s"} saved locally — they'll sync when you reconnect.` + : "Changes are saved locally and will sync when you reconnect."} + +
+ )} + {online && pending > 0 && ( +

+ Syncing {pending} pending change{pending === 1 ? "" : "s"}… +

+ )} +

Storage

{error &&

{error}

} diff --git a/src/plugins/cloud-sync/autoSync.ts b/src/plugins/cloud-sync/autoSync.ts index 019decf..f6546da 100644 --- a/src/plugins/cloud-sync/autoSync.ts +++ b/src/plugins/cloud-sync/autoSync.ts @@ -1,23 +1,27 @@ -// Background document auto-sync. +// Background, offline-aware document auto-sync. // -// Runs outside React: tracks the signed-in user via the Supabase auth session -// and listens to host garage-change events (vehicles/setups/templates/types/ -// notes). On a change it debounces, then upserts (put) or deletes (delete) the -// single cloud record — so edits propagate up and deletes propagate everywhere. -// On sign-in it reconciles (pull cloud docs down, push local docs up). Only the -// free "documents" storage type auto-syncs here; log blobs stay manual/opt-in. +// Runs outside React: tracks the signed-in user via the Supabase session and +// listens to host garage-change events (vehicles/setups/templates/types/notes). +// • Online: debounce, then upsert (put) or delete the single changed record. +// • Offline (or a failed push): the change is recorded as *pending* so it isn't +// lost; on reconnect the pending set flushes first as priority-1 (replacing +// the cloud state), then a timestamp-aware reconcile merges the rest. +// On sign-in it reconciles too. Only the free "documents" storage type syncs +// here; log blobs stay manual/opt-in. import { supabase } from "@/integrations/supabase/client"; import { onGarageChange, type GarageChange } from "@/lib/garageEvents"; import { isQuotaError } from "./cloudClient"; -import { deleteRecord, pullDocs, pushDocs, pushRecord } from "./syncEngine"; +import { deleteRecord, pushRecord, reconcileDocs } from "./syncEngine"; +import { clearPending, listPending, markPending, pendingKeySet } from "./pendingSync"; +import { pendingId } from "./merge"; import { storageTypeForStore } from "./storageTypes"; const DEBOUNCE_MS = 800; let currentUserId: string | null = null; let started = false; -const pending = new Map>(); +const timers = new Map>(); /** Injected so this module stays free of any toast/UI dependency. */ type Notifier = (message: string, kind: "error" | "info") => void; @@ -26,19 +30,25 @@ export function setAutoSyncNotifier(fn: Notifier): void { notify = fn; } -function recordKey(change: GarageChange): string { - return `${change.store}:${change.key}`; +function isOnline(): boolean { + return typeof navigator === "undefined" ? true : navigator.onLine; +} + +async function pushOne(userId: string, change: GarageChange): Promise { + if (change.type === "delete") await deleteRecord(userId, change.store, change.key); + else await pushRecord(userId, change.store, change.key); } async function flush(change: GarageChange): Promise { const userId = currentUserId; if (!userId) return; + if (!isOnline()) { + await markPending(change); + return; + } try { - if (change.type === "delete") { - await deleteRecord(userId, change.store, change.key); - } else { - await pushRecord(userId, change.store, change.key); - } + await pushOne(userId, change); + await clearPending(change.store, change.key); } catch (err) { if (isQuotaError(err)) { notify( @@ -46,29 +56,52 @@ async function flush(change: GarageChange): Promise { "error", ); } else { - console.error("auto-sync failed", err); + // Network/other failure → keep it as a pending change to retry on reconnect. + await markPending(change); } } } function schedule(change: GarageChange): void { if (!currentUserId) return; // only sync while signed in - const key = recordKey(change); - const existing = pending.get(key); + if (!isOnline()) { + void markPending(change); + return; + } + const key = pendingId(change.store, change.key); + const existing = timers.get(key); if (existing) clearTimeout(existing); - pending.set( + timers.set( key, setTimeout(() => { - pending.delete(key); + timers.delete(key); void flush(change); }, DEBOUNCE_MS), ); } -async function reconcile(userId: string): Promise { +/** Push every pending change (priority-1); drop each that confirms. */ +async function flushPending(userId: string): Promise { + for (const change of await listPending()) { + try { + await pushOne(userId, change); + await clearPending(change.store, change.key); + } catch (err) { + if (isQuotaError(err)) { + notify( + `Cloud ${storageTypeForStore(change.store)} storage is full — some changes didn't sync.`, + "error", + ); + } + // otherwise leave it pending for the next reconnect + } + } +} + +async function runReconcile(userId: string): Promise { try { - await pullDocs(userId); // cloud → local - await pushDocs(userId); // local-only → cloud (additive) + await flushPending(userId); + await reconcileDocs(userId, await pendingKeySet()); } catch (err) { if (isQuotaError(err)) { notify("Cloud document storage is full — some items didn't sync.", "error"); @@ -78,6 +111,14 @@ async function reconcile(userId: string): Promise { } } +function handleOnline(): void { + if (currentUserId) void runReconcile(currentUserId); +} + +function handleOffline(): void { + notify("You're offline — changes are saved locally and will sync when you reconnect.", "info"); +} + /** Start the background document auto-sync. Idempotent. */ export function startAutoSync(): void { if (started) return; @@ -85,15 +126,20 @@ export function startAutoSync(): void { void supabase.auth.getSession().then(({ data }) => { currentUserId = data.session?.user?.id ?? null; - if (currentUserId) void reconcile(currentUserId); + if (currentUserId) void runReconcile(currentUserId); }); supabase.auth.onAuthStateChange((_event, session) => { const next = session?.user?.id ?? null; const newlySignedIn = next !== null && next !== currentUserId; currentUserId = next; - if (newlySignedIn) void reconcile(next); + if (newlySignedIn) void runReconcile(next); }); onGarageChange(schedule); + + if (typeof window !== "undefined") { + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + } } diff --git a/src/plugins/cloud-sync/merge.test.ts b/src/plugins/cloud-sync/merge.test.ts new file mode 100644 index 0000000..126acdd --- /dev/null +++ b/src/plugins/cloud-sync/merge.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { decideSync, recordUpdatedAt } from "./merge"; + +const base = { hasLocal: true, hasCloud: true, localT: 0, cloudT: 0, pending: false }; + +describe("decideSync", () => { + it("pushes a pending local change regardless of timestamps (priority-1)", () => { + expect(decideSync({ ...base, pending: true, localT: 1, cloudT: 999 })).toBe("push"); + }); + + it("skips a pending change with no local record (a pending delete)", () => { + // The delete is flushed separately; we must not resurrect it from the cloud. + expect(decideSync({ ...base, pending: true, hasLocal: false, hasCloud: true })).toBe("skip"); + }); + + it("pushes a local-only record (e.g. anon → new account migration)", () => { + expect(decideSync({ ...base, hasCloud: false })).toBe("push"); + }); + + it("pulls a cloud-only record", () => { + expect(decideSync({ ...base, hasLocal: false })).toBe("pull"); + }); + + it("last-write-wins when both exist", () => { + expect(decideSync({ ...base, localT: 200, cloudT: 100 })).toBe("push"); + expect(decideSync({ ...base, localT: 100, cloudT: 200 })).toBe("pull"); + expect(decideSync({ ...base, localT: 100, cloudT: 100 })).toBe("skip"); + }); + + it("skips when neither side has the record", () => { + expect(decideSync({ ...base, hasLocal: false, hasCloud: false })).toBe("skip"); + }); +}); + +describe("recordUpdatedAt", () => { + it("reads a numeric updatedAt, else 0", () => { + expect(recordUpdatedAt({ updatedAt: 42 })).toBe(42); + expect(recordUpdatedAt({})).toBe(0); + expect(recordUpdatedAt(null)).toBe(0); + expect(recordUpdatedAt({ updatedAt: "nope" })).toBe(0); + }); +}); diff --git a/src/plugins/cloud-sync/merge.ts b/src/plugins/cloud-sync/merge.ts new file mode 100644 index 0000000..be344b0 --- /dev/null +++ b/src/plugins/cloud-sync/merge.ts @@ -0,0 +1,48 @@ +// Pure conflict-resolution decision for the document auto-sync reconcile. +// +// Rules (see the cloud-sync section in CLAUDE.md): +// 1. A *pending* local change (edited offline or whose push failed) is +// priority-1: a pending put pushes up (replacing the cloud copy); a pending +// delete is skipped here (the delete is flushed separately, so we must not +// resurrect it from the cloud). +// 2. Otherwise last-write-wins by the record's own `updatedAt` (the logical +// edit time, NOT the server row time — a late-uploaded stale edit must not win). +// 3. Local-only → push (covers anon→account migration); cloud-only → pull. + +export type SyncAction = "push" | "pull" | "skip"; + +export interface MergeInput { + hasLocal: boolean; + hasCloud: boolean; + /** Record `updatedAt` (ms); 0 when unknown/absent. */ + localT: number; + cloudT: number; + /** A local change tracked as pending (offline or failed push). */ + pending: boolean; +} + +export function decideSync(i: MergeInput): SyncAction { + if (i.pending) return i.hasLocal ? "push" : "skip"; + if (i.hasLocal && !i.hasCloud) return "push"; + if (i.hasCloud && !i.hasLocal) return "pull"; + if (!i.hasLocal && !i.hasCloud) return "skip"; + if (i.localT > i.cloudT) return "push"; + if (i.cloudT > i.localT) return "pull"; + return "skip"; +} + +// Store names are fixed, space-free ids (e.g. "setup-templates"), and this +// composite is only ever compared for equality (never split), so a space +// separator is collision-safe even when record keys contain spaces. +const SEP = " "; + +/** Stable id for a (store, record key) pair — the reconcile/pending set key. */ +export function pendingId(store: string, key: string): string { + return store + SEP + key; +} + +/** Extract a record's logical edit time; 0 when absent. */ +export function recordUpdatedAt(data: unknown): number { + const u = (data as { updatedAt?: unknown } | null | undefined)?.updatedAt; + return typeof u === "number" ? u : 0; +} diff --git a/src/plugins/cloud-sync/pendingSync.ts b/src/plugins/cloud-sync/pendingSync.ts new file mode 100644 index 0000000..f35435e --- /dev/null +++ b/src/plugins/cloud-sync/pendingSync.ts @@ -0,0 +1,54 @@ +// Persistent "pending changes" set for offline-aware document sync. +// +// A pending entry is a local doc change (put or delete) not yet confirmed in the +// cloud — because we were offline or a push failed. On reconnect these flush +// first as priority-1 (replacing the cloud state). Stored in the plugin's own KV +// so they survive a reload while offline. + +import { getPluginStore } from "@/plugins/storage"; +import type { GarageChangeType } from "@/lib/garageEvents"; +import { pendingId } from "./merge"; + +export interface PendingChange { + store: string; + key: string; + type: GarageChangeType; +} + +const store = getPluginStore("cloud-sync"); +const KEY = "pending-changes"; + +async function read(): Promise { + return (await store.get(KEY)) ?? []; +} + +/** Record (or update) a pending change; the latest op for a key wins. */ +export async function markPending(change: PendingChange): Promise { + const list = await read(); + const i = list.findIndex((c) => c.store === change.store && c.key === change.key); + if (i >= 0) list[i] = change; + else list.push(change); + await store.set(KEY, list); +} + +/** Drop a pending entry once it's been confirmed in the cloud. */ +export async function clearPending(store_: string, key: string): Promise { + const list = await read(); + await store.set( + KEY, + list.filter((c) => !(c.store === store_ && c.key === key)), + ); +} + +export async function listPending(): Promise { + return read(); +} + +export async function pendingCount(): Promise { + return (await read()).length; +} + +/** Set of `pendingId`s currently pending — passed to the reconcile merge. */ +export async function pendingKeySet(): Promise> { + return new Set((await read()).map((c) => pendingId(c.store, c.key))); +} diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index aec3e08..2d06cb2 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -17,6 +17,7 @@ import { fetchStorageUsage, syncRecords, userFiles, type SyncRecordRow } from ". import { DOC_STORES, FILE_STORE, extractKey, type SyncSummary } from "./syncStores"; import { listSelectedFiles, markPushed } from "./fileSync"; import { DEFAULT_LIMITS, type StorageType, type StorageTypeUsage } from "./storageTypes"; +import { decideSync, pendingId, recordUpdatedAt } from "./merge"; export type { SyncSummary }; @@ -184,37 +185,94 @@ export async function deleteRecord(userId: string, store: string, key: string): if (error) throw new Error(error.message); } -/** Mirror only the structured (free documents storage type) stores up — no file blobs. */ -export async function pushDocs(userId: string): Promise { - const rows: SyncRecordRow[] = []; - for (const store of DOC_STORES) { - for (const record of await readAll(store)) { - rows.push({ user_id: userId, store, record_key: extractKey(store, record), data: record }); - } - } - if (rows.length) { - const { error } = await syncRecords().upsert(rows, { onConflict: "user_id,store,record_key" }); - if (error) throw new Error(`Failed to push documents: ${error.message}`); - } - return rows.length; +export interface DocReconcileResult { + pulled: number; + pushed: number; } -/** Bring only the documents-type records down into local IndexedDB (no files). */ -export async function pullDocs(userId: string): Promise { +/** + * Timestamp-aware two-way merge of the document stores (no file blobs): + * - newer side wins by the record's own `updatedAt` (last-write-wins); + * - local-only records push up (anon→account migration); + * - cloud-only records pull down; + * - anything in `pendingKeys` is treated as priority-1 local and pushed, + * overriding the timestamp comparison. + * Local writes go through `writeOne` (no garage event), so a pull doesn't echo + * back as a change. Run this AFTER flushing pending deletes. + */ +export async function reconcileDocs( + userId: string, + pendingKeys: Set, +): Promise { const { data, error } = await syncRecords() .select("store,record_key,data") .eq("user_id", userId); if (error) throw new Error(`Failed to read cloud documents: ${error.message}`); - const rows = (data ?? []) as Pick[]; - let records = 0; - for (const row of rows) { + const cloud = new Map(); + for (const row of (data ?? []) as Pick[]) { if ((DOC_STORES as readonly string[]).includes(row.store)) { - await writeOne(row.store, row.data); - records++; + cloud.set(pendingId(row.store, row.record_key), { + store: row.store, + key: row.record_key, + data: row.data, + t: recordUpdatedAt(row.data), + }); + } + } + + let pulled = 0; + let pushed = 0; + const toPush: SyncRecordRow[] = []; + const seen = new Set(); + + for (const store of DOC_STORES) { + for (const record of await readAll(store)) { + const key = extractKey(store, record); + const id = pendingId(store, key); + seen.add(id); + const c = cloud.get(id); + const action = decideSync({ + hasLocal: true, + hasCloud: !!c, + localT: recordUpdatedAt(record), + cloudT: c?.t ?? 0, + pending: pendingKeys.has(id), + }); + if (action === "push") { + toPush.push({ user_id: userId, store, record_key: key, data: record }); + pushed++; + } else if (action === "pull" && c) { + await writeOne(store, c.data); + pulled++; + } + } + } + + // Cloud-only records (not present locally) → pull down. + for (const [id, c] of cloud) { + if (seen.has(id)) continue; + const action = decideSync({ + hasLocal: false, + hasCloud: true, + localT: 0, + cloudT: c.t, + pending: pendingKeys.has(id), + }); + if (action === "pull") { + await writeOne(c.store, c.data); + pulled++; } } - return records; + + if (toPush.length) { + const { error: upErr } = await syncRecords().upsert(toPush, { + onConflict: "user_id,store,record_key", + }); + if (upErr) throw new Error(`Failed to push documents: ${upErr.message}`); + } + + return { pulled, pushed }; } /** Per-type storage usage from the server, with the advisory limits as fallback. */ From c03e77433786407b5ec8bc874479286fba079c0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 17:59:34 +0000 Subject: [PATCH 062/121] Sync user tracks & courses (documents storage type) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks/courses now cloud-sync like setups — auto-sync, delete propagation, and the offline-aware pending-wins/LWW merge. Only user tracks sync; built-in public tracks stay local. - Store-accessor layer (storeAccessors.ts): the engine's readAll/getOne/putOne route through a per-store accessor — default IndexedDB for existing stores, a localStorage accessor for tracks — so tracks sync without leaving localStorage or migrating data. - trackStorage: Track gains updatedAt (stamped on every CRUD); user edits emit garageEvents (put/delete, incl. rename = delete old + put new); listUserTracks/ getUserTrack/putUserTrackRaw back the accessor (raw put = no stamp/emit, the pull path). - "tracks" added to DOC_STORES, keyed by name. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 6 ++ CLAUDE.md | 14 +++-- src/lib/trackStorage.ts | 73 +++++++++++++++++++++-- src/plugins/cloud-sync/storeAccessors.ts | 57 ++++++++++++++++++ src/plugins/cloud-sync/syncEngine.ts | 13 ++-- src/plugins/cloud-sync/syncStores.test.ts | 6 ++ src/plugins/cloud-sync/syncStores.ts | 5 +- src/types/racing.ts | 1 + 8 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 src/plugins/cloud-sync/storeAccessors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c632c84..8ec48b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Regular accounts have no admin privileges (admin role remains driven by `user_roles`). +### Added +- 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. + ### Changed - Cloud document sync is now **offline-aware and conflict-safe**. Garage records (vehicles, setups, templates, notes) carry an edit timestamp, and sync uses diff --git a/CLAUDE.md b/CLAUDE.md index cf04df2..99c2f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ src/ │ ├── 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 +│ ├── trackStorage.ts # localStorage: tracks + courses (merged with public/tracks.json) + course drawings loader. User tracks emit garageEvents + carry updatedAt → cloud-synced via a store accessor (TRACKS_SYNC_STORE) │ ├── referenceUtils.ts # Reference lap comparison (legacy distance-based pace) │ ├── lapDelta.ts # ★ Position-based lap delta: arc-length resample + segment-projected gap (issue #29 port) │ ├── dbUtils.ts # ★ Shared IndexedDB: DB_NAME, DB_VERSION, openDB(), transaction helpers @@ -192,7 +192,8 @@ src/ │ │ ├── FileSyncToggle.tsx # Per-file sync toggle, mounted on each file row (off/pending/synced) │ │ ├── CloudFilesSection.tsx # FileManagerSection mount: lists all cloud files (on-device marked, others pullable) │ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus/cloudOnlyNames (pure, tested) -│ │ ├── syncStores.ts # Pure config: which IDB stores sync + how they're keyed (testable) +│ │ ├── syncStores.ts # Pure config: which stores sync + how they're keyed (testable) +│ │ ├── storeAccessors.ts # Per-store read/get/put: default IndexedDB accessor + a localStorage accessor for tracks (the non-IDB seam) │ │ ├── merge.ts # ★ Pure conflict resolution: decideSync (pending-wins + updatedAt LWW), pendingId (tested) │ │ ├── pendingSync.ts # Persistent offline "pending changes" set (plugin KV); flushed priority-1 on reconnect │ │ ├── storageTypes.ts # Pure: storage types (documents 5MB / logs 20MB) + usage math (tested) @@ -441,9 +442,14 @@ Backend (migrations `..._cloud_sync.sql`, `..._storage_quotas.sql`): | `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` (jsonb -docs) + `files` (blobs). Video stores are intentionally excluded (size). +`setups`, `notes`, `graph-prefs`, `vehicle-types`, `setup-templates`, `tracks` +(jsonb docs) + `files` (blobs). Video stores are intentionally excluded (size). `vehicle-types`/`setup-templates` ride along because setups are template-driven. +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** is managed on the Profile tab (`CloudLogsPanel`): `listCloudFiles` (now with `uploadedAt`) lists the user's cloud log files; diff --git a/src/lib/trackStorage.ts b/src/lib/trackStorage.ts index 458d775..7911aaf 100644 --- a/src/lib/trackStorage.ts +++ b/src/lib/trackStorage.ts @@ -1,8 +1,16 @@ import { Track, Course, LegacyTrack, SectorLine } from '@/types/racing'; +import { emitGarageChange } from '@/lib/garageEvents'; const STORAGE_KEY = 'racing-datalog-tracks-v2'; const LEGACY_STORAGE_KEY = 'racing-datalog-tracks'; +/** + * Sync "store" name for user tracks (cloud-sync documents type). Tracks live in + * localStorage, not IndexedDB, so cloud-sync reaches them through a dedicated + * store accessor — this constant is the agreed store key on both sides. + */ +export const TRACKS_SYNC_STORE = 'tracks'; + interface DefaultCourseJson { name: string; lengthFt?: number; @@ -252,6 +260,47 @@ export async function loadTracks(): Promise { return merged; } +// ── Cloud-sync accessor helpers (user tracks only) ─────────────────────────── + +/** All user-defined tracks (the syncable overlay; excludes built-in tracks). */ +export function listUserTracks(): Track[] { + return loadUserTracks(); +} + +/** One user track by name, or undefined. */ +export function getUserTrack(name: string): Track | undefined { + return loadUserTracks().find((t) => t.name === name); +} + +/** + * Upsert a user track straight into storage — NO timestamp stamp, NO garage + * event. This is the cloud-sync *pull* write path (preserving the cloud copy's + * updatedAt and avoiding a re-sync echo). User edits go through the CRUD below. + */ +export function putUserTrackRaw(track: Track): void { + const list = loadUserTracks(); + const i = list.findIndex((t) => t.name === track.name); + if (i >= 0) list[i] = track; + else list.push(track); + saveUserTracks(list); +} + +/** Stamp a track's edit time so cloud-sync can merge by last-write-wins. */ +function stampTrack(tracks: Track[], name: string): void { + const t = tracks.find((x) => x.name === name); + if (t) t.updatedAt = Date.now(); +} + +/** After a user edit, emit the right garage event so cloud-sync mirrors it. */ +function emitTrackChange(trackName: string): void { + const stillUser = loadUserTracks().some((t) => t.name === trackName); + emitGarageChange({ + store: TRACKS_SYNC_STORE, + key: trackName, + type: stillUser ? "put" : "delete", + }); +} + /** * Add a new track with an optional initial course. */ @@ -276,7 +325,9 @@ export async function addTrack(trackName: string, course?: Course): Promise c.name === courseName); if (course) { Object.assign(course, updates, { isUserDefined: true }); + stampTrack(tracks, trackName); saveUserTracks(tracks); + emitTrackChange(trackName); } } - + return tracks; } @@ -356,9 +415,12 @@ export async function deleteCourse(trackName: string, courseName: string): Promi const track = tracks.find(t => t.name === trackName); if (track) { track.courses = track.courses.filter(c => c.name !== courseName); + track.updatedAt = Date.now(); saveUserTracks(tracks); + // The track may now be gone from user storage (no user courses left). + emitTrackChange(trackName); } - + return tracks; } @@ -377,8 +439,9 @@ export async function deleteTrack(trackName: string): Promise { tracks = tracks.filter(t => t.name !== trackName); } saveUserTracks(tracks); + emitTrackChange(trackName); } - + return tracks; } diff --git a/src/plugins/cloud-sync/storeAccessors.ts b/src/plugins/cloud-sync/storeAccessors.ts new file mode 100644 index 0000000..644ee03 --- /dev/null +++ b/src/plugins/cloud-sync/storeAccessors.ts @@ -0,0 +1,57 @@ +// Per-store read/get/put accessors for the document sync engine. +// +// Most syncable stores live in IndexedDB and share one accessor. Tracks live in +// localStorage (trackStorage), so they get their own accessor backed by the +// user-track helpers. This is the seam that lets non-IDB data sync through the +// same engine — `reconcileDocs` / `pushRecord` / `writeOne` go through here +// instead of assuming IndexedDB. + +import { withReadTransaction, withWriteTransaction } from "@/lib/dbUtils"; +import type { Track } from "@/types/racing"; +import { + TRACKS_SYNC_STORE, + getUserTrack, + listUserTracks, + putUserTrackRaw, +} from "@/lib/trackStorage"; + +type Record_ = Record; + +export interface StoreAccessor { + readAll(): Promise; + getOne(key: string): Promise; + /** Raw write — must NOT emit a garage event or re-stamp (it's the pull path). */ + putOne(record: Record_): Promise; +} + +function idbAccessor(store: string): StoreAccessor { + return { + readAll: () => withReadTransaction(store, (s) => s.getAll()), + getOne: (key) => withReadTransaction(store, (s) => s.get(key)), + putOne: (record) => withWriteTransaction(store, (s) => s.put(record)), + }; +} + +const tracksAccessor: StoreAccessor = { + readAll: async () => listUserTracks() as unknown as Record_[], + getOne: async (key) => getUserTrack(key) as unknown as Record_ | undefined, + putOne: async (record) => putUserTrackRaw(record as unknown as Track), +}; + +const overrides: Record = { + [TRACKS_SYNC_STORE]: tracksAccessor, +}; + +const idbCache = new Map(); + +/** The accessor for a sync store (localStorage-backed for tracks, else IndexedDB). */ +export function getAccessor(store: string): StoreAccessor { + const override = overrides[store]; + if (override) return override; + let accessor = idbCache.get(store); + if (!accessor) { + accessor = idbAccessor(store); + idbCache.set(store, accessor); + } + return accessor; +} diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index 2d06cb2..0a934ce 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -11,8 +11,8 @@ // adding a new syncable store is a single entry in syncStores.ts. File blobs // can't live in jsonb, so they round-trip through the Storage bucket instead. -import { withReadTransaction, withWriteTransaction } from "@/lib/dbUtils"; import { getFile, saveFile } from "@/lib/fileStorage"; +import { getAccessor } from "./storeAccessors"; import { fetchStorageUsage, syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; import { DOC_STORES, FILE_STORE, extractKey, type SyncSummary } from "./syncStores"; import { listSelectedFiles, markPushed } from "./fileSync"; @@ -26,12 +26,14 @@ function blobPath(userId: string, name: string): string { return `${userId}/${encodeURIComponent(name)}`; } +// Route through the per-store accessor (IndexedDB for most stores, localStorage +// for tracks) instead of assuming IndexedDB. async function readAll(store: string): Promise[]> { - return withReadTransaction[]>(store, (s) => s.getAll()); + return getAccessor(store).readAll(); } async function writeOne(store: string, record: unknown): Promise { - await withWriteTransaction(store, (s) => s.put(record as Record)); + await getAccessor(store).putOne(record as Record); } /** Upload one local file blob + its index row. Returns false if not stored locally. */ @@ -163,10 +165,7 @@ export async function pullAll(userId: string): Promise { * (including the server quota rejection — see `isQuotaError`). */ export async function pushRecord(userId: string, store: string, key: string): Promise { - const record = await withReadTransaction | undefined>( - store, - (s) => s.get(key), - ); + const record = await getAccessor(store).getOne(key); if (record == null) return; const { error } = await syncRecords().upsert( [{ user_id: userId, store, record_key: key, data: record }], diff --git a/src/plugins/cloud-sync/syncStores.test.ts b/src/plugins/cloud-sync/syncStores.test.ts index 6a9955f..d4fc2d6 100644 --- a/src/plugins/cloud-sync/syncStores.test.ts +++ b/src/plugins/cloud-sync/syncStores.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { STORE_NAMES } from "@/lib/dbUtils"; +import { TRACKS_SYNC_STORE } from "@/lib/trackStorage"; import { extractKey, DOC_STORES, FILE_STORE } from "./syncStores"; describe("extractKey", () => { @@ -13,6 +14,10 @@ describe("extractKey", () => { it("coerces non-string keys to string", () => { expect(extractKey(STORE_NAMES.NOTES, { id: 42 })).toBe("42"); }); + + it("keys user tracks by name", () => { + expect(extractKey(TRACKS_SYNC_STORE, { name: "Local Kart Track" })).toBe("Local Kart Track"); + }); }); describe("synced store coverage", () => { @@ -35,6 +40,7 @@ describe("synced store coverage", () => { STORE_NAMES.VEHICLE_TYPES, STORE_NAMES.SETUP_TEMPLATES, STORE_NAMES.METADATA, + TRACKS_SYNC_STORE, ]) { expect(DOC_STORES).toContain(store); } diff --git a/src/plugins/cloud-sync/syncStores.ts b/src/plugins/cloud-sync/syncStores.ts index d23c03a..4d17495 100644 --- a/src/plugins/cloud-sync/syncStores.ts +++ b/src/plugins/cloud-sync/syncStores.ts @@ -3,8 +3,9 @@ // stays unit-testable in a node environment. import { STORE_NAMES } from "@/lib/dbUtils"; +import { TRACKS_SYNC_STORE } from "@/lib/trackStorage"; -/** IndexedDB key path for each syncable store. */ +/** Key path for each syncable store (IndexedDB stores + the localStorage tracks). */ const KEY_FIELD: Record = { [STORE_NAMES.METADATA]: "fileName", [STORE_NAMES.KARTS]: "id", @@ -15,6 +16,7 @@ const KEY_FIELD: Record = { // with them or pulled setups can't render. [STORE_NAMES.VEHICLE_TYPES]: "id", [STORE_NAMES.SETUP_TEMPLATES]: "id", + [TRACKS_SYNC_STORE]: "name", // user tracks (localStorage, via a store accessor) [STORE_NAMES.FILES]: "name", }; @@ -27,6 +29,7 @@ export const DOC_STORES = [ STORE_NAMES.GRAPH_PREFS, STORE_NAMES.VEHICLE_TYPES, STORE_NAMES.SETUP_TEMPLATES, + TRACKS_SYNC_STORE, ] as const; /** Store whose payload is a Blob, synced through the Storage bucket. */ diff --git a/src/types/racing.ts b/src/types/racing.ts index d265b6f..8d26d5a 100644 --- a/src/types/racing.ts +++ b/src/types/racing.ts @@ -37,6 +37,7 @@ export interface Track { shortName?: string; // max 8 chars, used for zip filenames and compact display courses: Course[]; isUserDefined?: boolean; // true if entire track is user-added + updatedAt?: number; // last local edit time (ms) — set on save; used for cloud-sync merge } // Legacy interface for backward compatibility during migration From 945fff77685d819a45c77e948b4e6c68bc19d28c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 18:50:33 +0000 Subject: [PATCH 063/121] Propagate log deletes to the cloud + prevent orphaned blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local delete of a synced log now offers an opt-in "also delete the cloud copy" toggle (off by default — the cloud copy is a backup). Plus orphan-safety for uploads. - New host mount seam MountSlot.FileDeleteConfirm: a plugin can register an action the file-manager delete-confirm runs after the local delete, without the host learning anything about cloud. cloud-sync mounts FileDeleteToggle there (shown only for synced files). - The toggle calls deleteCloudFile when online, or queues a pending {store:"files", type:"delete"} change offline/on failure; autoSync.pushOne flushes file-deletes via deleteCloudFile on reconnect. - uploadBlob rolls back the just-uploaded blob if the index-row write is rejected (e.g. server quota trigger) so it can't orphan in the bucket. - cleanupOrphanBlobs(userId) reclaims pre-existing orphans (bucket objects with no index row), run once per user when the Cloud logs panel opens. orphanedObjectNames is a pure, tested helper. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 10 ++- CLAUDE.md | 34 +++++++--- src/components/drawer/FilesTab.tsx | 19 +++++- src/plugins/cloud-sync/CloudLogsPanel.tsx | 15 ++++- src/plugins/cloud-sync/FileDeleteToggle.tsx | 75 +++++++++++++++++++++ src/plugins/cloud-sync/autoSync.ts | 13 +++- src/plugins/cloud-sync/fileSync.test.ts | 20 +++++- src/plugins/cloud-sync/fileSync.ts | 21 ++++++ src/plugins/cloud-sync/index.ts | 11 +++ src/plugins/cloud-sync/syncEngine.ts | 31 ++++++++- src/plugins/mounts.ts | 15 +++++ 11 files changed, 245 insertions(+), 19 deletions(-) create mode 100644 src/plugins/cloud-sync/FileDeleteToggle.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec48b0..400c832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,13 +65,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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. ### Changed -- Cloud document sync is now **offline-aware and conflict-safe**. Garage records +- Cloud document sync is now **offline-aware and conflict-safe**. + +### Fixed +- 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. 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 diff --git a/CLAUDE.md b/CLAUDE.md index 99c2f12..2a4599a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,14 +190,15 @@ src/ │ │ ├── index.ts # Plugin def — contributes the Labs panel + a FileRow mount (both lazy, cloud-gated) │ │ ├── CloudSyncPanel.tsx # Sign-in + push/pull UI (lazy-loaded) │ │ ├── FileSyncToggle.tsx # Per-file sync toggle, mounted on each file row (off/pending/synced) +│ │ ├── FileDeleteToggle.tsx # FileDeleteConfirm mount: opt-in "also delete the cloud copy" on local log delete (offline → pending) │ │ ├── CloudFilesSection.tsx # FileManagerSection mount: lists all cloud files (on-device marked, others pullable) -│ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus/cloudOnlyNames (pure, tested) +│ │ ├── fileSync.ts # Per-file selection state in the plugin store + fileSyncStatus/cloudOnlyNames/orphanedObjectNames (pure, tested) │ │ ├── syncStores.ts # Pure config: which stores sync + how they're keyed (testable) │ │ ├── storeAccessors.ts # Per-store read/get/put: default IndexedDB accessor + a localStorage accessor for tracks (the non-IDB seam) │ │ ├── merge.ts # ★ Pure conflict resolution: decideSync (pending-wins + updatedAt LWW), pendingId (tested) │ │ ├── pendingSync.ts # Persistent offline "pending changes" set (plugin KV); flushed priority-1 on reconnect │ │ ├── storageTypes.ts # Pure: storage types (documents 5MB / logs 20MB) + usage math (tested) -│ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord/pushDocs/pullDocs + getStorageUsage +│ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord + getStorageUsage + deleteCloudFile (rolls back orphan blob on index failure) + cleanupOrphanBlobs │ │ ├── autoSync.ts # Background doc auto-sync: subscribes to garageEvents, debounced upsert/delete + reconcile on sign-in │ │ ├── StoragePanel.tsx # Profile-tab panel: display-name editor + storage usage meters (lazy) │ │ ├── CloudLogsPanel.tsx # Profile-tab panel: list + delete cloud log files (cloud-only; opt-in local delete) (lazy) @@ -288,9 +289,12 @@ are all chromeless (`isBareSlot`) also drops the host's outer padding. 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 file row, ctx = that file) and -`MountSlot.FileManagerSection` (once under the list, ctx = the whole list). New -mount locations are just new slot strings. +`FilesTab` exposes three: `MountSlot.FileRow` (per file row, ctx = that file), +`MountSlot.FileManagerSection` (once under the list, ctx = the whole list), 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. **Cloud Sync (first-party plugin, `src/plugins/cloud-sync/`):** the first in-repo plugin built on the panel framework. Contributes a lazy Labs panel that @@ -451,12 +455,22 @@ 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** is managed on the Profile tab (`CloudLogsPanel`): -`listCloudFiles` (now with `uploadedAt`) lists the user's cloud log files; +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), clears the per-file -selection, and optionally deletes the local copy on this device. (Auto-propagation -of log deletes on local delete is still a separate follow-up.) +(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 diff --git a/src/components/drawer/FilesTab.tsx b/src/components/drawer/FilesTab.tsx index 5e31036..661b158 100644 --- a/src/components/drawer/FilesTab.tsx +++ b/src/components/drawer/FilesTab.tsx @@ -96,10 +96,22 @@ export function FilesTab({ } }, [confirmLoad, onLoadFile, 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( @@ -158,6 +170,11 @@ export function FilesTab({

Delete {confirmDelete}? This cannot be undone.

+
diff --git a/src/plugins/cloud-sync/CloudLogsPanel.tsx b/src/plugins/cloud-sync/CloudLogsPanel.tsx index 0782282..03dbcbc 100644 --- a/src/plugins/cloud-sync/CloudLogsPanel.tsx +++ b/src/plugins/cloud-sync/CloudLogsPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { FileText, Trash2, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import type { PluginPanelProps } from "@/plugins/panels"; @@ -7,7 +7,7 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useAuth } from "@/contexts/AuthContext"; import { deleteFile, listFiles } from "@/lib/fileStorage"; -import { deleteCloudFile, listCloudFiles, type CloudFile } from "./syncEngine"; +import { cleanupOrphanBlobs, deleteCloudFile, listCloudFiles, type CloudFile } from "./syncEngine"; import { unselectFile } from "./fileSync"; import { formatBytes } from "./storageTypes"; @@ -48,6 +48,17 @@ export default function CloudLogsPanel(_props: PluginPanelProps) { void refresh(); }, [refresh]); + // Reclaim any orphaned blobs (no index row) once per signed-in user. Orphans + // don't appear in the index-based list, so this just frees their storage. + const cleanedFor = useRef(null); + useEffect(() => { + if (!user || cleanedFor.current === user.id) return; + cleanedFor.current = user.id; + void cleanupOrphanBlobs(user.id).then((n) => { + if (n > 0) void refresh(); + }); + }, [user, refresh]); + if (loading) return

Loading…

; if (!user) { return ( diff --git a/src/plugins/cloud-sync/FileDeleteToggle.tsx b/src/plugins/cloud-sync/FileDeleteToggle.tsx new file mode 100644 index 0000000..37133cb --- /dev/null +++ b/src/plugins/cloud-sync/FileDeleteToggle.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import type { FileDeleteConfirmContext } from "@/plugins/mounts"; +import { fileSyncStatus, getFileRecord, unselectFile } from "./fileSync"; +import { deleteCloudFile } from "./syncEngine"; +import { markPending } from "./pendingSync"; +import { FILE_STORE } from "./syncStores"; + +/** + * Mounted inside the file delete-confirm banner. When the file being deleted is + * synced, it offers an opt-in "also delete the cloud copy" checkbox and, on + * confirm, removes the cloud blob + index (or queues it as a pending delete when + * offline / on failure, so it propagates on reconnect). The cloud copy is a + * backup, so the box defaults off — local deletion alone never touches it. + */ +export default function FileDeleteToggle({ ctx }: { ctx: FileDeleteConfirmContext }) { + const { user } = useAuth(); + const online = useOnlineStatus(); + const { fileName, registerOnConfirm } = ctx; + const [synced, setSynced] = useState(false); + const [alsoDelete, setAlsoDelete] = useState(false); + + useEffect(() => { + let active = true; + if (!user) { + setSynced(false); + return; + } + getFileRecord(fileName).then((r) => active && setSynced(fileSyncStatus(r) === "synced")); + return () => { + active = false; + }; + }, [user, fileName]); + + useEffect(() => { + if (!user || !synced || !alsoDelete) { + registerOnConfirm(null); + return; + } + const userId = user.id; + registerOnConfirm(async () => { + if (!online) { + await markPending({ store: FILE_STORE, key: fileName, type: "delete" }); + return; + } + try { + await deleteCloudFile(userId, fileName); + await unselectFile(fileName); + } catch { + // Network/other failure — retry the cloud delete on reconnect. + await markPending({ store: FILE_STORE, key: fileName, type: "delete" }); + } + }); + return () => registerOnConfirm(null); + }, [user, synced, alsoDelete, online, fileName, registerOnConfirm]); + + if (!user || !synced) return null; + + return ( +
+ + +
+ ); +} diff --git a/src/plugins/cloud-sync/autoSync.ts b/src/plugins/cloud-sync/autoSync.ts index f6546da..08cd0f8 100644 --- a/src/plugins/cloud-sync/autoSync.ts +++ b/src/plugins/cloud-sync/autoSync.ts @@ -12,10 +12,12 @@ import { supabase } from "@/integrations/supabase/client"; import { onGarageChange, type GarageChange } from "@/lib/garageEvents"; import { isQuotaError } from "./cloudClient"; -import { deleteRecord, pushRecord, reconcileDocs } from "./syncEngine"; +import { deleteCloudFile, deleteRecord, pushRecord, reconcileDocs } from "./syncEngine"; import { clearPending, listPending, markPending, pendingKeySet } from "./pendingSync"; +import { unselectFile } from "./fileSync"; import { pendingId } from "./merge"; import { storageTypeForStore } from "./storageTypes"; +import { FILE_STORE } from "./syncStores"; const DEBOUNCE_MS = 800; @@ -35,6 +37,15 @@ function isOnline(): boolean { } async function pushOne(userId: string, change: GarageChange): Promise { + if (change.store === FILE_STORE) { + // Files only ever queue here as a deferred *delete* (a log removed while + // offline). Remove the blob + its index, and drop the stale selection. + if (change.type === "delete") { + await deleteCloudFile(userId, change.key); + await unselectFile(change.key); + } + return; + } if (change.type === "delete") await deleteRecord(userId, change.store, change.key); else await pushRecord(userId, change.store, change.key); } diff --git a/src/plugins/cloud-sync/fileSync.test.ts b/src/plugins/cloud-sync/fileSync.test.ts index 3bb3383..5008d8f 100644 --- a/src/plugins/cloud-sync/fileSync.test.ts +++ b/src/plugins/cloud-sync/fileSync.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { fileSyncStatus, cloudOnlyNames } from "./fileSync"; +import { fileSyncStatus, cloudOnlyNames, orphanedObjectNames } from "./fileSync"; describe("fileSyncStatus", () => { it("is 'off' when there is no record (not selected)", () => { @@ -24,3 +24,21 @@ describe("cloudOnlyNames", () => { expect(cloudOnlyNames(["a", "b"], ["a", "b", "x"])).toEqual([]); }); }); + +describe("orphanedObjectNames", () => { + it("flags bucket objects with no matching index row", () => { + expect(orphanedObjectNames(["run1.dovex", "ghost.dovex"], ["run1.dovex"])).toEqual([ + "ghost.dovex", + ]); + }); + + it("decodes URL-encoded object names before comparing to raw index keys", () => { + // Bucket path segments are encodeURIComponent(name); index keys are raw. + expect(orphanedObjectNames(["my%20run.dovex"], ["my run.dovex"])).toEqual([]); + expect(orphanedObjectNames(["my%20run.dovex"], ["other.dovex"])).toEqual(["my%20run.dovex"]); + }); + + it("returns nothing when every object is indexed", () => { + expect(orphanedObjectNames(["a", "b"], ["a", "b"])).toEqual([]); + }); +}); diff --git a/src/plugins/cloud-sync/fileSync.ts b/src/plugins/cloud-sync/fileSync.ts index f89ace6..e8e23c0 100644 --- a/src/plugins/cloud-sync/fileSync.ts +++ b/src/plugins/cloud-sync/fileSync.ts @@ -54,3 +54,24 @@ export function cloudOnlyNames(cloudNames: string[], localNames: Iterable !local.has(n)); } + +/** + * Bucket object names with no matching index row — orphans to clean up. Pure. + * Object names are URL-encoded file names (the bucket path segment), while index + * keys are the raw file names, so each object name is decoded before comparing. + */ +export function orphanedObjectNames( + objectNames: string[], + indexedKeys: Iterable, +): string[] { + const indexed = new Set(indexedKeys); + return objectNames.filter((n) => { + let decoded = n; + try { + decoded = decodeURIComponent(n); + } catch { + // Malformed encoding — compare raw. + } + return !indexed.has(decoded); + }); +} diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index b23469f..88742ee 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -6,6 +6,7 @@ import { PANELS_POINT, PanelSlot, type PluginPanel } from "@/plugins/panels"; import { MOUNTS_POINT, MountSlot, type PluginMountDef, type FileRowContext, type FileManagerSectionContext, + type FileDeleteConfirmContext, } from "@/plugins/mounts"; // The panel pulls in the Supabase sync engine + storage modules, so it's lazy: @@ -15,6 +16,7 @@ const CloudSyncPanel = lazy(() => import("./CloudSyncPanel")); // Likewise the per-file toggle + cloud-only list: lazy so the file-manager // drawer doesn't pull the sync engine onto its chunk until they render. const FileSyncToggle = lazy(() => import("./FileSyncToggle")); +const FileDeleteToggle = lazy(() => import("./FileDeleteToggle")); const CloudFilesSection = lazy(() => import("./CloudFilesSection")); // Profile tab panels: storage usage meters + account, and cloud-log management. const StoragePanel = lazy(() => import("./StoragePanel")); @@ -49,6 +51,15 @@ const plugin: DataViewerPlugin = { component: FileSyncToggle, } satisfies PluginMountDef); + // "Also delete from the cloud" opt-in, shown in the file delete-confirm + // banner when the file is synced. + ctx.registry.contribute(MOUNTS_POINT, { + id: "cloud-sync-file-delete", + slot: MountSlot.FileDeleteConfirm, + order: 0, + component: FileDeleteToggle, + } satisfies PluginMountDef); + // Cloud-only files (in the cloud, not on this device) listed under the file // list, each with a per-file pull. ctx.registry.contribute(MOUNTS_POINT, { diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index 0a934ce..f020a47 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -15,7 +15,7 @@ import { getFile, saveFile } from "@/lib/fileStorage"; import { getAccessor } from "./storeAccessors"; import { fetchStorageUsage, syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; import { DOC_STORES, FILE_STORE, extractKey, type SyncSummary } from "./syncStores"; -import { listSelectedFiles, markPushed } from "./fileSync"; +import { listSelectedFiles, markPushed, orphanedObjectNames } from "./fileSync"; import { DEFAULT_LIMITS, type StorageType, type StorageTypeUsage } from "./storageTypes"; import { decideSync, pendingId, recordUpdatedAt } from "./merge"; @@ -40,7 +40,8 @@ async function writeOne(store: string, record: unknown): Promise { async function uploadBlob(userId: string, name: string): Promise { const blob = await getFile(name); if (!blob) return false; - const { error: upErr } = await userFiles().upload(blobPath(userId, name), blob, { + const path = blobPath(userId, name); + const { error: upErr } = await userFiles().upload(path, blob, { upsert: true, contentType: blob.type || "application/octet-stream", }); @@ -49,7 +50,12 @@ async function uploadBlob(userId: string, name: string): Promise { [{ user_id: userId, store: FILE_STORE, record_key: name, data: { size: blob.size } }], { onConflict: "user_id,store,record_key" }, ); - if (error) throw new Error(`Failed to index ${name}: ${error.message}`); + if (error) { + // The blob is uploaded but its index row was rejected (e.g. the server + // quota trigger). Roll the blob back so it can't orphan in the bucket. + await userFiles().remove([path]).catch(() => {}); + throw new Error(`Failed to index ${name}: ${error.message}`); + } return true; } @@ -98,6 +104,25 @@ export async function deleteCloudFile(userId: string, name: string): Promise { + const { data: objects, error: listErr } = await userFiles().list(userId, { limit: 1000 }); + if (listErr || !objects) return 0; + const { data: rows } = await syncRecords() + .select("record_key") + .eq("user_id", userId) + .eq("store", FILE_STORE); + const indexed = (rows ?? []).map((r) => (r as { record_key: string }).record_key); + const orphans = orphanedObjectNames(objects.map((o) => o.name), indexed); + if (!orphans.length) return 0; + const { error: rmErr } = await userFiles().remove(orphans.map((n) => `${userId}/${n}`)); + if (rmErr) return 0; + return orphans.length; +} + /** Download a single file blob from the cloud (does not persist it locally). */ export async function downloadCloudFile(userId: string, name: string): Promise { const { data, error } = await userFiles().download(blobPath(userId, name)); diff --git a/src/plugins/mounts.ts b/src/plugins/mounts.ts index 0a1e9cb..28f99ff 100644 --- a/src/plugins/mounts.ts +++ b/src/plugins/mounts.ts @@ -20,6 +20,9 @@ export const MountSlot = { FileRow: "file-row", /** Rendered once below the file list. Context: the whole list. */ FileManagerSection: "file-manager-section", + /** Rendered inside the file delete-confirm banner. Context: the target file + + * a hook to run an extra action when the user confirms the delete. */ + FileDeleteConfirm: "file-delete-confirm", } as const; export type MountSlot = (typeof MountSlot)[keyof typeof MountSlot]; @@ -36,6 +39,18 @@ export interface FileManagerSectionContext { onSaveFile: (name: string, blob: Blob) => Promise; } +/** Context handed to a `MountSlot.FileDeleteConfirm` component. */ +export interface FileDeleteConfirmContext { + /** The file about to be deleted locally. */ + fileName: string; + /** + * Register an extra action the host runs (after the local delete) when the + * user confirms — or `null` to clear it. Lets a plugin (e.g. cloud-sync) + * offer "also delete from the cloud" without the host knowing about cloud. + */ + registerOnConfirm: (fn: (() => Promise) | null) => void; +} + /** * A mount descriptor. The component receives its slot's context as a single * `ctx` prop (avoids generic prop-spreading; keeps the contract explicit). From bf24e3298d3ff71121e5d2da6d499034f8bb2b64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 19:02:52 +0000 Subject: [PATCH 064/121] Partial document push when over the cloud quota MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcileDocs/pushAll upserted all doc records in one batch, so a single over-limit record made the server quota trigger reject the whole statement and nothing synced. New pushDocRows tries the batch (the common under-limit case), then on a quota rejection falls back to per-record upserts — saving everything that fits and returning a skipped count. Non-quota errors still throw. The count surfaces as clearer messaging: the manual push toast and the background reconcile both report "N item(s) didn't fit — document storage is full" instead of a generic failure. SyncSummary/DocReconcileResult gain skipped. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 3 ++ CLAUDE.md | 11 +++-- src/plugins/cloud-sync/CloudSyncPanel.tsx | 8 +++- src/plugins/cloud-sync/autoSync.ts | 8 +++- src/plugins/cloud-sync/syncEngine.ts | 51 +++++++++++++++-------- src/plugins/cloud-sync/syncStores.ts | 2 + 6 files changed, 60 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 400c832..d131b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Cloud document sync is now **offline-aware and conflict-safe**. +- When local garage data exceeds the cloud **documents** limit, sync now does a + **partial push** — it saves everything that fits and tells you how many items + didn't — instead of rejecting the whole batch and syncing nothing. ### Fixed - Cloud log uploads no longer **orphan a blob** when the server quota rejects the diff --git a/CLAUDE.md b/CLAUDE.md index 2a4599a..cfc07eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,7 +198,7 @@ src/ │ │ ├── merge.ts # ★ Pure conflict resolution: decideSync (pending-wins + updatedAt LWW), pendingId (tested) │ │ ├── pendingSync.ts # Persistent offline "pending changes" set (plugin KV); flushed priority-1 on reconnect │ │ ├── storageTypes.ts # Pure: storage types (documents 5MB / logs 20MB) + usage math (tested) -│ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord + getStorageUsage + deleteCloudFile (rolls back orphan blob on index failure) + cleanupOrphanBlobs +│ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord + getStorageUsage + deleteCloudFile (rolls back orphan blob on index failure) + cleanupOrphanBlobs. Doc pushes chunk to a per-record fallback on quota (partial push + skipped count) │ │ ├── autoSync.ts # Background doc auto-sync: subscribes to garageEvents, debounced upsert/delete + reconcile on sign-in │ │ ├── StoragePanel.tsx # Profile-tab panel: display-name editor + storage usage meters (lazy) │ │ ├── CloudLogsPanel.tsx # Profile-tab panel: list + delete cloud log files (cloud-only; opt-in local delete) (lazy) @@ -422,9 +422,12 @@ offline or whose push failed is recorded in a persistent **pending set** **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. `autoSync` tracks `navigator.onLine` + window online/offline -events; the Profile-tab `StoragePanel` flags offline state + the pending count. -(Log-blob deletion propagation remains a follow-up.) +skipping pending keys. Its push (and `pushAll`'s) 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. **Storage types** (`storageTypes.ts`, enforced server-side) — distinct from future *subscription tiers*: **documents** = all structured stores (5 MB, free, diff --git a/src/plugins/cloud-sync/CloudSyncPanel.tsx b/src/plugins/cloud-sync/CloudSyncPanel.tsx index 9f449b1..5472c12 100644 --- a/src/plugins/cloud-sync/CloudSyncPanel.tsx +++ b/src/plugins/cloud-sync/CloudSyncPanel.tsx @@ -58,7 +58,13 @@ export default function CloudSyncPanel() { setBusy("push"); try { const r = await pushAll(user.id); - toast.success(`Pushed ${r.records} records and ${r.files} files to the cloud.`); + if (r.skipped > 0) { + toast.error( + `Pushed ${r.records} records and ${r.files} files, but ${r.skipped} didn't fit — cloud document storage is full.`, + ); + } else { + toast.success(`Pushed ${r.records} records and ${r.files} files to the cloud.`); + } } catch (e) { toast.error(e instanceof Error ? e.message : "Push failed"); } finally { diff --git a/src/plugins/cloud-sync/autoSync.ts b/src/plugins/cloud-sync/autoSync.ts index 08cd0f8..85d633b 100644 --- a/src/plugins/cloud-sync/autoSync.ts +++ b/src/plugins/cloud-sync/autoSync.ts @@ -112,7 +112,13 @@ async function flushPending(userId: string): Promise { async function runReconcile(userId: string): Promise { try { await flushPending(userId); - await reconcileDocs(userId, await pendingKeySet()); + const { skipped } = await reconcileDocs(userId, await pendingKeySet()); + if (skipped > 0) { + notify( + `Cloud document storage is full — ${skipped} item${skipped === 1 ? "" : "s"} didn't sync.`, + "error", + ); + } } catch (err) { if (isQuotaError(err)) { notify("Cloud document storage is full — some items didn't sync.", "error"); diff --git a/src/plugins/cloud-sync/syncEngine.ts b/src/plugins/cloud-sync/syncEngine.ts index f020a47..467f691 100644 --- a/src/plugins/cloud-sync/syncEngine.ts +++ b/src/plugins/cloud-sync/syncEngine.ts @@ -13,7 +13,7 @@ import { getFile, saveFile } from "@/lib/fileStorage"; import { getAccessor } from "./storeAccessors"; -import { fetchStorageUsage, syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; +import { fetchStorageUsage, isQuotaError, syncRecords, userFiles, type SyncRecordRow } from "./cloudClient"; import { DOC_STORES, FILE_STORE, extractKey, type SyncSummary } from "./syncStores"; import { listSelectedFiles, markPushed, orphanedObjectNames } from "./fileSync"; import { DEFAULT_LIMITS, type StorageType, type StorageTypeUsage } from "./storageTypes"; @@ -130,6 +130,32 @@ export async function downloadCloudFile(userId: string, name: string): Promise { + if (!rows.length) return { pushed: 0, skipped: 0 }; + const { error } = await syncRecords().upsert(rows, { onConflict: "user_id,store,record_key" }); + if (!error) return { pushed: rows.length, skipped: 0 }; + if (!isQuotaError(new Error(error.message))) { + throw new Error(`Failed to push documents: ${error.message}`); + } + let pushed = 0; + let skipped = 0; + for (const row of rows) { + const { error: rowErr } = await syncRecords().upsert([row], { + onConflict: "user_id,store,record_key", + }); + if (!rowErr) pushed++; + else if (isQuotaError(new Error(rowErr.message))) skipped++; + else throw new Error(`Failed to push documents: ${rowErr.message}`); + } + return { pushed, skipped }; +} + /** * Mirror local data up to the cloud: all structured (garage) records, plus only * the files the user has selected for sync. @@ -141,10 +167,7 @@ export async function pushAll(userId: string): Promise { rows.push({ user_id: userId, store, record_key: extractKey(store, record), data: record }); } } - if (rows.length) { - const { error } = await syncRecords().upsert(rows, { onConflict: "user_id,store,record_key" }); - if (error) throw new Error(`Failed to push records: ${error.message}`); - } + const { pushed, skipped } = await pushDocRows(rows); let files = 0; for (const name of await listSelectedFiles()) { @@ -154,7 +177,7 @@ export async function pushAll(userId: string): Promise { } } - return { records: rows.length, files }; + return { records: pushed, files, skipped }; } /** Bring the cloud copy down into local IndexedDB. */ @@ -179,7 +202,7 @@ export async function pullAll(userId: string): Promise { records++; } } - return { records, files }; + return { records, files, skipped: 0 }; } // ── Incremental (auto) sync ────────────────────────────────────────────────── @@ -212,6 +235,8 @@ export async function deleteRecord(userId: string, store: string, key: string): export interface DocReconcileResult { pulled: number; pushed: number; + /** Records that didn't fit under the documents quota (partial push). */ + skipped: number; } /** @@ -246,7 +271,6 @@ export async function reconcileDocs( } let pulled = 0; - let pushed = 0; const toPush: SyncRecordRow[] = []; const seen = new Set(); @@ -265,7 +289,6 @@ export async function reconcileDocs( }); if (action === "push") { toPush.push({ user_id: userId, store, record_key: key, data: record }); - pushed++; } else if (action === "pull" && c) { await writeOne(store, c.data); pulled++; @@ -289,14 +312,8 @@ export async function reconcileDocs( } } - if (toPush.length) { - const { error: upErr } = await syncRecords().upsert(toPush, { - onConflict: "user_id,store,record_key", - }); - if (upErr) throw new Error(`Failed to push documents: ${upErr.message}`); - } - - return { pulled, pushed }; + const { pushed, skipped } = await pushDocRows(toPush); + return { pulled, pushed, skipped }; } /** Per-type storage usage from the server, with the advisory limits as fallback. */ diff --git a/src/plugins/cloud-sync/syncStores.ts b/src/plugins/cloud-sync/syncStores.ts index 4d17495..2813a38 100644 --- a/src/plugins/cloud-sync/syncStores.ts +++ b/src/plugins/cloud-sync/syncStores.ts @@ -38,6 +38,8 @@ export const FILE_STORE = STORE_NAMES.FILES; export interface SyncSummary { records: number; files: number; + /** Document records that didn't fit under the quota (partial push). 0 on pull. */ + skipped: number; } /** Extract the cloud record_key for a store's record using its IndexedDB key path. */ From 11402b66f43ced4e06c08120e34b4ac58dbb0688 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:12:07 +0000 Subject: [PATCH 065/121] Cloud cleanup: move sync to Files tab, pricing cards, register captcha - Move the Cloud Sync panel (sign-in + push/pull) out of the Labs tab to the bottom of the file manager's Files tab via a new MountSlot.FileManagerFooter. Labs no longer shows a first-party panel (self-gates away unless enabled). - Add a reusable PricingCards component (4 tiers: Free offline, Free online, $1/mo 500MB, $10/mo 1GB + 100 AI credits; paid marked "Coming soon"); render it below the sample box on the landing page and on the registration page. - Registration: add Cloudflare Turnstile (reusable Turnstile component, renders nothing + never blocks when no VITE_TURNSTILE_SITE_KEY), thread the token into supabase signUp, and reject disposable/temporary emails (emailValidation util + tests). - Landing + About copy: "optional cloud storage" instead of "files never leave your device", now that cloud sync exists. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 12 +++ CLAUDE.md | 26 +++-- src/components/AboutDialog.tsx | 4 +- src/components/LandingPage.tsx | 17 +-- src/components/PricingCards.tsx | 120 ++++++++++++++++++++++ src/components/Turnstile.tsx | 76 ++++++++++++++ src/components/drawer/FilesTab.tsx | 9 +- src/contexts/AuthContext.tsx | 7 +- src/lib/emailValidation.test.ts | 35 +++++++ src/lib/emailValidation.ts | 63 ++++++++++++ src/pages/Register.tsx | 25 ++++- src/plugins/cloud-sync/CloudSyncPanel.tsx | 14 ++- src/plugins/cloud-sync/index.ts | 21 ++-- src/plugins/mounts.ts | 3 + 14 files changed, 394 insertions(+), 38 deletions(-) create mode 100644 src/components/PricingCards.tsx create mode 100644 src/components/Turnstile.tsx create mode 100644 src/lib/emailValidation.test.ts create mode 100644 src/lib/emailValidation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec48b0..17248a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,8 +69,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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, and paid tiers (marked + "Coming soon" until billing is wired up). +- 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 +- **Cloud Sync moved out of the Labs tab**: sign-in and manual push/pull now live + at the bottom of the file manager's **Files** tab, next to the files they back + up. 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. - 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. diff --git a/CLAUDE.md b/CLAUDE.md index 99c2f12..f8a0b54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,9 +268,11 @@ A plugin default-exports `{ id, name, version?, priority?, setup?(ctx) }`. In **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`), -`PanelSlot.Coach` (rendered by `CoachTab.tsx` — the dedicated AI Coach tab, home -for the `@perchwerks/eye-in-the-sky` coaching plugin), and `PanelSlot.Profile` +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 storage meters). All render contributed panels via `PluginPanelHost` and are **self-gating**: `Index.tsx` computes `hasLabsPanels`/`showCoach`/`showProfile` @@ -288,15 +290,19 @@ are all chromeless (`isBareSlot`) also drops the host's outer padding. 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 file row, ctx = that file) and -`MountSlot.FileManagerSection` (once under the list, ctx = the whole list). New -mount locations are just new slot strings. +`FilesTab` exposes three: `MountSlot.FileRow` (per file row, ctx = that file), +`MountSlot.FileManagerSection` (once under the list, ctx = the whole list), and +`MountSlot.FileManagerFooter` (near the bottom, above storage usage, ctx = the +whole list — home for the Cloud Sync panel). New mount locations are just new +slot strings. **Cloud Sync (first-party plugin, `src/plugins/cloud-sync/`):** the first -in-repo plugin built on the panel framework. Contributes a lazy Labs panel that -signs the user in (`useAuth`) and does manual push/pull of local IndexedDB data -to Supabase. Structured stores go to the `sync_records` table as jsonb -documents; raw session blobs go to the private `user-files` Storage bucket. See +in-repo plugin built on the panel framework. Contributes `CloudSyncPanel` (lazy) +as a `MountSlot.FileManagerFooter` mount — sign-in (`useAuth`) + manual push/pull +of local IndexedDB data to Supabase, sitting at the bottom of the file manager +(it used to be a Labs panel; no first-party panel targets Labs now). Structured +stores go to the `sync_records` table as jsonb documents; raw session blobs go to +the private `user-files` Storage bucket. See the Cloud Sync section below for the data model. **AI coach (npm package):** published to the public npm registry as 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/LandingPage.tsx b/src/components/LandingPage.tsx index 3f74823..0252dd8 100644 --- a/src/components/LandingPage.tsx +++ b/src/components/LandingPage.tsx @@ -8,6 +8,7 @@ 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"; @@ -92,8 +93,8 @@ export function LandingPage({
-
-
+
+
@@ -103,7 +104,7 @@ export function LandingPage({ Free Online VBO, MoTeC, AiM & NMEA Telemetry Viewer

- Open any Racelogic VBO, MoTeC i2 (LD/CSV), AiM MyChron, Alfano, u-blox UBX, NMEA or Dove datalog right in your browser. 100% offline — your files never leave your device. + Open any Racelogic VBO, MoTeC i2 (LD/CSV), AiM MyChron, Alfano, u-blox UBX, NMEA or Dove datalog right in your browser. Offline-first — with optional cloud storage to sync across your devices.

@@ -128,12 +129,16 @@ export function LandingPage({
+
+ + -
+
+
-
+
{GITHUB_LINKS.map((link) => ( -
+
Privacy Policy diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx new file mode 100644 index 0000000..19a9af3 --- /dev/null +++ b/src/components/PricingCards.tsx @@ -0,0 +1,120 @@ +import { Check } from "lucide-react"; + +interface Tier { + name: string; + blurb: string; + price: string; + cadence?: string; + inherits?: string; + features: string[]; + highlight?: boolean; + comingSoon?: boolean; +} + +const TIERS: Tier[] = [ + { + name: "Free", + blurb: "Offline", + price: "$0", + features: [ + "Full data viewer", + "Bluetooth (BLE) device connectivity", + "Save logs to your device", + "Add overlays & export videos", + "Offline mathematical session debrief", + ], + }, + { + name: "Free", + blurb: "Online account", + price: "$0", + highlight: true, + inherits: "Everything in Free, plus", + features: [ + "Setup info synced across all your devices", + "Sync your personal tracks", + "10 MB cloud log storage", + ], + }, + { + name: "Plus", + blurb: "For bigger garages", + price: "$1", + cadence: "/mo", + comingSoon: true, + inherits: "Everything in Free online, plus", + features: ["500 MB cloud log storage"], + }, + { + name: "Pro", + blurb: "With AI coaching", + price: "$10", + cadence: "/mo", + comingSoon: true, + inherits: "Everything in Plus, plus", + features: ["1 GB cloud log storage", "100 AI coaching credits / month"], + }, +]; + +function TierCard({ tier }: { tier: Tier }) { + return ( +
+ {tier.highlight && ( + + Recommended + + )} + {tier.comingSoon && ( + + Coming soon + + )} +
+

{tier.name}

+

{tier.blurb}

+
+
+ {tier.price} + {tier.cadence && {tier.cadence}} +
+ {tier.inherits && ( +

{tier.inherits}

+ )} +
    + {tier.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ ); +} + +/** + * Plans / pricing grid. Shown on the landing page (below the sample box) and on + * the registration page. Informational only — paid tiers are marked "Coming + * soon" until billing is wired up. + */ +export function PricingCards({ className }: { className?: string }) { + return ( +
+
+

Plans & pricing

+

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

+
+
+ {TIERS.map((tier) => ( + + ))} +
+
+ ); +} 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/drawer/FilesTab.tsx b/src/components/drawer/FilesTab.tsx index 5e31036..ebc53a0 100644 --- a/src/components/drawer/FilesTab.tsx +++ b/src/components/drawer/FilesTab.tsx @@ -10,7 +10,7 @@ const DataloggerDownload = lazy(() => ); import { listSessionVideos, deleteSessionVideo, StoredVideoMeta } from "@/lib/videoFileStorage"; import { PluginMount } from "@/plugins/PluginMount"; -import { MountSlot } from "@/plugins/mounts"; +import { MountSlot, getMounts } from "@/plugins/mounts"; function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -240,6 +240,13 @@ export function FilesTab({ )}
+ {/* Plugin-contributed footer (e.g. Cloud Sync sign-in / push-pull). */} + {getMounts(MountSlot.FileManagerFooter).length > 0 && ( +
+ +
+ )} + {/* Storage Usage */}
{storageQuota > 0 ? ( diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index b327bb8..e5a67e4 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -8,7 +8,7 @@ interface AuthContextValue { isAdmin: boolean; loading: boolean; login: (email: string, password: string) => Promise<{ error: Error | null }>; - signUp: (email: string, password: string, displayName?: string) => Promise<{ error: Error | null }>; + signUp: (email: string, password: string, displayName?: string, captchaToken?: string) => Promise<{ error: Error | null }>; signInWithGoogle: () => Promise<{ error: Error | null }>; logout: () => Promise; resetPassword: (email: string) => Promise<{ error: Error | null }>; @@ -101,7 +101,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { error }; }, []); - const signUp = useCallback(async (email: string, password: string, displayName?: string) => { + const signUp = useCallback(async (email: string, password: string, displayName?: string, captchaToken?: string) => { const trimmed = displayName?.trim(); const { error } = await supabase.auth.signUp({ email, @@ -111,6 +111,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Picked up by the handle_new_user trigger; blank → a random name is // generated server-side. A taken name is auto-suffixed there too. data: trimmed ? { display_name: trimmed } : {}, + // Verified server-side when Turnstile is enabled in the Supabase Auth + // settings; ignored otherwise (graceful fallback when no key is set). + ...(captchaToken ? { captchaToken } : {}), }, }); return { error }; diff --git a/src/lib/emailValidation.test.ts b/src/lib/emailValidation.test.ts new file mode 100644 index 0000000..5def8c6 --- /dev/null +++ b/src/lib/emailValidation.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { emailDomain, isDisposableEmail, looksLikeEmail } from "./emailValidation"; + +describe("emailDomain", () => { + it("returns the lower-cased domain", () => { + expect(emailDomain("Driver@Gmail.COM")).toBe("gmail.com"); + }); + it("returns null without a usable domain", () => { + expect(emailDomain("no-at-sign")).toBeNull(); + expect(emailDomain("trailing@")).toBeNull(); + }); +}); + +describe("isDisposableEmail", () => { + it("flags known disposable providers (case-insensitive)", () => { + expect(isDisposableEmail("a@mailinator.com")).toBe(true); + expect(isDisposableEmail("a@Guerrillamail.com")).toBe(true); + expect(isDisposableEmail("a@yopmail.com")).toBe(true); + }); + it("allows normal providers", () => { + expect(isDisposableEmail("a@gmail.com")).toBe(false); + expect(isDisposableEmail("racer@hackthetrack.net")).toBe(false); + }); + it("is false for malformed input", () => { + expect(isDisposableEmail("not-an-email")).toBe(false); + }); +}); + +describe("looksLikeEmail", () => { + it("accepts a basic address and rejects obvious junk", () => { + expect(looksLikeEmail("a@b.co")).toBe(true); + expect(looksLikeEmail("a@b")).toBe(false); + expect(looksLikeEmail("a b@c.com")).toBe(false); + }); +}); diff --git a/src/lib/emailValidation.ts b/src/lib/emailValidation.ts new file mode 100644 index 0000000..0679755 --- /dev/null +++ b/src/lib/emailValidation.ts @@ -0,0 +1,63 @@ +// Lightweight, offline disposable-email guard for account sign-up. Not meant to +// be exhaustive (an online list would break offline-first and go stale) — just a +// curated set of the common "5-minute mailbox" providers, plus basic shape +// checks. The server (Supabase auth) remains the real gate. + +const DISPOSABLE_DOMAINS = new Set([ + "10minutemail.com", + "10minutemail.net", + "20minutemail.com", + "33mail.com", + "burnermail.io", + "dispostable.com", + "emailondeck.com", + "fakeinbox.com", + "fexbox.org", + "getairmail.com", + "getnada.com", + "grr.la", + "guerrillamail.com", + "guerrillamail.info", + "guerrillamail.net", + "guerrillamail.org", + "inboxkitten.com", + "mailcatch.com", + "maildrop.cc", + "mailinator.com", + "mailnesia.com", + "mailsac.com", + "mailto.plus", + "minuteinbox.com", + "mintemail.com", + "mohmal.com", + "moakt.com", + "sharklasers.com", + "spam4.me", + "spamgourmet.com", + "temp-mail.org", + "tempmail.com", + "tempmailo.com", + "throwawaymail.com", + "tmpmail.org", + "trashmail.com", + "yopmail.com", + "yopmail.net", +]); + +/** The lower-cased domain part of an email, or null if it has no `@`. */ +export function emailDomain(email: string): string | null { + const at = email.lastIndexOf("@"); + if (at < 0 || at === email.length - 1) return null; + return email.slice(at + 1).trim().toLowerCase(); +} + +/** True if the email's domain is a known disposable / temporary-mail provider. */ +export function isDisposableEmail(email: string): boolean { + const domain = emailDomain(email); + return domain != null && DISPOSABLE_DOMAINS.has(domain); +} + +/** Very small structural sanity check (a real validator lives server-side). */ +export function looksLikeEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()); +} diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 37bbda5..7ca911e 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -7,6 +7,9 @@ import { useAuth } from '@/contexts/AuthContext'; import { toast } from '@/hooks/use-toast'; import { Gauge, ArrowLeft } from 'lucide-react'; import { useDocumentHead } from '@/hooks/useDocumentHead'; +import { Turnstile, turnstileEnabled } from '@/components/Turnstile'; +import { PricingCards } from '@/components/PricingCards'; +import { isDisposableEmail, looksLikeEmail } from '@/lib/emailValidation'; export default function Register() { useDocumentHead({ @@ -19,11 +22,20 @@ export default function Register() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [captchaToken, setCaptchaToken] = useState(null); const { signUp, signInWithGoogle } = useAuth(); const navigate = useNavigate(); const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); + if (!looksLikeEmail(email)) { + toast({ title: 'Enter a valid email address', variant: 'destructive' }); + return; + } + if (isDisposableEmail(email)) { + toast({ title: 'Please use a permanent email address', description: 'Disposable / temporary mailboxes are not allowed.', variant: 'destructive' }); + return; + } if (password !== confirmPassword) { toast({ title: 'Passwords do not match', variant: 'destructive' }); return; @@ -32,8 +44,12 @@ export default function Register() { toast({ title: 'Password must be at least 6 characters', variant: 'destructive' }); return; } + if (turnstileEnabled && !captchaToken) { + toast({ title: 'Please complete the captcha', variant: 'destructive' }); + return; + } setIsLoading(true); - const { error } = await signUp(email, password, displayName); + const { error } = await signUp(email, password, displayName, captchaToken ?? undefined); setIsLoading(false); if (error) { toast({ title: 'Registration failed', description: error.message, variant: 'destructive' }); @@ -53,8 +69,8 @@ export default function Register() { }; return ( -
-
+
+

HackTheTrack.net

@@ -89,6 +105,7 @@ export default function Register() { setConfirmPassword(e.target.value)} required />
+ @@ -104,6 +121,8 @@ export default function Register() { Back to Home
+ +
); } diff --git a/src/plugins/cloud-sync/CloudSyncPanel.tsx b/src/plugins/cloud-sync/CloudSyncPanel.tsx index 9f449b1..8b7f4cd 100644 --- a/src/plugins/cloud-sync/CloudSyncPanel.tsx +++ b/src/plugins/cloud-sync/CloudSyncPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; -import { CloudUpload, CloudDownload, LogOut, WifiOff, Loader2 } from "lucide-react"; +import { Cloud, CloudUpload, CloudDownload, LogOut, WifiOff, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { useOnlineStatus } from "@/hooks/useOnlineStatus"; @@ -32,7 +32,10 @@ export default function CloudSyncPanel() { } }; return ( -
+
+

+ Cloud Sync +

Sign in to back up and sync your files, garage and notes across devices. Cloud Sync is optional — the app works fully offline without it.

@@ -80,9 +83,12 @@ export default function CloudSyncPanel() { }; return ( -
+
- {user.email} +

+ + {user.email} +

diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index b23469f..0b4e627 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -28,18 +28,19 @@ const plugin: DataViewerPlugin = { version: "0.1.0", setup(ctx) { // Offline-first guard: when the cloud flag is off, contribute nothing. - // The Labs panel never registers, the panel chunk never loads, and the - // Labs tab stays hidden unless another plugin contributes there. + // No panels/mounts register, their chunks never load, and the Labs tab + // stays hidden unless another plugin contributes there. if (!enableCloud) return; - const panel: PluginPanel = { - id: "cloud-sync", - title: "Cloud Sync", - slot: PanelSlot.Labs, - order: 10, - icon: Cloud, + + // Sign-in + manual push/pull, mounted at the bottom of the file manager + // (it used to live in the Labs tab — moved here so cloud sign-in/sync sits + // next to the files it backs up, and Labs stays empty unless a plugin uses it). + ctx.registry.contribute(MOUNTS_POINT, { + id: "cloud-sync-footer", + slot: MountSlot.FileManagerFooter, + order: 0, component: CloudSyncPanel, - }; - ctx.registry.contribute(PANELS_POINT, panel); + } satisfies PluginMountDef); // Per-file sync toggle injected into each file-manager row. ctx.registry.contribute(MOUNTS_POINT, { diff --git a/src/plugins/mounts.ts b/src/plugins/mounts.ts index 0a1e9cb..0a2dd1c 100644 --- a/src/plugins/mounts.ts +++ b/src/plugins/mounts.ts @@ -20,6 +20,9 @@ export const MountSlot = { FileRow: "file-row", /** Rendered once below the file list. Context: the whole list. */ FileManagerSection: "file-manager-section", + /** Rendered near the bottom of the file manager (above storage usage). + * Context: the whole list. Home for the Cloud Sync sign-in / push-pull panel. */ + FileManagerFooter: "file-manager-footer", } as const; export type MountSlot = (typeof MountSlot)[keyof typeof MountSlot]; From c183c5908c2e9b844c4b2c63bd19b702982dc469 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:24:53 +0000 Subject: [PATCH 066/121] Cloud sync: login on Profile, download-all in Files footer, pricing fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move sign-in + push/pull (CloudSyncPanel) to the Profile tab as an "Account" panel (ordered first); it no longer lives in the file-manager footer. - The file-manager footer now hosts a dedicated "Download all cloud logs" bulk action (DownloadAllCloudLogs) — pulls every cloud log not yet on this device, self-hides when signed out. - Pricing: free-online log storage corrected to 20 MB; AI tier no longer references credits ("AI coaching (coming soon)"). https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 11 +-- CLAUDE.md | 23 +++--- src/components/PricingCards.tsx | 4 +- src/components/drawer/FilesTab.tsx | 11 +-- src/plugins/cloud-sync/CloudSyncPanel.tsx | 10 +-- .../cloud-sync/DownloadAllCloudLogs.tsx | 74 +++++++++++++++++++ src/plugins/cloud-sync/StoragePanel.tsx | 2 +- src/plugins/cloud-sync/index.ts | 32 +++++--- src/plugins/mounts.ts | 2 +- 9 files changed, 125 insertions(+), 44 deletions(-) create mode 100644 src/plugins/cloud-sync/DownloadAllCloudLogs.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 17248a3..5b7ab1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,17 +70,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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, and paid tiers (marked - "Coming soon" until billing is wired up). + registration page — Free offline, Free online (20 MB cloud logs), 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 - **Cloud Sync moved out of the Labs tab**: sign-in and manual push/pull now live - at the bottom of the file manager's **Files** tab, next to the files they back - up. The Labs tab no longer appears unless the experimental setting is on or a - plugin contributes to it. + 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. - Cloud document sync is now **offline-aware and conflict-safe**. Garage records diff --git a/CLAUDE.md b/CLAUDE.md index f8a0b54..3301552 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,8 +273,9 @@ 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 storage -meters). All render contributed panels via `PluginPanelHost` and are +(rendered by `ProfileTab.tsx`, far-right — cloud-sync contributes the Account +sign-in panel, storage meters, 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 @@ -293,16 +294,18 @@ ctx={…}>` at that spot, passing a typed context as a single `ctx` prop. `FilesTab` exposes three: `MountSlot.FileRow` (per file row, ctx = that file), `MountSlot.FileManagerSection` (once under the list, ctx = the whole list), and `MountSlot.FileManagerFooter` (near the bottom, above storage usage, ctx = the -whole list — home for the Cloud Sync panel). New mount locations are just new -slot strings. +whole list — home for the "Download all cloud logs" bulk action). New mount +locations are just new slot strings. **Cloud Sync (first-party plugin, `src/plugins/cloud-sync/`):** the first -in-repo plugin built on the panel framework. Contributes `CloudSyncPanel` (lazy) -as a `MountSlot.FileManagerFooter` mount — sign-in (`useAuth`) + manual push/pull -of local IndexedDB data to Supabase, sitting at the bottom of the file manager -(it used to be a Labs panel; no first-party panel targets Labs now). Structured -stores go to the `sync_records` table as jsonb documents; raw session blobs go to -the private `user-files` Storage bucket. See +in-repo plugin built on the panel framework. Sign-in + manual push/pull live in +`CloudSyncPanel` (lazy), contributed as the **Account** panel on the Profile tab +(`PanelSlot.Profile`, ordered first). The file manager's footer +(`MountSlot.FileManagerFooter`) gets a separate lazy `DownloadAllCloudLogs` mount +— a one-click bulk pull of every cloud log not yet on this device (self-hides +when signed out). (Cloud Sync used to be a Labs panel; no first-party panel +targets Labs now.) Structured stores go to the `sync_records` table as jsonb +documents; raw session blobs go to the private `user-files` Storage bucket. See the Cloud Sync section below for the data model. **AI coach (npm package):** published to the public npm registry as diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx index 19a9af3..901f610 100644 --- a/src/components/PricingCards.tsx +++ b/src/components/PricingCards.tsx @@ -33,7 +33,7 @@ const TIERS: Tier[] = [ features: [ "Setup info synced across all your devices", "Sync your personal tracks", - "10 MB cloud log storage", + "20 MB cloud log storage", ], }, { @@ -52,7 +52,7 @@ const TIERS: Tier[] = [ cadence: "/mo", comingSoon: true, inherits: "Everything in Plus, plus", - features: ["1 GB cloud log storage", "100 AI coaching credits / month"], + features: ["1 GB cloud log storage", "AI coaching (coming soon)"], }, ]; diff --git a/src/components/drawer/FilesTab.tsx b/src/components/drawer/FilesTab.tsx index ebc53a0..0c12db6 100644 --- a/src/components/drawer/FilesTab.tsx +++ b/src/components/drawer/FilesTab.tsx @@ -10,7 +10,7 @@ const DataloggerDownload = lazy(() => ); import { listSessionVideos, deleteSessionVideo, StoredVideoMeta } from "@/lib/videoFileStorage"; import { PluginMount } from "@/plugins/PluginMount"; -import { MountSlot, getMounts } from "@/plugins/mounts"; +import { MountSlot } from "@/plugins/mounts"; function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -240,12 +240,9 @@ export function FilesTab({ )}
- {/* Plugin-contributed footer (e.g. Cloud Sync sign-in / push-pull). */} - {getMounts(MountSlot.FileManagerFooter).length > 0 && ( -
- -
- )} + {/* Plugin-contributed footer (e.g. "Download all cloud logs"). + The mount owns its own chrome and self-hides when not applicable. */} + {/* Storage Usage */}
diff --git a/src/plugins/cloud-sync/CloudSyncPanel.tsx b/src/plugins/cloud-sync/CloudSyncPanel.tsx index 8b7f4cd..e1ed19a 100644 --- a/src/plugins/cloud-sync/CloudSyncPanel.tsx +++ b/src/plugins/cloud-sync/CloudSyncPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; -import { Cloud, CloudUpload, CloudDownload, LogOut, WifiOff, Loader2 } from "lucide-react"; +import { CloudUpload, CloudDownload, LogOut, WifiOff, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { useOnlineStatus } from "@/hooks/useOnlineStatus"; @@ -33,9 +33,6 @@ export default function CloudSyncPanel() { }; return (
-

- Cloud Sync -

Sign in to back up and sync your files, garage and notes across devices. Cloud Sync is optional — the app works fully offline without it.

@@ -85,10 +82,7 @@ export default function CloudSyncPanel() { return (
-

- - {user.email} -

+ {user.email} diff --git a/src/plugins/cloud-sync/DownloadAllCloudLogs.tsx b/src/plugins/cloud-sync/DownloadAllCloudLogs.tsx new file mode 100644 index 0000000..c72ef80 --- /dev/null +++ b/src/plugins/cloud-sync/DownloadAllCloudLogs.tsx @@ -0,0 +1,74 @@ +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { CloudDownload, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import type { FileManagerSectionContext } from "@/plugins/mounts"; +import { cloudOnlyNames, markPushed } from "./fileSync"; +import { listCloudFiles, downloadCloudFile } from "./syncEngine"; + +/** + * Bottom-of-file-list bulk action: pull every cloud log file that isn't already + * on this device. Sign-in lives on the Profile tab; this only shows when signed + * in. Pulled files persist via `ctx.onSaveFile` (which refreshes the list). + */ +export default function DownloadAllCloudLogs({ ctx }: { ctx: FileManagerSectionContext }) { + const { user } = useAuth(); + const online = useOnlineStatus(); + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); + + const localNames = useMemo(() => ctx.files.map((f) => f.name), [ctx.files]); + + if (!user) return null; + + const busy = progress !== null; + + const downloadAll = async () => { + if (busy) return; + setProgress({ done: 0, total: 0 }); + try { + const cloud = await listCloudFiles(user.id); + const pending = cloudOnlyNames(cloud.map((c) => c.name), localNames); + if (pending.length === 0) { + toast("All cloud logs are already on this device."); + return; + } + let ok = 0; + let failed = 0; + setProgress({ done: 0, total: pending.length }); + for (const name of pending) { + try { + const blob = await downloadCloudFile(user.id, name); + if (!blob) throw new Error("no data"); + await ctx.onSaveFile(name, blob); + await markPushed(name); + ok++; + } catch { + failed++; + } + setProgress({ done: ok + failed, total: pending.length }); + } + if (failed) toast.error(`Downloaded ${ok} log${ok === 1 ? "" : "s"}; ${failed} failed.`); + else toast.success(`Downloaded ${ok} cloud log${ok === 1 ? "" : "s"} to this device.`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to download cloud logs"); + } finally { + setProgress(null); + } + }; + + return ( +
+ + {!online && ( +

You're offline.

+ )} +
+ ); +} diff --git a/src/plugins/cloud-sync/StoragePanel.tsx b/src/plugins/cloud-sync/StoragePanel.tsx index c7931f6..41f0691 100644 --- a/src/plugins/cloud-sync/StoragePanel.tsx +++ b/src/plugins/cloud-sync/StoragePanel.tsx @@ -50,7 +50,7 @@ export default function StoragePanel(_props: PluginPanelProps) {

Not signed in

- Sign in under Labs → Cloud Sync to back up your garage and see your storage usage. + Sign in under Account (above) to back up your garage and see your storage usage.

); diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index 0b4e627..0292230 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -9,13 +9,15 @@ import { } from "@/plugins/mounts"; // The panel pulls in the Supabase sync engine + storage modules, so it's lazy: -// the chunk loads only when the Labs tab is opened, keeping the initial bundle -// lean (see Bundle Splitting in CLAUDE.md). +// the chunk loads only when the Profile tab is opened, keeping the initial +// bundle lean (see Bundle Splitting in CLAUDE.md). const CloudSyncPanel = lazy(() => import("./CloudSyncPanel")); -// Likewise the per-file toggle + cloud-only list: lazy so the file-manager -// drawer doesn't pull the sync engine onto its chunk until they render. +// Likewise the per-file toggle, cloud-only list, and bulk-download button: lazy +// so the file-manager drawer doesn't pull the sync engine onto its chunk until +// they render. const FileSyncToggle = lazy(() => import("./FileSyncToggle")); const CloudFilesSection = lazy(() => import("./CloudFilesSection")); +const DownloadAllCloudLogs = lazy(() => import("./DownloadAllCloudLogs")); // Profile tab panels: storage usage meters + account, and cloud-log management. const StoragePanel = lazy(() => import("./StoragePanel")); const CloudLogsPanel = lazy(() => import("./CloudLogsPanel")); @@ -32,14 +34,13 @@ const plugin: DataViewerPlugin = { // stays hidden unless another plugin contributes there. if (!enableCloud) return; - // Sign-in + manual push/pull, mounted at the bottom of the file manager - // (it used to live in the Labs tab — moved here so cloud sign-in/sync sits - // next to the files it backs up, and Labs stays empty unless a plugin uses it). + // "Download all cloud logs" bulk action at the bottom of the file list. + // (Sign-in itself lives on the Profile tab — see CloudSyncPanel below.) ctx.registry.contribute(MOUNTS_POINT, { - id: "cloud-sync-footer", + id: "cloud-sync-download-all", slot: MountSlot.FileManagerFooter, order: 0, - component: CloudSyncPanel, + component: DownloadAllCloudLogs, } satisfies PluginMountDef); // Per-file sync toggle injected into each file-manager row. @@ -59,7 +60,18 @@ const plugin: DataViewerPlugin = { component: CloudFilesSection, } satisfies PluginMountDef); - // Profile tab: storage usage meters (document + log storage types) + account. + // Profile tab: account / sign-in + manual push/pull (the login moved here + // from the file manager). Ordered first so it's the top of the Profile tab. + ctx.registry.contribute(PANELS_POINT, { + id: "cloud-sync-account", + title: "Account", + slot: PanelSlot.Profile, + order: -10, + icon: User, + component: CloudSyncPanel, + } satisfies PluginPanel); + + // Profile tab: storage usage meters (document + log storage types). ctx.registry.contribute(PANELS_POINT, { id: "cloud-sync-storage", title: "Profile", diff --git a/src/plugins/mounts.ts b/src/plugins/mounts.ts index 0a2dd1c..a967858 100644 --- a/src/plugins/mounts.ts +++ b/src/plugins/mounts.ts @@ -21,7 +21,7 @@ export const MountSlot = { /** Rendered once below the file list. Context: the whole list. */ FileManagerSection: "file-manager-section", /** Rendered near the bottom of the file manager (above storage usage). - * Context: the whole list. Home for the Cloud Sync sign-in / push-pull panel. */ + * Context: the whole list. Home for the "Download all cloud logs" action. */ FileManagerFooter: "file-manager-footer", } as const; export type MountSlot = (typeof MountSlot)[keyof typeof MountSlot]; From 71f57b3f4c9a24c5c4bf97c2574407bb971cb717 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 02:27:16 +0000 Subject: [PATCH 067/121] Add Stripe subscription backend (tiers, webhook, quota wiring) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce paid tiers (Plus $1/500 MB logs, Pro $10/1 GB logs) on top of the free 20 MB tier. Plan limits are data-driven via a new subscription_tiers table, and the cloud-sync quota trigger + usage RPC now resolve the caller's tier limit (falling back to free, then the legacy quota_limits baseline). user_subscriptions maps each user to a tier and is writable only by the service role, so entitlements can only be granted by the verified Stripe webhook — never the client. Adds three edge functions: create-checkout-session, stripe-webhook (signature-verified, the sole entitlement writer), and create-portal-session. Client upgrade/manage buttons are a follow-up. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 7 + CLAUDE.md | 34 +++- README.md | 10 ++ supabase/config.toml | 9 + .../create-checkout-session/index.ts | 103 ++++++++++++ .../functions/create-portal-session/index.ts | 69 ++++++++ supabase/functions/stripe-webhook/index.ts | 130 +++++++++++++++ .../20260526000000_stripe_subscriptions.sql | 154 ++++++++++++++++++ 8 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 supabase/functions/create-checkout-session/index.ts create mode 100644 supabase/functions/create-portal-session/index.ts create mode 100644 supabase/functions/stripe-webhook/index.ts create mode 100644 supabase/migrations/20260526000000_stripe_subscriptions.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 85cdf91..c7c0d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Paid subscription tiers (backend)**: Stripe-backed `Plus` ($1/mo, 500 MB + logs) and `Pro` ($10/mo, 1 GB logs) plans on top of the free 20 MB tier. Plan + limits are data-driven (`subscription_tiers` table) and the cloud-sync storage + quota is now enforced per the user's tier. New `create-checkout-session`, + `stripe-webhook`, and `create-portal-session` edge functions; entitlements are + granted solely by the verified Stripe webhook. (Upgrade buttons in the UI land + in a follow-up.) - Document storage + **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. The "documents" storage type diff --git a/CLAUDE.md b/CLAUDE.md index d2c36b2..543ba26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -451,9 +451,9 @@ Backend (migrations `..._cloud_sync.sql`, `..._storage_quotas.sql`): |--------|------|-------| | `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. | -| `quota_limits` | table | `(storage_type, max_bytes)` seeded `documents`=5 MB, `logs`=20 MB. Read by client + trigger. | -| `enforce_sync_quota` | trigger | BEFORE INSERT/UPDATE on `sync_records`: rejects writes that push a storage type over its limit (`quota_exceeded`). | -| `sync_storage_usage()` | RPC | Per-type `(used_bytes, limit_bytes)` for the caller. | +| `quota_limits` | table | `(storage_type, max_bytes)` seeded `documents`=5 MB, `logs`=20 MB. Legacy baseline/fallback once tiers exist (see below). | +| `enforce_sync_quota` | trigger | BEFORE INSERT/UPDATE on `sync_records`: rejects writes that push a storage type over the **caller's tier** limit (`tier_limit()`), falling back to `quota_limits` (`quota_exceeded`). | +| `sync_storage_usage()` | RPC | Per-type `(used_bytes, limit_bytes)` for the caller — `limit_bytes` 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. | @@ -498,6 +498,34 @@ 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. +### Subscriptions / Stripe (`..._stripe_subscriptions.sql` + 3 edge functions) + +Paid tiers scale the cloud-sync **logs** quota (`free` 20 MB → `plus` $1 500 MB +→ `pro` $10 1 GB; docs stay 5 MB). Tiers are **data**, not code: + +| Object | Type | Notes | +|--------|------|-------| +| `subscription_tiers` | table | One row per plan: `(tier PK, label, price_cents, logs_bytes, doc_bytes, ai_credits, stripe_price_id, sort_order)`. Authenticated read-all. Change a limit/price = UPDATE here. `stripe_price_id` is set after creating the Stripe Price. | +| `user_subscriptions` | table | `(user_id PK→auth.users, tier→subscription_tiers, status, stripe_customer_id, stripe_subscription_id, current_period_end, 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_limit(uuid, type)` | fn (SECURITY DEFINER) | Byte limit for a user + storage type from their tier; falls back to `free`, then `quota_limits`. Used by the quota trigger + usage RPC. | + +Edge functions (all `verify_jwt = false`; checkout/portal verify the JWT +manually like the rest of the repo, the webhook verifies the Stripe signature): + +- `create-checkout-session` — auth user → ensure Stripe customer (persisted on + `user_subscriptions`) → Checkout Session (subscription mode) for the tier's + `stripe_price_id` → 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 resolved from the Price id; `deleted` → `free`) via the service role. +- `create-portal-session` — returns a Stripe Billing Portal URL for + manage/cancel (no in-app billing UI). + +Secrets: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. **Client upgrade/manage +buttons (PricingCards + Profile StoragePanel) are a follow-up.** + --- ## Course Layouts (Drawing Feature) diff --git a/README.md b/README.md index 806f041..120064f 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,20 @@ The app includes an optional admin system for managing a community track databas | `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 — `???`) | | `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). After creating the Plus/Pro +> Products + recurring Prices in Stripe, store each Price id in the matching +> `subscription_tiers.stripe_price_id` row, and point a Stripe webhook (events: +> `checkout.session.completed`, `customer.subscription.created/updated/deleted`) +> at the `stripe-webhook` function URL. Use Stripe **test mode** first. Tier +> entitlements are granted only by the webhook, never the client. + > **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. diff --git a/supabase/config.toml b/supabase/config.toml index 352f492..454cbec 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -11,3 +11,12 @@ verify_jwt = false [functions.submit-message] verify_jwt = false + +[functions.create-checkout-session] +verify_jwt = false + +[functions.stripe-webhook] +verify_jwt = false + +[functions.create-portal-session] +verify_jwt = false diff --git a/supabase/functions/create-checkout-session/index.ts b/supabase/functions/create-checkout-session/index.ts new file mode 100644 index 0000000..302d164 --- /dev/null +++ b/supabase/functions/create-checkout-session/index.ts @@ -0,0 +1,103 @@ +// Creates a Stripe Checkout Session for a subscription tier and returns its URL. +// The caller's Supabase JWT (Authorization: Bearer …) identifies the user; the +// tier's Price id lives in the subscription_tiers table (set after you create +// the Price in Stripe). The actual entitlement is granted by stripe-webhook on +// completion — never by the client. +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import Stripe from "https://esm.sh/stripe@17.7.0?target=deno"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version', +}; + +const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', { + apiVersion: '2025-03-31.basil', + httpClient: Stripe.createFetchHttpClient(), +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Unauthorized' }, 401); + } + + // Identify the user from their JWT. + const authClient = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_ANON_KEY')!, + { global: { headers: { Authorization: authHeader } } }, + ); + const { data: { user }, error: userErr } = await authClient.auth.getUser(); + if (userErr || !user) { + return json({ error: 'Unauthorized' }, 401); + } + + const { tier, returnUrl } = await req.json().catch(() => ({})); + if (!tier || typeof tier !== 'string' || tier === 'free') { + return json({ error: 'Invalid tier' }, 400); + } + + const admin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, + ); + + // Resolve the tier's Stripe Price. + const { data: tierRow } = await admin + .from('subscription_tiers') + .select('stripe_price_id') + .eq('tier', tier) + .maybeSingle(); + if (!tierRow?.stripe_price_id) { + return json({ error: 'Tier is not purchasable' }, 400); + } + + // Reuse the user's Stripe customer if we have one, else create + persist it. + const { data: sub } = await admin + .from('user_subscriptions') + .select('stripe_customer_id') + .eq('user_id', user.id) + .maybeSingle(); + + let customerId = sub?.stripe_customer_id ?? undefined; + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email ?? undefined, + metadata: { user_id: user.id }, + }); + customerId = customer.id; + await admin.from('user_subscriptions').upsert( + { user_id: user.id, stripe_customer_id: customerId }, + { onConflict: 'user_id' }, + ); + } + + const base = (typeof returnUrl === 'string' && returnUrl) || req.headers.get('origin') || ''; + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + line_items: [{ price: tierRow.stripe_price_id, quantity: 1 }], + client_reference_id: user.id, + subscription_data: { metadata: { user_id: user.id, tier } }, + allow_promotion_codes: true, + success_url: `${base}?checkout=success`, + cancel_url: `${base}?checkout=cancel`, + }); + + return json({ url: session.url }); + } catch (e) { + console.error('create-checkout-session error', e); + return json({ error: 'Internal error' }, 500); + } +}); diff --git a/supabase/functions/create-portal-session/index.ts b/supabase/functions/create-portal-session/index.ts new file mode 100644 index 0000000..f267e76 --- /dev/null +++ b/supabase/functions/create-portal-session/index.ts @@ -0,0 +1,69 @@ +// Returns a Stripe Billing Portal URL so the user can manage / cancel their +// subscription on Stripe-hosted pages (no billing UI to build or maintain). +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import Stripe from "https://esm.sh/stripe@17.7.0?target=deno"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version', +}; + +const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', { + apiVersion: '2025-03-31.basil', + httpClient: Stripe.createFetchHttpClient(), +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Unauthorized' }, 401); + } + + const authClient = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_ANON_KEY')!, + { global: { headers: { Authorization: authHeader } } }, + ); + const { data: { user }, error: userErr } = await authClient.auth.getUser(); + if (userErr || !user) { + return json({ error: 'Unauthorized' }, 401); + } + + const { returnUrl } = await req.json().catch(() => ({})); + + const admin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, + ); + const { data: sub } = await admin + .from('user_subscriptions') + .select('stripe_customer_id') + .eq('user_id', user.id) + .maybeSingle(); + + if (!sub?.stripe_customer_id) { + return json({ error: 'No billing account' }, 400); + } + + const base = (typeof returnUrl === 'string' && returnUrl) || req.headers.get('origin') || undefined; + const session = await stripe.billingPortal.sessions.create({ + customer: sub.stripe_customer_id, + return_url: base, + }); + + return json({ url: session.url }); + } catch (e) { + console.error('create-portal-session error', e); + return json({ error: 'Internal error' }, 500); + } +}); diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts new file mode 100644 index 0000000..25af6cf --- /dev/null +++ b/supabase/functions/stripe-webhook/index.ts @@ -0,0 +1,130 @@ +// Stripe webhook — the ONLY thing that grants/revokes a tier. Verifies the +// Stripe signature, then mirrors the subscription state into user_subscriptions +// using the service role. Must be deployed with verify_jwt = false (Stripe does +// not send a Supabase JWT) — auth is the signature check instead. +import { createClient, type SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; +import Stripe from "https://esm.sh/stripe@17.7.0?target=deno"; + +const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', { + apiVersion: '2025-03-31.basil', + httpClient: Stripe.createFetchHttpClient(), +}); +const cryptoProvider = Stripe.createSubtleCryptoProvider(); + +const admin = (): SupabaseClient => createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, +); + +// Map a Stripe Price id → our tier slug (falls back to 'free' if unknown). +async function tierForPrice(db: SupabaseClient, priceId: string | undefined): Promise { + if (!priceId) return 'free'; + const { data } = await db + .from('subscription_tiers') + .select('tier') + .eq('stripe_price_id', priceId) + .maybeSingle(); + return data?.tier ?? 'free'; +} + +// Resolve our user_id for a subscription: prefer the metadata we stamped at +// checkout, else look up by Stripe customer id. +async function resolveUserId( + db: SupabaseClient, + sub: Stripe.Subscription, +): Promise { + const metaId = sub.metadata?.user_id; + if (metaId) return metaId; + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; + if (!customerId) return null; + const { data } = await db + .from('user_subscriptions') + .select('user_id') + .eq('stripe_customer_id', customerId) + .maybeSingle(); + return data?.user_id ?? null; +} + +// current_period_end moved onto subscription items in recent API versions; +// fall back across both shapes. +function periodEnd(sub: Stripe.Subscription): string | null { + const item = sub.items?.data?.[0] as (Stripe.SubscriptionItem & { current_period_end?: number }) | undefined; + const ts = item?.current_period_end + ?? (sub as unknown as { current_period_end?: number }).current_period_end; + return typeof ts === 'number' ? new Date(ts * 1000).toISOString() : null; +} + +async function applySubscription( + db: SupabaseClient, + sub: Stripe.Subscription, + opts: { deleted?: boolean } = {}, +): Promise { + const userId = await resolveUserId(db, sub); + if (!userId) { + console.error('stripe-webhook: no user for subscription', sub.id); + return; + } + + const priceId = sub.items?.data?.[0]?.price?.id; + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; + const tier = opts.deleted ? 'free' : await tierForPrice(db, priceId); + const status = opts.deleted ? 'canceled' : sub.status; + + await db.from('user_subscriptions').upsert({ + user_id: userId, + tier, + status, + stripe_customer_id: customerId, + stripe_subscription_id: sub.id, + current_period_end: periodEnd(sub), + updated_at: new Date().toISOString(), + }, { onConflict: 'user_id' }); +} + +Deno.serve(async (req) => { + const sig = req.headers.get('stripe-signature'); + const secret = Deno.env.get('STRIPE_WEBHOOK_SECRET'); + const body = await req.text(); + if (!sig || !secret) { + return new Response('Missing signature', { status: 400 }); + } + + let event: Stripe.Event; + try { + event = await stripe.webhooks.constructEventAsync(body, sig, secret, undefined, cryptoProvider); + } catch (e) { + console.error('stripe-webhook: signature verification failed', e); + return new Response('Invalid signature', { status: 400 }); + } + + try { + const db = admin(); + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + const subId = typeof session.subscription === 'string' + ? session.subscription + : session.subscription?.id; + if (subId) { + const sub = await stripe.subscriptions.retrieve(subId); + await applySubscription(db, sub); + } + break; + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + await applySubscription(db, event.data.object as Stripe.Subscription); + break; + case 'customer.subscription.deleted': + await applySubscription(db, event.data.object as Stripe.Subscription, { deleted: true }); + break; + } + } catch (e) { + console.error('stripe-webhook: handler error', e); + return new Response('Handler error', { status: 500 }); + } + + return new Response(JSON.stringify({ received: true }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); +}); diff --git a/supabase/migrations/20260526000000_stripe_subscriptions.sql b/supabase/migrations/20260526000000_stripe_subscriptions.sql new file mode 100644 index 0000000..40df0df --- /dev/null +++ b/supabase/migrations/20260526000000_stripe_subscriptions.sql @@ -0,0 +1,154 @@ +-- Subscription tiers + Stripe-backed user subscriptions. +-- +-- Builds on storage_quotas: that migration enforced two storage *types* +-- (documents / logs) against a single global quota_limits table. This one makes +-- the *limit* depend on the user's subscription tier: +-- +-- • subscription_tiers — one row per plan (free / plus / pro) with its per-type +-- byte limits, price, and Stripe price id. Limits are now DATA: changing a +-- plan's storage or price is an UPDATE here, not a code change. +-- • user_subscriptions — one row per user mapping them to a tier, plus the +-- Stripe customer/subscription ids + status. Written ONLY by the service role +-- (the stripe-webhook edge function) — users can read their own row but can +-- never grant themselves a tier. +-- +-- The enforce_sync_quota trigger + sync_storage_usage() now resolve the caller's +-- tier limit (falling back to free, then to quota_limits) instead of reading the +-- global table directly. quota_limits remains the ultimate baseline/fallback. + +-- ── Tiers (data-driven plan catalogue) ────────────────────────────────────── +create table if not exists public.subscription_tiers ( + tier text primary key, + label text not null, + price_cents integer not null default 0, + logs_bytes bigint not null, + doc_bytes bigint not null, + ai_credits integer not null default 0, + stripe_price_id text, -- null for free; set after creating the Stripe Price + sort_order integer not null default 0 +); + +insert into public.subscription_tiers + (tier, label, price_cents, logs_bytes, doc_bytes, ai_credits, sort_order) values + ('free', 'Free', 0, 20971520, 5242880, 0, 0), -- 20 MB logs / 5 MB docs + ('plus', 'Plus', 100, 524288000, 5242880, 0, 1), -- 500 MB logs / 5 MB docs + ('pro', 'Pro', 1000, 1073741824, 5242880, 0, 2) -- 1 GB logs / 5 MB docs +on conflict (tier) do update set + label = excluded.label, + price_cents = excluded.price_cents, + logs_bytes = excluded.logs_bytes, + doc_bytes = excluded.doc_bytes, + ai_credits = excluded.ai_credits, + sort_order = excluded.sort_order; + +alter table public.subscription_tiers enable row level security; + +drop policy if exists "Anyone authenticated reads tiers" on public.subscription_tiers; +create policy "Anyone authenticated reads tiers" + on public.subscription_tiers for select to authenticated + using (true); + +-- ── Per-user subscription state (service-role-written) ────────────────────── +create table if not exists public.user_subscriptions ( + user_id uuid primary key references auth.users(id) on delete cascade, + tier text not null default 'free' references public.subscription_tiers(tier), + status text not null default 'active', -- Stripe subscription.status + stripe_customer_id text, + stripe_subscription_id text, + current_period_end timestamptz, + updated_at timestamptz not null default now() +); + +create index if not exists user_subscriptions_customer_idx + on public.user_subscriptions (stripe_customer_id); + +alter table public.user_subscriptions enable row level security; + +-- Users may read their own row. No insert/update/delete policies exist, so only +-- the service role (which bypasses RLS) can write — i.e. the stripe-webhook fn. +drop policy if exists "Users read own subscription" on public.user_subscriptions; +create policy "Users read own subscription" + on public.user_subscriptions for select to authenticated + using (auth.uid() = user_id); + +-- ── Tier resolution helpers ───────────────────────────────────────────────── +-- The effective tier for a user: their subscription tier when the status grants +-- access (active / trialing / past_due grace), else 'free'. SECURITY DEFINER so +-- the quota trigger can resolve any row's tier regardless of RLS. +create or replace function public.user_tier(p_user uuid) +returns text language sql stable security definer set search_path = public as $$ + select coalesce( + (select s.tier + from public.user_subscriptions s + where s.user_id = p_user + and s.status in ('active', 'trialing', 'past_due')), + 'free'); +$$; + +-- The byte limit for a given user + storage type, from their effective tier. +-- Falls back to the free tier, then to the legacy quota_limits baseline. +create or replace function public.tier_limit(p_user uuid, p_type text) +returns bigint language sql stable security definer set search_path = public as $$ + select coalesce( + (select case when p_type = 'logs' then t.logs_bytes else t.doc_bytes end + from public.subscription_tiers t + where t.tier = public.user_tier(p_user)), + (select case when p_type = 'logs' then t.logs_bytes else t.doc_bytes end + from public.subscription_tiers t + where t.tier = 'free'), + (select max_bytes from public.quota_limits where storage_type = p_type)); +$$; + +grant execute on function public.user_tier(uuid) to authenticated; +grant execute on function public.tier_limit(uuid, text) to authenticated; + +-- ── Quota enforcement: now tier-aware ─────────────────────────────────────── +create or replace function public.enforce_sync_quota() +returns trigger language plpgsql as $$ +declare + v_type text := public.sync_storage_type(NEW.store); + v_limit bigint := public.tier_limit(NEW.user_id, v_type); + v_used bigint; + v_new bigint := public.sync_record_size(NEW.store, NEW.data); +begin + if v_limit is null then + return NEW; -- no limit configured for this type + end if; + + -- Current usage for this type, excluding the row being upserted. + select coalesce(sum(public.sync_record_size(store, data)), 0) + into v_used + from public.sync_records + where user_id = NEW.user_id + and public.sync_storage_type(store) = v_type + and not (store = NEW.store and record_key = NEW.record_key); + + if v_used + v_new > v_limit then + raise exception + 'quota_exceeded: % storage over limit (% bytes used + % new > % limit)', + v_type, v_used, v_new, v_limit + using errcode = 'check_violation'; + end if; + + return NEW; +end; +$$; + +-- (trigger sync_records_quota already bound to this function in storage_quotas) + +-- ── Usage readout: limits now reflect the caller's tier ───────────────────── +create or replace function public.sync_storage_usage() +returns table(storage_type text, used_bytes bigint, limit_bytes bigint) +language sql stable as $$ + select t.storage_type, + coalesce(( + select sum(public.sync_record_size(r.store, r.data)) + from public.sync_records r + where r.user_id = auth.uid() + and public.sync_storage_type(r.store) = t.storage_type + ), 0)::bigint, + public.tier_limit(auth.uid(), t.storage_type) + from (values ('documents'), ('logs')) as t(storage_type); +$$; + +grant execute on function public.sync_storage_usage() to authenticated; From 1a1416d3f9ce5ec521a67b5f440668bea910a7b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 03:34:51 +0000 Subject: [PATCH 068/121] Scope test coverage to logic, exclude the React view layer The coverage report was dominated by ~4,800 lines of untested presentational React code, masking solid coverage of the actual logic (parsers, protocol code, utilities). Coverage now targets lib/, hooks/, and plugins/ and excludes the view layer (components/*.tsx, pages, contexts, App.tsx) that is validated by integration/visual testing rather than Vitest line coverage. View .tsx is excluded specifically so the .ts logic files under video-overlays stay in scope, and hooks/lib remain in the report so the number reflects real test debt rather than hiding it. Headline line coverage goes from 14.5% to 25.5%. Thresholds raised from 1% to honest floors a few points below current actuals to guard against regressions. https://claude.ai/code/session_017fmZ5GDJkNec7sxGWt343G --- CLAUDE.md | 21 ++++++++++++++++++--- vitest.config.ts | 22 +++++++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d2c36b2..2afc566 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -636,17 +636,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. --- diff --git a/vitest.config.ts b/vitest.config.ts index e5f269f..5df2a80 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,20 +15,32 @@ export default defineConfig({ provider: "v8", reporter: ["text-summary", "json-summary", "lcov"], include: ["src/**/*.{ts,tsx}"], + // Coverage is scoped to *logic* worth unit-testing (parsers, utilities, + // protocol code, hooks, plugins). The React view layer is deliberately + // out of scope — presentational components, route/page shells, and + // context providers are validated by integration/visual testing, not + // Vitest line coverage. We exclude view code, NOT untested logic: hooks + // and lib/ stay in the report so the number is an honest signal. exclude: [ "src/**/*.{test,spec}.{ts,tsx}", + "src/components/**/*.tsx", // presentational React components (keeps video-overlays/*.ts logic in scope) + "src/pages/**", // route/page shells — view layer + "src/contexts/**", // provider wiring — view layer + "src/App.tsx", // app shell / routing "src/components/ui/**", // vendored shadcn/ui primitives "src/integrations/supabase/**", // auto-generated — DO NOT EDIT "src/**/*.d.ts", "src/main.tsx", "src/vite-env.d.ts", ], - // Gate intentionally low so it can be ratcheted up later as coverage grows. + // Floors guard against regressions in the logic we test. Set a few points + // below current actuals so routine churn doesn't redden CI; ratchet up as + // coverage grows. thresholds: { - lines: 1, - functions: 1, - branches: 1, - statements: 1, + lines: 20, + functions: 18, + branches: 18, + statements: 20, }, }, }, From d2fc96b1a18c625ccc4de6c04cbec6cc8e65ba49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 03:49:13 +0000 Subject: [PATCH 069/121] Add unit tests for parsers, reference/field utils, and garage events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the text-format parsers (dove, alfano, aim, vbo, nmea) at the detection + parse-behavior level, the reference-lap pace and field/channel resolver adapters, chart colors, device-settings validation, the cn() helper, and the garage pub/sub bus. Pure-logic only — no source changes. 160 tests across 11 files. https://claude.ai/code/session_017fmZ5GDJkNec7sxGWt343G --- src/lib/aimParser.test.ts | 107 +++++++++++ src/lib/alfanoParser.test.ts | 122 ++++++++++++ src/lib/chartColors.test.ts | 62 ++++++ src/lib/deviceSettingsSchema.test.ts | 149 +++++++++++++++ src/lib/doveParser.test.ts | 151 +++++++++++++++ src/lib/fieldResolver.test.ts | 122 ++++++++++++ src/lib/garageEvents.test.ts | 109 +++++++++++ src/lib/nmeaParser.test.ts | 238 +++++++++++++++++++++++ src/lib/referenceUtils.test.ts | 271 +++++++++++++++++++++++++++ src/lib/utils.test.ts | 68 +++++++ src/lib/vboParser.test.ts | 135 +++++++++++++ 11 files changed, 1534 insertions(+) create mode 100644 src/lib/aimParser.test.ts create mode 100644 src/lib/alfanoParser.test.ts create mode 100644 src/lib/chartColors.test.ts create mode 100644 src/lib/deviceSettingsSchema.test.ts create mode 100644 src/lib/doveParser.test.ts create mode 100644 src/lib/fieldResolver.test.ts create mode 100644 src/lib/garageEvents.test.ts create mode 100644 src/lib/nmeaParser.test.ts create mode 100644 src/lib/referenceUtils.test.ts create mode 100644 src/lib/utils.test.ts create mode 100644 src/lib/vboParser.test.ts diff --git a/src/lib/aimParser.test.ts b/src/lib/aimParser.test.ts new file mode 100644 index 0000000..09db39f --- /dev/null +++ b/src/lib/aimParser.test.ts @@ -0,0 +1,107 @@ +/** + * Unit tests for the AiM MyChron CSV parser. + * + * AiM exports use channel names like GPS_Speed / GPS_Lat / GPS_Long / Acc_Lat. + * Detection needs 2+ AiM-specific channel names in the first lines. Time is + * seconds (auto-scaled to ms); speed >50 in the first row is treated as km/h. + */ + +import { describe, it, expect } from "vitest"; +import { isAimFormat, parseAimFile } from "./aimParser"; + +// ─── Synthetic fixtures ───────────────────────────────────────────────────── + +/** Valid AiM CSV: header with AiM channels + N data rows. */ +function makeAimCsv(rows = 4): string { + const header = "Time,GPS_Speed,GPS_Lat,GPS_Long,GPS_Heading,Acc_Lat,Acc_Long,RPM"; + const lines = [header]; + for (let i = 0; i < rows; i++) { + const time = (i * 0.1).toFixed(1); // seconds + const speed = (60 + i).toString(); // km/h (>50 → detected as km/h) + const lat = "28.401"; + const lon = (-81.401 + i * 0.00001).toFixed(6); + lines.push([time, speed, lat, lon, "90", "0.5", "0.3", "5000"].join(",")); + } + return lines.join("\n"); +} + +// ─── isAimFormat ──────────────────────────────────────────────────────────── + +describe("isAimFormat", () => { + it("accepts a CSV with 2+ AiM channel headers", () => { + expect(isAimFormat(makeAimCsv())).toBe(true); + }); + + it("rejects content with fewer than 2 lines", () => { + expect(isAimFormat("GPS_Speed,GPS_Lat")).toBe(false); + }); + + it("rejects a single AiM channel (needs 2+ indicators)", () => { + expect(isAimFormat("time,gps_speed,foo\n0,60,1")).toBe(false); + }); + + it("rejects random text", () => { + expect(isAimFormat("nothing\nrelevant here")).toBe(false); + }); +}); + +// ─── parseAimFile ─────────────────────────────────────────────────────────── + +describe("parseAimFile", () => { + it("parses all valid rows into samples", () => { + const parsed = parseAimFile(makeAimCsv(4)); + expect(parsed.samples).toHaveLength(4); + }); + + it("makes the first sample t=0 and scales seconds→ms", () => { + const parsed = parseAimFile(makeAimCsv(4)); + expect(parsed.samples[0].t).toBe(0); + expect(parsed.samples[1].t).toBeCloseTo(100, 5); + }); + + it("derives a consistent speed triple and treats >50 as km/h", () => { + const parsed = parseAimFile(makeAimCsv(4)); + const s = parsed.samples[0]; + expect(s.speedMph).toBeCloseTo(s.speedMps * 2.23694, 4); + expect(s.speedKph).toBeCloseTo(s.speedMps * 3.6, 4); + // 60 km/h → m/s + expect(s.speedMps).toBeCloseTo(60 / 3.6, 5); + }); + + it("computes sane bounds", () => { + const parsed = parseAimFile(makeAimCsv(4)); + expect(parsed.bounds.minLat).toBeCloseTo(28.401, 5); + expect(parsed.bounds.minLon).toBeLessThan(parsed.bounds.maxLon); + }); + + it("reads heading from GPS_Heading", () => { + const parsed = parseAimFile(makeAimCsv(4)); + expect(parsed.samples[0].heading).toBe(90); + }); + + it("populates native G + RPM extra fields and builds mappings", () => { + const parsed = parseAimFile(makeAimCsv(4)); + const ef = parsed.samples[0].extraFields; + expect(ef["RPM"]).toBe(5000); + expect(ef["Lat G"]).toBeDefined(); + expect(ef["Lon G"]).toBeDefined(); + const names = parsed.fieldMappings.map((m) => m.name); + expect(names).toContain("Lat G"); + expect(names).toContain("RPM"); + }); + + it("skips rows with invalid coordinates", () => { + const lines = [ + "Time,GPS_Speed,GPS_Lat,GPS_Long", + "0.0,60,28.401,-81.401", + "0.1,61,0,0", // (0,0) → skipped + "0.2,62,28.40101,-81.40101", // close enough to pass teleportation filter + ]; + const parsed = parseAimFile(lines.join("\n")); + expect(parsed.samples).toHaveLength(2); + }); + + it("throws when no AiM header row can be found", () => { + expect(() => parseAimFile("just\nrandom\ntext lines")).toThrow(); + }); +}); diff --git a/src/lib/alfanoParser.test.ts b/src/lib/alfanoParser.test.ts new file mode 100644 index 0000000..d0c1e4b --- /dev/null +++ b/src/lib/alfanoParser.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for the Alfano CSV parser. + * + * Alfano exports have a metadata preamble (Driver:, Track:, …) then a header + * row with recognizable columns (gps_latitude, gps_longitude, gps_speed, …), + * then data rows. Delimiter is comma or semicolon. Speed is km/h. + */ + +import { describe, it, expect } from "vitest"; +import { isAlfanoFormat, parseAlfanoFile } from "./alfanoParser"; + +// ─── Synthetic fixtures ───────────────────────────────────────────────────── + +/** Valid Alfano CSV: metadata preamble + header + N rows, comma-delimited. */ +function makeAlfanoCsv(rows = 4, delimiter = ","): string { + const d = delimiter; + const lines = [ + `Driver:${d}Test Driver`, + `Track:${d}Orlando`, + ["Time", "GPS_Latitude", "GPS_Longitude", "GPS_Speed", "GPS_Heading", "RPM", "LatAcc"].join(d), + ]; + for (let i = 0; i < rows; i++) { + const time = (i * 0.1).toFixed(1); // seconds + const lat = "28.401"; + const lon = (-81.401 + i * 0.00001).toFixed(6); + const speed = (50 + i).toString(); // km/h + lines.push([time, lat, lon, speed, "90", "5000", "1.2"].join(d)); + } + return lines.join("\n"); +} + +// ─── isAlfanoFormat ───────────────────────────────────────────────────────── + +describe("isAlfanoFormat", () => { + it("accepts a CSV with Alfano headers", () => { + expect(isAlfanoFormat(makeAlfanoCsv())).toBe(true); + }); + + it("accepts a CSV detected purely by metadata preamble", () => { + const csv = "Driver: Mike\nTrack: OKC\nsomecol,othercol\n1,2"; + expect(isAlfanoFormat(csv)).toBe(true); + }); + + it("rejects VBO format markers", () => { + const csv = "[header]\ngps_speed gps_latitude\n[data]\n1 2"; + expect(isAlfanoFormat(csv)).toBe(false); + }); + + it("rejects random text without headers or metadata", () => { + expect(isAlfanoFormat("hello world\nnothing to see")).toBe(false); + }); +}); + +// ─── parseAlfanoFile ──────────────────────────────────────────────────────── + +describe("parseAlfanoFile", () => { + it("parses all valid rows into samples", () => { + const parsed = parseAlfanoFile(makeAlfanoCsv(4)); + expect(parsed.samples).toHaveLength(4); + }); + + it("makes the first sample t=0 and converts seconds→ms", () => { + const parsed = parseAlfanoFile(makeAlfanoCsv(4)); + expect(parsed.samples[0].t).toBe(0); + // second row: 0.1s relative → 100 ms + expect(parsed.samples[1].t).toBeCloseTo(100, 5); + }); + + it("derives a consistent speed triple from km/h", () => { + const parsed = parseAlfanoFile(makeAlfanoCsv(4)); + const s = parsed.samples[0]; + expect(s.speedMph).toBeCloseTo(s.speedMps * 2.23694, 4); + expect(s.speedKph).toBeCloseTo(s.speedMps * 3.6, 4); + // 50 km/h → m/s + expect(s.speedMps).toBeCloseTo(50 / 3.6, 5); + }); + + it("computes sane bounds", () => { + const parsed = parseAlfanoFile(makeAlfanoCsv(4)); + expect(parsed.bounds.minLat).toBeCloseTo(28.401, 5); + expect(parsed.bounds.minLon).toBeLessThan(parsed.bounds.maxLon); + }); + + it("reads heading from the GPS_Heading column", () => { + const parsed = parseAlfanoFile(makeAlfanoCsv(4)); + expect(parsed.samples[0].heading).toBe(90); + }); + + it("populates native G + RPM extra fields and exposes mappings", () => { + const parsed = parseAlfanoFile(makeAlfanoCsv(4)); + const ef = parsed.samples[0].extraFields; + expect(ef["RPM"]).toBe(5000); + expect(ef["Lat G (Native)"]).toBeDefined(); + const names = parsed.fieldMappings.map((m) => m.name); + expect(names).toContain("Lat G"); + expect(names).toContain("Lon G"); + expect(names).toContain("RPM"); + expect(names).toContain("Lat G (Native)"); + }); + + it("handles a semicolon-delimited export", () => { + const parsed = parseAlfanoFile(makeAlfanoCsv(4, ";")); + expect(parsed.samples).toHaveLength(4); + expect(parsed.samples[0].speedMps).toBeCloseTo(50 / 3.6, 5); + }); + + it("skips rows with invalid coordinates", () => { + const lines = [ + "Driver:,Test", + "Time,GPS_Latitude,GPS_Longitude,GPS_Speed", + "0.0,28.401,-81.401,50", + "0.1,0,0,51", // (0,0) → skipped + "0.2,28.402,-81.402,52", + ]; + const parsed = parseAlfanoFile(lines.join("\n")); + expect(parsed.samples).toHaveLength(2); + }); + + it("throws when no valid header row is found", () => { + expect(() => parseAlfanoFile("just\nrandom\ntext")).toThrow(); + }); +}); diff --git a/src/lib/chartColors.test.ts b/src/lib/chartColors.test.ts new file mode 100644 index 0000000..731b85b --- /dev/null +++ b/src/lib/chartColors.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { getChartColors, type ChartColorPalette } from "./chartColors"; + +// Pure theme-palette selector: getChartColors(isDark) returns a frozen-ish +// constant object — dark vs light. No DOM involved. + +const KEYS: (keyof ChartColorPalette)[] = [ + "background", + "grid", + "axisText", + "tooltipBg", + "tooltipBorder", + "scrubCursor", + "zeroLine", + "refLine", + "deltaText", +]; + +describe("getChartColors", () => { + it("returns a palette with every documented key for dark", () => { + const p = getChartColors(true); + for (const k of KEYS) { + expect(typeof p[k]).toBe("string"); + expect(p[k].length).toBeGreaterThan(0); + } + }); + + it("returns a palette with every documented key for light", () => { + const p = getChartColors(false); + for (const k of KEYS) { + expect(typeof p[k]).toBe("string"); + expect(p[k].length).toBeGreaterThan(0); + } + }); + + it("dark and light differ on every key (distinct themes)", () => { + const dark = getChartColors(true); + const light = getChartColors(false); + for (const k of KEYS) { + expect(dark[k]).not.toBe(light[k]); + } + }); + + it("dark background is near-black, light background is white", () => { + expect(getChartColors(true).background).toBe("hsl(220, 18%, 10%)"); + expect(getChartColors(false).background).toBe("hsl(0, 0%, 100%)"); + }); + + it("returns the same constant reference across calls (no per-call alloc)", () => { + expect(getChartColors(true)).toBe(getChartColors(true)); + expect(getChartColors(false)).toBe(getChartColors(false)); + }); + + it("all color values are valid hsl/hsla strings", () => { + for (const isDark of [true, false]) { + const p = getChartColors(isDark); + for (const k of KEYS) { + expect(p[k]).toMatch(/^hsla?\(/); + } + } + }); +}); diff --git a/src/lib/deviceSettingsSchema.test.ts b/src/lib/deviceSettingsSchema.test.ts new file mode 100644 index 0000000..204ae8d --- /dev/null +++ b/src/lib/deviceSettingsSchema.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { + DEVICE_SETTINGS_SCHEMA, + getSettingDef, + validateSettingValue, +} from "./deviceSettingsSchema"; + +// ─── schema shape ───────────────────────────────────────────────────────────── + +describe("DEVICE_SETTINGS_SCHEMA", () => { + it("has unique keys", () => { + const keys = DEVICE_SETTINGS_SCHEMA.map((d) => d.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it("every def has a non-empty label and a valid type", () => { + for (const d of DEVICE_SETTINGS_SCHEMA) { + expect(d.label.length).toBeGreaterThan(0); + expect(["string", "number"]).toContain(d.type); + } + }); + + it("includes the known device keys", () => { + const keys = DEVICE_SETTINGS_SCHEMA.map((d) => d.key); + expect(keys).toContain("bluetooth_name"); + expect(keys).toContain("bluetooth_pin"); + expect(keys).toContain("lap_detection_distance"); + expect(keys).toContain("use_legacy_csv"); + }); +}); + +// ─── getSettingDef ────────────────────────────────────────────────────────── + +describe("getSettingDef", () => { + it("returns the def for a known key", () => { + const def = getSettingDef("driver_name"); + expect(def).not.toBeNull(); + expect(def?.label).toBe("Driver Name"); + expect(def?.type).toBe("string"); + expect(def?.maxLength).toBe(30); + }); + + it("returns null for an unknown key", () => { + expect(getSettingDef("nonexistent_key")).toBeNull(); + }); + + it("is case-sensitive (does not match wrong casing)", () => { + expect(getSettingDef("BLUETOOTH_NAME")).toBeNull(); + }); +}); + +// ─── validateSettingValue: unknown keys ───────────────────────────────────── + +describe("validateSettingValue — unknown keys", () => { + it("returns null (no validation) for unknown keys, even garbage values", () => { + expect(validateSettingValue("mystery", "anything at all")).toBeNull(); + expect(validateSettingValue("mystery", "")).toBeNull(); + }); +}); + +// ─── validateSettingValue: number type ────────────────────────────────────── + +describe("validateSettingValue — number fields", () => { + it("accepts an in-range integer", () => { + expect(validateSettingValue("lap_detection_distance", "25")).toBeNull(); + }); + + it("rejects non-numeric input", () => { + expect(validateSettingValue("lap_detection_distance", "abc")).toBe( + "Must be a whole number" + ); + }); + + it("rejects a fractional value (whole numbers only)", () => { + expect(validateSettingValue("lap_detection_distance", "10.5")).toBe( + "Must be a whole number" + ); + }); + + it("enforces the minimum", () => { + // lap_detection_distance min = 1 + expect(validateSettingValue("lap_detection_distance", "0")).toBe( + "Minimum value is 1" + ); + }); + + it("enforces the maximum", () => { + // lap_detection_distance max = 50 + expect(validateSettingValue("lap_detection_distance", "51")).toBe( + "Maximum value is 50" + ); + }); + + it("accepts the exact boundary values", () => { + expect(validateSettingValue("lap_detection_distance", "1")).toBeNull(); + expect(validateSettingValue("lap_detection_distance", "50")).toBeNull(); + }); + + it("accepts a negative integer when min allows it (use_legacy_csv min 0 still 0)", () => { + // use_legacy_csv: 0 and 1 valid, 2 too big, -1 below min + expect(validateSettingValue("use_legacy_csv", "0")).toBeNull(); + expect(validateSettingValue("use_legacy_csv", "1")).toBeNull(); + expect(validateSettingValue("use_legacy_csv", "2")).toBe("Maximum value is 1"); + expect(validateSettingValue("use_legacy_csv", "-1")).toBe("Minimum value is 0"); + }); + + it("enforces maxLength (digit count) on numeric fields like bluetooth_pin", () => { + // bluetooth_pin: min 0, max 9999, maxLength 4 + expect(validateSettingValue("bluetooth_pin", "1234")).toBeNull(); + // 5 digits: caught by max (9999) before maxLength + expect(validateSettingValue("bluetooth_pin", "12345")).toBe( + "Maximum value is 9999" + ); + }); + + it("treats empty string as 0 (Number('') === 0) and applies range", () => { + // Number("") is 0, which is an integer; for lap_detection_distance min 1 → fails + expect(validateSettingValue("lap_detection_distance", "")).toBe( + "Minimum value is 1" + ); + }); +}); + +// ─── validateSettingValue: string type ────────────────────────────────────── + +describe("validateSettingValue — string fields", () => { + it("accepts a short string", () => { + expect(validateSettingValue("driver_name", "Mike")).toBeNull(); + }); + + it("accepts an empty string (no min length)", () => { + expect(validateSettingValue("driver_name", "")).toBeNull(); + }); + + it("accepts exactly maxLength characters", () => { + expect(validateSettingValue("driver_name", "x".repeat(30))).toBeNull(); + }); + + it("rejects a string over maxLength", () => { + expect(validateSettingValue("driver_name", "x".repeat(31))).toBe( + "Maximum 30 characters" + ); + }); + + it("does not apply numeric validation to string fields", () => { + // bluetooth_name is a string — non-numeric content is fine + expect(validateSettingValue("bluetooth_name", "My Logger!")).toBeNull(); + }); +}); diff --git a/src/lib/doveParser.test.ts b/src/lib/doveParser.test.ts new file mode 100644 index 0000000..af46e05 --- /dev/null +++ b/src/lib/doveParser.test.ts @@ -0,0 +1,151 @@ +/** + * Unit tests for the Dove CSV parser. + * + * Dove is a simple CSV: a header row (timestamp, lat, lng, speed_mph required) + * followed by data rows. Timestamps are Unix ms in the 2020–2030 range. + */ + +import { describe, it, expect } from "vitest"; +import { isDoveFormat, parseDoveFile } from "./doveParser"; + +// ─── Synthetic fixtures ───────────────────────────────────────────────────── + +// A Unix ms timestamp inside the accepted 1.5e12–2.0e12 window (≈2021-03). +const T0 = 1_614_700_000_000; + +/** Build a valid Dove CSV with N rows around Orlando, moving slowly east. */ +function makeDoveCsv(rows = 4): string { + const header = "timestamp,sats,hdop,lat,lng,speed_mph,heading_deg,rpm"; + const lines = [header]; + for (let i = 0; i < rows; i++) { + const t = T0 + i * 100; // 10 Hz + const lat = 28.401; + const lng = -81.401 + i * 0.00001; + const speed = 30 + i; + lines.push(`${t},12,0.9,${lat},${lng},${speed},90,5000`); + } + return lines.join("\n"); +} + +// ─── isDoveFormat ─────────────────────────────────────────────────────────── + +describe("isDoveFormat", () => { + it("accepts a valid Dove CSV", () => { + expect(isDoveFormat(makeDoveCsv())).toBe(true); + }); + + it("rejects content with fewer than 2 lines", () => { + expect(isDoveFormat("timestamp,lat,lng,speed_mph")).toBe(false); + }); + + it("rejects when required headers are missing", () => { + expect(isDoveFormat("foo,bar,baz\n1,2,3")).toBe(false); + }); + + it("rejects when the data row has no valid ms timestamp", () => { + // Seconds, not ms — outside the 1.5e12–2.0e12 window + const csv = "timestamp,lat,lng,speed_mph\n1614700000,28.4,-81.4,30"; + expect(isDoveFormat(csv)).toBe(false); + }); + + it("rejects VBO markers even with matching header words", () => { + const csv = "[header]\ntimestamp lat lng speed_mph\n1614700000000,28.4,-81.4,30"; + expect(isDoveFormat(csv)).toBe(false); + }); + + it("rejects Alfano-style gps_latitude headers", () => { + const csv = "gps_latitude,gps_longitude,timestamp,lat,lng,speed_mph\n1614700000000,a,b,28.4,-81.4,30"; + expect(isDoveFormat(csv)).toBe(false); + }); + + it("rejects random text", () => { + expect(isDoveFormat("just some\nrandom text here")).toBe(false); + }); +}); + +// ─── parseDoveFile ────────────────────────────────────────────────────────── + +describe("parseDoveFile", () => { + it("parses all valid rows into samples", () => { + const parsed = parseDoveFile(makeDoveCsv(4)); + expect(parsed.samples).toHaveLength(4); + }); + + it("makes the first sample t=0 (relative to file start)", () => { + const parsed = parseDoveFile(makeDoveCsv(4)); + expect(parsed.samples[0].t).toBe(0); + expect(parsed.samples[1].t).toBe(100); + }); + + it("derives a consistent speed triple from mph", () => { + const parsed = parseDoveFile(makeDoveCsv(4)); + const s = parsed.samples[0]; + // 30 mph → mps; verify the three-unit relationship + expect(s.speedMph).toBeCloseTo(s.speedMps * 2.23694, 4); + expect(s.speedKph).toBeCloseTo(s.speedMps * 3.6, 4); + expect(s.speedMps).toBeCloseTo(30 * 0.44704, 5); + }); + + it("computes sane bounds for the samples", () => { + const parsed = parseDoveFile(makeDoveCsv(4)); + expect(parsed.bounds.minLat).toBeCloseTo(28.401, 5); + expect(parsed.bounds.maxLat).toBeCloseTo(28.401, 5); + expect(parsed.bounds.minLon).toBeLessThan(parsed.bounds.maxLon); + }); + + it("sets startDate and duration from timestamps", () => { + const parsed = parseDoveFile(makeDoveCsv(4)); + expect(parsed.startDate).toBeInstanceOf(Date); + expect(parsed.startDate!.getTime()).toBe(T0); + expect(parsed.duration).toBe(300); // (4-1)*100 + }); + + it("reads heading directly from the heading_deg column", () => { + const parsed = parseDoveFile(makeDoveCsv(4)); + expect(parsed.samples[0].heading).toBe(90); + }); + + it("populates extra fields (Satellites, HDOP, RPM)", () => { + const parsed = parseDoveFile(makeDoveCsv(4)); + const ef = parsed.samples[0].extraFields; + expect(ef["Satellites"]).toBe(12); + expect(ef["HDOP"]).toBeCloseTo(0.9, 5); + expect(ef["RPM"]).toBe(5000); + }); + + it("always adds GPS-derived Lat G / Lon G field mappings", () => { + const parsed = parseDoveFile(makeDoveCsv(4)); + const names = parsed.fieldMappings.map((m) => m.name); + expect(names).toContain("Lat G"); + expect(names).toContain("Lon G"); + }); + + it("counts a row with bad coords as a zeroCoords rejection", () => { + const csv = [ + "timestamp,sats,hdop,lat,lng,speed_mph", + `${T0},12,0.9,28.401,-81.401,30`, + `${T0 + 100},12,0.9,0,0,31`, // (0,0) → zeroCoords + `${T0 + 200},12,0.9,28.402,-81.402,32`, + ].join("\n"); + const parsed = parseDoveFile(csv); + expect(parsed.samples).toHaveLength(2); + expect(parsed.parserStats!.totalRows).toBe(3); + expect(parsed.parserStats!.acceptedRows).toBe(2); + expect(parsed.parserStats!.rejected.zeroCoords).toBe(1); + }); + + it("counts a NaN-timestamp/speed row in the nanFields bucket", () => { + const csv = [ + "timestamp,sats,hdop,lat,lng,speed_mph", + `${T0},12,0.9,28.401,-81.401,30`, + `${T0 + 100},12,0.9,28.402,-81.402,notanumber`, // NaN speed + ].join("\n"); + const parsed = parseDoveFile(csv); + expect(parsed.samples).toHaveLength(1); + expect(parsed.parserStats!.rejected.nanFields).toBe(1); + }); + + it("throws when only a header is present", () => { + expect(() => parseDoveFile("timestamp,lat,lng,speed_mph")).toThrow(); + }); +}); diff --git a/src/lib/fieldResolver.test.ts b/src/lib/fieldResolver.test.ts new file mode 100644 index 0000000..c6b0d03 --- /dev/null +++ b/src/lib/fieldResolver.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { + getCanonicalFieldId, + isFieldHiddenByCanonical, + getFieldAliases, + FIELD_CATEGORIES, +} from "./fieldResolver"; + +// fieldResolver is the settings-facing adapter over the canonical channel +// registry (channels.ts). It resolves display names / aliases / already-canonical +// ids to a ChannelId and drives the field-default hide/show. + +// ─── getCanonicalFieldId ──────────────────────────────────────────────────── + +describe("getCanonicalFieldId", () => { + it("passes through an already-canonical id unchanged", () => { + expect(getCanonicalFieldId("rpm")).toBe("rpm"); + expect(getCanonicalFieldId("lat_g")).toBe("lat_g"); + }); + + it("resolves a canonical display label to its id (case-insensitive)", () => { + expect(getCanonicalFieldId("RPM")).toBe("rpm"); + expect(getCanonicalFieldId("Water Temp")).toBe("water_temp"); + expect(getCanonicalFieldId(" Throttle ")).toBe("throttle"); + }); + + it("resolves a registered alias to its id", () => { + expect(getCanonicalFieldId("Lateral G")).toBe("lat_g"); + expect(getCanonicalFieldId("Engine RPM")).toBe("rpm"); + expect(getCanonicalFieldId("Coolant Temp")).toBe("water_temp"); + expect(getCanonicalFieldId("TPS")).toBe("throttle"); + }); + + it("returns undefined for an unknown field name", () => { + expect(getCanonicalFieldId("Brake Bias Wizardry")).toBeUndefined(); + }); + + it("returns undefined for a custom: slug (not a canonical id)", () => { + expect(getCanonicalFieldId("custom:gizmo_voltage")).toBeUndefined(); + }); +}); + +// ─── isFieldHiddenByCanonical ──────────────────────────────────────────────── + +describe("isFieldHiddenByCanonical", () => { + it("returns true when the field's canonical id is in the hidden list", () => { + expect(isFieldHiddenByCanonical("rpm", ["rpm", "egt"])).toBe(true); + }); + + it("matches by canonical id even when given a display name or alias", () => { + // "Engine RPM" resolves to rpm, which is hidden + expect(isFieldHiddenByCanonical("Engine RPM", ["rpm"])).toBe(true); + expect(isFieldHiddenByCanonical("Lateral G", ["lat_g"])).toBe(true); + }); + + it("returns false when the canonical id is not hidden", () => { + expect(isFieldHiddenByCanonical("rpm", ["egt", "water_temp"])).toBe(false); + }); + + it("returns false for an unknown field name (no canonical mapping)", () => { + // unknown field → undefined canonical → not hidden, even if list is non-empty + expect(isFieldHiddenByCanonical("Mystery Channel", ["rpm"])).toBe(false); + }); + + it("returns false against an empty hidden list", () => { + expect(isFieldHiddenByCanonical("rpm", [])).toBe(false); + }); +}); + +// ─── getFieldAliases ──────────────────────────────────────────────────────── + +describe("getFieldAliases", () => { + it("returns the label followed by registered aliases", () => { + const aliases = getFieldAliases("rpm"); + expect(aliases[0]).toBe("RPM"); // label first + expect(aliases).toContain("Engine RPM"); + expect(aliases).toContain("Rpm"); + }); + + it("returns just the label when a channel has no aliases", () => { + // accel_x has an empty aliases array + expect(getFieldAliases("accel_x")).toEqual(["Accel X"]); + }); + + it("includes label + aliases for lat_g", () => { + const aliases = getFieldAliases("lat_g"); + expect(aliases).toContain("Lat G"); + expect(aliases).toContain("Lateral G"); + }); +}); + +// ─── FIELD_CATEGORIES ──────────────────────────────────────────────────────── + +describe("FIELD_CATEGORIES", () => { + it("groups fields under named categories", () => { + const names = FIELD_CATEGORIES.map((c) => c.category); + expect(names).toEqual(["GPS Data", "Computed", "Sensors"]); + }); + + it("every field references a real canonical id resolvable back to itself", () => { + for (const cat of FIELD_CATEGORIES) { + for (const f of cat.fields) { + // canonicalId must be a known channel id + expect(getCanonicalFieldId(f.canonicalId)).toBe(f.canonicalId); + expect(f.label.length).toBeGreaterThan(0); + expect(f.description.length).toBeGreaterThan(0); + } + } + }); + + it("places computed g-force ids in the Computed category", () => { + const computed = FIELD_CATEGORIES.find((c) => c.category === "Computed"); + const ids = computed?.fields.map((f) => f.canonicalId) ?? []; + expect(ids).toContain("lat_g"); + expect(ids).toContain("lon_g"); + }); + + it("has no duplicate canonical ids across categories", () => { + const ids = FIELD_CATEGORIES.flatMap((c) => c.fields.map((f) => f.canonicalId)); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/src/lib/garageEvents.test.ts b/src/lib/garageEvents.test.ts new file mode 100644 index 0000000..104cd4b --- /dev/null +++ b/src/lib/garageEvents.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + onGarageChange, + emitGarageChange, + type GarageChange, +} from "./garageEvents"; + +// The module holds a single process-wide listener Set. Each test subscribes and +// unsubscribes its own listeners so state can't leak between cases. + +const change = (over: Partial = {}): GarageChange => ({ + store: "karts", + key: "kart-1", + type: "put", + ...over, +}); + +describe("onGarageChange / emitGarageChange", () => { + afterEach(() => vi.restoreAllMocks()); + + it("delivers an emitted change to a subscribed listener", () => { + const seen: GarageChange[] = []; + const off = onGarageChange((c) => seen.push(c)); + const c = change(); + emitGarageChange(c); + off(); + expect(seen).toEqual([c]); + }); + + it("passes the exact change object through unmodified", () => { + let received: GarageChange | undefined; + const off = onGarageChange((c) => (received = c)); + const c = change({ store: "setups", key: "s-9", type: "delete" }); + emitGarageChange(c); + off(); + expect(received).toBe(c); // same reference, not a copy + }); + + it("fans out to every subscribed listener", () => { + const a = vi.fn(); + const b = vi.fn(); + const offA = onGarageChange(a); + const offB = onGarageChange(b); + emitGarageChange(change()); + offA(); + offB(); + expect(a).toHaveBeenCalledOnce(); + expect(b).toHaveBeenCalledOnce(); + }); + + it("returns an unsubscribe function that stops further delivery", () => { + const listener = vi.fn(); + const off = onGarageChange(listener); + emitGarageChange(change()); + off(); + emitGarageChange(change()); + expect(listener).toHaveBeenCalledOnce(); + }); + + it("unsubscribing one listener leaves the others subscribed", () => { + const a = vi.fn(); + const b = vi.fn(); + const offA = onGarageChange(a); + const offB = onGarageChange(b); + offA(); + emitGarageChange(change()); + offB(); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledOnce(); + }); + + it("calling unsubscribe twice is a no-op (Set.delete tolerates it)", () => { + const listener = vi.fn(); + const off = onGarageChange(listener); + off(); + expect(() => off()).not.toThrow(); + emitGarageChange(change()); + expect(listener).not.toHaveBeenCalled(); + }); + + it("emitting with no subscribers does nothing and does not throw", () => { + expect(() => emitGarageChange(change())).not.toThrow(); + }); + + it("isolates a throwing listener so siblings still receive the change", () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const boom = vi.fn(() => { + throw new Error("listener boom"); + }); + const ok = vi.fn(); + const offBoom = onGarageChange(boom); + const offOk = onGarageChange(ok); + expect(() => emitGarageChange(change())).not.toThrow(); + offBoom(); + offOk(); + expect(ok).toHaveBeenCalledOnce(); + expect(errSpy).toHaveBeenCalledOnce(); + }); + + it("registering the same listener reference twice only fires it once (Set dedupe)", () => { + const listener = vi.fn(); + const off1 = onGarageChange(listener); + const off2 = onGarageChange(listener); + emitGarageChange(change()); + off1(); + off2(); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/nmeaParser.test.ts b/src/lib/nmeaParser.test.ts new file mode 100644 index 0000000..98ee73b --- /dev/null +++ b/src/lib/nmeaParser.test.ts @@ -0,0 +1,238 @@ +/** + * Unit tests for the NMEA 0183 parser (`parseDatalog`). + * + * The NMEA parser is the fallback format and exports only `parseDatalog` (no + * `isXxxFormat` — `datalogParser` routes to it when no other format matches). + * Fields are TAB-separated (NMEA sentences use commas internally), so each + * line is typically a single sentence in `fields[0]`. We test: + * - $GPRMC parsing → lat/lon/speed (knots)/heading/date + * - $GPGGA enrichment → Satellites / HDOP / Altitude + * - t=0 first sample, consistent speed triple, sane bounds + * - rejection of invalid-fix (status != 'A') and zero-coord sentences + * It also exercises the real bundled sample for an end-to-end smoke test. + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parseDatalog } from "./nmeaParser"; +import { KNOTS_TO_MPS, MPS_TO_MPH, MPS_TO_KPH } from "./parserUtils"; + +const SAMPLES_DIR = resolve(__dirname, "../../public/samples"); + +/** + * Build a $GPRMC sentence. Position is near Orlando Kart Center. + * $GPRMC,hhmmss.ss,A,llll.ll,N,yyyyy.yy,W,knots,cog,ddmmyy,...,A + */ +function rmc(opts: { + time?: string; + lat?: string; + latDir?: string; + lon?: string; + lonDir?: string; + knots?: string; + cog?: string; + date?: string; + status?: string; +}): string { + return [ + "$GPRMC", + opts.time ?? "170130.00", + opts.status ?? "A", + opts.lat ?? "2824.64918", + opts.latDir ?? "N", + opts.lon ?? "08122.75706", + opts.lonDir ?? "W", + opts.knots ?? "10.0", + opts.cog ?? "90.0", + opts.date ?? "231125", + "", + "", + "A*65", + ].join(","); +} + +/** Build a $GPGGA sentence: time, lat, dir, lon, dir, fixQ, nsat, hdop, alt, M, ... */ +function gga(opts: { time?: string; fixQ?: string; nsat?: string; hdop?: string; alt?: string }): string { + return [ + "$GPGGA", + opts.time ?? "170130.00", + "2824.64924", + "N", + "08122.75702", + "W", + opts.fixQ ?? "1", + opts.nsat ?? "8", + opts.hdop ?? "1.2", + opts.alt ?? "47.8", + "M", + "-29.2", + "M", + ",*5A", + ].join(","); +} + +// Expected decimal degrees for the default RMC position. +// 28 + 24.64918/60 ≈ 28.41082 ; -(81 + 22.75706/60) ≈ -81.37928 +const EXPECTED_LAT = 28 + 24.64918 / 60; +const EXPECTED_LON = -(81 + 22.75706 / 60); + +// ─── parseDatalog: synthetic RMC ────────────────────────────────────────────── + +describe("parseDatalog (NMEA RMC)", () => { + it("parses RMC sentences into samples with decimal-degree coords", () => { + const content = [ + rmc({ time: "170130.00", lat: "2824.64918", lon: "08122.75706" }), + rmc({ time: "170130.10", lat: "2824.64920", lon: "08122.75708" }), + rmc({ time: "170130.20", lat: "2824.64922", lon: "08122.75710" }), + ].join("\n"); + const parsed = parseDatalog(content); + expect(parsed.samples).toHaveLength(3); + expect(parsed.samples[0].lat).toBeCloseTo(EXPECTED_LAT, 4); + expect(parsed.samples[0].lon).toBeCloseTo(EXPECTED_LON, 4); + }); + + it("makes the first sample t=0", () => { + const content = [ + rmc({ time: "170130.00" }), + rmc({ time: "170130.50", lat: "2824.64920" }), + ].join("\n"); + const parsed = parseDatalog(content); + expect(parsed.samples[0].t).toBe(0); + // 0.5s later → 500 ms + expect(parsed.samples[1].t).toBeCloseTo(500, 0); + }); + + it("converts knots to a consistent speed triple", () => { + const content = [ + rmc({ time: "170130.00", knots: "20.0" }), + rmc({ time: "170130.10", lat: "2824.64920", knots: "20.0" }), + ].join("\n"); + const parsed = parseDatalog(content); + const s = parsed.samples[0]; + expect(s.speedMps).toBeCloseTo(20 * KNOTS_TO_MPS, 5); + expect(s.speedMph).toBeCloseTo(s.speedMps * MPS_TO_MPH, 5); + expect(s.speedKph).toBeCloseTo(s.speedMps * MPS_TO_KPH, 5); + }); + + it("reads course-over-ground as heading (normalized to [0,360))", () => { + const content = [ + rmc({ time: "170130.00", cog: "123.4" }), + rmc({ time: "170130.10", lat: "2824.64920", cog: "124.0" }), + ].join("\n"); + const parsed = parseDatalog(content); + expect(parsed.samples[0].heading).toBeCloseTo(123.4, 4); + }); + + it("computes sane bounds", () => { + const content = [ + rmc({ time: "170130.00", lat: "2824.64918", lon: "08122.75706" }), + rmc({ time: "170130.10", lat: "2824.65000", lon: "08122.75800" }), + rmc({ time: "170130.20", lat: "2824.64800", lon: "08122.75600" }), + ].join("\n"); + const parsed = parseDatalog(content); + expect(parsed.bounds.minLat).toBeGreaterThan(28); + expect(parsed.bounds.maxLat).toBeLessThan(29); + expect(parsed.bounds.minLon).toBeGreaterThan(-82); + expect(parsed.bounds.maxLon).toBeLessThan(-81); + expect(parsed.bounds.minLat).toBeLessThan(parsed.bounds.maxLat); + }); + + it("parses the NMEA date into startDate (2025-11-23)", () => { + const content = [ + rmc({ time: "170130.00", date: "231125" }), + rmc({ time: "170130.10", lat: "2824.64920", date: "231125" }), + ].join("\n"); + const parsed = parseDatalog(content); + expect(parsed.startDate).toBeInstanceOf(Date); + expect(parsed.startDate!.getUTCFullYear()).toBe(2025); + expect(parsed.startDate!.getUTCMonth() + 1).toBe(11); + expect(parsed.startDate!.getUTCDate()).toBe(23); + }); + + it("preserves the raw NMEA sentence on each sample", () => { + const content = [ + rmc({ time: "170130.00" }), + rmc({ time: "170130.10", lat: "2824.64920" }), + ].join("\n"); + const parsed = parseDatalog(content); + expect(parsed.samples[0].rawNmea).toContain("$GPRMC"); + }); + + it("adds GPS-derived Lat G / Lon G field mappings", () => { + const content = [ + rmc({ time: "170130.00" }), + rmc({ time: "170130.10", lat: "2824.64920" }), + ].join("\n"); + const parsed = parseDatalog(content); + const names = parsed.fieldMappings.map((m) => m.name); + expect(names).toContain("Lat G"); + expect(names).toContain("Lon G"); + }); + + it("enriches samples with GGA Satellites/HDOP/Altitude at matching times", () => { + const content = [ + gga({ time: "170130.00", nsat: "9", hdop: "1.1", alt: "50.0" }), + rmc({ time: "170130.00" }), + rmc({ time: "170130.10", lat: "2824.64920" }), + ].join("\n"); + const parsed = parseDatalog(content); + const ex = parsed.samples[0].extraFields; + expect(ex["Satellites"]).toBe(9); + expect(ex["HDOP"]).toBeCloseTo(1.1, 4); + expect(ex["Altitude (m)"]).toBeCloseTo(50.0, 4); + const names = parsed.fieldMappings.map((m) => m.name); + expect(names).toContain("Satellites"); + }); + + it("skips RMC sentences with a non-valid fix status (not 'A')", () => { + const content = [ + rmc({ time: "170130.00" }), + rmc({ time: "170130.10", lat: "2824.64920", status: "V" }), // void fix → skipped + rmc({ time: "170130.20", lat: "2824.64922" }), + ].join("\n"); + const parsed = parseDatalog(content); + expect(parsed.samples).toHaveLength(2); + }); + + it("skips RMC sentences with zero coordinates", () => { + const content = [ + rmc({ time: "170130.00" }), + rmc({ time: "170130.10", lat: "0000.00000", lon: "00000.00000" }), // lat parses to 0 → skipped + rmc({ time: "170130.20", lat: "2824.64922" }), + ].join("\n"); + const parsed = parseDatalog(content); + expect(parsed.samples).toHaveLength(2); + }); + + it("throws on empty content", () => { + expect(() => parseDatalog("")).toThrow(); + }); + + it("throws when no valid GPS data is present", () => { + // All void-fix sentences → no accepted samples + const content = [ + rmc({ time: "170130.00", status: "V" }), + rmc({ time: "170130.10", status: "V" }), + ].join("\n"); + expect(() => parseDatalog(content)).toThrow(/No valid GPS data/); + }); +}); + +// ─── parseDatalog: real bundled sample smoke test ───────────────────────────── + +describe("parseDatalog (real okc-tillotson-plain.nmea sample)", () => { + it("parses the bundled NMEA file into a plausible session", () => { + const content = readFileSync(resolve(SAMPLES_DIR, "okc-tillotson-plain.nmea"), "utf-8"); + const parsed = parseDatalog(content); + expect(parsed.samples.length).toBeGreaterThan(1000); + expect(parsed.samples[0].t).toBe(0); + expect(parsed.bounds.minLat).toBeGreaterThan(28); + expect(parsed.bounds.maxLat).toBeLessThan(29); + expect(parsed.samples[0].rawNmea).toContain("$GPRMC"); + // time-ordered + for (let i = 1; i < parsed.samples.length; i += 250) { + expect(parsed.samples[i].t).toBeGreaterThanOrEqual(parsed.samples[i - 1].t); + } + }); +}); diff --git a/src/lib/referenceUtils.test.ts b/src/lib/referenceUtils.test.ts new file mode 100644 index 0000000..de2584a --- /dev/null +++ b/src/lib/referenceUtils.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from "vitest"; +import { + projectToPlane, + calculateDistanceArray, + calculatePace, + calculateReferenceSpeed, + computeReferenceData, +} from "./referenceUtils"; +import { EARTH_RADIUS_M } from "./parserUtils"; +import type { GpsSample } from "@/types/racing"; + +// Reference-lap comparison: equirectangular projection, cumulative arc-length, +// and distance-aligned pace / speed interpolation. Pure math — no DOM. + +function sample( + t: number, + lat: number, + lon: number, + speedMph = 0, + speedKph = 0 +): GpsSample { + return { t, lat, lon, speedMps: 0, speedMph, speedKph, extraFields: {} }; +} + +// ─── projectToPlane ───────────────────────────────────────────────────────── + +describe("projectToPlane", () => { + it("returns (0,0) at the projection center", () => { + expect(projectToPlane(40, -74, 40, -74)).toEqual({ x: 0, y: 0 }); + }); + + it("maps 1° of latitude north to ~111195 m on the y axis", () => { + const p = projectToPlane(1, 0, 0, 0); + // 1° = (π/180) * R + expect(p.y).toBeCloseTo((Math.PI / 180) * EARTH_RADIUS_M, 0); + expect(p.x).toBeCloseTo(0, 6); + }); + + it("scales longitude by cos(latitude)", () => { + // At 60° latitude, 1° of longitude spans half the equatorial distance. + const atEquator = projectToPlane(0, 1, 0, 0).x; + const at60 = projectToPlane(60, 1, 60, 0).x; + expect(at60).toBeCloseTo(atEquator * Math.cos((60 * Math.PI) / 180), 3); + }); + + it("is signed: west/south of center give negative coordinates", () => { + const p = projectToPlane(39, -75, 40, -74); + expect(p.x).toBeLessThan(0); // lon less than center → negative x + expect(p.y).toBeLessThan(0); // lat less than center → negative y + }); +}); + +// ─── calculateDistanceArray ────────────────────────────────────────────────── + +describe("calculateDistanceArray", () => { + it("returns [] for empty input", () => { + expect(calculateDistanceArray([])).toEqual([]); + }); + + it("returns [0] for a single sample", () => { + expect(calculateDistanceArray([sample(0, 40, -74)])).toEqual([0]); + }); + + it("is monotonically non-decreasing and starts at 0", () => { + const samples = [ + sample(0, 40.0, -74.0), + sample(1000, 40.001, -74.0), + sample(2000, 40.002, -74.001), + sample(3000, 40.003, -74.0), + ]; + const d = calculateDistanceArray(samples); + expect(d[0]).toBe(0); + for (let i = 1; i < d.length; i++) { + expect(d[i]).toBeGreaterThanOrEqual(d[i - 1]); + } + expect(d.length).toBe(samples.length); + }); + + it("computes roughly correct distance for a small straight north hop", () => { + // ~0.001° lat ≈ 111.195 m + const samples = [sample(0, 40.0, -74.0), sample(1000, 40.001, -74.0)]; + const d = calculateDistanceArray(samples); + expect(d[1]).toBeCloseTo(111.195, 0); + }); + + it("accumulates segment distances (two equal hops ≈ 2x one hop)", () => { + const samples = [ + sample(0, 40.0, -74.0), + sample(1000, 40.001, -74.0), + sample(2000, 40.002, -74.0), + ]; + const d = calculateDistanceArray(samples); + expect(d[2]).toBeCloseTo(d[1] * 2, 1); + }); +}); + +// ─── calculatePace ────────────────────────────────────────────────────────── + +describe("calculatePace", () => { + it("returns [] when either lap is empty", () => { + expect(calculatePace([], [sample(0, 40, -74)])).toEqual([]); + expect(calculatePace([sample(0, 40, -74)], [])).toEqual([]); + }); + + it("returns ~0 pace when current lap is identical to reference", () => { + const lap = [ + sample(0, 40.0, -74.0), + sample(1000, 40.001, -74.0), + sample(2000, 40.002, -74.0), + ]; + // identical reference (cloned) + const ref = lap.map((s) => ({ ...s })); + const pace = calculatePace(lap, ref); + expect(pace).toHaveLength(3); + for (const p of pace) { + expect(p).not.toBeNull(); + expect(p as number).toBeCloseTo(0, 6); + } + }); + + it("reports positive pace (behind) when current lap is slower over same path", () => { + // Same geometry; current lap takes twice as long → behind at matching distances. + const ref = [ + sample(0, 40.0, -74.0), + sample(1000, 40.001, -74.0), + sample(2000, 40.002, -74.0), + ]; + const slow = [ + sample(0, 40.0, -74.0), + sample(2000, 40.001, -74.0), + sample(4000, 40.002, -74.0), + ]; + const pace = calculatePace(slow, ref); + // first sample at distance 0 → both at t=0 → pace 0 + expect(pace[0] as number).toBeCloseTo(0, 6); + // later samples: current is later than ref at equal distance → positive + expect(pace[1] as number).toBeGreaterThan(0); + expect(pace[2] as number).toBeGreaterThan(0); + }); + + it("reports negative pace (ahead) when current lap is faster", () => { + const ref = [ + sample(0, 40.0, -74.0), + sample(2000, 40.001, -74.0), + sample(4000, 40.002, -74.0), + ]; + const fast = [ + sample(0, 40.0, -74.0), + sample(1000, 40.001, -74.0), + sample(2000, 40.002, -74.0), + ]; + const pace = calculatePace(fast, ref); + expect(pace[1] as number).toBeLessThan(0); + expect(pace[2] as number).toBeLessThan(0); + }); + + it("yields null where current distance exceeds the reference lap length", () => { + // Current lap travels farther than the (short) reference. + const ref = [sample(0, 40.0, -74.0), sample(1000, 40.001, -74.0)]; + const longer = [ + sample(0, 40.0, -74.0), + sample(1000, 40.001, -74.0), + sample(2000, 40.003, -74.0), // beyond ref's total distance + ]; + const pace = calculatePace(longer, ref); + expect(pace[pace.length - 1]).toBeNull(); + }); + + it("normalizes both laps to their own start time (offset-independent)", () => { + const lap = [ + sample(0, 40.0, -74.0), + sample(1000, 40.001, -74.0), + sample(2000, 40.002, -74.0), + ]; + // Reference identical geometry/timing but shifted +500000 ms absolute. + const refShifted = lap.map((s) => ({ ...s, t: s.t + 500000 })); + const pace = calculatePace(lap, refShifted); + for (const p of pace) { + expect(p as number).toBeCloseTo(0, 6); + } + }); +}); + +// ─── calculateReferenceSpeed ───────────────────────────────────────────────── + +describe("calculateReferenceSpeed", () => { + it("returns [] when either lap is empty", () => { + expect(calculateReferenceSpeed([], [sample(0, 40, -74)], false)).toEqual([]); + expect(calculateReferenceSpeed([sample(0, 40, -74)], [], false)).toEqual([]); + }); + + it("returns reference mph at matching distances when useKph is false", () => { + const ref = [ + sample(0, 40.0, -74.0, 50 /*mph*/, 80 /*kph*/), + sample(1000, 40.001, -74.0, 60, 96), + sample(2000, 40.002, -74.0, 70, 112), + ]; + const current = ref.map((s) => ({ ...s })); + const speeds = calculateReferenceSpeed(current, ref, false); + expect(speeds[0]).toBeCloseTo(50, 6); + expect(speeds[2]).toBeCloseTo(70, 6); + }); + + it("returns reference kph when useKph is true", () => { + const ref = [ + sample(0, 40.0, -74.0, 50, 80), + sample(1000, 40.001, -74.0, 60, 96), + ]; + const current = ref.map((s) => ({ ...s })); + const speeds = calculateReferenceSpeed(current, ref, true); + expect(speeds[0]).toBeCloseTo(80, 6); + expect(speeds[1]).toBeCloseTo(96, 6); + }); + + it("interpolates speed at an intermediate distance", () => { + // The reference spans 40→60 mph over ~0.002° of latitude. The current lap's + // SECOND sample sits halfway along that span (~0.001°), so the ref speed at + // that distance interpolates to the midpoint. (The first current sample is + // always at cumulative distance 0, so it pins to the ref's first speed, 40.) + const ref = [ + sample(0, 40.0, -74.0, 40, 0), + sample(1000, 40.002, -74.0, 60, 0), + ]; + const current = [ + sample(0, 40.0, -74.0, 999, 0), // distance 0 → ref start + sample(1000, 40.001, -74.0, 999, 0), // halfway in distance → midpoint + ]; + const speeds = calculateReferenceSpeed(current, ref, false); + expect(speeds[0] as number).toBeCloseTo(40, 1); // distance 0 → ref's first speed + expect(speeds[1] as number).toBeCloseTo(50, 0); // midway between 40 and 60 + }); + + it("returns null past the end of the reference lap", () => { + const ref = [sample(0, 40.0, -74.0, 50, 0), sample(1000, 40.001, -74.0, 60, 0)]; + const current = [ + sample(0, 40.0, -74.0, 0, 0), + sample(1000, 40.003, -74.0, 0, 0), // beyond ref distance + ]; + const speeds = calculateReferenceSpeed(current, ref, false); + expect(speeds[speeds.length - 1]).toBeNull(); + }); +}); + +// ─── computeReferenceData ──────────────────────────────────────────────────── + +describe("computeReferenceData", () => { + it("returns zeroed totalDistance for empty samples", () => { + const ref = computeReferenceData([]); + expect(ref.samples).toEqual([]); + expect(ref.distances).toEqual([]); + expect(ref.totalDistance).toBe(0); + }); + + it("totalDistance equals the last cumulative distance", () => { + const samples = [ + sample(0, 40.0, -74.0), + sample(1000, 40.001, -74.0), + sample(2000, 40.002, -74.0), + ]; + const ref = computeReferenceData(samples); + expect(ref.samples).toBe(samples); + expect(ref.distances).toHaveLength(3); + expect(ref.totalDistance).toBe(ref.distances[2]); + expect(ref.totalDistance).toBeGreaterThan(0); + }); + + it("totalDistance is 0 for a single sample", () => { + const ref = computeReferenceData([sample(0, 40, -74)]); + expect(ref.totalDistance).toBe(0); + }); +}); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..6e84992 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { cn } from "./utils"; + +// `cn` = clsx (conditional/variadic class joining) piped through tailwind-merge +// (last-wins conflict resolution for Tailwind utility classes). + +// ─── basic joining ────────────────────────────────────────────────────────── + +describe("cn — joining", () => { + it("joins multiple string args with spaces", () => { + expect(cn("a", "b", "c")).toBe("a b c"); + }); + + it("returns empty string for no args", () => { + expect(cn()).toBe(""); + }); + + it("flattens array inputs", () => { + expect(cn(["a", "b"], "c")).toBe("a b c"); + }); + + it("merges object inputs by truthy value (clsx semantics)", () => { + expect(cn({ a: true, b: false, c: true })).toBe("a c"); + }); +}); + +// ─── conditional / falsy values ────────────────────────────────────────────── + +describe("cn — conditionals & falsy", () => { + it("drops false, null, undefined, 0 and empty string", () => { + expect(cn("a", false, null, undefined, 0, "", "b")).toBe("a b"); + }); + + it("keeps a class chosen by a truthy ternary", () => { + const active = true; + expect(cn("base", active && "active")).toBe("base active"); + }); + + it("omits a class behind a falsy ternary", () => { + const active = false; + expect(cn("base", active && "active")).toBe("base"); + }); +}); + +// ─── tailwind-merge conflict resolution (last wins) ────────────────────────── + +describe("cn — tailwind-merge dedupe", () => { + it("keeps the last of conflicting padding utilities", () => { + expect(cn("p-2", "p-4")).toBe("p-4"); + }); + + it("keeps the last of conflicting text colors", () => { + expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500"); + }); + + it("does NOT collapse non-conflicting utilities", () => { + // px and py are different axes — both survive + expect(cn("px-2", "py-4")).toBe("px-2 py-4"); + }); + + it("lets a later override win even through conditional inputs", () => { + expect(cn("p-2", { "p-8": true })).toBe("p-8"); + }); + + it("preserves order of unrelated classes while resolving a conflict", () => { + expect(cn("flex", "p-2", "items-center", "p-6")).toBe("flex items-center p-6"); + }); +}); diff --git a/src/lib/vboParser.test.ts b/src/lib/vboParser.test.ts new file mode 100644 index 0000000..d9657a9 --- /dev/null +++ b/src/lib/vboParser.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for the VBO (Racelogic VBOX) parser. + * + * VBO files have [header] / [column names] / [data] sections. Data rows are + * space-delimited. Velocity is km/h; time is hhmmss.sss or seconds-since-midnight. + */ + +import { describe, it, expect } from "vitest"; +import { isVboFormat, parseVboFile } from "./vboParser"; + +// ─── Synthetic fixtures ───────────────────────────────────────────────────── + +/** Valid VBO with named columns + N space-delimited data rows. + * Coordinates given as decimal degrees (|val| ≤ 180 → used directly). */ +function makeVbo(rows = 4): string { + const lines = [ + "[header]", + "satellites", + "time", + "latitude", + "longitude", + "velocity", + "heading", + "height", + "", + "[column names]", + "sats time lat long velocity heading height", + "", + "[data]", + ]; + for (let i = 0; i < rows; i++) { + // time as seconds-since-midnight (small → *1000) + const time = (10 + i * 0.1).toFixed(2); + const lat = "28.401"; + const lon = (-81.401 + i * 0.00001).toFixed(6); + const vel = (50 + i).toString(); // km/h + lines.push(`12 ${time} ${lat} ${lon} ${vel} 90 30.5`); + } + return lines.join("\n"); +} + +// ─── isVboFormat ──────────────────────────────────────────────────────────── + +describe("isVboFormat", () => { + it("accepts content with [header]", () => { + expect(isVboFormat("[header]\nsome stuff")).toBe(true); + }); + + it("accepts content with [column names]", () => { + expect(isVboFormat("[column names]\nsats time")).toBe(true); + }); + + it("accepts content with [data]", () => { + expect(isVboFormat("[data]\n1 2 3")).toBe(true); + }); + + it("accepts a full synthetic VBO", () => { + expect(isVboFormat(makeVbo())).toBe(true); + }); + + it("rejects a plain CSV with no VBO sections", () => { + expect(isVboFormat("timestamp,lat,lng,speed_mph\n1,2,3,4")).toBe(false); + }); + + it("rejects random text", () => { + expect(isVboFormat("nothing relevant here")).toBe(false); + }); +}); + +// ─── parseVboFile ─────────────────────────────────────────────────────────── + +describe("parseVboFile", () => { + it("parses all valid rows into samples", () => { + const parsed = parseVboFile(makeVbo(4)); + expect(parsed.samples).toHaveLength(4); + }); + + it("makes the first sample t=0 and scales seconds→ms", () => { + const parsed = parseVboFile(makeVbo(4)); + expect(parsed.samples[0].t).toBe(0); + // 0.1s later → 100 ms + expect(parsed.samples[1].t).toBeCloseTo(100, 3); + }); + + it("derives a consistent speed triple from km/h velocity", () => { + const parsed = parseVboFile(makeVbo(4)); + const s = parsed.samples[0]; + expect(s.speedMph).toBeCloseTo(s.speedMps * 2.23694, 4); + expect(s.speedKph).toBeCloseTo(s.speedMps * 3.6, 4); + expect(s.speedMps).toBeCloseTo(50 / 3.6, 5); + }); + + it("reads decimal-degree coordinates directly", () => { + const parsed = parseVboFile(makeVbo(4)); + expect(parsed.samples[0].lat).toBeCloseTo(28.401, 5); + expect(parsed.samples[0].lon).toBeCloseTo(-81.401, 5); + }); + + it("computes sane bounds", () => { + const parsed = parseVboFile(makeVbo(4)); + expect(parsed.bounds.minLat).toBeCloseTo(28.401, 5); + expect(parsed.bounds.minLon).toBeLessThan(parsed.bounds.maxLon); + }); + + it("reads heading and altitude", () => { + const parsed = parseVboFile(makeVbo(4)); + expect(parsed.samples[0].heading).toBe(90); + expect(parsed.samples[0].extraFields["Altitude (m)"]).toBeCloseTo(30.5, 4); + }); + + it("always adds GPS-derived Lat G / Lon G mappings", () => { + const parsed = parseVboFile(makeVbo(4)); + const names = parsed.fieldMappings.map((m) => m.name); + expect(names).toContain("Lat G"); + expect(names).toContain("Lon G"); + expect(names).toContain("Satellites"); + }); + + it("parses VBO with no [column names] using positional defaults", () => { + // Standard VBOX positional order: sats time lat long velocity heading height + const vbo = [ + "[data]", + "12 10.00 28.401 -81.401 50 90 30", + "12 10.10 28.401 -81.4011 51 90 30", + "12 10.20 28.401 -81.4012 52 90 30", + ].join("\n"); + const parsed = parseVboFile(vbo); + expect(parsed.samples.length).toBeGreaterThanOrEqual(3); + expect(parsed.samples[0].lat).toBeCloseTo(28.401, 5); + }); + + it("throws when there is no [data] section", () => { + expect(() => parseVboFile("[header]\nsats time\n[column names]\nsats time")).toThrow(); + }); +}); From 9255f941e84e5ac0f4a63688908358d25fac1dd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 03:51:22 +0000 Subject: [PATCH 070/121] Add unit tests for GPS/math logic and video-overlay logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers g-force derivation, speed events, speed bounds, track geometry, and braking-zone detection in lib/, plus the video-overlay sector status, data-source resolver, overlay utils, registry, and theme logic. De-flaked the overlay-id uniqueness test (was a birthday-paradox loop) with a deterministic Date.now/Math.random stub. Raised coverage thresholds to floors just below the new actuals (37% lines) to lock in the gain. Pure-logic only — no source changes. https://claude.ai/code/session_017fmZ5GDJkNec7sxGWt343G --- .../video-overlays/dataSourceResolver.test.ts | 193 ++++++++++++ .../video-overlays/overlayUtils.test.ts | 166 +++++++++++ .../video-overlays/registry.test.ts | 114 +++++++ .../video-overlays/sectorUtils.test.ts | 174 +++++++++++ src/components/video-overlays/themes.test.ts | 100 +++++++ src/lib/brakingZones.test.ts | 256 ++++++++++++++++ src/lib/gforceCalculation.test.ts | 227 ++++++++++++++ src/lib/speedBounds.test.ts | 94 ++++++ src/lib/speedEvents.test.ts | 114 +++++++ src/lib/trackUtils.test.ts | 279 ++++++++++++++++++ vitest.config.ts | 8 +- 11 files changed, 1721 insertions(+), 4 deletions(-) create mode 100644 src/components/video-overlays/dataSourceResolver.test.ts create mode 100644 src/components/video-overlays/overlayUtils.test.ts create mode 100644 src/components/video-overlays/registry.test.ts create mode 100644 src/components/video-overlays/sectorUtils.test.ts create mode 100644 src/components/video-overlays/themes.test.ts create mode 100644 src/lib/brakingZones.test.ts create mode 100644 src/lib/gforceCalculation.test.ts create mode 100644 src/lib/speedBounds.test.ts create mode 100644 src/lib/speedEvents.test.ts create mode 100644 src/lib/trackUtils.test.ts diff --git a/src/components/video-overlays/dataSourceResolver.test.ts b/src/components/video-overlays/dataSourceResolver.test.ts new file mode 100644 index 0000000..3698f55 --- /dev/null +++ b/src/components/video-overlays/dataSourceResolver.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from "vitest"; +import { + buildDataSources, + resolveValue, + resolveRange, + resolveUnit, + resolveLabel, +} from "./dataSourceResolver"; +import type { GpsSample, FieldMapping } from "@/types/racing"; +import type { DataSourceDef } from "./types"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function makeSample(overrides: Partial = {}): GpsSample { + return { + t: 0, + lat: 0, + lon: 0, + speedMps: 10, + speedMph: 22.3694, + speedKph: 36, + extraFields: {}, + ...overrides, + }; +} + +const RPM_FIELD: FieldMapping = { index: 0, name: "rpm", label: "RPM", unit: "rpm", enabled: true }; +const LATG_FIELD: FieldMapping = { index: 1, name: "lat_g", label: "Lat G", unit: "G", enabled: true }; + +// ─── buildDataSources ───────────────────────────────────────────────────────── + +describe("buildDataSources", () => { + it("always includes speed and brake % sources", () => { + const sources = buildDataSources([], false, false); + const ids = sources.map((s) => s.id); + expect(ids).toContain("speed"); + expect(ids).toContain("__braking_g__"); + }); + + it("labels speed by the active unit (MPH vs KPH)", () => { + expect(buildDataSources([], false, false)[0].label).toBe("Speed (MPH)"); + expect(buildDataSources([], true, false)[0].unit).toBe("KPH"); + }); + + it("omits the pace source when there is no reference lap", () => { + const ids = buildDataSources([], false, false).map((s) => s.id); + expect(ids).not.toContain("__pace__"); + }); + + it("includes the pace source only when a reference exists", () => { + const ids = buildDataSources([], false, true).map((s) => s.id); + expect(ids).toContain("__pace__"); + }); + + it("creates one source per field mapping with a composed label", () => { + const sources = buildDataSources([RPM_FIELD], false, false); + const rpm = sources.find((s) => s.id === "rpm")!; + expect(rpm).toBeDefined(); + expect(rpm.label).toBe("RPM (rpm)"); + expect(rpm.unit).toBe("rpm"); + }); + + it("speed source reads the right unit field from a sample", () => { + const mph = buildDataSources([], false, false)[0]; + const kph = buildDataSources([], true, false)[0]; + const sample = makeSample({ speedMph: 50, speedKph: 80 }); + expect(mph.getValue(sample)).toBe(50); + expect(kph.getValue(sample)).toBe(80); + }); + + it("speed getMin/getMax fall back to 0/100 on an empty sample set", () => { + const speed = buildDataSources([], false, false)[0]; + expect(speed.getMin([])).toBe(0); + expect(speed.getMax([])).toBe(100); + }); + + it("field source getValue returns null when the extraField is missing", () => { + const rpm = buildDataSources([RPM_FIELD], false, false).find((s) => s.id === "rpm")!; + expect(rpm.getValue(makeSample())).toBeNull(); + expect(rpm.getValue(makeSample({ extraFields: { rpm: 8000 } }))).toBe(8000); + }); + + it("field source getMin/getMax compute over present values, falling back to 0/100", () => { + const rpm = buildDataSources([RPM_FIELD], false, false).find((s) => s.id === "rpm")!; + const samples = [ + makeSample({ extraFields: { rpm: 5000 } }), + makeSample({ extraFields: {} }), // skipped + makeSample({ extraFields: { rpm: 9000 } }), + ]; + expect(rpm.getMin(samples)).toBe(5000); + expect(rpm.getMax(samples)).toBe(9000); + expect(rpm.getMin([])).toBe(0); + expect(rpm.getMax([])).toBe(100); + }); +}); + +// ─── resolveValue ────────────────────────────────────────────────────────────── + +describe("resolveValue", () => { + const sources = buildDataSources([RPM_FIELD], false, true); + + it("resolves pace from paceData by index", () => { + expect(resolveValue("__pace__", makeSample(), 1, sources, [0.1, -0.5, 0.3])).toBe(-0.5); + }); + + it("returns null for pace when the index has no value", () => { + expect(resolveValue("__pace__", makeSample(), 5, sources, [0.1])).toBeNull(); + }); + + it("resolves braking from brakingGData by index", () => { + expect(resolveValue("__braking_g__", makeSample(), 2, sources, [], [0, 0, 75])).toBe(75); + }); + + it("returns null for braking when brakingGData is absent", () => { + expect(resolveValue("__braking_g__", makeSample(), 0, sources, [])).toBeNull(); + }); + + it("resolves a normal source via its getValue", () => { + const sample = makeSample({ extraFields: { rpm: 7200 } }); + expect(resolveValue("rpm", sample, 0, sources, [])).toBe(7200); + }); + + it("returns null for an unknown source id", () => { + expect(resolveValue("does_not_exist", makeSample(), 0, sources, [])).toBeNull(); + }); + + it("falls back to the canonical key for a legacy display-name source id", () => { + // "Lat G" is a legacy stored id; the source lives under canonical "lat_g". + const s = buildDataSources([LATG_FIELD], false, false); + const sample = makeSample({ extraFields: { lat_g: 0.8 } }); + expect(resolveValue("Lat G", sample, 0, s, [])).toBe(0.8); + }); +}); + +// ─── resolveRange ────────────────────────────────────────────────────────────── + +describe("resolveRange", () => { + const sources = buildDataSources([RPM_FIELD], false, true); + + it("returns a fixed 0-100 range for braking", () => { + expect(resolveRange("__braking_g__", [], sources, [])).toEqual({ min: 0, max: 100 }); + }); + + it("returns a symmetric range around zero for pace (min 0.5 magnitude)", () => { + // All small values → clamped to ±0.5 minimum. + expect(resolveRange("__pace__", [], sources, [0.1, -0.2])).toEqual({ min: -0.5, max: 0.5 }); + // Larger spread expands symmetrically to the max magnitude. + expect(resolveRange("__pace__", [], sources, [0.3, -1.4])).toEqual({ min: -1.4, max: 1.4 }); + }); + + it("ignores nulls in the pace data", () => { + expect(resolveRange("__pace__", [], sources, [null, 0.9, null])).toEqual({ min: -0.9, max: 0.9 }); + }); + + it("delegates to a source's getMin/getMax for normal sources", () => { + const samples = [ + makeSample({ extraFields: { rpm: 4000 } }), + makeSample({ extraFields: { rpm: 10000 } }), + ]; + expect(resolveRange("rpm", samples, sources, [])).toEqual({ min: 4000, max: 10000 }); + }); + + it("returns a default 0-100 range for an unknown source", () => { + expect(resolveRange("nope", [], sources, [])).toEqual({ min: 0, max: 100 }); + }); +}); + +// ─── resolveUnit & resolveLabel ────────────────────────────────────────────── + +describe("resolveUnit", () => { + const sources = buildDataSources([RPM_FIELD], false, false); + + it("returns the unit for a known source", () => { + expect(resolveUnit("rpm", sources)).toBe("rpm"); + expect(resolveUnit("speed", sources)).toBe("MPH"); + }); + + it("returns an empty string for an unknown source", () => { + expect(resolveUnit("ghost", sources)).toBe(""); + }); +}); + +describe("resolveLabel", () => { + const sources = buildDataSources([RPM_FIELD], false, false); + + it("returns the composed label for a known source", () => { + expect(resolveLabel("rpm", sources)).toBe("RPM (rpm)"); + }); + + it("falls back to the source id itself when unknown", () => { + expect(resolveLabel("unknownId", sources)).toBe("unknownId"); + }); +}); diff --git a/src/components/video-overlays/overlayUtils.test.ts b/src/components/video-overlays/overlayUtils.test.ts new file mode 100644 index 0000000..74f89f0 --- /dev/null +++ b/src/components/video-overlays/overlayUtils.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { + findNearestIndex, + findCurrentLap, + formatOverlayLapTime, + getOverlayLapStartTime, +} from "./overlayUtils"; +import type { GpsSample, Lap } from "@/types/racing"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function makeSample(t: number): GpsSample { + return { t, lat: 0, lon: 0, speedMps: 0, speedMph: 0, speedKph: 0, extraFields: {} }; +} + +function makeSamples(times: number[]): GpsSample[] { + return times.map(makeSample); +} + +function makeLap(overrides: Partial = {}): Lap { + return { + lapNumber: 1, + startTime: 0, + endTime: 1000, + lapTimeMs: 1000, + maxSpeedMph: 0, + maxSpeedKph: 0, + minSpeedMph: 0, + minSpeedKph: 0, + startIndex: 0, + endIndex: 10, + ...overrides, + }; +} + +// ─── findNearestIndex ───────────────────────────────────────────────────────── + +describe("findNearestIndex", () => { + it("returns 0 for an empty array", () => { + expect(findNearestIndex([], 500)).toBe(0); + }); + + it("returns the exact index when the time matches a sample", () => { + const samples = makeSamples([0, 100, 200, 300]); + expect(findNearestIndex(samples, 200)).toBe(2); + }); + + it("rounds to the nearest neighbor between two samples", () => { + const samples = makeSamples([0, 100, 200]); + // 130 is closer to 100 (idx 1) than 200 (idx 2). + expect(findNearestIndex(samples, 130)).toBe(1); + // 170 is closer to 200 (idx 2). + expect(findNearestIndex(samples, 170)).toBe(2); + }); + + it("clamps below the first sample", () => { + const samples = makeSamples([100, 200, 300]); + expect(findNearestIndex(samples, -50)).toBe(0); + }); + + it("clamps above the last sample", () => { + const samples = makeSamples([100, 200, 300]); + expect(findNearestIndex(samples, 9999)).toBe(2); + }); + + it("picks the later index on an exact tie", () => { + const samples = makeSamples([0, 100]); + // 50 is equidistant. lo settles on 1; the strict `<` comparison does not + // step back to 0 (|0-50| < |100-50| is false), so the upper index wins. + expect(findNearestIndex(samples, 50)).toBe(1); + }); +}); + +// ─── findCurrentLap ──────────────────────────────────────────────────────────── + +describe("findCurrentLap", () => { + const laps = [ + makeLap({ lapNumber: 1, startTime: 0, endTime: 1000 }), + makeLap({ lapNumber: 2, startTime: 1000, endTime: 2000 }), + makeLap({ lapNumber: 3, startTime: 2000, endTime: 3000 }), + ]; + + it("returns the explicitly selected lap when one is selected", () => { + expect(findCurrentLap(laps, 2, 9999)?.lapNumber).toBe(2); + }); + + it("returns null when the selected lap number does not exist", () => { + expect(findCurrentLap(laps, 99, 500)).toBeNull(); + }); + + it("finds the lap containing the current time when none is selected", () => { + expect(findCurrentLap(laps, null, 1500)?.lapNumber).toBe(2); + }); + + it("matches inclusively on the start and end boundaries", () => { + // 1000 is both lap 1's end and lap 2's start — the first match (lap 1) wins. + expect(findCurrentLap(laps, null, 1000)?.lapNumber).toBe(1); + }); + + it("returns null when the time falls outside every lap", () => { + expect(findCurrentLap(laps, null, 5000)).toBeNull(); + }); + + it("returns null for an empty lap list with no selection", () => { + expect(findCurrentLap([], null, 100)).toBeNull(); + }); +}); + +// ─── formatOverlayLapTime ───────────────────────────────────────────────────── + +describe("formatOverlayLapTime", () => { + it("formats sub-minute times without a minute component", () => { + expect(formatOverlayLapTime(23.456)).toBe("23.456"); + }); + + it("formats times over a minute as m:ss.mmm with zero-padding", () => { + expect(formatOverlayLapTime(83.456)).toBe("1:23.456"); + }); + + it("zero-pads seconds and milliseconds", () => { + expect(formatOverlayLapTime(65.004)).toBe("1:05.004"); + }); + + it("treats negative input as zero", () => { + expect(formatOverlayLapTime(-5)).toBe("0.000"); + }); + + it("formats exactly zero", () => { + expect(formatOverlayLapTime(0)).toBe("0.000"); + }); + + it("rolls milliseconds rounding correctly", () => { + // 1.9999s → ms rounds to 1000 → quirk: displays as "1.1000" (no carry to seconds). + expect(formatOverlayLapTime(1.9999)).toBe("1.1000"); + }); +}); + +// ─── getOverlayLapStartTime ─────────────────────────────────────────────────── + +describe("getOverlayLapStartTime", () => { + const samples = makeSamples([500, 600, 700]); + const laps = [ + makeLap({ lapNumber: 1, startTime: 1000 }), + makeLap({ lapNumber: 2, startTime: 2000 }), + ]; + + it("returns the first sample time when no lap is selected", () => { + expect(getOverlayLapStartTime(samples, laps, null)).toBe(500); + }); + + it("returns the first sample time when there are no laps", () => { + expect(getOverlayLapStartTime(samples, [], 1)).toBe(500); + }); + + it("returns undefined when no lap is selected and there are no samples", () => { + expect(getOverlayLapStartTime([], laps, null)).toBeUndefined(); + }); + + it("returns the selected lap's start time", () => { + expect(getOverlayLapStartTime(samples, laps, 2)).toBe(2000); + }); + + it("returns undefined when the selected lap is not found", () => { + expect(getOverlayLapStartTime(samples, laps, 99)).toBeUndefined(); + }); +}); diff --git a/src/components/video-overlays/registry.test.ts b/src/components/video-overlays/registry.test.ts new file mode 100644 index 0000000..20ae977 --- /dev/null +++ b/src/components/video-overlays/registry.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from "vitest"; +import { OVERLAY_TYPES, getOverlayTypeDef, generateOverlayId } from "./registry"; +import type { OverlayType } from "./types"; + +const ALL_TYPES: OverlayType[] = [ + "digital", + "analog", + "graph", + "bar", + "bubble", + "map", + "pace", + "sector", + "laptime", +]; + +// ─── OVERLAY_TYPES catalog ────────────────────────────────────────────────── + +describe("OVERLAY_TYPES", () => { + it("defines every OverlayType exactly once", () => { + const types = OVERLAY_TYPES.map((t) => t.type).sort(); + expect(types).toEqual([...ALL_TYPES].sort()); + expect(new Set(types).size).toBe(types.length); // no dupes + }); + + it("every def carries label, icon, and description strings", () => { + for (const def of OVERLAY_TYPES) { + expect(def.label.length).toBeGreaterThan(0); + expect(def.icon.length).toBeGreaterThan(0); + expect(def.description.length).toBeGreaterThan(0); + } + }); + + it("only bubble needs a secondary source (XY plot)", () => { + const needsSecondary = OVERLAY_TYPES.filter((t) => t.needsSecondarySource).map((t) => t.type); + expect(needsSecondary).toEqual(["bubble"]); + }); + + it("map/pace/sector/laptime are flagged special (no generic data source)", () => { + const special = OVERLAY_TYPES.filter((t) => t.isSpecial).map((t) => t.type).sort(); + expect(special).toEqual(["laptime", "map", "pace", "sector"]); + }); + + it("digital/analog/bar are NOT special and need no secondary source", () => { + for (const type of ["digital", "analog", "bar"] as const) { + const def = getOverlayTypeDef(type)!; + expect(def.isSpecial).toBeFalsy(); + expect(def.needsSecondarySource).toBeFalsy(); + } + }); + + it("graph defaults seed graphLength + color", () => { + const graph = getOverlayTypeDef("graph")!; + expect(graph.defaultConfig).toEqual({ graphLength: 100, color: "#00ccaa" }); + }); + + it("sector defaults enable animation; laptime defaults disable pace mode", () => { + expect(getOverlayTypeDef("sector")!.defaultConfig).toEqual({ showAnimation: true }); + expect(getOverlayTypeDef("laptime")!.defaultConfig).toEqual({ showPaceMode: false }); + }); +}); + +// ─── getOverlayTypeDef ──────────────────────────────────────────────────────── + +describe("getOverlayTypeDef", () => { + it("returns the matching def for every known type", () => { + for (const type of ALL_TYPES) { + expect(getOverlayTypeDef(type)?.type).toBe(type); + } + }); + + it("returns undefined for an unknown type", () => { + // Cast through unknown — exercising the runtime fallthrough, not the type system. + expect(getOverlayTypeDef("nope" as unknown as OverlayType)).toBeUndefined(); + }); +}); + +// ─── generateOverlayId ──────────────────────────────────────────────────────── + +describe("generateOverlayId", () => { + it("is prefixed with 'ov-'", () => { + expect(generateOverlayId()).toMatch(/^ov-/); + }); + + it("matches the ov--<4char> shape", () => { + expect(generateOverlayId()).toMatch(/^ov-[0-9a-z]+-[0-9a-z]{1,4}$/); + }); + + it("varies the suffix with Math.random (deterministic, no birthday-paradox flake)", () => { + // The id is `ov--<4 base36 chars of Math.random>`. Pin both + // sources so the test is deterministic: same timestamp, distinct random draws + // → distinct suffixes → distinct ids. (A real 1000-call loop is flaky because + // ~1.68M 4-char suffixes collide ~25% of the time within a single ms.) + vi.spyOn(Date, "now").mockReturnValue(0); + const rnd = vi.spyOn(Math, "random"); + rnd.mockReturnValueOnce(0.111111).mockReturnValueOnce(0.222222); + const a = generateOverlayId(); + const b = generateOverlayId(); + vi.restoreAllMocks(); + expect(a).not.toBe(b); + expect(a.startsWith("ov-0-")).toBe(true); + expect(b.startsWith("ov-0-")).toBe(true); + }); + + it("changes the time segment as the clock advances", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1000); + vi.spyOn(Math, "random").mockReturnValue(0.5); // hold suffix constant + const first = generateOverlayId(); + now.mockReturnValue(2000); + const second = generateOverlayId(); + vi.restoreAllMocks(); + expect(first).not.toBe(second); + }); +}); diff --git a/src/components/video-overlays/sectorUtils.test.ts b/src/components/video-overlays/sectorUtils.test.ts new file mode 100644 index 0000000..917da34 --- /dev/null +++ b/src/components/video-overlays/sectorUtils.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from "vitest"; +import { + SECTOR_COLORS, + computeBestSectors, + computeSectorSegments, + type SectorStatus, +} from "./sectorUtils"; +import type { Lap, GpsSample } from "@/types/racing"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function makeSample(t: number): GpsSample { + return { t, lat: 0, lon: 0, speedMps: 0, speedMph: 0, speedKph: 0, extraFields: {} }; +} + +/** Build a contiguous samples array with t = index * step (ms). */ +function makeSamples(count: number, step = 100): GpsSample[] { + return Array.from({ length: count }, (_, i) => makeSample(i * step)); +} + +function makeLap(overrides: Partial = {}): Lap { + return { + lapNumber: 2, + startTime: 0, + endTime: 3000, + lapTimeMs: 3000, + maxSpeedMph: 0, + maxSpeedKph: 0, + minSpeedMph: 0, + minSpeedKph: 0, + startIndex: 0, + endIndex: 30, + sectors: { s1: 1000, s2: 1000, s3: 1000 }, + ...overrides, + }; +} + +// ─── SECTOR_COLORS ────────────────────────────────────────────────────────── + +describe("SECTOR_COLORS", () => { + it("defines a color for every SectorStatus", () => { + const statuses: SectorStatus[] = ["outlap", "first", "best", "slower", "active"]; + for (const s of statuses) { + expect(SECTOR_COLORS[s]).toMatch(/^rgba\(/); + } + }); + + it("uses purple for best, red for slower, green for first", () => { + expect(SECTOR_COLORS.best).toContain("168, 85, 247"); + expect(SECTOR_COLORS.slower).toContain("239, 68, 68"); + expect(SECTOR_COLORS.first).toContain("34, 197, 94"); + }); +}); + +// ─── computeBestSectors ────────────────────────────────────────────────────── + +describe("computeBestSectors", () => { + it("returns Infinity for every sector when no laps", () => { + expect(computeBestSectors([])).toEqual({ s1: Infinity, s2: Infinity, s3: Infinity }); + }); + + it("ignores laps without sectors", () => { + const laps = [makeLap({ sectors: undefined })]; + expect(computeBestSectors(laps)).toEqual({ s1: Infinity, s2: Infinity, s3: Infinity }); + }); + + it("picks the minimum across laps per sector independently", () => { + const laps = [ + makeLap({ sectors: { s1: 1200, s2: 900, s3: 1100 } }), + makeLap({ sectors: { s1: 1000, s2: 950, s3: 1050 } }), + makeLap({ sectors: { s1: 1100, s2: 800, s3: 1200 } }), + ]; + expect(computeBestSectors(laps)).toEqual({ s1: 1000, s2: 800, s3: 1050 }); + }); + + it("handles partial sector data (undefined fields skipped)", () => { + const laps = [ + makeLap({ sectors: { s1: 1000 } }), + makeLap({ sectors: { s2: 500 } }), + ]; + expect(computeBestSectors(laps)).toEqual({ s1: 1000, s2: 500, s3: Infinity }); + }); +}); + +// ─── computeSectorSegments ─────────────────────────────────────────────────── + +describe("computeSectorSegments", () => { + it("returns a single outlap fallback when lap is null", () => { + const samples = makeSamples(10); + const result = computeSectorSegments(samples, null, 0, []); + expect(result).toEqual([{ status: "outlap", startIdx: 0, endIdx: 9 }]); + }); + + it("returns a single outlap fallback when lap has no sectors", () => { + const samples = makeSamples(10); + const lap = makeLap({ sectors: undefined }); + const result = computeSectorSegments(samples, lap, 0, [lap]); + expect(result).toEqual([{ status: "outlap", startIdx: 0, endIdx: 9 }]); + }); + + it("marks the in-progress sector as 'active' and the rest as 'outlap'", () => { + // s1=s2=s3=1000ms, lapStart=0. currentTime=500 → still in sector 1. + const samples = makeSamples(31); + const lap = makeLap(); + const result = computeSectorSegments(samples, lap, 500, [lap]); + expect(result).toHaveLength(3); + expect(result[0].status).toBe("active"); + expect(result[1].status).toBe("outlap"); + expect(result[2].status).toBe("outlap"); + }); + + it("colors a completed sector 'best' when it matches the best time (faster-than-reference)", () => { + // Current lap s1=1000 and the best s1 across laps is also 1000 → best. + const samples = makeSamples(31); + const lap = makeLap({ lapNumber: 3, sectors: { s1: 1000, s2: 1000, s3: 1000 } }); + const reference = makeLap({ lapNumber: 2, sectors: { s1: 1000, s2: 900, s3: 800 } }); + // currentTime past all crossings (3000) → all sectors complete. + const result = computeSectorSegments(samples, lap, 3000, [lap, reference]); + expect(result[0].status).toBe("best"); // s1 1000 <= best 1000 + }); + + it("colors a completed sector 'slower' when it is above the best time", () => { + const samples = makeSamples(31); + const lap = makeLap({ lapNumber: 3, sectors: { s1: 1200, s2: 1200, s3: 1200 } }); + const reference = makeLap({ lapNumber: 2, sectors: { s1: 1000, s2: 900, s3: 800 } }); + const result = computeSectorSegments(samples, lap, 3000, [lap, reference]); + expect(result[0].status).toBe("slower"); // s1 1200 > best 1000 + expect(result[1].status).toBe("slower"); + expect(result[2].status).toBe("slower"); + }); + + it("marks completed sectors as 'first' on the very first lap (no reference yet)", () => { + // isFirstLap && sectorTime === bestTime → first (green). On lap 1 it is its own best. + const samples = makeSamples(31); + const lap = makeLap({ lapNumber: 1, sectors: { s1: 1000, s2: 1000, s3: 1000 } }); + const result = computeSectorSegments(samples, lap, 3000, [lap]); + expect(result[0].status).toBe("first"); + expect(result[1].status).toBe("first"); + expect(result[2].status).toBe("first"); + }); + + it("treats a zero/undefined sector time as outlap", () => { + const samples = makeSamples(31); + // s1 missing → sector 1 is outlap regardless of time. + const lap = makeLap({ sectors: { s2: 1000, s3: 1000 } }); + const result = computeSectorSegments(samples, lap, 3000, [lap]); + expect(result[0].status).toBe("outlap"); + }); + + it("transitions sector 1 active → complete as currentTime crosses the s2 boundary", () => { + const samples = makeSamples(31); + const lap = makeLap({ lapNumber: 2, sectors: { s1: 1000, s2: 1000, s3: 1000 } }); + const reference = makeLap({ lapNumber: 1, sectors: { s1: 1000, s2: 1000, s3: 1000 } }); + + // Just before s2 crossing (1000) — sector 1 active. + const before = computeSectorSegments(samples, lap, 999, [lap, reference]); + expect(before[0].status).toBe("active"); + + // Exactly at the crossing — sector 1 complete (currentTime < s2CrossingTime is false). + const at = computeSectorSegments(samples, lap, 1000, [lap, reference]); + expect(at[0].status).not.toBe("active"); + }); + + it("returns indices within the sample range and ordered start<=end", () => { + const samples = makeSamples(31); + const lap = makeLap(); + const result = computeSectorSegments(samples, lap, 1500, [lap]); + for (const seg of result) { + expect(seg.startIdx).toBeGreaterThanOrEqual(0); + expect(seg.endIdx).toBeLessThanOrEqual(samples.length - 1); + expect(seg.startIdx).toBeLessThanOrEqual(seg.endIdx); + } + }); +}); diff --git a/src/components/video-overlays/themes.test.ts b/src/components/video-overlays/themes.test.ts new file mode 100644 index 0000000..f608ce5 --- /dev/null +++ b/src/components/video-overlays/themes.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { THEMES, getTheme } from "./themes"; +import type { ColorMode } from "./types"; + +// ─── THEMES registry shape ────────────────────────────────────────────────── + +describe("THEMES", () => { + it("defines exactly the classic and neon themes", () => { + expect(Object.keys(THEMES).sort()).toEqual(["classic", "neon"]); + }); + + it("each theme's id matches its registry key", () => { + for (const [key, theme] of Object.entries(THEMES)) { + expect(theme.id).toBe(key); + } + }); + + it("each theme exposes a human label", () => { + expect(THEMES.classic.label).toBe("Classic"); + expect(THEMES.neon.label).toBe("Neon"); + }); + + it("only neon carries a glowFilter (classic has none)", () => { + expect(THEMES.classic.glowFilter).toBeUndefined(); + expect(THEMES.neon.glowFilter).toContain("drop-shadow"); + }); +}); + +// ─── getTheme lookup ────────────────────────────────────────────────────────── + +describe("getTheme", () => { + it("returns the matching theme by id", () => { + expect(getTheme("classic")).toBe(THEMES.classic); + expect(getTheme("neon")).toBe(THEMES.neon); + }); + + it("falls back to classic for an unknown id", () => { + expect(getTheme("does-not-exist")).toBe(THEMES.classic); + expect(getTheme("")).toBe(THEMES.classic); + }); +}); + +// ─── color helper functions ───────────────────────────────────────────────── + +describe("theme color helpers", () => { + const modes: ColorMode[] = ["light", "dark"]; + + it("bg() scales alpha by opacity (classic dark = 0.6 * opacity)", () => { + expect(THEMES.classic.bg("dark", 1)).toBe("rgba(0, 0, 0, 0.6)"); + expect(THEMES.classic.bg("dark", 0.5)).toBe("rgba(0, 0, 0, 0.3)"); + expect(THEMES.classic.bg("dark", 0)).toBe("rgba(0, 0, 0, 0)"); + }); + + it("bg() light mode uses a different base alpha (classic light = 0.7 * opacity)", () => { + expect(THEMES.classic.bg("light", 1)).toBe("rgba(255, 255, 255, 0.7)"); + expect(THEMES.classic.bg("light", 0.5)).toBe("rgba(255, 255, 255, 0.35)"); + }); + + it("neon bg() differs between light and dark and scales by opacity", () => { + expect(THEMES.neon.bg("dark", 1)).toBe("rgba(10, 15, 30, 0.75)"); + expect(THEMES.neon.bg("light", 1)).toBe("rgba(240, 245, 255, 0.8)"); + expect(THEMES.neon.bg("dark", 1)).not.toBe(THEMES.neon.bg("light", 1)); + }); + + it("text() returns light text in dark mode and dark text in light mode", () => { + expect(THEMES.classic.text("dark")).toBe("#ffffff"); + expect(THEMES.classic.text("light")).toBe("#1a1a1a"); + expect(THEMES.neon.text("dark")).toBe("#e0f0ff"); + expect(THEMES.neon.text("light")).toBe("#0a1530"); + }); + + it("every color helper returns a non-empty string for both modes", () => { + const helpers = [ + "text", + "textSecondary", + "accent", + "border", + "needleColor", + "ringColor", + ] as const; + for (const theme of Object.values(THEMES)) { + for (const mode of modes) { + for (const h of helpers) { + const out = theme[h](mode); + expect(typeof out).toBe("string"); + expect(out.length).toBeGreaterThan(0); + } + // bg takes an extra opacity arg + expect(typeof theme.bg(mode, 1)).toBe("string"); + } + } + }); + + it("dark and light variants of each helper differ (themes are mode-aware)", () => { + for (const theme of Object.values(THEMES)) { + expect(theme.accent("dark")).not.toBe(theme.accent("light")); + expect(theme.text("dark")).not.toBe(theme.text("light")); + } + }); +}); diff --git a/src/lib/brakingZones.test.ts b/src/lib/brakingZones.test.ts new file mode 100644 index 0000000..c5b16ea --- /dev/null +++ b/src/lib/brakingZones.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_BRAKING_CONFIG, + detectBrakingZones, + computeBrakingGSeries, + computeBrakingGSeriesSG, + gToBrakePercent, +} from "./brakingZones"; +import type { GpsSample } from "@/types/racing"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Build samples from an mph series, spaced `dtMs` apart. Braking zone detection + * derives deceleration from `speedMph`, so that's the field that matters here. + */ +function makeSamples(speedsMph: number[], dtMs = 100): GpsSample[] { + return speedsMph.map((mph, i) => ({ + t: i * dtMs, + lat: 28.5 + i * 1e-5, // near a real Florida track + lon: -81.4 + i * 1e-5, + speedMps: mph * 0.44704, + speedMph: mph, + speedKph: mph * 1.60934, + extraFields: {}, + })); +} + +// ─── DEFAULT_BRAKING_CONFIG ──────────────────────────────────────────────────── + +describe("DEFAULT_BRAKING_CONFIG", () => { + it("matches the documented defaults", () => { + expect(DEFAULT_BRAKING_CONFIG).toEqual({ + entryThresholdG: -0.25, + exitThresholdG: -0.1, + minDurationMs: 120, + smoothingAlpha: 0.4, + }); + }); +}); + +// ─── detectBrakingZones ──────────────────────────────────────────────────────── + +describe("detectBrakingZones", () => { + it("returns [] for fewer than 3 samples", () => { + expect(detectBrakingZones([])).toEqual([]); + expect(detectBrakingZones(makeSamples([40, 30]))).toEqual([]); + }); + + it("returns [] for a steady-speed run (no deceleration)", () => { + const samples = makeSamples([40, 40, 40, 40, 40, 40]); + expect(detectBrakingZones(samples)).toEqual([]); + }); + + it("returns [] for pure acceleration", () => { + const samples = makeSamples([20, 25, 30, 35, 40, 45, 50]); + expect(detectBrakingZones(samples)).toEqual([]); + }); + + it("detects a clear hard-braking event", () => { + // Cruise at 60 then brake hard to 20 over several samples, then steady. + // Each 100ms drop of ~8mph ≈ 3.58 m/s over 0.1s = 35.8 m/s² ≈ 3.6G (clamped to 3). + const samples = makeSamples([ + 60, 60, 60, // cruising + 52, 44, 36, 28, 20, // braking + 20, 20, 20, // settled + ]); + const zones = detectBrakingZones(samples); + expect(zones.length).toBeGreaterThanOrEqual(1); + const z = zones[0]; + expect(z.speedDeltaMps).toBeLessThan(0); // lost speed + expect(z.durationMs).toBeGreaterThanOrEqual(DEFAULT_BRAKING_CONFIG.minDurationMs); + expect(z.path.length).toBeGreaterThanOrEqual(2); + // Path endpoints align with start/end coords. + expect(z.path[0]).toEqual({ lat: z.start.lat, lon: z.start.lon }); + expect(z.path[z.path.length - 1]).toEqual({ lat: z.end.lat, lon: z.end.lon }); + }); + + it("discards braking events shorter than minDurationMs", () => { + // A single hard 100ms decel dip. With minDurationMs=120 the zone (one step, + // 100ms) is too short and should be dropped. + const samples = makeSamples([60, 60, 40, 60, 60, 60, 60]); + const zones = detectBrakingZones(samples); + expect(zones.length).toBe(0); + }); + + it("respects a relaxed minDurationMs that admits a short zone", () => { + const samples = makeSamples([60, 60, 50, 40, 30, 30, 30]); + const zones = detectBrakingZones(samples, { + ...DEFAULT_BRAKING_CONFIG, + minDurationMs: 50, + }); + expect(zones.length).toBeGreaterThanOrEqual(1); + }); + + it("closes a braking zone that extends to the end of samples", () => { + // Braking continuously through the final sample. + const samples = makeSamples([60, 58, 50, 42, 34, 26, 18, 12]); + const zones = detectBrakingZones(samples, { + ...DEFAULT_BRAKING_CONFIG, + minDurationMs: 50, + }); + expect(zones.length).toBeGreaterThanOrEqual(1); + // Last zone should end on the final sample. + const last = zones[zones.length - 1]; + expect(last.end.t).toBe(samples[samples.length - 1].t); + }); + + it("ignores low-speed samples (both below MIN_SPEED 2 m/s ≈ 4.5 mph)", () => { + // Crawl from 4 → 0 mph: both samples under the min-speed gate → no braking zone. + const samples = makeSamples([4, 3, 2, 1, 0, 0, 0]); + expect(detectBrakingZones(samples)).toEqual([]); + }); + + it("ends an open braking zone when a GPS time gap appears", () => { + // Braking, then a >2s gap (invalid dt) mid-event; if the pre-gap portion was + // long enough it gets recorded, otherwise dropped — either way the function + // must not throw and must reset state. + const samples: GpsSample[] = makeSamples([60, 54, 46, 38, 30], 100); + // Insert a large time jump on the next sample (gap = 5s > MAX_DT 2s). + const tail = makeSamples([30, 30, 30], 100).map((s, i) => ({ + ...s, + t: samples[samples.length - 1].t + 5000 + i * 100, + })); + const combined = [...samples, ...tail]; + expect(() => detectBrakingZones(combined)).not.toThrow(); + }); +}); + +// ─── computeBrakingGSeries ───────────────────────────────────────────────────── + +describe("computeBrakingGSeries", () => { + it("returns [] for empty input", () => { + expect(computeBrakingGSeries([])).toEqual([]); + }); + + it("returns one value per sample, starting with 0", () => { + const samples = makeSamples([40, 38, 36, 34, 32]); + const series = computeBrakingGSeries(samples); + expect(series.length).toBe(samples.length); + expect(series[0]).toBe(0); + }); + + it("produces negative G during deceleration", () => { + const samples = makeSamples([60, 52, 44, 36, 28, 20]); + const series = computeBrakingGSeries(samples); + // After the first sample, the smoothed G should trend negative. + expect(series[series.length - 1]).toBeLessThan(0); + }); + + it("produces positive G during acceleration", () => { + const samples = makeSamples([20, 28, 36, 44, 52, 60]); + const series = computeBrakingGSeries(samples); + expect(series[series.length - 1]).toBeGreaterThan(0); + }); + + it("carries the previous value forward across a GPS time gap", () => { + const samples = makeSamples([60, 52, 44], 100); + // Append a sample 5s later (gap > MAX_DT) — its G should equal the prior smoothed value. + samples.push({ + ...makeSamples([36])[0], + t: samples[samples.length - 1].t + 5000, + }); + const series = computeBrakingGSeries(samples); + expect(series.length).toBe(4); + expect(series[3]).toBe(series[2]); // carried forward + }); + + it("carries forward when both samples are below the min speed gate", () => { + const samples = makeSamples([4, 3, 2, 1], 100); + const series = computeBrakingGSeries(samples); + // All deltas gated out → series stays at the seed 0. + expect(series.every((g) => g === 0)).toBe(true); + }); + + it("clamps raw acceleration to ±3G physical limit", () => { + // Impossible instantaneous drop → clamped magnitude not exceeding 3. + const samples = makeSamples([120, 20, 20], 100); + const series = computeBrakingGSeries(samples); + for (const g of series) { + expect(Math.abs(g)).toBeLessThanOrEqual(3 + 1e-9); + } + }); +}); + +// ─── computeBrakingGSeriesSG ─────────────────────────────────────────────────── + +describe("computeBrakingGSeriesSG", () => { + it("falls back to the EMA series for datasets smaller than the window", () => { + // 5 samples, default window 25 → falls back to computeBrakingGSeries. + const samples = makeSamples([60, 52, 44, 36, 28]); + const sg = computeBrakingGSeriesSG(samples); + const ema = computeBrakingGSeries(samples); + expect(sg).toEqual(ema); + }); + + it("returns one value per sample for a long series", () => { + // 40 samples decelerating — enough for the SG window (default 25). + const speeds = Array.from({ length: 40 }, (_, i) => Math.max(20, 60 - i)); + const samples = makeSamples(speeds, 100); + const sg = computeBrakingGSeriesSG(samples); + expect(sg.length).toBe(samples.length); + sg.forEach((g) => { + expect(Number.isFinite(g)).toBe(true); + expect(Math.abs(g)).toBeLessThanOrEqual(3 + 1e-9); + }); + }); + + it("reports negative G while braking on a long decel ramp", () => { + const speeds = Array.from({ length: 40 }, (_, i) => Math.max(15, 70 - 1.2 * i)); + const samples = makeSamples(speeds, 100); + const sg = computeBrakingGSeriesSG(samples); + // Mid-ramp (still decelerating, above the min-speed gate) should read negative. + expect(sg[15]).toBeLessThan(0); + }); + + it("zeroes G where speed is below the min-speed gate", () => { + // 30 samples all crawling under MIN_SPEED (≈4.5 mph). + const samples = makeSamples(new Array(30).fill(3), 100); + const sg = computeBrakingGSeriesSG(samples); + expect(sg.every((g) => g === 0)).toBe(true); + }); +}); + +// ─── gToBrakePercent ─────────────────────────────────────────────────────────── + +describe("gToBrakePercent", () => { + it("maps positive/zero G to 0% (acceleration is not braking)", () => { + expect(gToBrakePercent([0, 0.5, 2])).toEqual([0, 0, 0]); + }); + + it("maps -maxG to 100%", () => { + expect(gToBrakePercent([-1.5], 1.5)).toEqual([100]); + }); + + it("maps half of maxG to 50%", () => { + expect(gToBrakePercent([-0.75], 1.5)).toEqual([50]); + }); + + it("clamps decel beyond maxG to 100%", () => { + expect(gToBrakePercent([-3], 1.5)).toEqual([100]); + }); + + it("respects a custom maxG", () => { + // -0.5G with maxG 1.0 → 50%. + expect(gToBrakePercent([-0.5], 1.0)).toEqual([50]); + }); + + it("maps an empty series to an empty array", () => { + expect(gToBrakePercent([])).toEqual([]); + }); + + it("processes a mixed series elementwise", () => { + expect(gToBrakePercent([0, -0.75, 1, -1.5], 1.5)).toEqual([0, 50, 0, 100]); + }); +}); diff --git a/src/lib/gforceCalculation.test.ts b/src/lib/gforceCalculation.test.ts new file mode 100644 index 0000000..383f028 --- /dev/null +++ b/src/lib/gforceCalculation.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect } from "vitest"; +import { + calculateAccelerations, + smoothField, + applyGForceCalculations, +} from "./gforceCalculation"; +import { STANDARD_GRAVITY_MPS2 } from "./parserUtils"; +import type { GpsSample } from "@/types/racing"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +interface SampleOpts { + t: number; + speedMps?: number; + heading?: number; + extra?: Record; +} + +function makeSample(opts: SampleOpts): GpsSample { + const { t, speedMps = 0, heading, extra = {} } = opts; + return { + t, + lat: 0, + lon: 0, + speedMps, + speedMph: speedMps * 2.23694, + speedKph: speedMps * 3.6, + heading, + extraFields: { ...extra }, + }; +} + +const G = STANDARD_GRAVITY_MPS2; + +// ─── calculateAccelerations ────────────────────────────────────────────────── + +describe("calculateAccelerations", () => { + it("handles empty array without throwing", () => { + const samples: GpsSample[] = []; + expect(() => calculateAccelerations(samples)).not.toThrow(); + expect(samples).toEqual([]); + }); + + it("single sample: prev=next=curr → dt=0 → below MIN_DT → zeros", () => { + // With one sample, prevIdx=nextIdx=0, so dt = 0 < MIN_DT (0.05) → forced zeros. + const samples = [makeSample({ t: 1000, speedMps: 20 })]; + calculateAccelerations(samples); + expect(samples[0].extraFields["Lat G"]).toBe(0); + expect(samples[0].extraFields["Lon G"]).toBe(0); + }); + + it("computes longitudinal G from a constant acceleration", () => { + // 100ms spacing, speed rising 10 → 12 → 14 m/s. For the middle sample, + // central difference uses prev (10) and next (14) over dt = 0.2s. + // dv = 4, dt = 0.2 → accel = 20 m/s² → 20 / 9.80665 ≈ 2.039 G. + const samples = [ + makeSample({ t: 0, speedMps: 10 }), + makeSample({ t: 100, speedMps: 12 }), + makeSample({ t: 200, speedMps: 14 }), + ]; + calculateAccelerations(samples); + expect(samples[1].extraFields["Lon G"]).toBeCloseTo(20 / G, 4); + }); + + it("computes lateral G from a steady heading change at speed", () => { + // Speed 20 m/s, heading 0 → 5 → 10 deg over dt = 0.2s for the middle sample. + // dHeading (central) = 10 - 0 = 10 deg = 0.17453 rad. yawRate = 0.17453 / 0.2 = 0.87266 rad/s. + // latG = v * yawRate / g = 20 * 0.87266 / 9.80665 ≈ 1.7796 G. + const samples = [ + makeSample({ t: 0, speedMps: 20, heading: 0 }), + makeSample({ t: 100, speedMps: 20, heading: 5 }), + makeSample({ t: 200, speedMps: 20, heading: 10 }), + ]; + calculateAccelerations(samples); + const expected = (20 * ((10 * Math.PI) / 180) / 0.2) / G; + expect(samples[1].extraFields["Lat G"]).toBeCloseTo(expected, 4); + }); + + it("zeroes lateral G below MIN_SPEED_FOR_LAT_G (2 m/s)", () => { + // curr.speedMps = 1.5 < 2.0 → lat G not computed (stays 0), but lon G still computed. + const samples = [ + makeSample({ t: 0, speedMps: 1.0, heading: 0 }), + makeSample({ t: 100, speedMps: 1.5, heading: 30 }), + makeSample({ t: 200, speedMps: 2.0, heading: 60 }), + ]; + calculateAccelerations(samples); + expect(samples[1].extraFields["Lat G"]).toBe(0); + }); + + it("zeroes lateral G when heading data missing on prev or next", () => { + const samples = [ + makeSample({ t: 0, speedMps: 20 }), // no heading + makeSample({ t: 100, speedMps: 20, heading: 5 }), + makeSample({ t: 200, speedMps: 20, heading: 10 }), + ]; + calculateAccelerations(samples); + expect(samples[1].extraFields["Lat G"]).toBe(0); + }); + + it("rejects samples with too-large time gaps (> MAX_DT 2s) → zeros", () => { + // Middle sample: prev t=0, next t=5000 → dt = 5s > 2s → forced zeros. + const samples = [ + makeSample({ t: 0, speedMps: 10 }), + makeSample({ t: 2500, speedMps: 20 }), + makeSample({ t: 5000, speedMps: 30 }), + ]; + calculateAccelerations(samples); + expect(samples[1].extraFields["Lat G"]).toBe(0); + expect(samples[1].extraFields["Lon G"]).toBe(0); + }); + + it("skips samples with poor HDOP (> MAX_HDOP_FOR_G 5.0)", () => { + const samples = [ + makeSample({ t: 0, speedMps: 10 }), + makeSample({ t: 100, speedMps: 12, extra: { HDOP: 8 } }), + makeSample({ t: 200, speedMps: 14 }), + ]; + calculateAccelerations(samples); + expect(samples[1].extraFields["Lat G"]).toBe(0); + expect(samples[1].extraFields["Lon G"]).toBe(0); + }); + + it("rejects physically impossible heading rate (> MAX_HEADING_RATE 180 deg/s) → latG stays 0", () => { + // heading 0 → 90 over central dt = 0.2s → 90/0.2 = 450 deg/s > 180 → rejected. + const samples = [ + makeSample({ t: 0, speedMps: 20, heading: 0 }), + makeSample({ t: 100, speedMps: 20, heading: 45 }), + makeSample({ t: 200, speedMps: 20, heading: 90 }), + ]; + calculateAccelerations(samples); + expect(samples[1].extraFields["Lat G"]).toBe(0); + }); + + it("clamps longitudinal G to ±3 (MAX_G)", () => { + // Huge speed jump over 100ms → enormous accel → clamped to +3. + const samples = [ + makeSample({ t: 0, speedMps: 0 }), + makeSample({ t: 50, speedMps: 50 }), + makeSample({ t: 100, speedMps: 100 }), + ]; + calculateAccelerations(samples); + expect(samples[1].extraFields["Lon G"]).toBe(3); + }); + + it("clamps negative (braking) longitudinal G to -3", () => { + const samples = [ + makeSample({ t: 0, speedMps: 100 }), + makeSample({ t: 50, speedMps: 50 }), + makeSample({ t: 100, speedMps: 0 }), + ]; + calculateAccelerations(samples); + expect(samples[1].extraFields["Lon G"]).toBe(-3); + }); +}); + +// ─── smoothField ───────────────────────────────────────────────────────────── + +describe("smoothField", () => { + it("handles empty array", () => { + const samples: GpsSample[] = []; + expect(() => smoothField(samples, "Lat G")).not.toThrow(); + }); + + it("averages within the window (default window 5 → halfWindow 2)", () => { + const samples = [ + makeSample({ t: 0, extra: { v: 0 } }), + makeSample({ t: 1, extra: { v: 10 } }), + makeSample({ t: 2, extra: { v: 20 } }), + makeSample({ t: 3, extra: { v: 30 } }), + makeSample({ t: 4, extra: { v: 40 } }), + ]; + smoothField(samples, "v", 5); + // Middle index 2: window [0..4] → mean(0,10,20,30,40) = 20. + expect(samples[2].extraFields["v"]).toBe(20); + // Index 0: window [0..2] (clamped) → mean(0,10,20) = 10. + expect(samples[0].extraFields["v"]).toBe(10); + // Index 4: window [2..4] (clamped) → mean(20,30,40) = 30. + expect(samples[4].extraFields["v"]).toBe(30); + }); + + it("treats missing field values as 0", () => { + const samples = [ + makeSample({ t: 0, extra: { v: 10 } }), + makeSample({ t: 1 }), // no 'v' + makeSample({ t: 2, extra: { v: 20 } }), + ]; + smoothField(samples, "v", 3); + // Index 1: window [0..2] → mean(10, 0, 20) = 10. + expect(samples[1].extraFields["v"]).toBe(10); + }); + + it("window of 1 leaves values unchanged (halfWindow 0)", () => { + const samples = [ + makeSample({ t: 0, extra: { v: 5 } }), + makeSample({ t: 1, extra: { v: 99 } }), + ]; + smoothField(samples, "v", 1); + expect(samples[0].extraFields["v"]).toBe(5); + expect(samples[1].extraFields["v"]).toBe(99); + }); +}); + +// ─── applyGForceCalculations ───────────────────────────────────────────────── + +describe("applyGForceCalculations", () => { + it("populates and smooths both Lat G and Lon G fields", () => { + const samples = [ + makeSample({ t: 0, speedMps: 10, heading: 0 }), + makeSample({ t: 100, speedMps: 12, heading: 5 }), + makeSample({ t: 200, speedMps: 14, heading: 10 }), + makeSample({ t: 300, speedMps: 16, heading: 15 }), + makeSample({ t: 400, speedMps: 18, heading: 20 }), + ]; + applyGForceCalculations(samples, 5); + for (const s of samples) { + expect(typeof s.extraFields["Lat G"]).toBe("number"); + expect(typeof s.extraFields["Lon G"]).toBe("number"); + expect(Number.isFinite(s.extraFields["Lat G"])).toBe(true); + expect(Number.isFinite(s.extraFields["Lon G"])).toBe(true); + } + }); + + it("handles empty input gracefully", () => { + const samples: GpsSample[] = []; + expect(() => applyGForceCalculations(samples)).not.toThrow(); + }); +}); diff --git a/src/lib/speedBounds.test.ts b/src/lib/speedBounds.test.ts new file mode 100644 index 0000000..9e80397 --- /dev/null +++ b/src/lib/speedBounds.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { computeHeatmapSpeedBoundsMph } from "./speedBounds"; + +// ─── computeHeatmapSpeedBoundsMph ──────────────────────────────────────────── + +describe("computeHeatmapSpeedBoundsMph", () => { + it("returns {0, 1} for empty input", () => { + expect(computeHeatmapSpeedBoundsMph([])).toEqual({ minSpeed: 0, maxSpeed: 1 }); + }); + + it("uses raw min/max for a clean speed series", () => { + const speeds = [25, 40, 55, 60, 45, 30]; + const { minSpeed, maxSpeed } = computeHeatmapSpeedBoundsMph(speeds); + expect(minSpeed).toBe(25); + expect(maxSpeed).toBe(60); + }); + + it("floors maxSpeed at 1 even when all speeds are 0", () => { + // rawMax = Math.max(...[0,0,0], 1) = 1. + const { maxSpeed } = computeHeatmapSpeedBoundsMph([0, 0, 0]); + expect(maxSpeed).toBe(1); + }); + + it("excludes a SHORT low-speed run (<= maxGlitchSamples) from the min bound", () => { + // 3 zero samples (a glitch, <= default 10) at the start; real driving 30-60. + // Those zeros are excluded → min should be the lowest real speed (30). + const speeds = [0, 0, 0, 30, 45, 60, 55, 40, 35]; + const { minSpeed } = computeHeatmapSpeedBoundsMph(speeds); + expect(minSpeed).toBe(30); + }); + + it("keeps a LONG low-speed run in the min bound (genuine slow section)", () => { + // 15 consecutive low samples (> default 10) → not a glitch → min stays 0, + // unless the low ratio is small enough to override (here it's large). + const lows = new Array(15).fill(0.2); + const speeds = [...lows, 30, 45, 60]; + const { minSpeed } = computeHeatmapSpeedBoundsMph(speeds); + expect(minSpeed).toBeCloseTo(0.2, 6); + }); + + it("treats rare low samples (<=5% ratio) as bad data even in a long run", () => { + // One low sample (0.5) buried in a long array of real speeds. The single low + // value is below threshold, lowRatio = 1/N is tiny (<=5%) → use the lowest + // NON-low speed instead. + const real = new Array(50).fill(0).map((_, i) => 30 + (i % 10)); // 30..39 + const speeds = [...real, 0.5]; // 1 low sample out of 51 → ~2% + const { minSpeed } = computeHeatmapSpeedBoundsMph(speeds); + // The 0.5 should be discarded; min becomes the lowest real value (30). + expect(minSpeed).toBe(30); + }); + + it("does NOT override when low ratio exceeds 5%", () => { + // 5 low samples out of 50 = 10% > 5% → no rare-data override; the long run + // (each low sample is its own run of length 1 <= glitch limit, so excluded + // by glitch logic actually). Use a contiguous long run to keep min low. + const lows = new Array(15).fill(0.3); // long contiguous run > 10 + const real = new Array(40).fill(50); + const speeds = [...lows, ...real]; // 15/55 ≈ 27% low + const { minSpeed } = computeHeatmapSpeedBoundsMph(speeds); + expect(minSpeed).toBeCloseTo(0.3, 6); + }); + + it("handles a low-speed run that extends to the end of the array", () => { + // Trailing short low run (3 samples) should be treated as a glitch and excluded. + const speeds = [40, 55, 60, 45, 30, 0, 0, 0]; + const { minSpeed } = computeHeatmapSpeedBoundsMph(speeds); + expect(minSpeed).toBe(30); + }); + + it("respects a custom minSpeedThresholdMph", () => { + // Threshold 5: speeds of 3 are 'low'. A short run of them is excluded. + const speeds = [3, 3, 20, 40, 60, 50, 30]; + const { minSpeed } = computeHeatmapSpeedBoundsMph(speeds, { + minSpeedThresholdMph: 5, + }); + expect(minSpeed).toBe(20); + }); + + it("respects a custom maxGlitchSamples (smaller window keeps longer lows)", () => { + // 5 leading zeros. With maxGlitchSamples=3, this run (5) is NOT a glitch, + // so min stays 0 (and 5/8 low ratio is too large for the rare-data override). + const speeds = [0, 0, 0, 0, 0, 40, 50, 60]; + const { minSpeed } = computeHeatmapSpeedBoundsMph(speeds, { + maxGlitchSamples: 3, + }); + expect(minSpeed).toBe(0); + }); + + it("single sample (above threshold) returns that value for both bounds", () => { + const { minSpeed, maxSpeed } = computeHeatmapSpeedBoundsMph([42]); + expect(minSpeed).toBe(42); + expect(maxSpeed).toBe(42); + }); +}); diff --git a/src/lib/speedEvents.test.ts b/src/lib/speedEvents.test.ts new file mode 100644 index 0000000..6e6735b --- /dev/null +++ b/src/lib/speedEvents.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { findSpeedEvents } from "./speedEvents"; +import type { GpsSample } from "@/types/racing"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +// Build a sample where t advances by `dtMs` per index and speed comes from a series. +function makeSamples(speedsMph: number[], dtMs = 200): GpsSample[] { + return speedsMph.map((mph, i) => ({ + t: i * dtMs, + lat: 28.4 + i * 1e-5, // near a real Florida track + lon: -81.5 + i * 1e-5, + speedMps: mph / 2.23694, + speedMph: mph, + speedKph: mph * 1.60934, + extraFields: {}, + })); +} + +// ─── findSpeedEvents ───────────────────────────────────────────────────────── + +describe("findSpeedEvents", () => { + it("returns [] for empty input", () => { + expect(findSpeedEvents([])).toEqual([]); + }); + + it("returns [] when fewer than smoothingWindow + debounceCount samples", () => { + // Defaults: window 5 + debounce 2 = 7 needed. 6 samples → too few. + const samples = makeSamples([10, 20, 30, 40, 50, 60]); + expect(findSpeedEvents(samples)).toEqual([]); + }); + + it("returns [] for a perfectly monotonic increasing series (no extrema)", () => { + const samples = makeSamples([10, 12, 14, 16, 18, 20, 22, 24, 26, 28]); + expect(findSpeedEvents(samples)).toEqual([]); + }); + + it("detects a single peak in an up-then-down series", () => { + // Rise to ~60 then fall. With a long separation between this and any other + // extremum, one peak should be reported. + const speeds = [20, 30, 40, 50, 60, 50, 40, 30, 20, 15]; + const samples = makeSamples(speeds, 1000); // 1s spacing → easily passes minSeparation + const events = findSpeedEvents(samples); + expect(events.length).toBeGreaterThanOrEqual(1); + const peak = events.find((e) => e.type === "peak"); + expect(peak).toBeDefined(); + // Carries lat/lon/index/time from the candidate sample. + expect(peak!.lat).toBeCloseTo(samples[peak!.index].lat, 8); + expect(peak!.time).toBe(samples[peak!.index].t); + }); + + it("detects alternating peak and valley over a full oscillation", () => { + // Up to 60, down to 10, back up to 55. Should give a peak then a valley + // (alternating), with large swings well above minSwing. + const speeds = [ + 10, 25, 40, 55, 60, // peak around idx 4 + 50, 35, 20, 12, 10, // valley around idx 9 + 20, 35, 50, 55, 55, // up again + ]; + const samples = makeSamples(speeds, 1000); + const events = findSpeedEvents(samples); + expect(events.length).toBeGreaterThanOrEqual(2); + // First two events should alternate in type. + expect(events[0].type).not.toBe(events[1].type); + }); + + it("honors minSeparationMs (suppresses closely-spaced extrema)", () => { + // A rapid oscillation with tight time spacing — minSeparation should + // suppress the second extremum even though shape qualifies. + const speeds = [10, 30, 50, 30, 50, 30, 50, 30, 10, 5]; + // 50ms spacing → whole series spans ~450ms, under the 1000ms default separation. + const samples = makeSamples(speeds, 50); + const events = findSpeedEvents(samples, { + smoothingWindow: 3, + debounceCount: 1, + minSwing: 1, + }); + // At most one event can clear the 1000ms separation gate. + expect(events.length).toBeLessThanOrEqual(1); + }); + + it("honors minSwing (small wobbles below prominence are dropped)", () => { + // First a big peak, then a tiny dip and tiny peak (swing < minSwing) that + // should be filtered out, leaving essentially the prominent extrema only. + const speeds = [ + 10, 30, 50, 70, 80, // big peak + 70, 50, 30, 20, 15, // big valley region + 16, 17, 16, 17, 16, // tiny wobble — swing ~1mph + ]; + const samples = makeSamples(speeds, 1000); + const tightSwing = findSpeedEvents(samples, { minSwing: 20 }); + // With a large minSwing, the tiny terminal wobble produces no extra markers. + const looseSwing = findSpeedEvents(samples, { minSwing: 0.5 }); + expect(looseSwing.length).toBeGreaterThanOrEqual(tightSwing.length); + }); + + it("rounds nothing — reports the smoothed speed value as-is", () => { + // The 'speed' field is the smoothed candidate value (not necessarily integer). + const speeds = [20, 30, 40, 50, 60, 50, 40, 30, 20, 15]; + const samples = makeSamples(speeds, 1000); + const events = findSpeedEvents(samples); + for (const e of events) { + expect(Number.isFinite(e.speed)).toBe(true); + } + }); + + it("custom debounceCount of 1 still requires confirmation but is more permissive", () => { + const speeds = [10, 20, 30, 40, 50, 45, 40, 35, 30, 25]; + const samples = makeSamples(speeds, 1000); + const events = findSpeedEvents(samples, { debounceCount: 1, smoothingWindow: 3 }); + // Should detect the peak around index 4. + expect(events.some((e) => e.type === "peak")).toBe(true); + }); +}); diff --git a/src/lib/trackUtils.test.ts b/src/lib/trackUtils.test.ts new file mode 100644 index 0000000..d04f0a9 --- /dev/null +++ b/src/lib/trackUtils.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_TRACK_SEARCH_RADIUS_M, + parseSectorLine, + abbreviateTrackName, + getTrackDisplayName, + findNearestTrack, + calculatePolylineLength, + formatTrackLength, + resamplePolyline, +} from "./trackUtils"; +import { haversineDistance } from "./parserUtils"; + +// ─── DEFAULT_TRACK_SEARCH_RADIUS_M ───────────────────────────────────────────── + +describe("DEFAULT_TRACK_SEARCH_RADIUS_M", () => { + it("is ~5 miles in meters", () => { + expect(DEFAULT_TRACK_SEARCH_RADIUS_M).toBe(8047); + // 8047 m ≈ 5.000 mi. + expect(DEFAULT_TRACK_SEARCH_RADIUS_M / 1609.344).toBeCloseTo(5, 2); + }); +}); + +// ─── parseSectorLine ────────────────────────────────────────────────────────── + +describe("parseSectorLine", () => { + it("parses numeric string coordinates into a SectorLine", () => { + const line = parseSectorLine({ + aLat: "28.50100", + aLon: "-81.40200", + bLat: "28.50150", + bLon: "-81.40250", + }); + expect(line).toEqual({ + a: { lat: 28.501, lon: -81.402 }, + b: { lat: 28.5015, lon: -81.4025 }, + }); + }); + + it("returns undefined if any coordinate is non-numeric (NaN)", () => { + expect( + parseSectorLine({ aLat: "abc", aLon: "-81.4", bLat: "28.5", bLon: "-81.4" }), + ).toBeUndefined(); + expect( + parseSectorLine({ aLat: "28.5", aLon: "", bLat: "28.5", bLon: "-81.4" }), + ).toBeUndefined(); + }); + + it("parses partial-numeric strings via parseFloat (leading number wins)", () => { + // parseFloat("28.5deg") === 28.5 — documents the lenient parse. + const line = parseSectorLine({ + aLat: "28.5deg", + aLon: "-81.4", + bLat: "28.6", + bLon: "-81.5", + }); + expect(line?.a.lat).toBe(28.5); + }); +}); + +// ─── abbreviateTrackName ─────────────────────────────────────────────────────── + +describe("abbreviateTrackName", () => { + it("takes first letter of each word for multi-word names", () => { + expect(abbreviateTrackName("Orlando Kart Center")).toBe("OKC"); + }); + + it("takes the first 4 characters for single-word names", () => { + expect(abbreviateTrackName("Bushnell")).toBe("BUSH"); + }); + + it("uses the whole word uppercased when shorter than 4 chars", () => { + expect(abbreviateTrackName("Pit")).toBe("PIT"); + expect(abbreviateTrackName("ax")).toBe("AX"); + }); + + it("returns empty string for empty / whitespace-only input", () => { + expect(abbreviateTrackName("")).toBe(""); + expect(abbreviateTrackName(" ")).toBe(""); + }); + + it("collapses repeated whitespace between words", () => { + expect(abbreviateTrackName("Daytona International Speedway")).toBe("DIS"); + }); + + it("trims surrounding whitespace before abbreviating", () => { + expect(abbreviateTrackName(" Sebring ")).toBe("SEBR"); + }); +}); + +// ─── getTrackDisplayName ─────────────────────────────────────────────────────── + +describe("getTrackDisplayName", () => { + it("prefers an explicit shortName", () => { + expect(getTrackDisplayName({ name: "Orlando Kart Center", shortName: "OKC1" })).toBe("OKC1"); + }); + + it("falls back to the abbreviation when shortName is absent", () => { + expect(getTrackDisplayName({ name: "Orlando Kart Center" })).toBe("OKC"); + }); + + it("falls back to abbreviation when shortName is an empty string", () => { + expect(getTrackDisplayName({ name: "Bushnell", shortName: "" })).toBe("BUSH"); + }); +}); + +// ─── findNearestTrack ────────────────────────────────────────────────────────── + +describe("findNearestTrack", () => { + const okc = { + name: "Orlando Kart Center", + courses: [{ startFinishA: { lat: 28.5, lon: -81.4 } }], + }; + const sebring = { + name: "Sebring", + courses: [{ startFinishA: { lat: 27.45, lon: -81.35 } }], + }; + + it("returns null for no tracks", () => { + expect(findNearestTrack(28.5, -81.4, [])).toBeNull(); + }); + + it("returns the track when the point sits on its start/finish", () => { + const t = findNearestTrack(28.5, -81.4, [okc, sebring]); + expect(t).toBe(okc); + }); + + it("picks the closest of several tracks", () => { + // A point near Sebring's S/F. + const t = findNearestTrack(27.451, -81.351, [okc, sebring]); + expect(t).toBe(sebring); + }); + + it("returns null when the nearest track is beyond the threshold", () => { + // A point ~50km away from both → outside the 8047m default radius. + const far = findNearestTrack(29.5, -82.5, [okc, sebring]); + expect(far).toBeNull(); + }); + + it("respects a custom threshold", () => { + // ~300m from OKC S/F. Within default but outside a tight 100m threshold. + const near = { lat: 28.5, lon: -81.39695 }; + const dist = haversineDistance(near.lat, near.lon, 28.5, -81.4); + expect(dist).toBeGreaterThan(100); + expect(dist).toBeLessThan(DEFAULT_TRACK_SEARCH_RADIUS_M); + expect(findNearestTrack(near.lat, near.lon, [okc])).toBe(okc); + expect(findNearestTrack(near.lat, near.lon, [okc], 100)).toBeNull(); + }); + + it("scans all courses of a multi-course track", () => { + const multi = { + name: "Multi", + courses: [ + { startFinishA: { lat: 10, lon: 10 } }, // far + { startFinishA: { lat: 28.5, lon: -81.4 } }, // near our point + ], + }; + expect(findNearestTrack(28.5, -81.4, [multi])).toBe(multi); + }); +}); + +// ─── calculatePolylineLength ─────────────────────────────────────────────────── + +describe("calculatePolylineLength", () => { + it("returns 0 for an empty or single-point polyline", () => { + expect(calculatePolylineLength([])).toBe(0); + expect(calculatePolylineLength([{ lat: 28.5, lon: -81.4 }])).toBe(0); + }); + + it("sums segment haversine distances", () => { + // Two ~1° longitude steps at the equator ≈ 2 * 111195 m. + const pts = [ + { lat: 0, lon: 0 }, + { lat: 0, lon: 1 }, + { lat: 0, lon: 2 }, + ]; + expect(calculatePolylineLength(pts)).toBeCloseTo(2 * 111195, -1); + }); + + it("matches a single segment's haversine distance", () => { + const a = { lat: 28.5, lon: -81.4 }; + const b = { lat: 28.51, lon: -81.41 }; + expect(calculatePolylineLength([a, b])).toBeCloseTo( + haversineDistance(a.lat, a.lon, b.lat, b.lon), + 6, + ); + }); +}); + +// ─── formatTrackLength ───────────────────────────────────────────────────────── + +describe("formatTrackLength", () => { + it("formats meters into ft / m with rounding", () => { + // 1000 m = 3280.84 ft → "3,281 ft / 1,000 m". + expect(formatTrackLength(1000)).toBe("3,281 ft / 1,000 m"); + }); + + it("handles zero", () => { + expect(formatTrackLength(0)).toBe("0 ft / 0 m"); + }); + + it("rounds fractional meters", () => { + // 100.4 m → 329.42 ft → "329 ft / 100 m". + expect(formatTrackLength(100.4)).toBe("329 ft / 100 m"); + }); +}); + +// ─── resamplePolyline ────────────────────────────────────────────────────────── + +describe("resamplePolyline", () => { + it("returns a copy for fewer than 2 points", () => { + expect(resamplePolyline([])).toEqual([]); + const single = [{ lat: 28.5, lon: -81.4 }]; + const out = resamplePolyline(single); + expect(out).toEqual(single); + expect(out).not.toBe(single); // shallow copy of the array + }); + + it("always includes the first point", () => { + const pts = [ + { lat: 0, lon: 0 }, + { lat: 0, lon: 0.01 }, + ]; + const out = resamplePolyline(pts, 100); + expect(out[0]).toEqual({ lat: 0, lon: 0 }); + }); + + it("emits roughly evenly spaced points along a straight segment", () => { + // ~1112m east at the equator (0.01°). Spacing 100m → ~11 interior points + start. + const pts = [ + { lat: 0, lon: 0 }, + { lat: 0, lon: 0.01 }, + ]; + const out = resamplePolyline(pts, 100); + // First point + floor(1112/100) ≈ 11 emitted points. + expect(out.length).toBeGreaterThanOrEqual(11); + // Consecutive emitted points should be ~100m apart (within tolerance). + for (let i = 1; i < out.length; i++) { + const d = haversineDistance(out[i - 1].lat, out[i - 1].lon, out[i].lat, out[i].lon); + expect(d).toBeCloseTo(100, -1); + } + }); + + it("skips zero-length segments (duplicate points)", () => { + const pts = [ + { lat: 0, lon: 0 }, + { lat: 0, lon: 0 }, // duplicate → segDist 0, skipped + { lat: 0, lon: 0.01 }, + ]; + const out = resamplePolyline(pts, 100); + expect(out.length).toBeGreaterThan(1); + expect(out[0]).toEqual({ lat: 0, lon: 0 }); + }); + + it("carries leftover distance across a too-short segment (documented quirk)", () => { + // First segment is ~66.7m — too short to fit a 100m step, so the loop emits + // nothing and forwards carry = 66.7m. On the long second segment `walked` is + // SEEDED with that carry (66.7m) and the first emitted point lands at + // walked = 66.7 + 100 = ~166.7m INTO segment 2 — i.e. ~233m from the polyline + // start, not 100m. This is a known quirk: the carry shifts the first point of + // the next segment by the short segment's length rather than continuing + // seamlessly. Subsequent points are then a clean 100m apart. + const pts = [ + { lat: 0, lon: 0 }, + { lat: 0, lon: 0.0006 }, // ~66.7m short hop (no point fits) + { lat: 0, lon: 0.01 }, // long straight (~1045m) east + ]; + const out = resamplePolyline(pts, 100); + expect(out.length).toBeGreaterThanOrEqual(2); + // First interior point sits ~233m from start (66.7 carry + 100 + 66.7 seg1). + const firstGap = haversineDistance(out[0].lat, out[0].lon, out[1].lat, out[1].lon); + expect(firstGap).toBeCloseTo(233, -1); + // From there, consecutive interior points are a clean ~100m apart. + for (let i = 2; i < out.length; i++) { + const step = haversineDistance(out[i - 1].lat, out[i - 1].lon, out[i].lat, out[i].lon); + expect(step).toBeCloseTo(100, -1); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5df2a80..58f5793 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,10 +37,10 @@ export default defineConfig({ // below current actuals so routine churn doesn't redden CI; ratchet up as // coverage grows. thresholds: { - lines: 20, - functions: 18, - branches: 18, - statements: 20, + lines: 33, + functions: 26, + branches: 33, + statements: 32, }, }, }, From 834d7ecc3ba1163e7aadeb31a5a84ca73471dd78 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 03:58:30 +0000 Subject: [PATCH 071/121] Wire up subscription tier UI (upgrade / current / manage) Adds the client surface for the Stripe backend: a useSubscription() hook that reads the tier catalogue + the user's plan, live "Upgrade"/"Current plan" actions on the Plans & pricing cards for signed-in users (a paid tier stays "Coming soon" until its Stripe Price is configured), and a "Manage subscription" portal link on the Profile tab. Billing logic is split pure (billing.ts, unit-tested) vs Supabase I/O (billingClient.ts) so the pure tier/CTA logic is testable without pulling in the client. Kept in core rather than the cloud-sync plugin since PricingCards renders regardless of the cloud flag. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 15 ++-- CLAUDE.md | 22 +++++- src/components/PricingCards.tsx | 93 ++++++++++++++++++++++--- src/hooks/useSubscription.ts | 67 ++++++++++++++++++ src/lib/billing.test.ts | 64 +++++++++++++++++ src/lib/billing.ts | 67 ++++++++++++++++++ src/lib/billingClient.ts | 55 +++++++++++++++ src/plugins/cloud-sync/StoragePanel.tsx | 53 +++++++++++++- 8 files changed, 417 insertions(+), 19 deletions(-) create mode 100644 src/hooks/useSubscription.ts create mode 100644 src/lib/billing.test.ts create mode 100644 src/lib/billing.ts create mode 100644 src/lib/billingClient.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c0d3e..d2b7a30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Paid subscription tiers (backend)**: Stripe-backed `Plus` ($1/mo, 500 MB - logs) and `Pro` ($10/mo, 1 GB logs) plans on top of the free 20 MB tier. Plan - limits are data-driven (`subscription_tiers` table) and the cloud-sync storage - quota is now enforced per the user's tier. New `create-checkout-session`, +- **Paid subscription tiers**: Stripe-backed `Plus` ($1/mo, 500 MB logs) and + `Pro` ($10/mo, 1 GB logs) plans on top of the free 20 MB tier. 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. (Upgrade buttons in the UI land - in a follow-up.) + 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. - Document storage + **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. The "documents" storage type diff --git a/CLAUDE.md b/CLAUDE.md index ab8d69c..a75f0c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,7 @@ src/ │ ├── 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) +│ ├── useSubscription.ts # Reads subscription tier catalogue + the user's plan (online, account-gated) │ └── useOnlineStatus.ts # Navigator.onLine wrapper ├── lib/ │ ├── datalogParser.ts # ★ Format auto-detection router (entry point for all parsing) @@ -176,6 +177,8 @@ src/ │ │ ├── types.ts # ITrackDatabase interface │ │ ├── supabaseAdapter.ts # Supabase implementation │ │ └── index.ts # Factory: getDatabase() +│ ├── billing.ts # ★ Pure subscription logic + row shapes (effectiveTier, pricingCta) — unit-tested, no Supabase import +│ ├── billingClient.ts # Supabase I/O for tiers/subscriptions + Stripe checkout/portal (functions.invoke) │ └── utils.ts # Tailwind cn() helper ├── plugins/ # ★ Plugin framework (auto-discovered via import.meta.glob) │ ├── types.ts # DataViewerPlugin / PluginContext / PluginRegistry contracts @@ -200,7 +203,7 @@ src/ │ │ ├── storageTypes.ts # Pure: storage types (documents 5MB / logs 20MB) + usage math (tested) │ │ ├── syncEngine.ts # pushAll/pushFile/pullAll + incremental pushRecord/deleteRecord + getStorageUsage + deleteCloudFile (rolls back orphan blob on index failure) + cleanupOrphanBlobs. Doc pushes chunk to a per-record fallback on quota (partial push + skipped count) │ │ ├── autoSync.ts # Background doc auto-sync: subscribes to garageEvents, debounced upsert/delete + reconcile on sign-in -│ │ ├── StoragePanel.tsx # Profile-tab panel: display-name editor + storage usage meters (lazy) +│ │ ├── StoragePanel.tsx # Profile-tab panel: display-name editor + plan/Manage-subscription + storage usage meters (lazy) │ │ ├── CloudLogsPanel.tsx # Profile-tab panel: list + delete cloud log files (cloud-only; opt-in local delete) (lazy) │ │ ├── profile.ts # getMyProfile / updateDisplayName (unique display names; taken-name handling) │ │ └── cloudClient.ts # Typed access to sync_records + bucket + sync_storage_usage RPC (escape hatch until types regen) @@ -523,8 +526,21 @@ manually like the rest of the repo, the webhook verifies the Stripe signature): - `create-portal-session` — returns a Stripe Billing Portal URL for manage/cancel (no in-app billing UI). -Secrets: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. **Client upgrade/manage -buttons (PricingCards + Profile StoragePanel) are a follow-up.** +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` + +row shapes); `lib/billingClient.ts` is the Supabase I/O (`fetchTiers`, +`fetchMySubscription`, `createCheckout`, `createPortal` via `functions.invoke`), +through the same untyped escape hatch as `cloudClient.ts`. `hooks/useSubscription.ts` +reads the tier catalogue + the user's subscription (online + account-gated, +returns the free baseline otherwise). `PricingCards` shows live **Upgrade** / +**Current plan** actions for signed-in users (a paid tier with no `stripe_price_id` +stays "Coming soon" — graceful pre-config state); cloud-sync's Profile-tab +`StoragePanel` shows the current plan + a **Manage subscription** portal link when +subscribed. **Stripe setup (create Products/Prices, set `stripe_price_id`, secrets, +webhook) is still operator config — see README.** --- diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx index 901f610..a5c3b62 100644 --- a/src/components/PricingCards.tsx +++ b/src/components/PricingCards.tsx @@ -1,4 +1,13 @@ -import { Check } from "lucide-react"; +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 { useSubscription } from "@/hooks/useSubscription"; +import { pricingCta } from "@/lib/billing"; +import { createCheckout } from "@/lib/billingClient"; + +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === "true"; interface Tier { name: string; @@ -9,6 +18,8 @@ interface Tier { features: string[]; highlight?: boolean; comingSoon?: boolean; + /** Maps the card to a subscription tier slug (the offline card has none). */ + slug?: "free" | "plus" | "pro"; } const TIERS: Tier[] = [ @@ -29,6 +40,7 @@ const TIERS: Tier[] = [ blurb: "Online account", price: "$0", highlight: true, + slug: "free", inherits: "Everything in Free, plus", features: [ "Setup info synced across all your devices", @@ -42,6 +54,7 @@ const TIERS: Tier[] = [ price: "$1", cadence: "/mo", comingSoon: true, + slug: "plus", inherits: "Everything in Free online, plus", features: ["500 MB cloud log storage"], }, @@ -51,12 +64,21 @@ const TIERS: Tier[] = [ price: "$10", cadence: "/mo", comingSoon: true, + slug: "pro", inherits: "Everything in Plus, plus", features: ["1 GB cloud log storage", "AI coaching (coming soon)"], }, ]; -function TierCard({ tier }: { tier: Tier }) { +function TierCard({ + tier, + cta, + showComingSoon, +}: { + tier: Tier; + cta?: ReactNode; + showComingSoon: boolean; +}) { return (
)} - {tier.comingSoon && ( + {showComingSoon && ( Coming soon @@ -92,16 +114,36 @@ function TierCard({ tier }: { tier: Tier }) { ))} + {cta &&
{cta}
}
); } /** - * Plans / pricing grid. Shown on the landing page (below the sample box) and on - * the registration page. Informational only — paid tiers are marked "Coming - * soon" until billing is wired up. + * Plans / pricing grid. Shown on the landing page (the empty-state of the main + * app) and on the registration page. Informational for signed-out visitors; + * signed-in users get live "Upgrade" / "Current plan" actions on the paid tiers + * (a paid tier whose Stripe Price isn't configured yet stays "Coming soon"). */ export function PricingCards({ className }: { className?: string }) { + const { user } = useAuth(); + const { tiers, currentTier } = useSubscription(); + const [busy, setBusy] = useState(null); + + const signedIn = !!user; + const purchasable = new Set(tiers.filter((t) => t.stripe_price_id).map((t) => t.tier)); + + const onUpgrade = async (slug: string) => { + setBusy(slug); + try { + const url = await createCheckout(slug, window.location.href); + window.location.href = url; + } catch (e) { + toast.error(e instanceof Error ? e.message : "Couldn't start checkout."); + setBusy(null); + } + }; + return (
@@ -111,9 +153,42 @@ export function PricingCards({ className }: { className?: string }) {

- {TIERS.map((tier) => ( - - ))} + {TIERS.map((tier) => { + const kind = pricingCta({ + slug: tier.slug, + signedIn, + cloudEnabled: enableCloud, + currentTier, + purchasable: !!tier.slug && purchasable.has(tier.slug), + }); + + let cta: ReactNode = null; + if (kind === "current") { + cta = ( + + ); + } else if (kind === "upgrade" && tier.slug) { + const slug = tier.slug; + const isBusy = busy === slug; + cta = ( + + ); + } + + return ( + + ); + })}
); diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts new file mode 100644 index 0000000..2cbbc23 --- /dev/null +++ b/src/hooks/useSubscription.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import { effectiveTier, type SubscriptionTierRow, type UserSubscriptionRow } from "@/lib/billing"; +import { fetchMySubscription, fetchTiers } from "@/lib/billingClient"; + +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === "true"; + +export interface SubscriptionState { + loading: boolean; + error: string | null; + /** All plans (sorted), for prices/labels/limits. Empty when signed out. */ + tiers: SubscriptionTierRow[]; + /** The user's raw subscription row (may be inactive). */ + subscription: UserSubscriptionRow | null; + /** The effective tier ('free' when signed out or the subscription is inactive). */ + currentTier: string; + refresh: () => Promise; +} + +/** + * Reads the catalogue of subscription tiers + the current user's subscription. + * Online-only and account-gated: returns the free baseline when cloud is + * disabled or no one is signed in, and never throws into render. + */ +export function useSubscription(): SubscriptionState { + const { user, loading: authLoading } = useAuth(); + const online = useOnlineStatus(); + const [tiers, setTiers] = useState([]); + const [subscription, setSubscription] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!enableCloud || !user) { + setTiers([]); + setSubscription(null); + setError(null); + return; + } + setLoading(true); + try { + const [t, s] = await Promise.all([fetchTiers(), fetchMySubscription(user.id)]); + setTiers(t); + setSubscription(s); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load subscription"); + } finally { + setLoading(false); + } + }, [user]); + + // Re-read on mount, on sign-in/out, and when connectivity returns. + useEffect(() => { + void refresh(); + }, [refresh, online]); + + return { + loading: authLoading || loading, + error, + tiers, + subscription, + currentTier: effectiveTier(subscription), + refresh, + }; +} diff --git a/src/lib/billing.test.ts b/src/lib/billing.test.ts new file mode 100644 index 0000000..63c6d18 --- /dev/null +++ b/src/lib/billing.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { isActiveStatus, effectiveTier, isPaidTier, pricingCta } from "./billing"; + +describe("isActiveStatus", () => { + it("treats active / trialing / past_due as granting access", () => { + expect(isActiveStatus("active")).toBe(true); + expect(isActiveStatus("trialing")).toBe(true); + expect(isActiveStatus("past_due")).toBe(true); + }); + it("treats everything else (and null) as inactive", () => { + expect(isActiveStatus("canceled")).toBe(false); + expect(isActiveStatus("incomplete")).toBe(false); + expect(isActiveStatus("unpaid")).toBe(false); + expect(isActiveStatus(null)).toBe(false); + expect(isActiveStatus(undefined)).toBe(false); + }); +}); + +describe("effectiveTier", () => { + it("returns the tier when the subscription is active", () => { + expect(effectiveTier({ tier: "pro", status: "active" })).toBe("pro"); + expect(effectiveTier({ tier: "plus", status: "trialing" })).toBe("plus"); + }); + it("falls back to free when inactive or missing", () => { + expect(effectiveTier({ tier: "pro", status: "canceled" })).toBe("free"); + expect(effectiveTier(null)).toBe("free"); + expect(effectiveTier(undefined)).toBe("free"); + }); +}); + +describe("isPaidTier", () => { + it("is true for anything but free", () => { + expect(isPaidTier("free")).toBe(false); + expect(isPaidTier("plus")).toBe(true); + expect(isPaidTier("pro")).toBe(true); + }); +}); + +describe("pricingCta", () => { + const base = { signedIn: true, cloudEnabled: true, currentTier: "free", purchasable: true }; + + it("shows nothing when signed out, cloud disabled, or no slug", () => { + expect(pricingCta({ ...base, slug: "plus", signedIn: false })).toBe("none"); + expect(pricingCta({ ...base, slug: "plus", cloudEnabled: false })).toBe("none"); + expect(pricingCta({ ...base, slug: undefined })).toBe("none"); + }); + + it("marks the user's current tier as current", () => { + expect(pricingCta({ ...base, slug: "free", currentTier: "free" })).toBe("current"); + expect(pricingCta({ ...base, slug: "pro", currentTier: "pro" })).toBe("current"); + }); + + it("offers an upgrade for a purchasable paid tier above the current one", () => { + expect(pricingCta({ ...base, slug: "plus", currentTier: "free", purchasable: true })).toBe("upgrade"); + }); + + it("keeps a paid tier non-actionable (Coming soon) when no Stripe Price is set", () => { + expect(pricingCta({ ...base, slug: "pro", currentTier: "free", purchasable: false })).toBe("none"); + }); + + it("never offers an upgrade to the free-online card", () => { + expect(pricingCta({ ...base, slug: "free", currentTier: "pro" })).toBe("none"); + }); +}); diff --git a/src/lib/billing.ts b/src/lib/billing.ts new file mode 100644 index 0000000..542de7e --- /dev/null +++ b/src/lib/billing.ts @@ -0,0 +1,67 @@ +// Pure subscription/billing logic + row shapes (no Supabase import, so it's +// unit-testable and safe to pull into any component). The Supabase I/O lives in +// billingClient.ts. + +export interface SubscriptionTierRow { + tier: string; + label: string; + price_cents: number; + logs_bytes: number; + doc_bytes: number; + ai_credits: number; + stripe_price_id: string | null; + sort_order: number; +} + +export interface UserSubscriptionRow { + user_id: string; + tier: string; + status: string; + current_period_end: string | null; +} + +// A subscription grants its tier only while the status is one of these; anything +// else (canceled, incomplete, unpaid…) falls back to free. Mirrors the server's +// user_tier() definition so client display and server enforcement agree. +export const ACTIVE_STATUSES = ["active", "trialing", "past_due"] as const; + +export function isActiveStatus(status: string | null | undefined): boolean { + return !!status && (ACTIVE_STATUSES as readonly string[]).includes(status); +} + +/** The tier a subscription row actually entitles the user to right now. */ +export function effectiveTier( + sub: Pick | null | undefined, +): string { + return sub && isActiveStatus(sub.status) ? sub.tier : "free"; +} + +export function isPaidTier(tier: string): boolean { + return tier !== "free"; +} + +export type PricingCtaKind = "none" | "current" | "upgrade"; + +export interface PricingCtaInput { + /** The card's tier slug ('free' | 'plus' | 'pro'); undefined for the offline card. */ + slug?: string; + signedIn: boolean; + cloudEnabled: boolean; + /** The user's effective tier. */ + currentTier: string; + /** Whether this tier has a Stripe Price configured (purchasable). */ + purchasable: boolean; +} + +/** + * Which call-to-action a pricing card should show. Pure so it can be unit-tested. + * "none" means render no button (informational only — e.g. signed out, the free + * tiers, or a paid tier whose Stripe Price isn't configured yet, which keeps the + * "Coming soon" badge). + */ +export function pricingCta(i: PricingCtaInput): PricingCtaKind { + if (!i.slug || !i.cloudEnabled || !i.signedIn) return "none"; + if (i.slug === i.currentTier) return "current"; + if (i.slug === "free") return "none"; + return i.purchasable ? "upgrade" : "none"; +} diff --git a/src/lib/billingClient.ts b/src/lib/billingClient.ts new file mode 100644 index 0000000..3446b59 --- /dev/null +++ b/src/lib/billingClient.ts @@ -0,0 +1,55 @@ +// Supabase I/O for subscriptions + billing (Stripe). Pure logic + row shapes +// live in billing.ts. +// +// subscription_tiers / user_subscriptions are not in the generated Database type +// yet (Lovable regenerates `integrations/supabase/types.ts` after the migration +// deploys), so — exactly like cloud-sync's cloudClient.ts — we route those +// tables through an untyped view of the shared client. Checkout/portal go +// through the typed `functions.invoke`. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { supabase } from "@/integrations/supabase/client"; +import type { SubscriptionTierRow, UserSubscriptionRow } from "./billing"; + +const untyped = supabase as unknown as SupabaseClient; + +export async function fetchTiers(): Promise { + const { data, error } = await untyped + .from("subscription_tiers") + .select("*") + .order("sort_order", { ascending: true }); + if (error) throw new Error(`Failed to load tiers: ${error.message}`); + return (data ?? []) as SubscriptionTierRow[]; +} + +export async function fetchMySubscription(userId: string): Promise { + const { data, error } = await untyped + .from("user_subscriptions") + .select("user_id, tier, status, current_period_end") + .eq("user_id", userId) + .maybeSingle(); + if (error) throw new Error(`Failed to load subscription: ${error.message}`); + return (data ?? null) as UserSubscriptionRow | null; +} + +/** Start Stripe Checkout for a tier; resolves to the hosted URL to redirect to. */ +export async function createCheckout(tier: string, returnUrl: string): Promise { + const { data, error } = await supabase.functions.invoke("create-checkout-session", { + body: { tier, returnUrl }, + }); + if (error) throw new Error(error.message); + const url = (data as { url?: string } | null)?.url; + if (!url) throw new Error("No checkout URL returned"); + return url; +} + +/** Open the Stripe Billing Portal; resolves to the hosted URL to redirect to. */ +export async function createPortal(returnUrl: string): Promise { + const { data, error } = await supabase.functions.invoke("create-portal-session", { + body: { returnUrl }, + }); + if (error) throw new Error(error.message); + const url = (data as { url?: string } | null)?.url; + if (!url) throw new Error("No portal URL returned"); + return url; +} diff --git a/src/plugins/cloud-sync/StoragePanel.tsx b/src/plugins/cloud-sync/StoragePanel.tsx index 41f0691..5174e8e 100644 --- a/src/plugins/cloud-sync/StoragePanel.tsx +++ b/src/plugins/cloud-sync/StoragePanel.tsx @@ -1,11 +1,14 @@ import { useCallback, useEffect, useState } from "react"; -import { Check, CloudOff, Pencil, User as UserIcon, X } from "lucide-react"; +import { Check, CloudOff, CreditCard, Loader2, Pencil, User as UserIcon, X } from "lucide-react"; import { toast } from "sonner"; import type { PluginPanelProps } from "@/plugins/panels"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/contexts/AuthContext"; import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import { useSubscription } from "@/hooks/useSubscription"; +import { isPaidTier } from "@/lib/billing"; +import { createPortal } from "@/lib/billingClient"; import { getStorageUsage } from "./syncEngine"; import { getMyProfile, updateDisplayName } from "./profile"; import { pendingCount } from "./pendingSync"; @@ -60,6 +63,8 @@ export default function StoragePanel(_props: PluginPanelProps) {
+ + {!online && (
@@ -172,6 +177,52 @@ function DisplayName({ userId, email }: { userId: string; email: string }) { ); } +function PlanSection() { + const { tiers, currentTier, subscription, loading } = useSubscription(); + const [busy, setBusy] = useState(false); + + const label = tiers.find((t) => t.tier === currentTier)?.label + ?? currentTier.charAt(0).toUpperCase() + currentTier.slice(1); + const subscribed = isPaidTier(currentTier); + const renews = subscription?.current_period_end + ? new Date(subscription.current_period_end).toLocaleDateString() + : null; + + const manage = async () => { + setBusy(true); + 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(false); + } + }; + + return ( +
+

Plan

+
+
+

{loading ? "…" : label}

+ {subscribed && renews && ( +

Renews {renews}

+ )} + {!subscribed && ( +

Upgrade from the Plans & pricing cards.

+ )} +
+ {subscribed && ( + + )} +
+
+ ); +} + function Meter({ usage }: { usage: StorageTypeUsage }) { const pct = Math.round(usageFraction(usage) * 100); const over = usage.usedBytes > usage.limitBytes; From b439888aebe17c4c800a56e6a26209f22d408c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 03:58:35 +0000 Subject: [PATCH 072/121] Update @perchwerks/eye-in-the-sky to 0.2.5 Bump the bundled AI coach plugin from 0.2.4 to 0.2.5. No dependency or peer-dependency changes; typecheck, build (loads the external plugin), lint, and the full test suite all pass. Changelog reference updated to match the shipped coach version. https://claude.ai/code/session_017fmZ5GDJkNec7sxGWt343G --- CHANGELOG.md | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85cdf91..779ffde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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.0) ships a full-bleed analysis + 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 diff --git a/package-lock.json b/package-lock.json index 71db8ff..80de011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "0.2.4" + "@perchwerks/eye-in-the-sky": "0.2.5" } }, "node_modules/@alloc/quick-lru": { @@ -2473,9 +2473,9 @@ } }, "node_modules/@perchwerks/eye-in-the-sky": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.4.tgz", - "integrity": "sha512-KLITvw1kwA/kR5kJbn7Vhz2UxAxqDYoPi6vD4SXtsyuPUpAr4L4N1fSRea7CyiQoorhlsGGCs9pN6Bg8RG12Cg==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.5.tgz", + "integrity": "sha512-AbWDkL1ERzb9FBvvxONmp6oZBISBAP7txDaBo1L2mAmIx7BLLGzjLT/RF8DHf6dotQJ+L0yGvGCSlSmROsATig==", "license": "GPL-3.0-or-later", "optional": true, "dependencies": { diff --git a/package.json b/package.json index 7d5a229..7e1e6e8 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,6 @@ "vitest": "^4.1.6" }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "0.2.4" + "@perchwerks/eye-in-the-sky": "0.2.5" } } From efe58c37e6e29259d918586ea6b8dd16a95314c8 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 00:23:45 +0000 Subject: [PATCH 073/121] Work in progress --- bun.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 3d4a0e4..9559760 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,7 @@ "vitest": "^4.1.6", }, "optionalDependencies": { - "@perchwerks/eye-in-the-sky": "0.2.4", + "@perchwerks/eye-in-the-sky": "0.2.5", }, }, }, @@ -369,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.2.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.4.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-KLITvw1kwA/kR5kJbn7Vhz2UxAxqDYoPi6vD4SXtsyuPUpAr4L4N1fSRea7CyiQoorhlsGGCs9pN6Bg8RG12Cg=="], + "@perchwerks/eye-in-the-sky": ["@perchwerks/eye-in-the-sky@0.2.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@perchwerks/eye-in-the-sky/-/eye-in-the-sky-0.2.5.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-AbWDkL1ERzb9FBvvxONmp6oZBISBAP7txDaBo1L2mAmIx7BLLGzjLT/RF8DHf6dotQJ+L0yGvGCSlSmROsATig=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], From 25064e2b332e9a7a293044272874867928b4af3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 00:54:11 +0000 Subject: [PATCH 074/121] Add Terms of Service + rewrite Privacy Policy for cloud/AI/billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old privacy policy claimed nothing ever leaves the device and no account is needed — both false once cloud sync, Stripe billing, and AI coaching are enabled. Rewrote it to accurately describe each online feature (what's collected, why, sub-processors, retention, GDPR/CCPA rights, 16+ age policy, self-hosting), and added a Terms of Service page (as-is/liability disclaimers, subscription billing terms, AI "not safety advice" disclaimer). Both pages adapt to the build flags so offline-only deployments show the simpler, accurate text. Surfaced via a /terms route, a footer link, and a passive 16+ / agreement notice on sign-up. Content/links only — no data-flow changes; account deletion, IP retention TTLs, and an AI opt-in gate are deferred. Placeholders the operator must confirm: governing-law jurisdiction, operating entity name, contact email, and the specific AI provider. https://claude.ai/code/session_017fmZ5GDJkNec7sxGWt343G --- CHANGELOG.md | 8 + CLAUDE.md | 2 +- src/App.tsx | 2 + src/components/LandingPage.tsx | 6 +- src/pages/Privacy.tsx | 415 ++++++++++++++++++++++++++++----- src/pages/Register.tsx | 7 + src/pages/Terms.tsx | 249 ++++++++++++++++++++ 7 files changed, 632 insertions(+), 57 deletions(-) create mode 100644 src/pages/Terms.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b93f7e6..3e477cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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, 500 MB logs) and `Pro` ($10/mo, 1 GB logs) plans on top of the free 20 MB tier. Plan limits are data-driven (`subscription_tiers` table) and the cloud-sync storage quota is diff --git a/CLAUDE.md b/CLAUDE.md index a75f0c5..429ec8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ src/ ├── pages/ │ ├── 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 +│ ├── Login.tsx / Register.tsx / Privacy.tsx / Terms.tsx │ └── NotFound.tsx ├── components/ │ ├── ui/ # shadcn/ui primitives (button, dialog, tabs, etc.) diff --git a/src/App.tsx b/src/App.tsx index a7e09b0..97eec8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ 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")); @@ -52,6 +53,7 @@ const App = () => { } /> } /> + } /> {(enableAdmin || enableCloud) && } />} {enableAdmin && } />} {enableCloud && } />} diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx index 0252dd8..43e350e 100644 --- a/src/components/LandingPage.tsx +++ b/src/components/LandingPage.tsx @@ -1,4 +1,4 @@ -import { Gauge, Github, Heart, Shield, BookOpen, Play, Loader2, LogIn, LogOut } 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"; @@ -158,6 +158,10 @@ export function LandingPage({ Privacy Policy + + + Terms of Service + {enableAdmin && ( diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx index c1b2638..18107bc 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -2,73 +2,378 @@ import { Link } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { useDocumentHead } from "@/hooks/useDocumentHead"; -const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === 'true'; +const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === "true"; +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === "true"; + +// NOTE FOR THE OPERATOR: this policy adapts to the build flags. With cloud +// features off it describes the offline-only app; with VITE_ENABLE_CLOUD on it +// also covers accounts, payments and AI. Placeholders to confirm before relying +// on this for the hosted service: the operating entity's legal name, a contact +// email, and the specific AI provider used by the coaching plugin. This is a +// drafted policy, not legal advice — have it reviewed for your jurisdiction. const Privacy = () => { useDocumentHead({ title: "Privacy Policy — HackTheTrack", - description: "How HackTheTrack handles your data: 100% local-first telemetry storage in your browser, no cookies, no analytics, no tracking.", + description: + "How HackTheTrack handles your data: offline-first telemetry stored in your browser, with optional cloud sync and AI features when you create an account.", canonical: "https://hackthetrack.net/privacy", }); return ( -
- - - Back to app - - -

Privacy Policy

- -
-
-

Local-First Data Storage

-

- All of your telemetry data, session files, lap notes, kart profiles, setup sheets, - graph preferences, and video sync settings are stored entirely in your browser using - IndexedDB and localStorage. Nothing leaves your device. -

-
- -
-

No Cookies or Tracking

-

- This application does not use cookies, analytics scripts, or any third-party tracking. - There are no advertising networks, no telemetry beacons, and no fingerprinting. -

-
- - {enableAdmin && ( +
+ + + Back to app + + +

Privacy Policy

+ +
-

Track & Course Submissions

+

+ The short version +

- When you submit a track or course to the community database, your IP address is logged - solely for the purpose of spam prevention and rate limiting. This information is not - shared with any third party and is used only to enforce submission limits and block abuse. + HackTheTrack is offline-first. By default, everything you do — + importing telemetry, taking notes, building kart profiles and setup + sheets — stays in your browser and{" "} + never leaves your device. + {enableCloud + ? " Some features are optional and online: creating an account to back up and sync your data, paid storage plans, and AI coaching. Those features only send data off your device after you choose to use them, and this policy explains exactly what each one collects." + : " This build has no accounts, no cloud sync and no analytics."}

- )} - -
-

No Personal Information Required

-

- No account, email address, or personal information is required to use any core feature - of this application. It works fully offline once loaded. -

-
- -
-

Clearing Your Data

-

- Since all data is stored locally in your browser, you can remove it at any time by - clearing your site data through your browser's settings (Settings → Privacy → Clear - browsing data → Site data), or by using your browser's developer tools to delete - the IndexedDB database and localStorage entries for this site. -

-
-
-

Last updated: February 2026

-
+
+

+ Local-First Data Storage +

+

+ Your telemetry data, session files, lap notes, kart profiles, setup + sheets, graph preferences, and video sync settings are stored in your + browser using IndexedDB and localStorage. Using the core app requires{" "} + + no account and no personal information + + , and works fully offline once loaded. +

+
+ +
+

+ No Tracking or Advertising +

+

+ We do not use analytics scripts, advertising networks, telemetry + beacons, or fingerprinting, and we do not sell or rent your data to + anyone. We use only the storage strictly necessary to run the app + (see “Cookies & Local Storage” below). +

+
+ + {enableCloud && ( + <> +
+

+ Optional Accounts & Cloud Sync +

+

+ If you create an account, you can back up and sync your data + across devices. This is entirely optional and opt-in. When you + use it, the following is stored on our backend (Supabase) under + your account and protected so that only you can access it: +

+
    +
  • + Account details:{" "} + your email address, an encrypted password (or a Google sign-in + identifier if you use Google), and a display name (which you + choose, or which is randomly generated). +
  • +
  • + Garage data:{" "} + vehicles, setup sheets, setup templates, notes, graph + preferences and your custom tracks/courses. Notes and names are + free text — anything you type there is stored as written. +
  • +
  • + Session logs:{" "} + only the telemetry files you explicitly choose to sync. These + contain{" "} + + precise GPS location traces + {" "} + of where and when you drove. +
  • +
+

+ Our legal basis for this processing is performance of our + agreement with you — we cannot provide sync without storing your + data. You can delete cloud copies at any time (see “Your Rights”). +

+
+ +
+

+ Payments & Subscriptions +

+

+ Paid storage plans are processed by{" "} + Stripe. We do not + receive or store your full card number — Stripe handles card data + directly. We receive only what we need to manage your + subscription (e.g. plan, status, and a Stripe customer/ + subscription identifier). Stripe’s handling of your payment data + is governed by{" "} + + Stripe’s Privacy Policy + + . +

+
+ +
+

+ AI Coaching +

+

+ If you use the optional AI coaching feature, the telemetry needed + to generate feedback (such as GPS traces and lap data, and any + driver name attached to the session) is sent to a{" "} + + third-party AI provider + {" "} + to be processed. We send this only when you choose to run the + coach. AI output is generated automatically and may be inaccurate + — it is informational only and must not be relied on for safety + decisions (see our{" "} + + Terms of Service + + ). +

+
+ + )} + + {(enableCloud || enableAdmin) && ( +
+

+ Security & Abuse Prevention (IP logging) +

+

+ To prevent spam and abuse, we briefly log the{" "} + IP address associated + with certain actions — {enableCloud ? "sign-in attempts, " : ""} + contact-form messages + {enableAdmin ? ", and community track/course submissions" : ""}. + This is used solely for rate-limiting and blocking abuse (our + legitimate interest in keeping the service available) and is not + used to track you or shared with advertisers. +

+
+ )} + +
+

+ Contact Form +

+

+ If you send us a message through the in-app contact form, we receive + your message, the category you select, and — only if you provide it — + your email address so we can reply. Providing an email is optional. +

+
+ + {enableCloud && ( +
+

+ Third-Party Services (Sub-processors) +

+

+ When you use the optional online features, the following providers + process data on our behalf: +

+
    +
  • + Supabase — account, + database and file storage for cloud sync. +
  • +
  • + Stripe — subscription + payments. +
  • +
  • + Google — only if you + choose “Sign in with Google”. +
  • +
  • + Cloudflare Turnstile{" "} + — bot/abuse protection on sign-up. +
  • +
  • + + The AI coaching provider + {" "} + — only if you use AI coaching. +
  • +
+

+ Map tiles (CartoDB, Esri) and weather (OpenWeatherMap) are loaded + from third parties when you view a map or fetch weather; like any + web request, these receive your IP address and the data needed to + serve the request. +

+
+ )} + +
+

+ Cookies & Local Storage +

+

+ We do not use advertising or tracking cookies. + {enableCloud + ? " If you sign in, we store a session/authentication token in your browser so you stay logged in — this is strictly necessary for the account feature to work." + : ""}{" "} + All other storage (IndexedDB and localStorage) holds your own app data + on your device. +

+
+ + {enableCloud && ( +
+

+ Your Rights +

+

+ If you have an account, you have rights over your data, including + under the EU/UK GDPR and similar laws (e.g. California’s CCPA/CPRA): +

+
    +
  • + Access & portability:{" "} + your synced data is your own telemetry — you can pull and download + it from within the app at any time. +
  • +
  • + Rectification: edit + your display name, notes and other data directly in the app. +
  • +
  • + Erasure: delete cloud + copies of your files and garage data from within the app, or + request full deletion of your account and all associated data via + the contact form. +
  • +
  • + Objection / restriction:{" "} + you can stop using online features at any time and continue using + the app fully offline. +
  • +
+

+ To exercise any right we can’t fully self-serve in the app, contact + us through the in-app contact form. You also have the right to + complain to your local data-protection authority. +

+
+ )} + + {enableCloud && ( + <> +
+

+ Data Retention +

+

+ We keep your account and synced data for as long as your account + exists. When you delete data or your account, we remove it from + active storage. Abuse-prevention records (such as IP-based + rate-limit data) are kept only as long as needed for that purpose + and then cleared. +

+
+ +
+

+ International Transfers +

+

+ Our providers may process data in countries outside your own, + including the United States. Where required, transfers are covered + by appropriate safeguards (such as the providers’ Standard + Contractual Clauses). +

+
+ + )} + + {enableCloud && ( +
+

+ Children +

+

+ Online accounts are intended for users{" "} + 16 or older. If you are + under 16, please use the app in its offline mode only and do not + create an account. If you believe a child under 16 has created an + account, contact us and we will remove it. +

+
+ )} + +
+

+ Self-Hosting +

+

+ HackTheTrack is open source. If someone else runs their own instance, + they — not us — control any data collected by that instance, and this + policy describes only the official hosted service. +

+
+ +
+

+ Clearing Your Data +

+

+ Because local data lives in your browser, you can remove it any time + by clearing this site’s data (Settings → Privacy → Clear browsing data + → Site data) or by deleting the IndexedDB database and localStorage + entries via your browser’s developer tools. + {enableCloud + ? " Cloud data is removed separately using the in-app delete controls or by requesting account deletion." + : ""} +

+
+ +
+

+ Changes & Contact +

+

+ We may update this policy as the app evolves; material changes will be + reflected by the “Last updated” date below. Questions or requests can + be sent through the in-app contact form. +

+
+
+ +

+ Last updated: May 2026 +

+
); }; diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 7ca911e..cf1025b 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -109,6 +109,13 @@ export default function Register() { +

+ You must be 16 or older to create an account. By continuing you + agree to our{' '} + Terms of Service{' '} + and{' '} + Privacy Policy. +

diff --git a/src/pages/Terms.tsx b/src/pages/Terms.tsx new file mode 100644 index 0000000..78600d0 --- /dev/null +++ b/src/pages/Terms.tsx @@ -0,0 +1,249 @@ +import { Link } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; +import { useDocumentHead } from "@/hooks/useDocumentHead"; + +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === "true"; + +// NOTE FOR THE OPERATOR: drafted Terms, not legal advice. Before relying on +// these for the hosted service, confirm: the operating entity's legal name, a +// contact email, and the governing-law jurisdiction (left as a placeholder +// below). Have them reviewed by a lawyer for your jurisdiction. + +const Terms = () => { + useDocumentHead({ + title: "Terms of Service — HackTheTrack", + description: + "The terms for using HackTheTrack: offline-first telemetry app with optional cloud sync, paid storage plans, and AI coaching.", + canonical: "https://hackthetrack.net/terms", + }); + return ( +

+ + + Back to app + + +

Terms of Service

+ +
+
+

+ 1. Acceptance +

+

+ By using HackTheTrack (“the Service”), you agree to these Terms and to + our{" "} + + Privacy Policy + + . If you do not agree, please do not use the Service. +

+
+ +
+

+ 2. The Service +

+

+ HackTheTrack is an offline-first motorsport telemetry viewer. The core + app runs entirely in your browser and stores your data on your device. + {enableCloud + ? " Optional online features — creating an account, cloud sync, paid storage plans, and AI coaching — are available but are not required to use the core app." + : ""} +

+
+ +
+

+ 3. Eligibility +

+

+ The offline app is available to anyone.{" "} + {enableCloud + ? "You must be at least 16 years old to create an account or use any online feature. If you are under 16, you may use the app in offline mode only and must not create an account." + : "There is no account in this build."} +

+
+ + {enableCloud && ( +
+

+ 4. Accounts & Security +

+

+ You are responsible for keeping your account credentials secure and + for activity under your account. Provide accurate information when + registering, and let us know if you suspect unauthorized access. +

+
+ )} + +
+

+ 5. Acceptable Use +

+

+ Don’t misuse the Service: no unlawful activity, no attempts to break, + overload, or gain unauthorized access to the Service or other users’ + data, no uploading of content you don’t have the right to use, and no + using the Service to store or transmit malicious or infringing + material. +

+
+ + {enableCloud && ( +
+

+ 6. Subscriptions & Billing +

+

+ Paid storage plans are billed through Stripe on a recurring basis + (e.g. monthly) until cancelled. By subscribing, you authorize the + recurring charge for your chosen plan. +

+
    +
  • + You can cancel at any time from the billing portal; cancellation + stops future renewals and your plan remains active until the end + of the current paid period. +
  • +
  • + Prices and plan limits are shown in-app and may change with notice; + changes apply from your next billing period. +
  • +
  • + Where required by law (for example, EU/UK consumer withdrawal + rights), you may be entitled to a refund — contact us and we’ll + honor your statutory rights. +
  • +
  • + If a payment fails or a subscription lapses, online storage limits + revert to the free tier; your data on your device is unaffected. +
  • +
+
+ )} + +
+

+ 7. Your Content & Data +

+

+ Your telemetry, notes, and other content remain yours. We don’t claim + ownership of it. + {enableCloud + ? " If you use cloud sync, you grant us the limited permission needed to store, transmit, and process your content solely to provide the Service to you (for example, to sync it across your devices or generate AI coaching when you request it)." + : ""} +

+
+ + {enableCloud && ( +
+

+ 8. AI Features — No Professional or Safety Advice +

+

+ AI coaching is generated automatically and may be incomplete or + wrong. It is provided for informational and entertainment purposes + only. It is{" "} + not professional + coaching, engineering, or safety advice, and you must not rely on it + for decisions affecting safety on or off the track. You are solely + responsible for how you drive. Using the AI feature sends the + necessary session data to a third-party AI provider for processing + (see the{" "} + + Privacy Policy + + ). +

+
+ )} + +
+

+ 9. Open Source & Self-Hosting +

+

+ HackTheTrack’s source code is open source and licensed separately + under its repository license; these Terms govern your use of the{" "} + official hosted Service, not the code itself. If you run your + own instance, you are responsible for it and for any data your + instance collects, and you must provide your own terms and privacy + notice to your users. +

+
+ +
+

+ 10. Disclaimer of Warranties +

+

+ The Service is provided{" "} + “as is” and “as available,”{" "} + without warranties of any kind, whether express or implied, including + fitness for a particular purpose, accuracy of telemetry or timing, and + uninterrupted availability. Telemetry, lap times, and derived data may + contain errors; do not rely on them for competition scoring or safety. +

+
+ +
+

+ 11. Limitation of Liability +

+

+ To the maximum extent permitted by law, HackTheTrack and its operators + are not liable for any indirect, incidental, or consequential damages, + or for loss of data, arising from your use of the Service. Nothing in + these Terms limits liability that cannot be limited by law. Keep your + own backups of important data. +

+
+ +
+

+ 12. Termination +

+

+ You may stop using the Service at any time + {enableCloud ? " and delete your account" : ""}. We may suspend or + terminate access that violates these Terms or harms the Service or its + users. +

+
+ +
+

+ 13. Changes to These Terms +

+

+ We may update these Terms as the Service evolves. Material changes will + be reflected by the “Last updated” date below; continuing to use the + Service after changes means you accept them. +

+
+ +
+

+ 14. Governing Law & Contact +

+

+ These Terms are governed by the laws of [JURISDICTION TO BE + CONFIRMED], without regard to conflict-of-laws rules. Questions about + these Terms can be sent through the in-app contact form. +

+
+
+ +

+ Last updated: May 2026 +

+
+ ); +}; + +export default Terms; From 9a6e662b7ad645db5a0d41284546cdace80e7a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 00:54:21 +0000 Subject: [PATCH 075/121] Add a Premium subscription tier ($3/mo, Pro storage, no AI) Premium slots between Plus and Pro: same 1 GB logs quota as Pro but no AI credits, at $3/mo. Added as a data-only seed migration on top of the existing subscription_tiers catalogue (Pro's sort_order bumped to keep ordering), plus a fifth pricing card. Pricing/quota numbers are provisional. https://claude.ai/code/session_01K4mWVsXnwhtEi92FVBVhB3 --- CHANGELOG.md | 5 +++-- CLAUDE.md | 4 +++- README.md | 4 ++-- src/components/PricingCards.tsx | 18 +++++++++++---- src/lib/billing.test.ts | 4 +++- .../20260527000000_premium_tier.sql | 22 +++++++++++++++++++ 6 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 supabase/migrations/20260527000000_premium_tier.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index b93f7e6..7d5a4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Paid subscription tiers**: Stripe-backed `Plus` ($1/mo, 500 MB logs) and - `Pro` ($10/mo, 1 GB logs) plans on top of the free 20 MB tier. Plan limits are +- **Paid subscription tiers**: Stripe-backed `Plus` ($1/mo, 500 MB logs), + `Premium` ($3/mo, 1 GB logs), and `Pro` ($10/mo, 1 GB logs + AI coaching) + plans on top of the free 20 MB tier. 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 diff --git a/CLAUDE.md b/CLAUDE.md index a75f0c5..f0104a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -504,7 +504,9 @@ escape hatch confined to that one module. ### Subscriptions / Stripe (`..._stripe_subscriptions.sql` + 3 edge functions) Paid tiers scale the cloud-sync **logs** quota (`free` 20 MB → `plus` $1 500 MB -→ `pro` $10 1 GB; docs stay 5 MB). Tiers are **data**, not code: +→ `premium` $3 1 GB → `pro` $10 1 GB; docs stay 5 MB). `premium` matches `pro`'s +storage but carries no AI credits. Tiers are **data**, not code (numbers are +provisional): | Object | Type | Notes | |--------|------|-------| diff --git a/README.md b/README.md index 120064f..9ceea18 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,8 @@ The app includes an optional admin system for managing a community track databas > **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). After creating the Plus/Pro -> Products + recurring Prices in Stripe, store each Price id in the matching +> edge-function secrets (not `VITE_` client vars). After creating the +> Plus/Premium/Pro Products + recurring Prices in Stripe, store each Price id in the matching > `subscription_tiers.stripe_price_id` row, and point a Stripe webhook (events: > `checkout.session.completed`, `customer.subscription.created/updated/deleted`) > at the `stripe-webhook` function URL. Use Stripe **test mode** first. Tier diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx index a5c3b62..aef6776 100644 --- a/src/components/PricingCards.tsx +++ b/src/components/PricingCards.tsx @@ -19,7 +19,7 @@ interface Tier { highlight?: boolean; comingSoon?: boolean; /** Maps the card to a subscription tier slug (the offline card has none). */ - slug?: "free" | "plus" | "pro"; + slug?: "free" | "plus" | "premium" | "pro"; } const TIERS: Tier[] = [ @@ -58,6 +58,16 @@ const TIERS: Tier[] = [ inherits: "Everything in Free online, plus", features: ["500 MB cloud log storage"], }, + { + name: "Premium", + blurb: "Max storage", + price: "$3", + cadence: "/mo", + comingSoon: true, + slug: "premium", + inherits: "Everything in Plus, plus", + features: ["1 GB cloud log storage"], + }, { name: "Pro", blurb: "With AI coaching", @@ -65,8 +75,8 @@ const TIERS: Tier[] = [ cadence: "/mo", comingSoon: true, slug: "pro", - inherits: "Everything in Plus, plus", - features: ["1 GB cloud log storage", "AI coaching (coming soon)"], + inherits: "Everything in Premium, plus", + features: ["AI coaching (coming soon)"], }, ]; @@ -152,7 +162,7 @@ export function PricingCards({ className }: { className?: string }) { Start free and fully offline. Add an account for cross-device sync — upgrade only if you need more.

-
+
{TIERS.map((tier) => { const kind = pricingCta({ slug: tier.slug, diff --git a/src/lib/billing.test.ts b/src/lib/billing.test.ts index 63c6d18..dbcad88 100644 --- a/src/lib/billing.test.ts +++ b/src/lib/billing.test.ts @@ -50,8 +50,10 @@ describe("pricingCta", () => { expect(pricingCta({ ...base, slug: "pro", currentTier: "pro" })).toBe("current"); }); - it("offers an upgrade for a purchasable paid tier above the current one", () => { + it("offers an upgrade for any purchasable paid tier (slug-agnostic)", () => { expect(pricingCta({ ...base, slug: "plus", currentTier: "free", purchasable: true })).toBe("upgrade"); + expect(pricingCta({ ...base, slug: "premium", currentTier: "free", purchasable: true })).toBe("upgrade"); + expect(pricingCta({ ...base, slug: "premium", currentTier: "premium", purchasable: true })).toBe("current"); }); it("keeps a paid tier non-actionable (Coming soon) when no Stripe Price is set", () => { diff --git a/supabase/migrations/20260527000000_premium_tier.sql b/supabase/migrations/20260527000000_premium_tier.sql new file mode 100644 index 0000000..40543ae --- /dev/null +++ b/supabase/migrations/20260527000000_premium_tier.sql @@ -0,0 +1,22 @@ +-- Add a "Premium" subscription tier. +-- +-- Premium has the same storage as Pro (the paid logs ceiling) but no AI credits, +-- at a lower price — it slots between Plus and Pro. Tiers are data, so this is a +-- pure seed change on top of the catalogue introduced in the stripe-subscriptions +-- migration; no schema, trigger, or function changes are needed. Pro's sort_order +-- is bumped so the catalogue stays ordered free → plus → premium → pro. +-- +-- NOTE: pricing + storage numbers here are provisional and expected to change. + +insert into public.subscription_tiers + (tier, label, price_cents, logs_bytes, doc_bytes, ai_credits, sort_order) values + ('premium', 'Premium', 300, 1073741824, 5242880, 0, 2) -- 1 GB logs / 5 MB docs, no AI +on conflict (tier) do update set + label = excluded.label, + price_cents = excluded.price_cents, + logs_bytes = excluded.logs_bytes, + doc_bytes = excluded.doc_bytes, + ai_credits = excluded.ai_credits, + sort_order = excluded.sort_order; + +update public.subscription_tiers set sort_order = 3 where tier = 'pro'; From 1edf93f74e12184601da65405ae722bb27430c37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 00:57:35 +0000 Subject: [PATCH 076/121] Move pricing cards above the registration form Pricing cards previously sat below the sign-up details on the registration page; lift them above the form so visitors see plan options before filling in the form. Branding header stays at the top. https://claude.ai/code/session_01LbrorWzLKPgH6wsnKYxXb5 --- CHANGELOG.md | 2 ++ src/pages/Register.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b93f7e6..c425222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rejects **disposable / temporary email** addresses. ### Changed +- **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. diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 7ca911e..22d2bc6 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -70,12 +70,14 @@ export default function Register() { return (
-
-
- -

HackTheTrack.net

-
+
+ +

HackTheTrack.net

+
+ + +

Create account

@@ -121,8 +123,6 @@ export default function Register() { Back to Home
- -
); } From fb583b37978d591ae7b8297489a2167118181f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 01:01:39 +0000 Subject: [PATCH 077/121] Update Privacy Policy with operating entity and cloud/AI disclosures Name PerchWerks LLC (Windermere, FL) as the operator, reconcile the "nothing leaves your device" copy with the now-optional accounts, cloud sync, and Stripe billing, add a generic third-party AI-processing clause, and add a Florida governing-law section. https://claude.ai/code/session_01LbrorWzLKPgH6wsnKYxXb5 --- CHANGELOG.md | 4 ++++ src/pages/Privacy.tsx | 54 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c425222..8d10222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Registration page** now shows the **Plans & pricing** cards above the sign-up form instead of below it. +- **Privacy Policy** updated to name the operating entity (**PerchWerks LLC**, + Windermere, Florida), reconcile the local-first claims with optional accounts / + cloud sync / Stripe billing, add a generic AI-processing clause, and add a + governing-law section (Florida, USA). - **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. diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx index c1b2638..e950d83 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -7,7 +7,7 @@ const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === 'true'; const Privacy = () => { useDocumentHead({ title: "Privacy Policy — HackTheTrack", - description: "How HackTheTrack handles your data: 100% local-first telemetry storage in your browser, no cookies, no analytics, no tracking.", + description: "How HackTheTrack handles your data: local-first telemetry storage in your browser with optional cloud sync, no cookies, no analytics, no tracking.", canonical: "https://hackthetrack.net/privacy", }); return ( @@ -20,12 +20,45 @@ const Privacy = () => {

Privacy Policy

+
+

Who Operates This Service

+

+ HackTheTrack is operated by PerchWerks LLC, + based in Windermere, Florida, United States. Where this policy refers to + “we,” “us,” or “our,” it means PerchWerks LLC. +

+
+

Local-First Data Storage

- All of your telemetry data, session files, lap notes, kart profiles, setup sheets, - graph preferences, and video sync settings are stored entirely in your browser using - IndexedDB and localStorage. Nothing leaves your device. + By default, all of your telemetry data, session files, lap notes, kart profiles, setup + sheets, graph preferences, and video sync settings are stored entirely in your browser + using IndexedDB and localStorage. Unless you sign in + and opt into cloud sync, nothing leaves your device. +

+
+ +
+

Optional Accounts & Cloud Sync

+

+ If you choose to create an account and enable cloud sync, the data you sync (such as + session logs, your garage, setups, and notes) is stored on our cloud infrastructure so it + is available across your devices. This is entirely optional — the core app works fully + offline without an account. We use your email address for authentication and account-related + communication only. Paid subscriptions are processed by our payment provider (Stripe); we do + not store your full payment card details. +

+
+ +
+

AI-Powered Features

+

+ Some optional features may use AI processing. When you use such a feature, the relevant + data (for example, telemetry from the session you are analyzing) may be sent to a + third-party AI provider to generate the result. These features are opt-in and are not + used unless you actively invoke them. The specific AI provider may change over time; this + policy will be updated to reflect the provider in use.

@@ -52,7 +85,8 @@ const Privacy = () => {

No Personal Information Required

No account, email address, or personal information is required to use any core feature - of this application. It works fully offline once loaded. + of this application. It works fully offline once loaded. An account is only needed if you + choose to use the optional cloud features described above.

@@ -65,9 +99,17 @@ const Privacy = () => { the IndexedDB database and localStorage entries for this site.

+ +
+

Governing Law

+

+ This Privacy Policy and any dispute arising from it are governed by the laws of the State + of Florida, United States, without regard to its conflict-of-law provisions. +

+
-

Last updated: February 2026

+

Last updated: May 2026

); }; From 2f4e2842990cbab1ca3cf84df2530df24c90c3a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 01:03:34 +0000 Subject: [PATCH 078/121] Revert "Update Privacy Policy with operating entity and cloud/AI disclosures" This reverts commit fb583b37978d591ae7b8297489a2167118181f96. --- CHANGELOG.md | 4 ---- src/pages/Privacy.tsx | 54 +++++-------------------------------------- 2 files changed, 6 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d10222..c425222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,10 +94,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Registration page** now shows the **Plans & pricing** cards above the sign-up form instead of below it. -- **Privacy Policy** updated to name the operating entity (**PerchWerks LLC**, - Windermere, Florida), reconcile the local-first claims with optional accounts / - cloud sync / Stripe billing, add a generic AI-processing clause, and add a - governing-law section (Florida, USA). - **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. diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx index e950d83..c1b2638 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -7,7 +7,7 @@ const enableAdmin = import.meta.env.VITE_ENABLE_ADMIN === 'true'; const Privacy = () => { useDocumentHead({ title: "Privacy Policy — HackTheTrack", - description: "How HackTheTrack handles your data: local-first telemetry storage in your browser with optional cloud sync, no cookies, no analytics, no tracking.", + description: "How HackTheTrack handles your data: 100% local-first telemetry storage in your browser, no cookies, no analytics, no tracking.", canonical: "https://hackthetrack.net/privacy", }); return ( @@ -20,45 +20,12 @@ const Privacy = () => {

Privacy Policy

-
-

Who Operates This Service

-

- HackTheTrack is operated by PerchWerks LLC, - based in Windermere, Florida, United States. Where this policy refers to - “we,” “us,” or “our,” it means PerchWerks LLC. -

-
-

Local-First Data Storage

- By default, all of your telemetry data, session files, lap notes, kart profiles, setup - sheets, graph preferences, and video sync settings are stored entirely in your browser - using IndexedDB and localStorage. Unless you sign in - and opt into cloud sync, nothing leaves your device. -

-
- -
-

Optional Accounts & Cloud Sync

-

- If you choose to create an account and enable cloud sync, the data you sync (such as - session logs, your garage, setups, and notes) is stored on our cloud infrastructure so it - is available across your devices. This is entirely optional — the core app works fully - offline without an account. We use your email address for authentication and account-related - communication only. Paid subscriptions are processed by our payment provider (Stripe); we do - not store your full payment card details. -

-
- -
-

AI-Powered Features

-

- Some optional features may use AI processing. When you use such a feature, the relevant - data (for example, telemetry from the session you are analyzing) may be sent to a - third-party AI provider to generate the result. These features are opt-in and are not - used unless you actively invoke them. The specific AI provider may change over time; this - policy will be updated to reflect the provider in use. + All of your telemetry data, session files, lap notes, kart profiles, setup sheets, + graph preferences, and video sync settings are stored entirely in your browser using + IndexedDB and localStorage. Nothing leaves your device.

@@ -85,8 +52,7 @@ const Privacy = () => {

No Personal Information Required

No account, email address, or personal information is required to use any core feature - of this application. It works fully offline once loaded. An account is only needed if you - choose to use the optional cloud features described above. + of this application. It works fully offline once loaded.

@@ -99,17 +65,9 @@ const Privacy = () => { the IndexedDB database and localStorage entries for this site.

- -
-

Governing Law

-

- This Privacy Policy and any dispute arising from it are governed by the laws of the State - of Florida, United States, without regard to its conflict-of-law provisions. -

-
-

Last updated: May 2026

+

Last updated: February 2026

); }; From 21f39dabb52485cde595ff743fe757c4983e1a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 01:50:39 +0000 Subject: [PATCH 079/121] Add GDPR self-service data export, account deletion, and IP retention Bring the app in line with the privacy policy's promises of data portability, erasure, and IP-retention limits. - IP TTL: daily pg_cron purge nulls submitter IPs on submissions/messages after 90 days and clears expired bans + stale rate-limit rows. - Data export: "Download my data" zips all server-side account data plus local browser data (new export-account-data edge fn + pure manifest). - Account deletion: self-service, email-OTP gated, scheduled 7 days out and cancellable, then hard-deleted (Storage objects + auth row) by the cron-driven process-account-deletions worker. The grace window guards a hijacked session from wiping a user's race history. - Admin can set a TTL when banning an IP (defaults to 90 days). - Docs: Privacy policy, README, CHANGELOG, CLAUDE.md updated. https://claude.ai/code/session_013mqN9aCP2Leghar6oPdM8t --- CHANGELOG.md | 20 ++ CLAUDE.md | 39 ++++ README.md | 17 +- src/components/admin/BannedIpsTab.tsx | 25 +- src/pages/Privacy.tsx | 31 ++- src/plugins/cloud-sync/DataPrivacyPanel.tsx | 217 ++++++++++++++++++ src/plugins/cloud-sync/accountDeletion.ts | 60 +++++ src/plugins/cloud-sync/accountExport.ts | 95 ++++++++ src/plugins/cloud-sync/exportManifest.test.ts | 64 ++++++ src/plugins/cloud-sync/exportManifest.ts | 85 +++++++ src/plugins/cloud-sync/index.ts | 15 +- supabase/config.toml | 9 + .../functions/export-account-data/index.ts | 89 +++++++ .../process-account-deletions/index.ts | 75 ++++++ .../request-account-deletion/index.ts | 74 ++++++ .../20260527010000_gdpr_compliance.sql | 141 ++++++++++++ 16 files changed, 1043 insertions(+), 13 deletions(-) create mode 100644 src/plugins/cloud-sync/DataPrivacyPanel.tsx create mode 100644 src/plugins/cloud-sync/accountDeletion.ts create mode 100644 src/plugins/cloud-sync/accountExport.ts create mode 100644 src/plugins/cloud-sync/exportManifest.test.ts create mode 100644 src/plugins/cloud-sync/exportManifest.ts create mode 100644 supabase/functions/export-account-data/index.ts create mode 100644 supabase/functions/process-account-deletions/index.ts create mode 100644 supabase/functions/request-account-deletion/index.ts create mode 100644 supabase/migrations/20260527010000_gdpr_compliance.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6b52c..354e16b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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 IP-address retention (TTL):** a daily job nulls the submitter IP on + contact messages and community submissions **90 days** after they're received, + and clears expired IP bans and stale sign-in rate-limit records — so + abuse-prevention data is minimised even without traffic to trigger the existing + reactive cleanup. +- **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. - **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 diff --git a/CLAUDE.md b/CLAUDE.md index 4bce56e..81f0b7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -544,6 +544,45 @@ stays "Coming soon" — graceful pre-config state); cloud-sync's Profile-tab subscribed. **Stripe setup (create Products/Prices, set `stripe_price_id`, secrets, webhook) is still 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) | Nulls `submitted_by_ip` on `submissions`/`messages` older than **90 days**; 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. + --- ## Course Layouts (Drawing Feature) diff --git a/README.md b/README.md index 9ceea18..5914cea 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ The app includes an optional admin system for managing a community track databas | `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. @@ -146,6 +147,17 @@ The admin system uses Lovable Cloud (Supabase) for the database. The schema is c - **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 +- **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 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 @@ -168,7 +180,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 @@ -177,6 +189,9 @@ 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 | +| `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/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/pages/Privacy.tsx b/src/pages/Privacy.tsx index 18107bc..6a252fd 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -261,8 +261,10 @@ const Privacy = () => {
  • Access & portability:{" "} - your synced data is your own telemetry — you can pull and download - it from within the app at any time. + download a complete copy of everything we hold — your account data + plus the data stored in this browser — as a ZIP from{" "} + Profile → Data & privacy + . Synced files can also be pulled back to any device at any time.
  • Rectification: edit @@ -270,9 +272,15 @@ const Privacy = () => {
  • Erasure: delete cloud - copies of your files and garage data from within the app, or - request full deletion of your account and all associated data via - the contact form. + copies of individual files and garage data from within the app, or + delete your entire account{" "} + and all associated data yourself from{" "} + Profile → Data & privacy + . For your protection (e.g. against a hijacked session), account + deletion is confirmed by an emailed code and then scheduled{" "} + 7 days out — you can + cancel any time before then, after which all your data is + permanently erased.
  • Objection / restriction:{" "} @@ -297,9 +305,14 @@ const Privacy = () => {

    We keep your account and synced data for as long as your account exists. When you delete data or your account, we remove it from - active storage. Abuse-prevention records (such as IP-based - rate-limit data) are kept only as long as needed for that purpose - and then cleared. + active storage; a deleted account and all its data are permanently + erased 7 days after you request deletion (the cancellable grace + window described under “Your Rights”). Abuse-prevention IP + addresses are minimised automatically: the IP attached to a + contact-form message or community submission is erased{" "} + 90 days after it was + received, and expired IP bans and sign-in rate-limit records are + cleared daily.

    @@ -353,7 +366,7 @@ const Privacy = () => { → Site data) or by deleting the IndexedDB database and localStorage entries via your browser’s developer tools. {enableCloud - ? " Cloud data is removed separately using the in-app delete controls or by requesting account deletion." + ? " Cloud data is removed separately using the in-app delete controls, or all at once by deleting your account from Profile → Data & privacy." : ""}

    diff --git a/src/plugins/cloud-sync/DataPrivacyPanel.tsx b/src/plugins/cloud-sync/DataPrivacyPanel.tsx new file mode 100644 index 0000000..d16aa43 --- /dev/null +++ b/src/plugins/cloud-sync/DataPrivacyPanel.tsx @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useState } from "react"; +import { AlertTriangle, Download, Loader2, ShieldX, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { PluginPanelProps } from "@/plugins/panels"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import { downloadAccountExport } from "./accountExport"; +import { + cancelAccountDeletion, + getPendingDeletion, + scheduleAccountDeletion, + sendDeletionCode, + verifyDeletionCode, + type PendingDeletion, +} from "./accountDeletion"; + +type DeleteStep = "idle" | "code" | "working"; + +// Profile-tab panel for GDPR self-service: export everything as a ZIP, and a +// scheduled (7-day, reversible) account deletion gated by an emailed code. +export default function DataPrivacyPanel(_props: PluginPanelProps) { + const { user } = useAuth(); + const online = useOnlineStatus(); + const email = user?.email ?? ""; + + const [exporting, setExporting] = useState(false); + const [exportPhase, setExportPhase] = useState(""); + const [pending, setPending] = useState(null); + + const refreshPending = useCallback(async () => { + if (!user) return setPending(null); + try { + setPending(await getPendingDeletion(user.id)); + } catch { + /* non-fatal: leave as-is */ + } + }, [user]); + + useEffect(() => { + void refreshPending(); + }, [refreshPending]); + + const runExport = async () => { + setExporting(true); + try { + await downloadAccountExport((p) => setExportPhase(p.phase)); + toast.success("Your data export has been downloaded."); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Export failed."); + } finally { + setExporting(false); + setExportPhase(""); + } + }; + + return ( +
    +
    +

    Your data

    +

    + Download a copy of everything we hold about you — {user ? "your account data plus " : ""} + the data stored in this browser — as a ZIP. This is your right to access and portability. +

    + +
    + + {user && ( +
    +

    Delete account

    + {pending ? ( + + ) : ( + + )} +
    + )} +
    + ); +} + +function PendingNotice({ + pending, + userId, + online, + onChange, +}: { + pending: PendingDeletion; + userId: string; + online: boolean; + onChange: () => Promise; +}) { + const [busy, setBusy] = useState(false); + const when = new Date(pending.scheduled_for).toLocaleString(); + + const cancel = async () => { + setBusy(true); + try { + await cancelAccountDeletion(userId); + toast.success("Account deletion cancelled."); + await onChange(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Couldn't cancel deletion."); + } finally { + setBusy(false); + } + }; + + return ( +
    +
    + + + Your account and all its data are scheduled for permanent deletion on{" "} + {when}. You can cancel any time before then. + +
    + +
    + ); +} + +function DeleteFlow({ + email, + online, + onScheduled, +}: { + email: string; + online: boolean; + onScheduled: () => Promise; +}) { + const [step, setStep] = useState("idle"); + const [code, setCode] = useState(""); + + const startCode = async () => { + setStep("working"); + try { + await sendDeletionCode(email); + toast.success(`We emailed a confirmation code to ${email}.`); + setStep("code"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Couldn't send the code."); + setStep("idle"); + } + }; + + const confirm = async () => { + setStep("working"); + try { + await verifyDeletionCode(email, code); + const result = await scheduleAccountDeletion(); + toast.success(`Deletion scheduled for ${new Date(result.scheduled_for).toLocaleDateString()}.`); + setCode(""); + setStep("idle"); + await onScheduled(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "That code didn't work — try again."); + setStep("code"); + } + }; + + return ( +
    +

    + Deletes your account and everything stored under it (profile, synced files, garage data, + subscription record). To protect against a hijacked session, we email you a code first and + then wait 7 days before erasing anything — you can cancel during that time. + Data stored only in this browser is not removed by this; clear it from your browser settings. +

    + + {step === "idle" && ( + + )} + + {step === "working" && ( + + )} + + {step === "code" && ( +
    + setCode(e.target.value)} + placeholder="6-digit code" + inputMode="numeric" + autoComplete="one-time-code" + maxLength={10} + className="h-9 w-40" + /> +
    + + +
    +
    + )} + + {!online && ( +

    You're offline — account deletion needs a connection.

    + )} +
    + ); +} diff --git a/src/plugins/cloud-sync/accountDeletion.ts b/src/plugins/cloud-sync/accountDeletion.ts new file mode 100644 index 0000000..078d473 --- /dev/null +++ b/src/plugins/cloud-sync/accountDeletion.ts @@ -0,0 +1,60 @@ +// Client side of self-service account deletion. +// +// Flow: the user proves control of the account email via a one-time code +// (Supabase Auth email OTP — no extra mail provider needed), then we ask the +// request-account-deletion edge function to schedule deletion 7 days out. The +// window is reversible: cancel deletes the pending row (RLS allows the owner). +// Until then the app shows a deletion banner. The irreversible purge is done +// server-side by the process-account-deletions worker once the window elapses. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { supabase } from "@/integrations/supabase/client"; + +// account_deletions isn't in the generated Database type yet (regenerated after +// the migration deploys), so route it through an untyped view — same pattern as +// cloudClient.ts / billingClient.ts. +const untyped = supabase as unknown as SupabaseClient; + +export interface PendingDeletion { + requested_at: string; + scheduled_for: string; +} + +/** Email a one-time code to the signed-in user's address (re-verification). */ +export async function sendDeletionCode(email: string): Promise { + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { shouldCreateUser: false }, + }); + if (error) throw new Error(error.message); +} + +/** Verify the emailed code. Resolves on success; throws on a bad/expired code. */ +export async function verifyDeletionCode(email: string, token: string): Promise { + const { error } = await supabase.auth.verifyOtp({ email, token: token.trim(), type: "email" }); + if (error) throw new Error(error.message); +} + +/** Schedule deletion (idempotent server-side). Returns the scheduled date. */ +export async function scheduleAccountDeletion(): Promise { + const { data, error } = await supabase.functions.invoke("request-account-deletion"); + if (error) throw new Error(error.message); + return data as PendingDeletion; +} + +/** The caller's pending deletion request, or null if none. */ +export async function getPendingDeletion(userId: string): Promise { + const { data, error } = await untyped + .from("account_deletions") + .select("requested_at, scheduled_for") + .eq("user_id", userId) + .maybeSingle(); + if (error) throw new Error(error.message); + return (data ?? null) as PendingDeletion | null; +} + +/** Cancel a pending deletion (owner-only via RLS). */ +export async function cancelAccountDeletion(userId: string): Promise { + const { error } = await untyped.from("account_deletions").delete().eq("user_id", userId); + if (error) throw new Error(error.message); +} diff --git a/src/plugins/cloud-sync/accountExport.ts b/src/plugins/cloud-sync/accountExport.ts new file mode 100644 index 0000000..c0dccdb --- /dev/null +++ b/src/plugins/cloud-sync/accountExport.ts @@ -0,0 +1,95 @@ +// Orchestrates the "Download my data" export: pulls the server-side account +// document (when signed in), gathers all local browser data, downloads the +// cloud + local file blobs, and zips the lot for the user. The pure manifest +// assembly lives in exportManifest.ts; this layer does the I/O. + +import JSZip from "jszip"; +import { supabase } from "@/integrations/supabase/client"; +import { getFile, listFiles } from "@/lib/fileStorage"; +import { getAccessor } from "./storeAccessors"; +import { downloadCloudFile } from "./syncEngine"; +import { DOC_STORES } from "./syncStores"; +import { buildExportTextFiles, type CloudExport, type LocalExport } from "./exportManifest"; + +const SETTINGS_KEY = "dove-dataviewer-settings"; + +/** Fetch the server-side account export. Returns null when signed out. */ +async function fetchCloudExport(): Promise { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) return null; + const { data, error } = await supabase.functions.invoke("export-account-data"); + if (error) throw new Error(error.message); + return data as CloudExport; +} + +/** Read all local browser data the export should include. */ +async function gatherLocal(): Promise { + let settings: unknown = null; + try { + const raw = localStorage.getItem(SETTINGS_KEY); + settings = raw ? JSON.parse(raw) : null; + } catch { + settings = null; + } + + const stores: Record = {}; + for (const store of DOC_STORES) { + try { + stores[store] = await getAccessor(store).readAll(); + } catch { + stores[store] = []; + } + } + + const fileNames = (await listFiles()).map((f) => f.name); + return { settings, stores, fileNames }; +} + +export interface ExportProgress { + /** Human-readable phase, surfaced in the UI. */ + phase: string; +} + +/** + * Build the export ZIP and trigger a browser download. `onProgress` is optional + * and reports coarse phases ("Gathering…", "Downloading files…", "Zipping…"). + */ +export async function downloadAccountExport(onProgress?: (p: ExportProgress) => void): Promise { + onProgress?.({ phase: "Gathering your data…" }); + const [cloud, local] = await Promise.all([fetchCloudExport(), gatherLocal()]); + + const zip = new JSZip(); + for (const [path, content] of Object.entries(buildExportTextFiles(cloud, local))) { + zip.file(path, content); + } + + // Local session-file blobs. + onProgress?.({ phase: "Adding local files…" }); + for (const name of local.fileNames) { + const blob = await getFile(name); + if (blob) zip.file(`local/files/${name}`, blob); + } + + // Cloud session-file blobs (downloaded with the user's own session). + const cloudFiles = cloud?.cloud_files ?? []; + if (cloudFiles.length) { + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + onProgress?.({ phase: `Downloading ${cloudFiles.length} cloud file${cloudFiles.length === 1 ? "" : "s"}…` }); + for (const f of cloudFiles) { + const blob = await downloadCloudFile(user.id, f.name); + if (blob) zip.file(`cloud/files/${f.name}`, blob); + } + } + } + + onProgress?.({ phase: "Zipping…" }); + const out = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(out); + const a = document.createElement("a"); + a.href = url; + const date = new Date().toISOString().slice(0, 10); + a.download = `hackthetrack-data-export-${date}.zip`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/plugins/cloud-sync/exportManifest.test.ts b/src/plugins/cloud-sync/exportManifest.test.ts new file mode 100644 index 0000000..299b98d --- /dev/null +++ b/src/plugins/cloud-sync/exportManifest.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { buildExportTextFiles, buildReadme, type CloudExport, type LocalExport } from "./exportManifest"; + +const localFixture: LocalExport = { + settings: { useKph: true }, + stores: { + karts: [{ id: "k1", name: "Kart" }], + notes: [], + }, + fileNames: ["session1.csv", "session2.ubx"], +}; + +describe("buildExportTextFiles", () => { + it("includes local settings + one file per store, plus a README", () => { + const files = buildExportTextFiles(null, localFixture); + expect(Object.keys(files)).toContain("local/settings.json"); + expect(Object.keys(files)).toContain("local/stores/karts.json"); + expect(Object.keys(files)).toContain("local/stores/notes.json"); + expect(Object.keys(files)).toContain("README.txt"); + expect(JSON.parse(files["local/stores/karts.json"])).toEqual([{ id: "k1", name: "Kart" }]); + }); + + it("omits all cloud/* entries when signed out (no cloud export)", () => { + const files = buildExportTextFiles(null, localFixture); + expect(Object.keys(files).some((p) => p.startsWith("cloud/"))).toBe(false); + }); + + it("includes cloud entries when a cloud export is present", () => { + const cloud: CloudExport = { + account: { user_id: "u1", email: "a@b.com" }, + profile: { display_name: "Speedy" }, + subscription: { tier: "pro" }, + roles: ["user"], + garage_records: [{ store: "notes", record_key: "n1", data: {} }], + contact_messages: [{ category: "Bug Report", message: "hi" }], + cloud_files: [{ name: "lap.csv" }], + }; + const files = buildExportTextFiles(cloud, localFixture); + expect(JSON.parse(files["cloud/profile.json"])).toEqual({ display_name: "Speedy" }); + expect(JSON.parse(files["cloud/roles.json"])).toEqual(["user"]); + expect(JSON.parse(files["cloud/cloud-files-index.json"])).toEqual([{ name: "lap.csv" }]); + // No pending deletion → that file is absent. + expect(Object.keys(files)).not.toContain("cloud/pending-deletion.json"); + }); + + it("adds a pending-deletion file only when one exists", () => { + const cloud: CloudExport = { pending_deletion: { scheduled_for: "2026-06-01T00:00:00Z" } }; + const files = buildExportTextFiles(cloud, localFixture); + expect(Object.keys(files)).toContain("cloud/pending-deletion.json"); + }); +}); + +describe("buildReadme", () => { + it("reports cloud + local file counts", () => { + const readme = buildReadme({ cloud_files: [{ name: "a" }, { name: "b" }] }, localFixture); + expect(readme).toContain("Cloud session files: 2"); + expect(readme).toContain("Local session files: 2"); + }); + + it("treats a null cloud export as zero cloud files", () => { + const readme = buildReadme(null, localFixture); + expect(readme).toContain("Cloud session files: 0"); + }); +}); diff --git a/src/plugins/cloud-sync/exportManifest.ts b/src/plugins/cloud-sync/exportManifest.ts new file mode 100644 index 0000000..fa88e4b --- /dev/null +++ b/src/plugins/cloud-sync/exportManifest.ts @@ -0,0 +1,85 @@ +// Pure assembly of a GDPR data-export bundle's *text* entries (JSON + README). +// File blobs (cloud + local) are binary and get added by the orchestrator +// (accountExport.ts); keeping this layer pure makes the manifest unit-testable +// without a browser, IndexedDB, or the Supabase client. + +/** Server-side export document returned by the export-account-data function. */ +export interface CloudExport { + export_version?: number; + exported_at?: string; + account?: unknown; + profile?: unknown; + subscription?: unknown; + roles?: unknown; + pending_deletion?: unknown; + cloud_files?: Array<{ name: string }>; + garage_records?: unknown; + contact_messages?: unknown; +} + +/** Local browser data gathered by the orchestrator. */ +export interface LocalExport { + settings: unknown; + /** Document stores (IndexedDB + the localStorage tracks), keyed by store name. */ + stores: Record; + /** Names of local session-file blobs (added as binaries separately). */ + fileNames: string[]; +} + +const pretty = (v: unknown): string => JSON.stringify(v ?? null, null, 2); + +/** + * The text (JSON) entries of the export zip, keyed by their path inside the + * archive. Binary blobs are added separately by the caller. + */ +export function buildExportTextFiles(cloud: CloudExport | null, local: LocalExport): Record { + const files: Record = {}; + + if (cloud) { + files['cloud/account.json'] = pretty(cloud.account); + files['cloud/profile.json'] = pretty(cloud.profile); + files['cloud/subscription.json'] = pretty(cloud.subscription); + files['cloud/roles.json'] = pretty(cloud.roles ?? []); + files['cloud/garage-records.json'] = pretty(cloud.garage_records ?? []); + files['cloud/contact-messages.json'] = pretty(cloud.contact_messages ?? []); + files['cloud/cloud-files-index.json'] = pretty(cloud.cloud_files ?? []); + if (cloud.pending_deletion) { + files['cloud/pending-deletion.json'] = pretty(cloud.pending_deletion); + } + } + + files['local/settings.json'] = pretty(local.settings); + for (const [store, rows] of Object.entries(local.stores)) { + files[`local/stores/${store}.json`] = pretty(rows); + } + + files['README.txt'] = buildReadme(cloud, local); + return files; +} + +export function buildReadme(cloud: CloudExport | null, local: LocalExport): string { + const localFileCount = local.fileNames.length; + const cloudFileCount = cloud?.cloud_files?.length ?? 0; + const lines = [ + 'HackTheTrack / Dove\'s DataViewer — your data export', + `Generated: ${new Date().toISOString()}`, + '', + 'This archive contains everything we hold about you, for your records and for', + 'portability (GDPR Article 20). It is yours to keep.', + '', + 'cloud/ — data stored on our backend under your account (only present if you', + ' are signed in): your profile, subscription, roles, synced garage', + ' records, contact messages you sent, and any pending account-deletion', + ' request. cloud/files/ holds the raw session logs you chose to sync.', + 'local/ — data stored only in this browser on this device: app settings, the', + ' garage stores (vehicles, setups, notes, custom tracks, …), and', + ' local/files/ session logs that live only on this device.', + '', + `Cloud session files: ${cloudFileCount}`, + `Local session files: ${localFileCount}`, + '', + 'JSON files are UTF-8 and can be opened in any text editor. Session logs keep', + 'their original file names and formats (CSV, UBX, etc.).', + ]; + return lines.join('\n'); +} diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index 92e68d4..a04f972 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -1,5 +1,5 @@ import { lazy } from "react"; -import { Cloud, User } from "lucide-react"; +import { Cloud, ShieldCheck, User } from "lucide-react"; import { toast } from "sonner"; import type { DataViewerPlugin } from "@/plugins/types"; import { PANELS_POINT, PanelSlot, type PluginPanel } from "@/plugins/panels"; @@ -23,6 +23,8 @@ const DownloadAllCloudLogs = lazy(() => import("./DownloadAllCloudLogs")); // Profile tab panels: storage usage meters + account, and cloud-log management. const StoragePanel = lazy(() => import("./StoragePanel")); const CloudLogsPanel = lazy(() => import("./CloudLogsPanel")); +// Profile tab: GDPR self-service — export everything + scheduled account deletion. +const DataPrivacyPanel = lazy(() => import("./DataPrivacyPanel")); const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === 'true'; @@ -102,6 +104,17 @@ const plugin: DataViewerPlugin = { component: CloudLogsPanel, } satisfies PluginPanel); + // Profile tab: data export + account deletion (GDPR self-service). Last so + // the destructive controls sit at the bottom of the tab. + ctx.registry.contribute(PANELS_POINT, { + id: "cloud-sync-data-privacy", + title: "Data & privacy", + slot: PanelSlot.Profile, + order: 20, + icon: ShieldCheck, + component: DataPrivacyPanel, + } satisfies PluginPanel); + // Background document auto-sync. Dynamically imported so the sync engine // stays off the initial bundle; the notifier routes quota warnings to a // toast (keeping autoSync itself free of any UI dependency). diff --git a/supabase/config.toml b/supabase/config.toml index 454cbec..c1ae948 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -20,3 +20,12 @@ verify_jwt = false [functions.create-portal-session] verify_jwt = false + +[functions.export-account-data] +verify_jwt = false + +[functions.request-account-deletion] +verify_jwt = false + +[functions.process-account-deletions] +verify_jwt = false diff --git a/supabase/functions/export-account-data/index.ts b/supabase/functions/export-account-data/index.ts new file mode 100644 index 0000000..f3b2842 --- /dev/null +++ b/supabase/functions/export-account-data/index.ts @@ -0,0 +1,89 @@ +// Returns everything we hold server-side for the authenticated caller, as one +// JSON document (GDPR access / portability). The client merges this with the +// local browser data and zips it — see the cloud-sync data-export panel. +// +// Runs with the service role so it can also include admin-gated rows the user +// can't read directly (their user_roles, and contact messages they sent by +// email). Every query is still scoped to the caller's own id/email — never a +// blanket export. +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version', +}; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Unauthorized' }, 401); + } + + const authClient = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_ANON_KEY')!, + { global: { headers: { Authorization: authHeader } } }, + ); + const { data: { user }, error: userErr } = await authClient.auth.getUser(); + if (userErr || !user) { + return json({ error: 'Unauthorized' }, 401); + } + + const admin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, + ); + + const [profile, subscription, roles, syncRecords, messages, pendingDeletion] = await Promise.all([ + admin.from('profiles').select('display_name, created_at, updated_at').eq('user_id', user.id).maybeSingle(), + admin.from('user_subscriptions').select('tier, status, stripe_customer_id, stripe_subscription_id, current_period_end, updated_at').eq('user_id', user.id).maybeSingle(), + admin.from('user_roles').select('role').eq('user_id', user.id), + admin.from('sync_records').select('store, record_key, data, updated_at').eq('user_id', user.id), + user.email + ? admin.from('messages').select('category, email, message, created_at').eq('email', user.email) + : Promise.resolve({ data: [] }), + admin.from('account_deletions').select('requested_at, scheduled_for').eq('user_id', user.id).maybeSingle(), + ]); + + const records = (syncRecords.data ?? []) as Array<{ store: string; record_key: string; data: unknown; updated_at?: string }>; + // The file store holds only index rows (size); the raw blobs live in the + // user-files bucket and the client downloads them directly via its session. + const cloudFiles = records + .filter((r) => r.store === 'files') + .map((r) => ({ name: r.record_key, ...(r.data as Record ?? {}) })); + + const exportDoc = { + export_version: 1, + exported_at: new Date().toISOString(), + account: { + user_id: user.id, + email: user.email ?? null, + created_at: user.created_at, + last_sign_in_at: user.last_sign_in_at ?? null, + provider: user.app_metadata?.provider ?? null, + }, + profile: profile.data ?? null, + subscription: subscription.data ?? null, + roles: (roles.data ?? []).map((r: { role: string }) => r.role), + pending_deletion: pendingDeletion.data ?? null, + cloud_files: cloudFiles, + garage_records: records.filter((r) => r.store !== 'files'), + contact_messages: messages.data ?? [], + }; + + return json(exportDoc); + } catch (e) { + console.error('export-account-data error', e); + return json({ error: 'Internal error' }, 500); + } +}); diff --git a/supabase/functions/process-account-deletions/index.ts b/supabase/functions/process-account-deletions/index.ts new file mode 100644 index 0000000..f66929b --- /dev/null +++ b/supabase/functions/process-account-deletions/index.ts @@ -0,0 +1,75 @@ +// Hard-deletes accounts whose 7-day grace window has elapsed. Cron-invoked: a +// daily pg_cron job posts here with the shared `x-cron-secret`. Does the +// irreversible work SQL shouldn't: removes the user's Storage objects, then +// deletes the auth user (which cascades profiles, sync_records, +// user_subscriptions, user_roles and the account_deletions row via FKs). +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-cron-secret', +}; + +const SYNC_BUCKET = 'user-files'; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + +/** Remove every object under `${userId}/` in the private user-files bucket. */ +async function removeUserFiles(admin: ReturnType, userId: string): Promise { + const bucket = admin.storage.from(SYNC_BUCKET); + // List in pages; the folder is flat ({userId}/{filename}), so one level is enough. + for (;;) { + const { data: objects, error } = await bucket.list(userId, { limit: 1000 }); + if (error || !objects || objects.length === 0) return; + const paths = objects.map((o) => `${userId}/${o.name}`); + const { error: rmErr } = await bucket.remove(paths); + if (rmErr) throw rmErr; + if (objects.length < 1000) return; + } +} + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + // Cron-only: reject anything without the shared secret. + const secret = Deno.env.get('DELETION_CRON_SECRET'); + if (!secret || req.headers.get('x-cron-secret') !== secret) { + return json({ error: 'Forbidden' }, 403); + } + + try { + const admin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, + ); + + const { data: due, error } = await admin.rpc('due_account_deletions'); + if (error) throw error; + + const userIds = (due ?? []) as string[]; + const results: Array<{ user_id: string; ok: boolean; error?: string }> = []; + + for (const userId of userIds) { + try { + await removeUserFiles(admin, userId); + const { error: delErr } = await admin.auth.admin.deleteUser(userId); + if (delErr) throw delErr; + // The account_deletions row is removed by the auth.users FK cascade. + results.push({ user_id: userId, ok: true }); + } catch (e) { + console.error('process-account-deletions: failed for', userId, e); + results.push({ user_id: userId, ok: false, error: e instanceof Error ? e.message : String(e) }); + } + } + + return json({ processed: results.length, results }); + } catch (e) { + console.error('process-account-deletions error', e); + return json({ error: 'Internal error' }, 500); + } +}); diff --git a/supabase/functions/request-account-deletion/index.ts b/supabase/functions/request-account-deletion/index.ts new file mode 100644 index 0000000..812b662 --- /dev/null +++ b/supabase/functions/request-account-deletion/index.ts @@ -0,0 +1,74 @@ +// Schedules deletion of the authenticated caller's account for 7 days out +// (reversible). The client performs an email-OTP re-verification before calling +// this (so a hijacked session alone can't trigger it via the normal UI); the +// 7-day reversible window is the durable safeguard, and only the service role +// can write the row so the window can't be shortened client-side. +// +// Idempotent: calling again keeps the original schedule (never extends or +// shortens an in-flight request). +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version', +}; + +const GRACE_DAYS = 7; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Unauthorized' }, 401); + } + + const authClient = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_ANON_KEY')!, + { global: { headers: { Authorization: authHeader } } }, + ); + const { data: { user }, error: userErr } = await authClient.auth.getUser(); + if (userErr || !user) { + return json({ error: 'Unauthorized' }, 401); + } + + const admin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, + ); + + // Keep an existing request's schedule; only create one if none is pending. + const { data: existing } = await admin + .from('account_deletions') + .select('requested_at, scheduled_for') + .eq('user_id', user.id) + .maybeSingle(); + + if (existing) { + return json({ scheduled_for: existing.scheduled_for, requested_at: existing.requested_at }); + } + + const now = new Date(); + const scheduledFor = new Date(now.getTime() + GRACE_DAYS * 24 * 60 * 60 * 1000); + const { error } = await admin.from('account_deletions').insert({ + user_id: user.id, + requested_at: now.toISOString(), + scheduled_for: scheduledFor.toISOString(), + }); + if (error) throw error; + + return json({ scheduled_for: scheduledFor.toISOString(), requested_at: now.toISOString() }); + } catch (e) { + console.error('request-account-deletion error', e); + return json({ error: 'Internal error' }, 500); + } +}); diff --git a/supabase/migrations/20260527010000_gdpr_compliance.sql b/supabase/migrations/20260527010000_gdpr_compliance.sql new file mode 100644 index 0000000..3585574 --- /dev/null +++ b/supabase/migrations/20260527010000_gdpr_compliance.sql @@ -0,0 +1,141 @@ +-- GDPR compliance: personal-data retention (IP TTL) + scheduled account deletion. +-- +-- Two concerns: +-- 1. Abuse-prevention IP minimisation. `submitted_by_ip` on submissions and +-- messages is nulled 90 days after the row was created; expired `banned_ips` +-- and stale `login_attempts` rows are deleted. A daily pg_cron job runs the +-- purge so data is cleared even when there's no traffic to trigger the +-- reactive cleanup the edge functions already do. +-- 2. Self-service account deletion is *scheduled*, not immediate. A 7-day grace +-- window (reversible by the user) guards against a hijacked session wiping a +-- user's race history. `account_deletions` holds the pending request; the +-- `process-account-deletions` edge function does the irreversible work +-- (Storage objects + the auth row — neither of which SQL should delete +-- directly) once the window elapses. + +-- ── Extensions (scheduling + outbound HTTP for the deletion worker) ─────────── +create extension if not exists pg_cron; +create extension if not exists pg_net; + +-- ── 1. IP retention purge ───────────────────────────────────────────────────── +-- SECURITY DEFINER so the cron job (and an authorized edge function) can run it +-- regardless of the caller. Touches only abuse-prevention columns/rows. +create or replace function public.purge_expired_personal_data() +returns void +language plpgsql +security definer +set search_path = public +as $$ +begin + -- Drop the submitter IP once the abuse-investigation window has passed; the + -- submission/message content itself is retained for the operator. + update public.submissions + set submitted_by_ip = null + where submitted_by_ip is not null + and created_at < now() - interval '90 days'; + + update public.messages + set submitted_by_ip = null + where submitted_by_ip is not null + and created_at < now() - interval '90 days'; + + -- Expired bans no longer protect anything — remove the IP entirely. + delete from public.banned_ips + where expires_at is not null + and expires_at < now(); + + -- Stale rate-limit rows (the edge function also clears these reactively). + delete from public.login_attempts + where locked_until is not null + and locked_until < now(); +end; +$$; + +revoke all on function public.purge_expired_personal_data() from public, anon, authenticated; + +-- ── 2. Scheduled account deletion ───────────────────────────────────────────── +create table if not exists public.account_deletions ( + user_id uuid primary key references auth.users (id) on delete cascade, + requested_at timestamptz not null default now(), + scheduled_for timestamptz not null +); + +alter table public.account_deletions enable row level security; + +-- A user may SEE and CANCEL (delete) their own pending request. There is no +-- INSERT/UPDATE policy: only the service role (the request-account-deletion edge +-- function) can create a request, so a client can never shorten the 7-day window +-- or schedule a deletion for someone else. +drop policy if exists "Users read own deletion" on public.account_deletions; +create policy "Users read own deletion" + on public.account_deletions for select to authenticated + using (auth.uid() = user_id); + +drop policy if exists "Users cancel own deletion" on public.account_deletions; +create policy "Users cancel own deletion" + on public.account_deletions for delete to authenticated + using (auth.uid() = user_id); + +-- Accounts whose grace window has elapsed — read by the deletion worker. +create or replace function public.due_account_deletions() +returns setof uuid +language sql +security definer +set search_path = public +as $$ + select user_id from public.account_deletions where scheduled_for <= now(); +$$; + +revoke all on function public.due_account_deletions() from public, anon, authenticated; + +-- ── 3. Schedule the jobs (idempotent, and tolerant of missing config) ───────── +do $$ +declare + v_secret text; + v_url text := 'https://svjlieovpyiffbqwhtgk.supabase.co/functions/v1/process-account-deletions'; +begin + -- Re-running the migration must not error on already-scheduled jobs. + if exists (select 1 from cron.job where jobname = 'purge-expired-personal-data') then + perform cron.unschedule('purge-expired-personal-data'); + end if; + if exists (select 1 from cron.job where jobname = 'process-account-deletions') then + perform cron.unschedule('process-account-deletions'); + end if; + + -- IP retention purge: pure SQL, no secret needed — always scheduled. 03:17 UTC. + perform cron.schedule( + 'purge-expired-personal-data', + '17 3 * * *', + $job$ select public.purge_expired_personal_data(); $job$ + ); + + -- Account-deletion worker: posts to the edge function, which needs a shared + -- secret (DELETION_CRON_SECRET env on the function). The secret lives in Vault + -- under `deletion_cron_secret`; when present we auto-wire the daily job, else + -- we leave a notice so the operator can add it (see README). 03:37 UTC. + begin + select decrypted_secret into v_secret + from vault.decrypted_secrets + where name = 'deletion_cron_secret' + limit 1; + exception when others then + v_secret := null; + end; + + if v_secret is not null then + perform cron.schedule( + 'process-account-deletions', + '37 3 * * *', + format( + $job$ select net.http_post( + url := %L, + headers := jsonb_build_object('Content-Type', 'application/json', 'x-cron-secret', %L), + body := '{}'::jsonb + ); $job$, + v_url, v_secret + ) + ); + else + raise notice 'account_deletions: set Vault secret "deletion_cron_secret" (and the matching DELETION_CRON_SECRET env on the process-account-deletions function), then re-run this migration to auto-schedule the worker. See README.'; + end if; +end $$; From 8647b282827489ff1dae40136f074aece86701dc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 01:55:16 +0000 Subject: [PATCH 080/121] Complete Stripe integration: monthly/annual, signup plan select, grace + log trimming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the existing Stripe foundation (tiers, user_subscriptions, checkout/ webhook/portal edge functions) to deliver full subscription billing: - Monthly & annual billing resolved live by Stripe lookup_key (`${tier}_${interval}`), so the dashboard is the source of truth — no Price ids in code. New public `stripe-prices` edge function feeds the catalogue. - No-Stripe failback: pricing UI shows only the two free cards (Guest + Free) and hides paid tiers entirely when no secret key is configured. - Plan + interval selection at sign-up via PlanChooser; a paid choice creates the account first, stashes the intent (pendingCheckout), and resumes to Checkout on first sign-in after email confirmation (PendingCheckoutRedirect). - Cancellation grace: webhook tracks cancel_at_period_end/billing_interval and sets a 60-day grace_until on cancellation; subscription_grace_trim migration adds a pg_cron-scheduled trim_expired_logs() that trims synced logs newest-first to the free allowance once grace expires (encode_uri_component parity addresses the right bucket objects). - Profile panel surfaces renewal/cancellation/grace dates + portal link. Pure logic + the pending-checkout parser are unit-tested. Docs (README, CLAUDE.md, CHANGELOG) updated. Live Stripe flows can't be exercised until keys are added; lint/typecheck/test/build all pass. https://claude.ai/code/session_012D8zxba3CCmUdqgT16zZav --- CHANGELOG.md | 16 ++ CLAUDE.md | 71 ++++-- README.md | 33 ++- src/App.tsx | 4 + src/components/PendingCheckoutRedirect.tsx | 47 ++++ src/components/PlanChooser.tsx | 112 +++++++++ src/components/PricingCards.tsx | 226 ++++++++++++------ src/hooks/useStripePrices.ts | 50 ++++ src/lib/billing.test.ts | 71 +++++- src/lib/billing.ts | 67 ++++++ src/lib/billingClient.ts | 33 ++- src/lib/pendingCheckout.test.ts | 28 +++ src/lib/pendingCheckout.ts | 54 +++++ src/pages/Register.tsx | 17 +- src/plugins/cloud-sync/StoragePanel.tsx | 21 +- supabase/config.toml | 3 + .../create-checkout-session/index.ts | 34 ++- supabase/functions/stripe-prices/index.ts | 70 ++++++ supabase/functions/stripe-webhook/index.ts | 68 +++++- ...20260528000000_subscription_grace_trim.sql | 135 +++++++++++ 20 files changed, 1026 insertions(+), 134 deletions(-) create mode 100644 src/components/PendingCheckoutRedirect.tsx create mode 100644 src/components/PlanChooser.tsx create mode 100644 src/hooks/useStripePrices.ts create mode 100644 src/lib/pendingCheckout.test.ts create mode 100644 src/lib/pendingCheckout.ts create mode 100644 supabase/functions/stripe-prices/index.ts create mode 100644 supabase/migrations/20260528000000_subscription_grace_trim.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6b52c..8553e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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". +- **Cancellation grace + log trimming**: cancelling ends service at the period + boundary and drops you to the free tier's limits, 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** + to the free allowance. The Profile tab surfaces the cancellation/grace date. - Document storage + **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. The "documents" storage type diff --git a/CLAUDE.md b/CLAUDE.md index 4bce56e..f4b6ac0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,7 @@ src/ │ ├── useSettings.ts # User preferences (units, smoothing, dark mode, etc.) │ ├── useSessionMetadata.ts # Per-file metadata (selected track/course) │ ├── useSubscription.ts # Reads subscription tier catalogue + the user's plan (online, account-gated) +│ ├── useStripePrices.ts # Reads the live Stripe price catalogue (configured? + monthly/annual prices); drives the no-Stripe failback │ └── useOnlineStatus.ts # Navigator.onLine wrapper ├── lib/ │ ├── datalogParser.ts # ★ Format auto-detection router (entry point for all parsing) @@ -177,8 +178,9 @@ src/ │ │ ├── types.ts # ITrackDatabase interface │ │ ├── supabaseAdapter.ts # Supabase implementation │ │ └── index.ts # Factory: getDatabase() -│ ├── billing.ts # ★ Pure subscription logic + row shapes (effectiveTier, pricingCta) — unit-tested, no Supabase import -│ ├── billingClient.ts # Supabase I/O for tiers/subscriptions + Stripe checkout/portal (functions.invoke) +│ ├── billing.ts # ★ Pure subscription logic + row/price shapes (effectiveTier, pricingCta, lookupKey, paidTiersVisible, priceFor, formatPrice) — unit-tested, no Supabase import +│ ├── billingClient.ts # Supabase I/O for tiers/subscriptions + Stripe prices/checkout/portal (functions.invoke) +│ ├── pendingCheckout.ts # localStorage stash for a plan chosen at sign-up; redeemed on first sign-in (account-first paid flow) — pure parse is unit-tested │ └── utils.ts # Tailwind cn() helper ├── plugins/ # ★ Plugin framework (auto-discovered via import.meta.glob) │ ├── types.ts # DataViewerPlugin / PluginContext / PluginRegistry contracts @@ -501,48 +503,71 @@ 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. -### Subscriptions / Stripe (`..._stripe_subscriptions.sql` + 3 edge functions) +### Subscriptions / Stripe (`..._stripe_subscriptions.sql`, `..._subscription_grace_trim.sql` + 4 edge functions) Paid tiers scale the cloud-sync **logs** quota (`free` 20 MB → `plus` $1 500 MB → `premium` $3 1 GB → `pro` $10 1 GB; docs stay 5 MB). `premium` matches `pro`'s -storage but carries no AI credits. Tiers are **data**, not code (numbers are -provisional): +storage but 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, logs_bytes, doc_bytes, ai_credits, stripe_price_id, sort_order)`. Authenticated read-all. Change a limit/price = UPDATE here. `stripe_price_id` is set after creating the Stripe Price. | -| `user_subscriptions` | table | `(user_id PK→auth.users, tier→subscription_tiers, status, stripe_customer_id, stripe_subscription_id, current_period_end, updated_at)`. RLS: owner **read-only** — only the service role (webhook) writes, so no one can self-grant a tier. | +| `subscription_tiers` | table | One row per plan: `(tier PK, label, price_cents, logs_bytes, doc_bytes, ai_credits, stripe_price_id, sort_order)`. 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_limit(uuid, type)` | fn (SECURITY DEFINER) | Byte limit for a user + storage type from their tier; falls back to `free`, then `quota_limits`. Used by the quota trigger + 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) down to the free `logs_bytes`. 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. + +**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): -- `create-checkout-session` — auth user → ensure Stripe customer (persisted on - `user_subscriptions`) → Checkout Session (subscription mode) for the tier's - `stripe_price_id` → returns the hosted URL. +- `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 resolved from the Price id; `deleted` → `free`) via the service role. + (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/cancel (no in-app billing UI). + manage/upgrade/downgrade/cancel (no in-app billing UI). 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` + -row shapes); `lib/billingClient.ts` is the Supabase I/O (`fetchTiers`, -`fetchMySubscription`, `createCheckout`, `createPortal` via `functions.invoke`), -through the same untyped escape hatch as `cloudClient.ts`. `hooks/useSubscription.ts` -reads the tier catalogue + the user's subscription (online + account-gated, -returns the free baseline otherwise). `PricingCards` shows live **Upgrade** / -**Current plan** actions for signed-in users (a paid tier with no `stripe_price_id` -stays "Coming soon" — graceful pre-config state); cloud-sync's Profile-tab -`StoragePanel` shows the current plan + a **Manage subscription** portal link when -subscribed. **Stripe setup (create Products/Prices, set `stripe_price_id`, secrets, -webhook) is still operator config — see README.** +unit-tested layer (`isActiveStatus`/`effectiveTier`/`isPaidTier`/`pricingCta`, +plus `lookupKey`/`tiersWithPrices`/`paidTiersVisible`/`priceFor`/`formatPrice` + +row/price shapes); `lib/billingClient.ts` is the Supabase I/O (`fetchTiers`, +`fetchMySubscription`, `fetchStripeConfig`, `createCheckout(tier, interval)`, +`createPortal`), 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` has a **monthly/annual toggle**, shows live **Upgrade** / +**Current plan** actions, and — the **failback** — hides the paid tiers entirely +when `paidTiersVisible(config)` is false (only Guest + Free cards). `PlanChooser` +(sign-up) picks tier + interval; 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. +cloud-sync's Profile-tab `StoragePanel` shows the plan + renewal/cancellation/ +grace date + a **Manage subscription** portal link. **Stripe setup (create +Products/Prices with the lookup_keys, secrets, webhook, enable pg_cron) is +operator config — see README.** --- diff --git a/README.md b/README.md index 9ceea18..53a649f 100644 --- a/README.md +++ b/README.md @@ -121,12 +121,26 @@ The app includes an optional admin system for managing a community track databas > **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). After creating the -> Plus/Premium/Pro Products + recurring Prices in Stripe, store each Price id in the matching -> `subscription_tiers.stripe_price_id` row, and point a Stripe webhook (events: +> 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. Use Stripe **test mode** first. Tier -> entitlements are granted only by the webhook, never the client. +> 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. +> +> **Cancellation grace + log trimming:** a cancelled subscription ends at the +> period boundary and drops to the free tier's limits 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` in +> the `subscription_grace_trim` migration) deletes their synced log files +> newest-first down to the free `logs_bytes` allowance. 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`. @@ -146,6 +160,10 @@ The admin system uses Lovable Cloud (Supabase) for the database. The schema is c - **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 +- **quota_limits** — Baseline per-storage-type byte limits (documents/logs) +- **subscription_tiers** — Data-driven plan catalogue (free/plus/premium/pro): label, price, per-type byte limits +- **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 > 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 @@ -177,6 +195,11 @@ 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 | ### Track Short Names diff --git a/src/App.tsx b/src/App.tsx index 97eec8f..ec689e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,9 @@ 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"; @@ -50,6 +53,7 @@ const App = () => { + {enableCloud && } } /> } /> 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/PlanChooser.tsx b/src/components/PlanChooser.tsx new file mode 100644 index 0000000..e2cae07 --- /dev/null +++ b/src/components/PlanChooser.tsx @@ -0,0 +1,112 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useStripePrices } from "@/hooks/useStripePrices"; +import { + type BillingInterval, + formatPrice, + paidTiersVisible, + priceFor, + tiersWithPrices, +} from "@/lib/billing"; + +export interface PlanSelection { + tier: string; + interval: BillingInterval; +} + +const TIER_LABEL: Record = { + free: "Free", + plus: "Plus", + premium: "Premium", + pro: "Pro", +}; +const PAID_ORDER = ["plus", "premium", "pro"]; + +/** + * Plan + billing-interval picker for sign-up. Renders nothing when Stripe isn't + * configured (the account is simply free), so the failback needs no special + * handling. Controlled: the parent owns the selection so it can act on submit. + */ +export function PlanChooser({ + value, + onChange, +}: { + value: PlanSelection; + onChange: (v: PlanSelection) => void; +}) { + const { config } = useStripePrices(); + if (!paidTiersVisible(config)) return null; + + const available = tiersWithPrices(config.prices); + const paidTiers = PAID_ORDER.filter((t) => available.has(t)); + const isPaid = value.tier !== "free"; + + 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 ( +
    + + + + {isPaid && ( +
    + {(["monthly", "annual"] as const).map((opt) => ( + + ))} +
    + )} + + {isPaid && ( +

    + You'll be sent to secure checkout after confirming your email and signing in. +

    + )} +
    + ); +} diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx index aef6776..2510067 100644 --- a/src/components/PricingCards.tsx +++ b/src/components/PricingCards.tsx @@ -3,26 +3,43 @@ 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 { pricingCta } from "@/lib/billing"; +import { + type BillingInterval, + formatPrice, + paidTiersVisible, + priceFor, + pricingCta, + tiersWithPrices, +} from "@/lib/billing"; import { createCheckout } from "@/lib/billingClient"; const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === "true"; -interface Tier { +type PaidSlug = "plus" | "premium" | "pro"; + +interface FreeTier { name: string; blurb: string; price: string; - cadence?: string; inherits?: string; features: string[]; highlight?: boolean; - comingSoon?: boolean; /** Maps the card to a subscription tier slug (the offline card has none). */ - slug?: "free" | "plus" | "premium" | "pro"; + slug?: "free"; } -const TIERS: Tier[] = [ +interface PaidTier { + name: string; + blurb: string; + inherits: string; + features: string[]; + slug: PaidSlug; +} + +// The two always-on free cards. Prices are fixed ($0) — no Stripe needed. +const FREE_TIERS: FreeTier[] = [ { name: "Free", blurb: "Offline", @@ -48,12 +65,14 @@ const TIERS: Tier[] = [ "20 MB cloud log storage", ], }, +]; + +// Paid tiers — feature copy is static; the price is resolved live from Stripe +// (by lookup_key) and these cards are hidden entirely when Stripe isn't wired up. +const PAID_TIERS: PaidTier[] = [ { name: "Plus", blurb: "For bigger garages", - price: "$1", - cadence: "/mo", - comingSoon: true, slug: "plus", inherits: "Everything in Free online, plus", features: ["500 MB cloud log storage"], @@ -61,9 +80,6 @@ const TIERS: Tier[] = [ { name: "Premium", blurb: "Max storage", - price: "$3", - cadence: "/mo", - comingSoon: true, slug: "premium", inherits: "Everything in Plus, plus", features: ["1 GB cloud log storage"], @@ -71,9 +87,6 @@ const TIERS: Tier[] = [ { name: "Pro", blurb: "With AI coaching", - price: "$10", - cadence: "/mo", - comingSoon: true, slug: "pro", inherits: "Everything in Premium, plus", features: ["AI coaching (coming soon)"], @@ -81,43 +94,46 @@ const TIERS: Tier[] = [ ]; function TierCard({ - tier, + name, + blurb, + price, + cadence, + inherits, + features, + highlight, cta, - showComingSoon, }: { - tier: Tier; + name: string; + blurb: string; + price: string; + cadence?: string; + inherits?: string; + features: string[]; + highlight?: boolean; cta?: ReactNode; - showComingSoon: boolean; }) { return (
    - {tier.highlight && ( + {highlight && ( Recommended )} - {showComingSoon && ( - - Coming soon - - )}
    -

    {tier.name}

    -

    {tier.blurb}

    +

    {name}

    +

    {blurb}

    - {tier.price} - {tier.cadence && {tier.cadence}} + {price} + {cadence && {cadence}}
    - {tier.inherits && ( -

    {tier.inherits}

    - )} + {inherits &&

    {inherits}

    }
      - {tier.features.map((f) => ( + {features.map((f) => (
    • {f} @@ -129,24 +145,57 @@ function TierCard({ ); } +function IntervalToggle({ + value, + onChange, +}: { + value: BillingInterval; + onChange: (v: BillingInterval) => void; +}) { + return ( +
      + {(["monthly", "annual"] as const).map((opt) => ( + + ))} +
      + ); +} + /** * Plans / pricing grid. Shown on the landing page (the empty-state of the main - * app) and on the registration page. Informational for signed-out visitors; - * signed-in users get live "Upgrade" / "Current plan" actions on the paid tiers - * (a paid tier whose Stripe Price isn't configured yet stays "Coming soon"). + * app) and on the registration page. The two free cards always render; + * signed-in users get live "Upgrade" / "Current plan" actions on the paid tiers. + * When Stripe isn't configured the paid tiers are hidden entirely (free-only + * failback), and a monthly/annual toggle appears only when paid plans are shown. */ export function PricingCards({ className }: { className?: string }) { const { user } = useAuth(); - const { tiers, currentTier } = useSubscription(); + const { currentTier } = useSubscription(); + const { config } = useStripePrices(); + const [interval, setInterval] = useState("monthly"); const [busy, setBusy] = useState(null); const signedIn = !!user; - const purchasable = new Set(tiers.filter((t) => t.stripe_price_id).map((t) => t.tier)); + const showPaid = paidTiersVisible(config); + const purchasable = tiersWithPrices(config.prices); + const cadence = interval === "annual" ? "/yr" : "/mo"; - const onUpgrade = async (slug: string) => { + const onUpgrade = async (slug: PaidSlug) => { setBusy(slug); try { - const url = await createCheckout(slug, window.location.href); + const url = await createCheckout(slug, interval, window.location.href); window.location.href = url; } catch (e) { toast.error(e instanceof Error ? e.message : "Couldn't start checkout."); @@ -154,6 +203,33 @@ export function PricingCards({ className }: { className?: string }) { } }; + 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 ( + + ); + } + return null; + }; + return (
      @@ -161,44 +237,42 @@ export function PricingCards({ className }: { className?: string }) {

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

      + {showPaid && ( +
      + +
      + )}
      - {TIERS.map((tier) => { - const kind = pricingCta({ - slug: tier.slug, - signedIn, - cloudEnabled: enableCloud, - currentTier, - purchasable: !!tier.slug && purchasable.has(tier.slug), - }); - - let cta: ReactNode = null; - if (kind === "current") { - cta = ( - - ); - } else if (kind === "upgrade" && tier.slug) { - const slug = tier.slug; - const isBusy = busy === slug; - cta = ( - + {FREE_TIERS.map((tier) => ( + + ))} + {showPaid && + PAID_TIERS.map((tier) => { + const price = priceFor(config.prices, tier.slug, interval); + if (!price) return null; // this interval isn't priced for this tier + return ( + ); - } - - return ( - - ); - })} + })}
      ); diff --git a/src/hooks/useStripePrices.ts b/src/hooks/useStripePrices.ts new file mode 100644 index 0000000..6b99288 --- /dev/null +++ b/src/hooks/useStripePrices.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { useOnlineStatus } from "@/hooks/useOnlineStatus"; +import type { StripeConfig } from "@/lib/billing"; +import { fetchStripeConfig } from "@/lib/billingClient"; + +const enableCloud = import.meta.env.VITE_ENABLE_CLOUD === "true"; + +export interface StripePricesState { + loading: boolean; + config: StripeConfig; +} + +const UNCONFIGURED: StripeConfig = { configured: false, prices: [] }; + +/** + * Reads the live Stripe pricing catalogue (monthly/annual prices per paid tier) + * for the pricing UI. Online-only and cloud-gated; never throws into render — + * any failure reads as "not configured", which collapses the UI to the free + * cards. Public data, so it works signed-out. + */ +export function useStripePrices(): StripePricesState { + const online = useOnlineStatus(); + const [config, setConfig] = useState(UNCONFIGURED); + const [loading, setLoading] = useState(enableCloud); + + useEffect(() => { + if (!enableCloud) { + setConfig(UNCONFIGURED); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + fetchStripeConfig() + .then((c) => { + if (!cancelled) setConfig(c); + }) + .catch(() => { + if (!cancelled) setConfig(UNCONFIGURED); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [online]); + + return { loading, config }; +} diff --git a/src/lib/billing.test.ts b/src/lib/billing.test.ts index dbcad88..9748c29 100644 --- a/src/lib/billing.test.ts +++ b/src/lib/billing.test.ts @@ -1,5 +1,25 @@ import { describe, it, expect } from "vitest"; -import { isActiveStatus, effectiveTier, isPaidTier, pricingCta } from "./billing"; +import { + isActiveStatus, + effectiveTier, + isPaidTier, + pricingCta, + lookupKey, + tiersWithPrices, + paidTiersVisible, + priceFor, + formatPrice, + type StripePrice, +} from "./billing"; + +const price = (tier: string, interval: "monthly" | "annual", unitAmount: number): StripePrice => ({ + tier, + interval, + lookupKey: `${tier}_${interval}`, + priceId: `price_${tier}_${interval}`, + unitAmount, + currency: "usd", +}); describe("isActiveStatus", () => { it("treats active / trialing / past_due as granting access", () => { @@ -64,3 +84,52 @@ describe("pricingCta", () => { expect(pricingCta({ ...base, slug: "free", currentTier: "pro" })).toBe("none"); }); }); + +describe("lookupKey", () => { + it("joins tier + interval the way Stripe lookup_keys are named", () => { + expect(lookupKey("plus", "monthly")).toBe("plus_monthly"); + expect(lookupKey("pro", "annual")).toBe("pro_annual"); + }); +}); + +describe("tiersWithPrices", () => { + it("collects the distinct tiers that have a price", () => { + const prices = [price("plus", "monthly", 100), price("plus", "annual", 1000), price("pro", "monthly", 1000)]; + expect(tiersWithPrices(prices)).toEqual(new Set(["plus", "pro"])); + }); + it("is empty for no prices", () => { + expect(tiersWithPrices([]).size).toBe(0); + }); +}); + +describe("paidTiersVisible (no-Stripe failback)", () => { + it("is false when unconfigured, configured-but-empty, or null", () => { + expect(paidTiersVisible(null)).toBe(false); + expect(paidTiersVisible({ configured: false, prices: [] })).toBe(false); + expect(paidTiersVisible({ configured: false, prices: [price("plus", "monthly", 100)] })).toBe(false); + expect(paidTiersVisible({ configured: true, prices: [] })).toBe(false); + }); + it("is true only when configured with at least one price", () => { + expect(paidTiersVisible({ configured: true, prices: [price("plus", "monthly", 100)] })).toBe(true); + }); +}); + +describe("priceFor", () => { + const prices = [price("plus", "monthly", 100), price("plus", "annual", 1000)]; + it("matches on tier + interval", () => { + expect(priceFor(prices, "plus", "annual")?.unitAmount).toBe(1000); + }); + it("returns undefined when the interval isn't priced", () => { + expect(priceFor(prices, "pro", "monthly")).toBeUndefined(); + }); +}); + +describe("formatPrice", () => { + it("drops cents for whole amounts and keeps them otherwise", () => { + expect(formatPrice(100, "usd")).toBe("$1"); + expect(formatPrice(1099, "usd")).toBe("$10.99"); + }); + it("returns empty string for null (metered) amounts", () => { + expect(formatPrice(null, "usd")).toBe(""); + }); +}); diff --git a/src/lib/billing.ts b/src/lib/billing.ts index 542de7e..018bd82 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -18,6 +18,73 @@ export interface UserSubscriptionRow { tier: string; status: string; current_period_end: string | null; + cancel_at_period_end?: boolean; + billing_interval?: string | null; + grace_until?: string | null; +} + +// Paid plans bill either monthly or annually. The slug encodes both halves of a +// Stripe lookup_key: `${tier}_${interval}` (e.g. "pro_annual"). +export type BillingInterval = "monthly" | "annual"; + +/** A live Stripe Price for one (tier × interval), as returned by stripe-prices. */ +export interface StripePrice { + tier: string; + interval: BillingInterval; + lookupKey: string; + priceId: string; + /** Amount in the currency's minor unit (cents); null for metered prices. */ + unitAmount: number | null; + currency: string; +} + +/** The pricing-catalogue response: whether Stripe is wired up + its live prices. */ +export interface StripeConfig { + configured: boolean; + prices: StripePrice[]; +} + +/** The Stripe lookup_key for a tier + interval. */ +export function lookupKey(tier: string, interval: BillingInterval): string { + return `${tier}_${interval}`; +} + +/** The set of tiers that have at least one purchasable price configured. */ +export function tiersWithPrices(prices: StripePrice[]): Set { + return new Set(prices.map((p) => p.tier)); +} + +/** + * Whether the paid tiers should be shown at all. The failback when Stripe isn't + * wired up (no secret key / no prices) is to surface only the free cards — paid + * tiers are hidden entirely, not shown as "coming soon". + */ +export function paidTiersVisible(config: StripeConfig | null | undefined): boolean { + return !!config?.configured && (config?.prices.length ?? 0) > 0; +} + +/** Find the live price for a tier + interval, if configured. */ +export function priceFor( + prices: StripePrice[], + tier: string, + interval: BillingInterval, +): StripePrice | undefined { + return prices.find((p) => p.tier === tier && p.interval === interval); +} + +/** Format a minor-unit amount as a localized currency string (no trailing .00). */ +export function formatPrice(unitAmount: number | null | undefined, currency: string): string { + if (unitAmount == null) return ""; + const major = unitAmount / 100; + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency.toUpperCase(), + maximumFractionDigits: Number.isInteger(major) ? 0 : 2, + }).format(major); + } catch { + return `${major}`; + } } // A subscription grants its tier only while the status is one of these; anything diff --git a/src/lib/billingClient.ts b/src/lib/billingClient.ts index 3446b59..3f98860 100644 --- a/src/lib/billingClient.ts +++ b/src/lib/billingClient.ts @@ -9,7 +9,12 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { supabase } from "@/integrations/supabase/client"; -import type { SubscriptionTierRow, UserSubscriptionRow } from "./billing"; +import type { + BillingInterval, + StripeConfig, + SubscriptionTierRow, + UserSubscriptionRow, +} from "./billing"; const untyped = supabase as unknown as SupabaseClient; @@ -25,17 +30,35 @@ export async function fetchTiers(): Promise { export async function fetchMySubscription(userId: string): Promise { const { data, error } = await untyped .from("user_subscriptions") - .select("user_id, tier, status, current_period_end") + .select("user_id, tier, status, current_period_end, cancel_at_period_end, billing_interval, grace_until") .eq("user_id", userId) .maybeSingle(); if (error) throw new Error(`Failed to load subscription: ${error.message}`); return (data ?? null) as UserSubscriptionRow | null; } -/** Start Stripe Checkout for a tier; resolves to the hosted URL to redirect to. */ -export async function createCheckout(tier: string, returnUrl: string): Promise { +/** + * The live pricing catalogue (whether Stripe is wired up + its prices). Never + * throws into render: a network/function error reads as "not configured", which + * makes the UI fall back to the free-only cards. + */ +export async function fetchStripeConfig(): Promise { + const { data, error } = await supabase.functions.invoke("stripe-prices", { body: {} }); + if (error || !data) return { configured: false, prices: [] }; + return data as StripeConfig; +} + +/** + * Start Stripe Checkout for a tier + billing interval; resolves to the hosted + * URL to redirect to. + */ +export async function createCheckout( + tier: string, + interval: BillingInterval, + returnUrl: string, +): Promise { const { data, error } = await supabase.functions.invoke("create-checkout-session", { - body: { tier, returnUrl }, + body: { tier, interval, returnUrl }, }); if (error) throw new Error(error.message); const url = (data as { url?: string } | null)?.url; diff --git a/src/lib/pendingCheckout.test.ts b/src/lib/pendingCheckout.test.ts new file mode 100644 index 0000000..ba529dd --- /dev/null +++ b/src/lib/pendingCheckout.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { parsePendingCheckout } from "./pendingCheckout"; + +const NOW = 1_000_000_000_000; +const DAY = 24 * 60 * 60 * 1000; + +describe("parsePendingCheckout", () => { + it("returns null for empty / malformed input", () => { + expect(parsePendingCheckout(null, NOW)).toBeNull(); + expect(parsePendingCheckout("not json", NOW)).toBeNull(); + expect(parsePendingCheckout("{}", NOW)).toBeNull(); + }); + + it("rejects the free tier and unknown intervals", () => { + expect(parsePendingCheckout(JSON.stringify({ tier: "free", interval: "monthly", ts: NOW }), NOW)).toBeNull(); + expect(parsePendingCheckout(JSON.stringify({ tier: "pro", interval: "weekly", ts: NOW }), NOW)).toBeNull(); + }); + + it("expires intents older than 24h", () => { + const stale = { tier: "pro", interval: "annual", ts: NOW - DAY - 1 }; + expect(parsePendingCheckout(JSON.stringify(stale), NOW)).toBeNull(); + }); + + it("parses a valid, fresh paid intent", () => { + const intent = { tier: "plus", interval: "annual", ts: NOW - 1000 }; + expect(parsePendingCheckout(JSON.stringify(intent), NOW)).toEqual(intent); + }); +}); diff --git a/src/lib/pendingCheckout.ts b/src/lib/pendingCheckout.ts new file mode 100644 index 0000000..016dc15 --- /dev/null +++ b/src/lib/pendingCheckout.ts @@ -0,0 +1,54 @@ +// A paid plan chosen at sign-up can't go straight to Stripe Checkout: sign-up +// requires email confirmation, so there's no session yet. We stash the choice +// here (localStorage) and redirect to Checkout on the user's first authenticated +// load (see usePendingCheckout). The intent expires so a stale choice never +// hijacks a later, unrelated sign-in. + +import type { BillingInterval } from "./billing"; + +const KEY = "dove-pending-checkout"; +const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h + +export interface PendingCheckout { + tier: string; + interval: BillingInterval; + ts: number; +} + +/** Parse + validate a stored intent, dropping anything malformed or expired. Pure. */ +export function parsePendingCheckout(raw: string | null, now: number): PendingCheckout | null { + if (!raw) return null; + try { + const v = JSON.parse(raw) as Partial; + if (typeof v.tier !== "string" || v.tier === "free") return null; + if (v.interval !== "monthly" && v.interval !== "annual") return null; + if (typeof v.ts !== "number" || now - v.ts > MAX_AGE_MS) return null; + return { tier: v.tier, interval: v.interval, ts: v.ts }; + } catch { + return null; + } +} + +export function setPendingCheckout(tier: string, interval: BillingInterval): void { + try { + localStorage.setItem(KEY, JSON.stringify({ tier, interval, ts: Date.now() })); + } catch { + /* storage unavailable — checkout just won't auto-resume */ + } +} + +export function getPendingCheckout(): PendingCheckout | null { + try { + return parsePendingCheckout(localStorage.getItem(KEY), Date.now()); + } catch { + return null; + } +} + +export function clearPendingCheckout(): void { + try { + localStorage.removeItem(KEY); + } catch { + /* ignore */ + } +} diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 3f98195..614951f 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -9,7 +9,10 @@ import { Gauge, ArrowLeft } from 'lucide-react'; import { useDocumentHead } from '@/hooks/useDocumentHead'; import { Turnstile, turnstileEnabled } from '@/components/Turnstile'; import { PricingCards } from '@/components/PricingCards'; +import { PlanChooser, type PlanSelection } from '@/components/PlanChooser'; import { isDisposableEmail, looksLikeEmail } from '@/lib/emailValidation'; +import { isPaidTier } from '@/lib/billing'; +import { setPendingCheckout } from '@/lib/pendingCheckout'; export default function Register() { useDocumentHead({ @@ -23,6 +26,7 @@ export default function Register() { const [confirmPassword, setConfirmPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [captchaToken, setCaptchaToken] = useState(null); + const [plan, setPlan] = useState({ tier: 'free', interval: 'monthly' }); const { signUp, signInWithGoogle } = useAuth(); const navigate = useNavigate(); @@ -54,7 +58,17 @@ export default function Register() { if (error) { toast({ title: 'Registration failed', description: error.message, variant: 'destructive' }); } else { - toast({ title: 'Account created', description: 'Check your email to confirm your account.' }); + // Account-first paid flow: stash the chosen plan so checkout resumes on + // the user's first sign-in after confirming their email. + if (isPaidTier(plan.tier)) { + setPendingCheckout(plan.tier, plan.interval); + toast({ + title: 'Account created', + description: 'Confirm your email, then sign in to finish checkout for your plan.', + }); + } else { + toast({ title: 'Account created', description: 'Check your email to confirm your account.' }); + } navigate('/login'); } }; @@ -99,6 +113,7 @@ export default function Register() { setDisplayName(e.target.value)} />
    +
    setPassword(e.target.value)} required /> diff --git a/src/plugins/cloud-sync/StoragePanel.tsx b/src/plugins/cloud-sync/StoragePanel.tsx index 5174e8e..20b07b4 100644 --- a/src/plugins/cloud-sync/StoragePanel.tsx +++ b/src/plugins/cloud-sync/StoragePanel.tsx @@ -187,6 +187,14 @@ function PlanSection() { const renews = subscription?.current_period_end ? new Date(subscription.current_period_end).toLocaleDateString() : null; + const cancelsAtPeriodEnd = !!subscription?.cancel_at_period_end; + // A cancelled subscription drops to free but keeps a grace window before logs + // are trimmed; the row persists (with a Stripe customer) so they can resubscribe. + const graceUntil = subscription?.grace_until + ? new Date(subscription.grace_until).toLocaleDateString() + : null; + const inGrace = !subscribed && !!graceUntil; + const canManage = !!subscription?.current_period_end || subscribed || inGrace; const manage = async () => { setBusy(true); @@ -206,13 +214,20 @@ function PlanSection() {

    {loading ? "…" : label}

    {subscribed && renews && ( -

    Renews {renews}

    +

    + {cancelsAtPeriodEnd ? `Cancels ${renews}` : `Renews ${renews}`} +

    + )} + {inGrace && ( +

    + Subscription ended. Cloud logs trim to the free tier on {graceUntil} — resubscribe to keep them. +

    )} - {!subscribed && ( + {!subscribed && !inGrace && (

    Upgrade from the Plans & pricing cards.

    )}
    - {subscribed && ( + {canManage && (
    - -

    - You must be 16 or older to create an account. By continuing you - agree to our{' '} - Terms of Service{' '} - and{' '} - Privacy Policy. -

    diff --git a/supabase/migrations/20260527010000_gdpr_compliance.sql b/supabase/migrations/20260527010000_gdpr_compliance.sql index 3585574..e65286f 100644 --- a/supabase/migrations/20260527010000_gdpr_compliance.sql +++ b/supabase/migrations/20260527010000_gdpr_compliance.sql @@ -1,11 +1,15 @@ -- GDPR compliance: personal-data retention (IP TTL) + scheduled account deletion. -- -- Two concerns: --- 1. Abuse-prevention IP minimisation. `submitted_by_ip` on submissions and --- messages is nulled 90 days after the row was created; expired `banned_ips` --- and stale `login_attempts` rows are deleted. A daily pg_cron job runs the --- purge so data is cleared even when there's no traffic to trigger the --- reactive cleanup the edge functions already do. +-- 1. Personal-data minimisation, in two layers: +-- a. `submitted_by_ip` on submissions and messages is nulled 90 days +-- after the row was created (the abuse-investigation window); +-- b. the rows themselves are then deleted once their content is no longer +-- needed — contact messages and *reviewed* submissions after 1 year +-- (pending submissions are kept so they can still be moderated). +-- Expired `banned_ips` and stale `login_attempts` rows are deleted too. A +-- daily pg_cron job runs the purge so data is cleared even when there's no +-- traffic to trigger the reactive cleanup the edge functions already do. -- 2. Self-service account deletion is *scheduled*, not immediate. A 7-day grace -- window (reversible by the user) guards against a hijacked session wiping a -- user's race history. `account_deletions` holds the pending request; the @@ -27,8 +31,7 @@ security definer set search_path = public as $$ begin - -- Drop the submitter IP once the abuse-investigation window has passed; the - -- submission/message content itself is retained for the operator. + -- (a) Drop the submitter IP once the abuse-investigation window (90d) passes. update public.submissions set submitted_by_ip = null where submitted_by_ip is not null @@ -39,6 +42,17 @@ begin where submitted_by_ip is not null and created_at < now() - interval '90 days'; + -- (b) Delete the rows themselves once their content is no longer needed (1y). + -- Contact messages (email + free-text) go entirely. + delete from public.messages + where created_at < now() - interval '1 year'; + + -- Reviewed submissions go; pending ones are kept so they can still be + -- moderated regardless of age. + delete from public.submissions + where status <> 'pending' + and created_at < now() - interval '1 year'; + -- Expired bans no longer protect anything — remove the IP entirely. delete from public.banned_ips where expires_at is not null From 21e5f2f3335be2d0cceb977905fab0d00b70651e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:00:13 +0000 Subject: [PATCH 082/121] Gate the AI (Pro) tier as coming-soon, not self-service purchasable The AI-coaching plan is shown as a teaser but can't be bought at sign-up or via the Upgrade button, and create-checkout-session rejects it server-side. 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, mirrored in the edge function. https://claude.ai/code/session_012D8zxba3CCmUdqgT16zZav --- CHANGELOG.md | 6 ++++ CLAUDE.md | 8 +++++ README.md | 8 +++++ src/components/PlanChooser.tsx | 6 +++- src/components/PricingCards.tsx | 30 ++++++++++++++----- src/lib/billing.test.ts | 12 ++++++++ src/lib/billing.ts | 11 +++++++ .../create-checkout-session/index.ts | 7 +++++ 8 files changed, 79 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8553e70..eefa535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 limits, but your cloud logs are kept for a **60-day grace window** to re-subscribe or download. After it expires, a diff --git a/CLAUDE.md b/CLAUDE.md index f4b6ac0..c51fa34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -524,6 +524,14 @@ 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. +**Coming-soon tiers:** `COMING_SOON_TIERS` in `lib/billing.ts` (currently `pro`, +the AI plan) lists tiers that exist but aren't self-service purchasable yet — +shown as "Coming soon", excluded from `PlanChooser`, no Upgrade button, and +rejected by `create-checkout-session` (mirror the set there). 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 diff --git a/README.md b/README.md index 53a649f..3990fc8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,14 @@ The app includes an optional admin system for managing a community track databas > Stripe **test mode** first. Tier entitlements are granted only by the webhook, > never the client. > +> **Coming-soon / comped tiers:** the AI (Pro) tier is listed in +> `COMING_SOON_TIERS` (`src/lib/billing.ts`, mirrored in `create-checkout-session`) +> so it shows as "Coming soon" and can't be bought via the app. To give it to a +> tester/friend, create the subscription directly in Stripe on the `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 limits immediately, but the > user's cloud logs are kept for a 60-day grace window (`grace_until`). After it diff --git a/src/components/PlanChooser.tsx b/src/components/PlanChooser.tsx index e2cae07..3bcc83f 100644 --- a/src/components/PlanChooser.tsx +++ b/src/components/PlanChooser.tsx @@ -9,6 +9,7 @@ import { useStripePrices } from "@/hooks/useStripePrices"; import { type BillingInterval, formatPrice, + isComingSoon, paidTiersVisible, priceFor, tiersWithPrices, @@ -43,7 +44,10 @@ export function PlanChooser({ if (!paidTiersVisible(config)) return null; const available = tiersWithPrices(config.prices); - const paidTiers = PAID_ORDER.filter((t) => available.has(t)); + // Coming-soon tiers (e.g. the AI plan) aren't self-service purchasable at + // sign-up — they're comped manually via Stripe, not chosen here. + const paidTiers = PAID_ORDER.filter((t) => available.has(t) && !isComingSoon(t)); + if (paidTiers.length === 0) return null; const isPaid = value.tier !== "free"; const setInterval = (interval: BillingInterval) => { diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx index 2510067..d3d7ba9 100644 --- a/src/components/PricingCards.tsx +++ b/src/components/PricingCards.tsx @@ -8,6 +8,7 @@ import { useSubscription } from "@/hooks/useSubscription"; import { type BillingInterval, formatPrice, + isComingSoon, paidTiersVisible, priceFor, pricingCta, @@ -101,6 +102,7 @@ function TierCard({ inherits, features, highlight, + comingSoon, cta, }: { name: string; @@ -110,6 +112,7 @@ function TierCard({ inherits?: string; features: string[]; highlight?: boolean; + comingSoon?: boolean; cta?: ReactNode; }) { return ( @@ -123,14 +126,21 @@ function TierCard({ Recommended )} + {comingSoon && ( + + Coming soon + + )}

    {name}

    {blurb}

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

    {inherits}

    }
      {features.map((f) => ( @@ -258,18 +268,22 @@ export function PricingCards({ className }: { className?: string }) { ))} {showPaid && PAID_TIERS.map((tier) => { + const soon = isComingSoon(tier.slug); const price = priceFor(config.prices, tier.slug, interval); - if (!price) return null; // this interval isn't priced for this tier + // Purchasable tiers without a price for this interval are hidden; + // coming-soon tiers always show (as a teaser) but can't be bought. + if (!soon && !price) return null; return ( ); })} diff --git a/src/lib/billing.test.ts b/src/lib/billing.test.ts index 9748c29..a705b39 100644 --- a/src/lib/billing.test.ts +++ b/src/lib/billing.test.ts @@ -9,6 +9,7 @@ import { paidTiersVisible, priceFor, formatPrice, + isComingSoon, type StripePrice, } from "./billing"; @@ -124,6 +125,17 @@ describe("priceFor", () => { }); }); +describe("isComingSoon", () => { + it("flags the AI (pro) tier as not-yet-purchasable", () => { + expect(isComingSoon("pro")).toBe(true); + }); + it("treats the other tiers as available", () => { + expect(isComingSoon("free")).toBe(false); + expect(isComingSoon("plus")).toBe(false); + expect(isComingSoon("premium")).toBe(false); + }); +}); + describe("formatPrice", () => { it("drops cents for whole amounts and keeps them otherwise", () => { expect(formatPrice(100, "usd")).toBe("$1"); diff --git a/src/lib/billing.ts b/src/lib/billing.ts index 018bd82..e600dc6 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -107,6 +107,17 @@ export function isPaidTier(tier: string): boolean { return tier !== "free"; } +// Tiers that exist but aren't yet self-service purchasable — shown as +// "Coming soon" and never selectable for checkout (the create-checkout-session +// edge function rejects them too). They can still be granted manually (e.g. +// comping a tester) by creating the subscription in Stripe, which the webhook +// honours. Keep this in sync with create-checkout-session's COMING_SOON set. +export const COMING_SOON_TIERS = new Set(["pro"]); + +export function isComingSoon(tier: string): boolean { + return COMING_SOON_TIERS.has(tier); +} + export type PricingCtaKind = "none" | "current" | "upgrade"; export interface PricingCtaInput { diff --git a/supabase/functions/create-checkout-session/index.ts b/supabase/functions/create-checkout-session/index.ts index 5f0714e..c914b25 100644 --- a/supabase/functions/create-checkout-session/index.ts +++ b/supabase/functions/create-checkout-session/index.ts @@ -48,6 +48,13 @@ Deno.serve(async (req) => { if (!tier || typeof tier !== 'string' || tier === 'free') { return json({ error: 'Invalid tier' }, 400); } + // Tiers that exist but aren't self-service purchasable yet (e.g. the AI + // plan). They can still be comped by creating the subscription directly in + // Stripe — the webhook honours it. Keep in sync with billing.ts COMING_SOON_TIERS. + const COMING_SOON = new Set(['pro']); + if (COMING_SOON.has(tier)) { + return json({ error: 'Tier is coming soon and not yet available to purchase' }, 400); + } const billingInterval = interval === 'annual' ? 'annual' : 'monthly'; const admin = createClient( From ca106b17373d0bca0f214e5dabb067dd8a122351 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:15:09 +0000 Subject: [PATCH 083/121] Add customizable engine-type combobox for vehicles Replace the vehicle form's free-text Engine input with a searchable combobox backed by a reusable, per-user engine list. Users type to filter previously saved engines and create new ones inline when no match exists. Existing vehicles' engine names are auto-imported, and a "manage" link opens a small delete-only menu (engines in use by a vehicle are protected from deletion). Engines persist in a new IndexedDB store (db v10) and emit garage change events so they ride along with the rest of the garage over cloud sync. Pure search/dedup logic lives in engineUtils with tests. https://claude.ai/code/session_014aFrVH8nLTmVuRHhAU4gmt --- CHANGELOG.md | 8 ++ CLAUDE.md | 8 +- src/components/drawer/EngineCombobox.tsx | 174 +++++++++++++++++++++++ src/components/drawer/VehiclesTab.tsx | 34 ++++- src/hooks/useEngineManager.ts | 53 +++++++ src/lib/dbUtils.ts | 8 +- src/lib/engineStorage.ts | 55 +++++++ src/lib/engineUtils.test.ts | 70 +++++++++ src/lib/engineUtils.ts | 53 +++++++ src/plugins/cloud-sync/syncStores.ts | 2 + 10 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 src/components/drawer/EngineCombobox.tsx create mode 100644 src/hooks/useEngineManager.ts create mode 100644 src/lib/engineStorage.ts create mode 100644 src/lib/engineUtils.test.ts create mode 100644 src/lib/engineUtils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6b52c..39a1528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index 4bce56e..fa3b216 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ src/ │ ├── admin/ # Admin tabs: TracksTab, CoursesTab, SubmissionsTab, BannedIpsTab, ToolsTab, MessagesTab │ ├── tabs/ # Main view tabs: GraphViewTab, RaceLineTab, LapTimesTab, LabsTab, CoachTab, ProfileTab │ ├── graphview/ # Pro mode: GraphPanel, GraphViewPanel, MiniMap, SingleSeriesChart, InfoBox -│ ├── drawer/ # File manager drawer tabs: FilesTab, KartsTab, NotesTab, SetupsTab, DeviceSettingsTab, DeviceTracksTab +│ ├── drawer/ # File manager drawer tabs: FilesTab, KartsTab/VehiclesTab, NotesTab, SetupsTab, DeviceSettingsTab, DeviceTracksTab, EngineCombobox │ ├── 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) @@ -114,6 +114,7 @@ src/ │ ├── useFileManager.ts # IndexedDB file CRUD │ ├── useKartManager.ts # Backward compat re-export → useVehicleManager │ ├── useVehicleManager.ts # Vehicle profiles CRUD +│ ├── useEngineManager.ts # Reusable engine-type list CRUD (search/create/import) │ ├── useTemplateManager.ts # Vehicle types & setup templates CRUD │ ├── useNoteManager.ts # Session notes CRUD │ ├── useSetupManager.ts # Generic setup sheets CRUD (template-driven) @@ -151,6 +152,8 @@ src/ │ ├── fileStorage.ts # IndexedDB: raw file blobs │ ├── kartStorage.ts # Old kart storage (kept for compat) │ ├── vehicleStorage.ts # ★ Vehicle profiles CRUD (replaces kartStorage) +│ ├── engineStorage.ts # IndexedDB: reusable engine-type list (emits garage events) +│ ├── engineUtils.ts # Pure engine search/dedup/create-offer helpers │ ├── templateStorage.ts # ★ Vehicle types + setup templates, default kart schema │ ├── noteStorage.ts # IndexedDB: session notes │ ├── setupStorage.ts # IndexedDB: kart setups @@ -395,7 +398,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 10. | Store | Key | Module | |-------|-----|--------| @@ -409,6 +412,7 @@ 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` | To add a new store: increment `DB_VERSION`, add store name to `STORE_NAMES`, add creation logic in `openDB()`, create a corresponding storage module. 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/VehiclesTab.tsx b/src/components/drawer/VehiclesTab.tsx index b75d149..ef05cd3 100644 --- a/src/components/drawer/VehiclesTab.tsx +++ b/src/components/drawer/VehiclesTab.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { Pencil, Trash2, Car } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -7,6 +7,8 @@ import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Vehicle } from "@/lib/vehicleStorage"; import { VehicleType } from "@/lib/templateStorage"; +import { useEngineManager } from "@/hooks/useEngineManager"; +import { EngineCombobox } from "./EngineCombobox"; interface VehiclesTabProps { vehicles: Vehicle[]; @@ -31,6 +33,24 @@ export function VehiclesTab({ vehicles, vehicleTypes, onAdd, onUpdate, onRemove const [form, setForm] = useState(emptyForm(defaultTypeId)); const [confirmDelete, setConfirmDelete] = useState(null); + const { engines, addEngine, importEngines, removeEngine } = useEngineManager(); + + // Seed the reusable engine list from engines already saved on vehicles. + const vehicleEngineKey = useMemo( + () => vehicles.map(v => v.engine).filter(Boolean).join("|"), + [vehicles], + ); + useEffect(() => { + const names = vehicles.map(v => v.engine).filter(Boolean); + if (names.length) importEngines(names); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vehicleEngineKey, importEngines]); + + const usedEngineNames = useMemo( + () => vehicles.map(v => v.engine).filter(Boolean), + [vehicles], + ); + const resetForm = useCallback(() => { setEditingId(null); setForm(emptyForm(defaultTypeId)); @@ -129,10 +149,14 @@ export function VehiclesTab({ vehicles, vehicleTypes, onAdd, onUpdate, onRemove setForm(f => ({ ...f, name: e.target.value }))} placeholder="Vehicle name" className="h-8 text-sm" />
-
- - setForm(f => ({ ...f, engine: e.target.value }))} placeholder="Engine type" className="h-8 text-sm" /> -
+ setForm(f => ({ ...f, engine }))} + engines={engines} + onCreate={addEngine} + onDelete={removeEngine} + usedNames={usedEngineNames} + />
setForm(f => ({ ...f, number: parseInt(e.target.value) || 0 }))} placeholder="0" className="h-8 text-sm" /> diff --git a/src/hooks/useEngineManager.ts b/src/hooks/useEngineManager.ts new file mode 100644 index 0000000..62068ce --- /dev/null +++ b/src/hooks/useEngineManager.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useCallback } from "react"; +import { Engine, listEngines, saveEngine, deleteEngine } from "@/lib/engineStorage"; +import { distinctEngineNames, findEngineByName, normalizeEngineName } from "@/lib/engineUtils"; + +export function useEngineManager() { + const [engines, setEngines] = useState([]); + + const refresh = useCallback(async () => { + setEngines(await listEngines()); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + /** Create an engine by name (deduped, case-insensitive). Returns the engine name. */ + const addEngine = useCallback( + async (name: string): Promise => { + const display = normalizeEngineName(name); + if (!display) return null; + const existing = findEngineByName(engines, display); + if (existing) return existing.name; + await saveEngine({ id: crypto.randomUUID(), name: display, createdAt: Date.now() }); + await refresh(); + return display; + }, + [engines, refresh], + ); + + /** Ensure every supplied name exists in the list (used to seed from existing vehicles). */ + const importEngines = useCallback( + async (names: string[]) => { + const current = await listEngines(); + const missing = distinctEngineNames(names).filter((n) => !findEngineByName(current, n)); + if (missing.length === 0) return; + for (const name of missing) { + await saveEngine({ id: crypto.randomUUID(), name, createdAt: Date.now() }); + } + await refresh(); + }, + [refresh], + ); + + const removeEngine = useCallback( + async (id: string) => { + await deleteEngine(id); + await refresh(); + }, + [refresh], + ); + + return { engines, refresh, addEngine, importEngines, removeEngine }; +} diff --git a/src/lib/dbUtils.ts b/src/lib/dbUtils.ts index fe3a1ae..5743560 100644 --- a/src/lib/dbUtils.ts +++ b/src/lib/dbUtils.ts @@ -5,7 +5,7 @@ */ export const DB_NAME = "dove-file-manager"; -export const DB_VERSION = 9; +export const DB_VERSION = 10; export const STORE_NAMES = { FILES: "files", @@ -18,6 +18,7 @@ export const STORE_NAMES = { VEHICLE_TYPES: "vehicle-types", SETUP_TEMPLATES: "setup-templates", SESSION_VIDEOS: "session-videos", + ENGINES: "engines", // reusable engine-type list for vehicle profiles } as const; /** @@ -68,6 +69,11 @@ export function openDB(): Promise { db.createObjectStore(STORE_NAMES.SESSION_VIDEOS, { keyPath: "sessionFileName" }); } + // v10: Reusable engine-type list + if (!db.objectStoreNames.contains(STORE_NAMES.ENGINES)) { + db.createObjectStore(STORE_NAMES.ENGINES, { keyPath: "id" }); + } + // v8 migration: add vehicleId index to setups if upgrading from v7 if (oldVersion < 8) { try { diff --git a/src/lib/engineStorage.ts b/src/lib/engineStorage.ts new file mode 100644 index 0000000..a389aa1 --- /dev/null +++ b/src/lib/engineStorage.ts @@ -0,0 +1,55 @@ +/** + * IndexedDB CRUD for the "engines" object store — a reusable list of engine + * types users build up while creating vehicles. Each write/delete emits a + * garage change so the cloud-sync plugin can carry it across devices. + */ + +import { openDB, STORE_NAMES } from './dbUtils'; +import { emitGarageChange } from './garageEvents'; + +export interface Engine { + id: string; + name: string; + createdAt: number; + /** Last local edit time (ms) — set by saveEngine; used for sync merge. */ + updatedAt?: number; +} + +const ENGINES_STORE = STORE_NAMES.ENGINES; + +export async function saveEngine(engine: Engine): Promise { + const stamped: Engine = { ...engine, updatedAt: Date.now() }; + const db = await openDB(); + const tx = db.transaction(ENGINES_STORE, "readwrite"); + tx.objectStore(ENGINES_STORE).put(stamped); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + emitGarageChange({ store: ENGINES_STORE, key: engine.id, type: "put" }); +} + +export async function listEngines(): Promise { + const db = await openDB(); + const tx = db.transaction(ENGINES_STORE, "readonly"); + const request = tx.objectStore(ENGINES_STORE).getAll(); + const results = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + db.close(); + return results; +} + +export async function deleteEngine(id: string): Promise { + const db = await openDB(); + const tx = db.transaction(ENGINES_STORE, "readwrite"); + tx.objectStore(ENGINES_STORE).delete(id); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + emitGarageChange({ store: ENGINES_STORE, key: id, type: "delete" }); +} diff --git a/src/lib/engineUtils.test.ts b/src/lib/engineUtils.test.ts new file mode 100644 index 0000000..6521e5b --- /dev/null +++ b/src/lib/engineUtils.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import type { Engine } from "./engineStorage"; +import { + normalizeEngineName, + engineNameKey, + findEngineByName, + filterEngines, + shouldOfferCreate, + distinctEngineNames, +} from "./engineUtils"; + +const engine = (name: string): Engine => ({ id: name, name, createdAt: 0 }); + +describe("normalizeEngineName / engineNameKey", () => { + it("trims the display name and lowercases the key", () => { + expect(normalizeEngineName(" IAME X30 ")).toBe("IAME X30"); + expect(engineNameKey(" IAME X30 ")).toBe("iame x30"); + }); +}); + +describe("findEngineByName", () => { + const engines = [engine("IAME X30"), engine("Rotax Max")]; + + it("matches case-insensitively and ignores surrounding whitespace", () => { + expect(findEngineByName(engines, " iame x30 ")?.name).toBe("IAME X30"); + }); + + it("returns undefined for no match or empty query", () => { + expect(findEngineByName(engines, "Briggs")).toBeUndefined(); + expect(findEngineByName(engines, " ")).toBeUndefined(); + }); +}); + +describe("filterEngines", () => { + const engines = [engine("Rotax Max"), engine("IAME X30"), engine("IAME KA100")]; + + it("returns all sorted by name when the query is empty", () => { + expect(filterEngines(engines, "").map((e) => e.name)).toEqual([ + "IAME KA100", + "IAME X30", + "Rotax Max", + ]); + }); + + it("filters by case-insensitive substring", () => { + expect(filterEngines(engines, "iame").map((e) => e.name)).toEqual(["IAME KA100", "IAME X30"]); + }); +}); + +describe("shouldOfferCreate", () => { + const engines = [engine("IAME X30")]; + + it("offers create for a novel, non-empty name", () => { + expect(shouldOfferCreate("Rotax", engines)).toBe(true); + }); + + it("does not offer create for an existing name (case-insensitive) or blank input", () => { + expect(shouldOfferCreate(" iame x30 ", engines)).toBe(false); + expect(shouldOfferCreate(" ", engines)).toBe(false); + }); +}); + +describe("distinctEngineNames", () => { + it("dedupes case-insensitively, drops blanks, and keeps first-seen casing", () => { + expect(distinctEngineNames(["IAME X30", "", " iame x30 ", "Rotax", "rotax"])).toEqual([ + "IAME X30", + "Rotax", + ]); + }); +}); diff --git a/src/lib/engineUtils.ts b/src/lib/engineUtils.ts new file mode 100644 index 0000000..94b6c93 --- /dev/null +++ b/src/lib/engineUtils.ts @@ -0,0 +1,53 @@ +/** + * Pure helpers for the reusable engine-type list. + * Kept free of IndexedDB so the search / dedup / create-offer logic stays + * unit-testable and can be shared by the storage hook and the combobox UI. + */ + +import type { Engine } from "./engineStorage"; + +/** Trimmed display form of a typed engine name. */ +export function normalizeEngineName(name: string): string { + return name.trim(); +} + +/** Case-insensitive comparison key for an engine name. */ +export function engineNameKey(name: string): string { + return name.trim().toLowerCase(); +} + +/** Find a saved engine matching a name (case-insensitive, trimmed). */ +export function findEngineByName(engines: Engine[], name: string): Engine | undefined { + const key = engineNameKey(name); + if (!key) return undefined; + return engines.find((e) => engineNameKey(e.name) === key); +} + +/** Filter the saved list by a query (case-insensitive substring; empty → all), sorted by name. */ +export function filterEngines(engines: Engine[], query: string): Engine[] { + const q = engineNameKey(query); + const matches = q ? engines.filter((e) => engineNameKey(e.name).includes(q)) : engines.slice(); + return matches.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Whether to offer "create" for the typed query (non-empty and not an exact saved match). */ +export function shouldOfferCreate(query: string, engines: Engine[]): boolean { + const name = normalizeEngineName(query); + if (!name) return false; + return !findEngineByName(engines, name); +} + +/** Distinct, trimmed, non-empty engine names from raw values (first-seen casing wins). */ +export function distinctEngineNames(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const raw of values) { + const name = normalizeEngineName(raw ?? ""); + if (!name) continue; + const key = engineNameKey(name); + if (seen.has(key)) continue; + seen.add(key); + result.push(name); + } + return result; +} diff --git a/src/plugins/cloud-sync/syncStores.ts b/src/plugins/cloud-sync/syncStores.ts index 2813a38..fed5c71 100644 --- a/src/plugins/cloud-sync/syncStores.ts +++ b/src/plugins/cloud-sync/syncStores.ts @@ -16,6 +16,7 @@ const KEY_FIELD: Record = { // with them or pulled setups can't render. [STORE_NAMES.VEHICLE_TYPES]: "id", [STORE_NAMES.SETUP_TEMPLATES]: "id", + [STORE_NAMES.ENGINES]: "id", [TRACKS_SYNC_STORE]: "name", // user tracks (localStorage, via a store accessor) [STORE_NAMES.FILES]: "name", }; @@ -29,6 +30,7 @@ export const DOC_STORES = [ STORE_NAMES.GRAPH_PREFS, STORE_NAMES.VEHICLE_TYPES, STORE_NAMES.SETUP_TEMPLATES, + STORE_NAMES.ENGINES, TRACKS_SYNC_STORE, ] as const; From 10e820297027b670f6b057ccd6e13bcae9319c3c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 03:49:22 +0000 Subject: [PATCH 084/121] Add lap snapshots: frozen "course fastest lap" per engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture a single lap (GPS samples ± a 5s buffer, course geometry, engine, and a copy of the vehicle/setup) as an immutable baseline for cross-session comparison and future AI coaching. - One snapshot per (course, engine): assigning an engine+setup prompts to save/update the course fastest lap when it's faster; a manual save lives in the lap-list Snapshots picker. A faster lap replaces it in place. - Loaded as a comparison overlay through the external-reference slot, so it never auto-plays and is excluded from playback and the video player. - Local-first and unlimited on-device; cloud-synced via a dedicated lap_snapshots table with per-tier COUNT limits (free 5 / plus 10 / premium 20 / pro 50), not byte document storage. Always pushes on save; a local delete never propagates to the cloud (cloud copy removed only from Profile -> Lap snapshots, like the log menu), with tombstones to prevent reconcile resurrection. https://claude.ai/code/session_01L9h3QDcyTEXmVe6tWMio6T --- CHANGELOG.md | 20 ++ CLAUDE.md | 43 +++- README.md | 4 +- src/components/LapSnapshotControls.tsx | 145 ++++++++++++ src/components/LapSnapshotPromptDialog.tsx | 62 ++++++ src/hooks/useLapSnapshots.ts | 207 ++++++++++++++++++ src/lib/billing.ts | 2 + src/lib/dbUtils.ts | 11 +- src/lib/lapSnapshot.test.ts | 127 +++++++++++ src/lib/lapSnapshot.ts | 173 +++++++++++++++ src/lib/lapSnapshotStorage.ts | 80 +++++++ src/pages/Index.tsx | 72 +++++- src/plugins/cloud-sync/LapSnapshotsPanel.tsx | 204 +++++++++++++++++ src/plugins/cloud-sync/autoSync.ts | 27 ++- src/plugins/cloud-sync/cloudClient.ts | 28 +++ src/plugins/cloud-sync/index.ts | 14 +- src/plugins/cloud-sync/snapshotSync.ts | 118 ++++++++++ src/plugins/cloud-sync/snapshotTombstones.ts | 33 +++ .../20260529000000_lap_snapshots.sql | 116 ++++++++++ 19 files changed, 1471 insertions(+), 15 deletions(-) create mode 100644 src/components/LapSnapshotControls.tsx create mode 100644 src/components/LapSnapshotPromptDialog.tsx create mode 100644 src/hooks/useLapSnapshots.ts create mode 100644 src/lib/lapSnapshot.test.ts create mode 100644 src/lib/lapSnapshot.ts create mode 100644 src/lib/lapSnapshotStorage.ts create mode 100644 src/plugins/cloud-sync/LapSnapshotsPanel.tsx create mode 100644 src/plugins/cloud-sync/snapshotSync.ts create mode 100644 src/plugins/cloud-sync/snapshotTombstones.ts create mode 100644 supabase/migrations/20260529000000_lap_snapshots.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 252cbbb..756b8a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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 a comparison overlay only** — never auto-plays, and is excluded + from playback and the video player (it rides the reference-overlay slot, not + the lap selection). Available next to the lap dropdown in both simple and + pro mode. + - **Local-first & unlimited on-device; cloud-synced with per-tier COUNT limits** + (free 5 / plus 10 / premium 20 / pro 50) via a dedicated `lap_snapshots` + table — not byte document storage. 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, diff --git a/CLAUDE.md b/CLAUDE.md index 312e304..4be8fee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,12 +104,15 @@ src/ │ ├── FileImport.tsx # Drag-and-drop file import │ ├── DataloggerDownload.tsx # BLE device download UI │ ├── ContactDialog.tsx # Public contact form dialog (categories shared const) +│ ├── LapSnapshotControls.tsx # ★ Lap-list snapshot picker: save + load-as-overlay +│ ├── LapSnapshotPromptDialog.tsx # ★ "New course fastest lap" save prompt │ └── ... ├── 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 +│ ├── useLapSnapshots.ts # ★ Lap snapshot orchestration (capture/prompt/overlay) │ ├── useVideoSync.ts # Video ↔ telemetry synchronization │ ├── useFileManager.ts # IndexedDB file CRUD │ ├── useKartManager.ts # Backward compat re-export → useVehicleManager @@ -138,6 +141,8 @@ src/ │ ├── fieldResolver.ts # Settings-facing adapter over channels.ts (canonical id resolution + field categories) │ ├── courseDetection.ts # ★ Auto course detection, direction detection, waypoint mode │ ├── lapCalculation.ts # Start/finish line crossing detection → Lap[] +│ ├── lapSnapshot.ts # ★ Pure snapshot types/keying/buffer (course+engine identity) +│ ├── lapSnapshotStorage.ts # ★ IndexedDB CRUD for lap snapshots (emits garageEvents) │ ├── brakingZones.ts # Braking zone detection from G-force data │ ├── speedEvents.ts # Min/max speed event detection │ ├── speedBounds.ts # Speed range utilities @@ -400,7 +405,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 10. +Single shared database: `"dove-file-manager"`, version 11. | Store | Key | Module | |-------|-----|--------| @@ -415,11 +420,47 @@ Single shared database: `"dove-file-manager"`, version 10. | `setup-templates` | `id` | `templateStorage.ts` | | `session-videos` | `sessionFileName` | `videoFileStorage.ts` | | `engines` | `id` | `engineStorage.ts` | +| `lap-snapshots` | `id` (indexed by `courseKey`, `engineKey`) | `lapSnapshotStorage.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`), so the count never + inflates. `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** with a + per-tier **COUNT** quota (free 5 / plus 10 / premium 20 / pro 50 via + `subscription_tiers.snapshot_count`), enforced by a trigger — NOT byte document + storage. 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. + +--- + ## Cloud Sync (`src/plugins/cloud-sync/`) Optional per-user backup/sync of the IndexedDB data above to Supabase. Built as diff --git a/README.md b/README.md index 707d727..725ce00 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) @@ -170,7 +171,8 @@ The admin system uses Lovable Cloud (Supabase) for the database. The schema is c - **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 - **quota_limits** — Baseline per-storage-type byte limits (documents/logs) -- **subscription_tiers** — Data-driven plan catalogue (free/plus/premium/pro): label, price, per-type byte limits +- **lap_snapshots** — Per-user frozen "course fastest lap" captures (one per course+engine), RLS-scoped; a dedicated, count-quota'd data type (not byte storage) +- **subscription_tiers** — Data-driven plan catalogue (free/plus/premium/pro): label, price, per-type byte limits, and lap-snapshot count limit (`snapshot_count`: 5/10/20/50) - **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) diff --git a/src/components/LapSnapshotControls.tsx b/src/components/LapSnapshotControls.tsx new file mode 100644 index 0000000..a66eda2 --- /dev/null +++ b/src/components/LapSnapshotControls.tsx @@ -0,0 +1,145 @@ +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: () => Promise; +} + +/** + * Lap-list companion for snapshots: save the current lap as a "course fastest + * lap", and load a saved snapshot as a 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, +}: LapSnapshotControlsProps) { + const [open, setOpen] = useState(false); + if (!hasCourse) return null; + + const count = snapshotsForCourse.length; + + const handleSave = async () => { + const result = await onSave(); + if (result.saved) { + toast.success(result.replaced ? "Course fastest lap updated." : "Lap snapshot saved."); + setOpen(false); + } 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 + + + + {!canSnapshot && ( +

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

+ )} + +
+

+ Compare a snapshot (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/hooks/useLapSnapshots.ts b/src/hooks/useLapSnapshots.ts new file mode 100644 index 0000000..daac563 --- /dev/null +++ b/src/hooks/useLapSnapshots.ts @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { GpsSample, Lap, ParsedData, TrackCourseSelection } from "@/types/racing"; +import type { Vehicle } from "@/lib/vehicleStorage"; +import type { VehicleSetup } from "@/lib/setupStorage"; +import { STORE_NAMES } from "@/lib/dbUtils"; +import { onGarageChange } from "@/lib/garageEvents"; +import { formatLapTime } from "@/lib/lapCalculation"; +import { + buildSnapshot, fastestLap, makeCourseKey, makeSnapshotId, normalizeEngine, + snapshotLapSamples, snapshotPromptKind, + type LapSnapshot, type SnapshotPromptKind, +} from "@/lib/lapSnapshot"; +import { + deleteSnapshot, listSnapshots, saveSnapshot, +} from "@/lib/lapSnapshotStorage"; + +export interface UseLapSnapshotsParams { + data: ParsedData | null; + laps: Lap[]; + selection: TrackCourseSelection | null; + selectedLapNumber: number | null; + currentFileName: string | null; + vehicles: Vehicle[]; + setups: VehicleSetup[]; + sessionKartId: string | null; + sessionSetupId: string | null; + /** Load a lap's samples as the (non-playable) comparison overlay. */ + onLoadOverlay: (samples: GpsSample[], label: string) => void; + onClearOverlay: () => void; +} + +export interface SnapshotPromptState { + kind: SnapshotPromptKind; + candidate: LapSnapshot; + existing: LapSnapshot | null; +} + +/** Human label for a loaded snapshot overlay — engine is shown so 2t vs 4t reads clearly. */ +export function snapshotLabel(snap: LapSnapshot): string { + return `${snap.engine || "Snapshot"} · ${formatLapTime(snap.lapTimeMs)}`; +} + +export interface SaveSnapshotResult { + saved: boolean; + replaced: boolean; + reason?: "no-engine" | "no-course" | "no-lap"; +} + +/** + * Orchestrates lap snapshots for the active session: the per-course list, the + * save-as-snapshot action, the "new course fastest lap" prompt on engine + * assignment, and loading a snapshot as a comparison overlay. + */ +export function useLapSnapshots(params: UseLapSnapshotsParams) { + const { + data, laps, selection, selectedLapNumber, currentFileName, + vehicles, setups, sessionKartId, sessionSetupId, onLoadOverlay, onClearOverlay, + } = params; + + const [snapshots, setSnapshots] = useState([]); + const [activeSnapshotId, setActiveSnapshotId] = useState(null); + const [prompt, setPrompt] = useState(null); + + const refresh = useCallback(async () => { + setSnapshots(await listSnapshots()); + }, []); + + // Load on mount + whenever the snapshot store changes (local saves, cloud pulls). + useEffect(() => { + void refresh(); + return onGarageChange((change) => { + if (change.store === STORE_NAMES.LAP_SNAPSHOTS) void refresh(); + }); + }, [refresh]); + + const courseKey = useMemo( + () => (selection ? makeCourseKey(selection.trackName, selection.courseName) : null), + [selection], + ); + + const snapshotsForCourse = useMemo( + () => (courseKey ? snapshots.filter((s) => s.courseKey === courseKey).sort((a, b) => a.lapTimeMs - b.lapTimeMs) : []), + [snapshots, courseKey], + ); + + // The engine/vehicle/setup a snapshot would be saved under, for given assignment. + const resolveContext = useCallback( + (kartId: string | null, setupId: string | null) => { + const vehicle = kartId ? vehicles.find((v) => v.id === kartId) ?? null : null; + const setup = setupId ? setups.find((s) => s.id === setupId) ?? null : null; + const engine = (vehicle?.engine ?? "").trim(); + return { vehicle, setup, engine }; + }, + [vehicles, setups], + ); + + /** Build the snapshot for a lap under a given engine/setup assignment, or null. */ + const buildCandidate = useCallback( + (lap: Lap | null, kartId: string | null, setupId: string | null): LapSnapshot | null => { + if (!lap || !data || !selection?.course) return null; + const { vehicle, setup, engine } = resolveContext(kartId, setupId); + if (!engine) return null; + + const id = makeSnapshotId( + makeCourseKey(selection.trackName, selection.courseName), + normalizeEngine(engine), + ); + const existing = snapshots.find((s) => s.id === id) ?? null; + + return buildSnapshot({ + lap, + samples: data.samples, + course: selection.course, + trackName: selection.trackName, + courseName: selection.courseName, + engine, + sourceFileName: currentFileName ?? "session", + recordedAt: data.startDate?.getTime(), + vehicle: vehicle ? { id: vehicle.id, name: vehicle.name, number: vehicle.number } : undefined, + setup: setup ?? undefined, + createdAt: existing?.createdAt, + }); + }, + [data, selection, snapshots, currentFileName, resolveContext], + ); + + /** True when the session has everything needed to capture a snapshot. */ + const canSnapshot = useMemo( + () => Boolean(selection?.course && laps.length > 0 && resolveContext(sessionKartId, sessionSetupId).engine), + [selection, laps.length, resolveContext, sessionKartId, sessionSetupId], + ); + + // ── Overlay loading (shares the external-reference slot; never auto-plays) ─── + const loadSnapshot = useCallback( + (snap: LapSnapshot) => { + onLoadOverlay(snapshotLapSamples(snap), snapshotLabel(snap)); + setActiveSnapshotId(snap.id); + }, + [onLoadOverlay], + ); + + const clearActive = useCallback(() => { + onClearOverlay(); + setActiveSnapshotId(null); + }, [onClearOverlay]); + + // ── Save (manual) ──────────────────────────────────────────────────────── + const saveSelectedLap = useCallback(async (): Promise => { + if (!selection?.course) return { saved: false, replaced: false, reason: "no-course" }; + const lap = + (selectedLapNumber !== null ? laps.find((l) => l.lapNumber === selectedLapNumber) : null) ?? + fastestLap(laps); + if (!lap) return { saved: false, replaced: false, reason: "no-lap" }; + const candidate = buildCandidate(lap, sessionKartId, sessionSetupId); + if (!candidate) return { saved: false, replaced: false, reason: "no-engine" }; + const replaced = snapshots.some((s) => s.id === candidate.id); + await saveSnapshot(candidate); + return { saved: true, replaced }; + }, [selection, selectedLapNumber, laps, buildCandidate, sessionKartId, sessionSetupId, snapshots]); + + const removeSnapshot = useCallback( + async (id: string) => { + await deleteSnapshot(id); + if (activeSnapshotId === id) clearActive(); + }, + [activeSnapshotId, clearActive], + ); + + // ── Auto-prompt on engine/setup assignment ────────────────────────────────── + const maybePromptOnAssignment = useCallback( + (kartId: string | null, setupId: string | null) => { + const best = fastestLap(laps); + const candidate = buildCandidate(best, kartId, setupId); + if (!candidate) return; + const existing = snapshots.find((s) => s.id === candidate.id) ?? null; + const kind = snapshotPromptKind(candidate.lapTimeMs, existing); + if (!kind) return; + setPrompt({ kind, candidate, existing }); + }, + [laps, buildCandidate, snapshots], + ); + + const confirmPrompt = useCallback(async () => { + if (!prompt) return; + await saveSnapshot(prompt.candidate); + setPrompt(null); + }, [prompt]); + + const dismissPrompt = useCallback(() => setPrompt(null), []); + + return { + snapshots, + snapshotsForCourse, + activeSnapshotId, + canSnapshot, + loadSnapshot, + clearActive, + setActiveSnapshotId, + saveSelectedLap, + removeSnapshot, + refresh, + prompt, + maybePromptOnAssignment, + confirmPrompt, + dismissPrompt, + }; +} diff --git a/src/lib/billing.ts b/src/lib/billing.ts index e600dc6..a529097 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -9,6 +9,8 @@ export interface SubscriptionTierRow { logs_bytes: number; doc_bytes: number; ai_credits: number; + /** Max cloud lap snapshots for the tier (count, not bytes). */ + snapshot_count: number; stripe_price_id: string | null; sort_order: number; } diff --git a/src/lib/dbUtils.ts b/src/lib/dbUtils.ts index 5743560..9e4e01a 100644 --- a/src/lib/dbUtils.ts +++ b/src/lib/dbUtils.ts @@ -5,7 +5,7 @@ */ export const DB_NAME = "dove-file-manager"; -export const DB_VERSION = 10; +export const DB_VERSION = 11; export const STORE_NAMES = { FILES: "files", @@ -19,6 +19,7 @@ export const STORE_NAMES = { SETUP_TEMPLATES: "setup-templates", SESSION_VIDEOS: "session-videos", ENGINES: "engines", // reusable engine-type list for vehicle profiles + LAP_SNAPSHOTS: "lap-snapshots", // frozen "course fastest lap" captures per engine } as const; /** @@ -74,6 +75,14 @@ export function openDB(): Promise { db.createObjectStore(STORE_NAMES.ENGINES, { keyPath: "id" }); } + // v11: Lap snapshots ("course fastest lap" per engine), keyed by a stable + // id and indexed by course (for the lap-list picker) and engine. + if (!db.objectStoreNames.contains(STORE_NAMES.LAP_SNAPSHOTS)) { + const snapStore = db.createObjectStore(STORE_NAMES.LAP_SNAPSHOTS, { keyPath: "id" }); + snapStore.createIndex("courseKey", "courseKey", { unique: false }); + snapStore.createIndex("engineKey", "engineKey", { unique: false }); + } + // v8 migration: add vehicleId index to setups if upgrading from v7 if (oldVersion < 8) { try { diff --git a/src/lib/lapSnapshot.test.ts b/src/lib/lapSnapshot.test.ts new file mode 100644 index 0000000..2d62804 --- /dev/null +++ b/src/lib/lapSnapshot.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import type { Course, GpsSample, Lap } from "@/types/racing"; +import { + buildSnapshot, fastestLap, makeCourseKey, makeSnapshotId, normalizeEngine, + snapshotLapSamples, snapshotPromptKind, SNAPSHOT_BUFFER_MS, +} from "./lapSnapshot"; + +const course: Course = { + name: "Full CW", + startFinishA: { lat: 35.0, lon: -97.0 }, + startFinishB: { lat: 35.0, lon: -97.001 }, +}; + +// 31 samples at 1s intervals, t = 0..30000. +function makeSamples(): GpsSample[] { + return Array.from({ length: 31 }, (_, i) => ({ + t: i * 1000, + lat: 35 + i * 1e-5, + lon: -97 + i * 1e-5, + speedMps: 20, + speedMph: 44.7, + speedKph: 72, + extraFields: {}, + })); +} + +function makeLap(startIndex: number, endIndex: number, samples: GpsSample[], lapNumber = 1): Lap { + return { + lapNumber, + startTime: samples[startIndex].t, + endTime: samples[endIndex].t, + lapTimeMs: samples[endIndex].t - samples[startIndex].t, + maxSpeedMph: 44.7, maxSpeedKph: 72, minSpeedMph: 0, minSpeedKph: 0, + startIndex, endIndex, + }; +} + +describe("normalizeEngine", () => { + it("trims and lowercases", () => { + expect(normalizeEngine(" Rotax Max ")).toBe("rotax max"); + }); +}); + +describe("key helpers", () => { + it("derives a stable id from course + engine", () => { + const ck = makeCourseKey("OKC", "Full CW"); + const id = makeSnapshotId(ck, normalizeEngine("Rotax")); + expect(id).toBe(makeSnapshotId(makeCourseKey("OKC", "Full CW"), "rotax")); + }); + + it("distinguishes different courses and engines", () => { + const a = makeSnapshotId(makeCourseKey("OKC", "Full CW"), "rotax"); + const b = makeSnapshotId(makeCourseKey("OKC", "Full CCW"), "rotax"); + const c = makeSnapshotId(makeCourseKey("OKC", "Full CW"), "tm"); + expect(new Set([a, b, c]).size).toBe(3); + }); +}); + +describe("buildSnapshot", () => { + const samples = makeSamples(); + const lap = makeLap(10, 20, samples); // lap t = 10000..20000 + + it("captures the lap with a 5s buffer on each side, clamped to the data", () => { + const snap = buildSnapshot({ + lap, samples, course, trackName: "OKC", courseName: "Full CW", + engine: "Rotax", sourceFileName: "s.dove", + }); + // Buffer reaches exactly ±5000ms: t 5000..25000 (21 samples). + expect(snap.samples[0].t).toBe(lap.startTime - SNAPSHOT_BUFFER_MS); + expect(snap.samples[snap.samples.length - 1].t).toBe(lap.endTime + SNAPSHOT_BUFFER_MS); + expect(snap.lapStartMs).toBe(10000); + expect(snap.lapEndMs).toBe(20000); + expect(snap.id).toBe(makeSnapshotId(makeCourseKey("OKC", "Full CW"), "rotax")); + expect(snap.engine).toBe("Rotax"); + expect(snap.engineKey).toBe("rotax"); + expect(snap.lapTimeMs).toBe(10000); + }); + + it("does not run past the start/end of the sample array", () => { + const earlyLap = makeLap(0, 3, samples); + const snap = buildSnapshot({ + lap: earlyLap, samples, course, trackName: "OKC", courseName: "Full CW", + engine: "X", sourceFileName: "s.dove", + }); + expect(snap.samples[0].t).toBe(0); // clamped to the first sample + }); + + it("preserves createdAt when replacing an existing snapshot", () => { + const snap = buildSnapshot({ + lap, samples, course, trackName: "OKC", courseName: "Full CW", + engine: "Rotax", sourceFileName: "s.dove", createdAt: 12345, now: 99999, + }); + expect(snap.createdAt).toBe(12345); + expect(snap.updatedAt).toBe(99999); + }); + + it("trims the buffer back to the clean lap for overlay comparison", () => { + const snap = buildSnapshot({ + lap, samples, course, trackName: "OKC", courseName: "Full CW", + engine: "Rotax", sourceFileName: "s.dove", + }); + const clean = snapshotLapSamples(snap); + expect(clean[0].t).toBe(10000); + expect(clean[clean.length - 1].t).toBe(20000); + expect(clean.length).toBe(11); + }); +}); + +describe("fastestLap", () => { + it("returns the min lapTimeMs lap, or null when empty", () => { + const samples = makeSamples(); + const laps = [makeLap(0, 10, samples, 1), makeLap(0, 5, samples, 2), makeLap(0, 8, samples, 3)]; + expect(fastestLap(laps)?.lapNumber).toBe(2); + expect(fastestLap([])).toBeNull(); + }); +}); + +describe("snapshotPromptKind", () => { + it("prompts 'new' when nothing exists", () => { + expect(snapshotPromptKind(60000, null)).toBe("new"); + }); + it("prompts 'faster' only when the candidate beats the existing", () => { + expect(snapshotPromptKind(59000, { lapTimeMs: 60000 })).toBe("faster"); + expect(snapshotPromptKind(60000, { lapTimeMs: 60000 })).toBeNull(); + expect(snapshotPromptKind(61000, { lapTimeMs: 60000 })).toBeNull(); + }); +}); diff --git a/src/lib/lapSnapshot.ts b/src/lib/lapSnapshot.ts new file mode 100644 index 0000000..3e67f46 --- /dev/null +++ b/src/lib/lapSnapshot.ts @@ -0,0 +1,173 @@ +// Lap snapshots — frozen, single-lap "course fastest lap" captures. +// +// A snapshot freezes one lap (its GPS samples ± a 5s buffer), the course +// geometry, the engine string, and a copy of the vehicle/setup at capture time. +// It NEVER changes once saved (unless deleted); that historical immutability is +// the whole point — it's a baseline you can load and compare against any later +// session on the same course, regardless of the engine you're running now. +// +// Identity is (course + engine): the engine is the layman's "primary key" for +// the comparison, the chassis travels with the frozen setup. There's exactly one +// snapshot per (course, engine) — a faster lap replaces it in place (same id), so +// it can never inflate the stored count. This module is pure (no IndexedDB / no +// React) so the keying + buffer logic stays unit-testable. + +import type { Course, GpsSample, Lap } from "@/types/racing"; +import type { VehicleSetup } from "./setupStorage"; + +/** Samples kept on each side of the lap, so a later start/finish nudge still fits. */ +export const SNAPSHOT_BUFFER_MS = 5000; + +// Separator for composite keys — only ever compared for equality, never split. +// ASCII unit separator (0x1F): never appears in a track / course / engine name. +const SEP = String.fromCharCode(31); + +/** Frozen vehicle context stored with a snapshot (engine is the match key). */ +export interface SnapshotVehicle { + id?: string; + name?: string; + number?: number; +} + +export interface LapSnapshot { + /** Stable id derived from courseKey + engineKey — one snapshot per engine+course. */ + id: string; + + // ── Matching identity ────────────────────────────────────────────────────── + trackName: string; + courseName: string; + /** Composite of track + course — indexed for per-course lookup. */ + courseKey: string; + /** Display engine string (free-text, from the vehicle). */ + engine: string; + /** Normalized engine (trimmed + lowercased) for matching — indexed. */ + engineKey: string; + + // ── Frozen lap payload (immutable once saved) ─────────────────────────────── + /** Course geometry at capture time, so overlays survive later course edits. */ + course: Course; + lapTimeMs: number; + sourceFileName: string; + sourceLapNumber: number; + /** Session start (epoch ms) if known — for display. */ + recordedAt?: number; + /** Buffered samples: the actual lap ± SNAPSHOT_BUFFER_MS on each side. */ + samples: GpsSample[]; + /** `sample.t` of the actual lap start within `samples`. */ + lapStartMs: number; + /** `sample.t` of the actual lap end within `samples`. */ + lapEndMs: number; + + // ── Frozen setup / chassis context ────────────────────────────────────────── + vehicle?: SnapshotVehicle; + setup?: VehicleSetup; + + // ── Bookkeeping ───────────────────────────────────────────────────────────── + createdAt: number; + /** Last local write (ms) — used for sync merge. */ + updatedAt: number; +} + +/** Normalize an engine string so "Rotax Max", " rotax max " match. */ +export function normalizeEngine(engine: string): string { + return engine.trim().toLowerCase(); +} + +/** Composite course identity (track + course); only ever compared for equality. */ +export function makeCourseKey(trackName: string, courseName: string): string { + return `${trackName.trim()}${SEP}${courseName.trim()}`; +} + +/** Stable snapshot id for a (course, engine) pair — same pair ⇒ same id ⇒ replace. */ +export function makeSnapshotId(courseKey: string, engineKey: string): string { + return `snap${SEP}${courseKey}${SEP}${engineKey}`; +} + +export interface BuildSnapshotInput { + lap: Lap; + /** Full session samples (`ParsedData.samples`). */ + samples: GpsSample[]; + course: Course; + trackName: string; + courseName: string; + engine: string; + sourceFileName: string; + recordedAt?: number; + vehicle?: SnapshotVehicle; + setup?: VehicleSetup; + /** Preserve the original capture time when replacing an existing snapshot. */ + createdAt?: number; + now?: number; +} + +/** Slice the lap from session samples with a ±5s buffer, returning a frozen snapshot. */ +export function buildSnapshot(input: BuildSnapshotInput): LapSnapshot { + const { + lap, samples, course, trackName, courseName, engine, + sourceFileName, recordedAt, vehicle, setup, + } = input; + const now = input.now ?? Date.now(); + + const lapStartMs = samples[lap.startIndex]?.t ?? 0; + const lapEndMs = samples[lap.endIndex]?.t ?? lapStartMs; + + // Expand the slice outwards by the buffer window, clamped to the array. + let startIdx = lap.startIndex; + while (startIdx > 0 && lapStartMs - samples[startIdx - 1].t <= SNAPSHOT_BUFFER_MS) startIdx--; + let endIdx = lap.endIndex; + while (endIdx < samples.length - 1 && samples[endIdx + 1].t - lapEndMs <= SNAPSHOT_BUFFER_MS) endIdx++; + + const buffered = samples.slice(startIdx, endIdx + 1).map((s) => ({ ...s })); + + const courseKey = makeCourseKey(trackName, courseName); + const engineKey = normalizeEngine(engine); + + return { + id: makeSnapshotId(courseKey, engineKey), + trackName: trackName.trim(), + courseName: courseName.trim(), + courseKey, + engine: engine.trim(), + engineKey, + course, + lapTimeMs: lap.lapTimeMs, + sourceFileName, + sourceLapNumber: lap.lapNumber, + recordedAt, + samples: buffered, + lapStartMs, + lapEndMs, + vehicle, + setup, + createdAt: input.createdAt ?? now, + updatedAt: now, + }; +} + +/** The clean lap samples (buffer trimmed) — used as the comparison overlay. */ +export function snapshotLapSamples(snap: LapSnapshot): GpsSample[] { + const clean = snap.samples.filter((s) => s.t >= snap.lapStartMs && s.t <= snap.lapEndMs); + // Defensive: if the markers don't line up (legacy/edited data), fall back to all. + return clean.length > 0 ? clean : snap.samples; +} + +/** The fastest lap in a list (min lapTimeMs), or null when empty. */ +export function fastestLap(laps: Lap[]): Lap | null { + if (laps.length === 0) return null; + return laps.reduce((min, l) => (l.lapTimeMs < min.lapTimeMs ? l : min), laps[0]); +} + +export type SnapshotPromptKind = "new" | "faster"; + +/** + * Decide whether assigning an engine to this session should prompt to save/update + * the course fastest-lap snapshot. Returns the prompt kind, or null when the + * existing snapshot is already as fast or faster (no prompt). + */ +export function snapshotPromptKind( + candidateLapMs: number, + existing: Pick | null | undefined, +): SnapshotPromptKind | null { + if (!existing) return "new"; + return candidateLapMs < existing.lapTimeMs ? "faster" : null; +} diff --git a/src/lib/lapSnapshotStorage.ts b/src/lib/lapSnapshotStorage.ts new file mode 100644 index 0000000..834858c --- /dev/null +++ b/src/lib/lapSnapshotStorage.ts @@ -0,0 +1,80 @@ +// IndexedDB CRUD for the "lap-snapshots" object store. +// +// Local-first and unlimited on-device (the cloud count quota is enforced +// server-side by the sync plugin). Saving emits a garage change so the cloud-sync +// plugin can push it; unlike garage docs, a local DELETE never propagates to the +// cloud (the sync plugin ignores snapshot deletes) — the cloud copy is removed +// only explicitly from the profile page, just like the log menu. + +import { openDB, STORE_NAMES } from "./dbUtils"; +import { emitGarageChange } from "./garageEvents"; +import type { LapSnapshot } from "./lapSnapshot"; + +const STORE = STORE_NAMES.LAP_SNAPSHOTS; + +function reqPromise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** All snapshots, newest first. */ +export async function listSnapshots(): Promise { + const db = await openDB(); + const tx = db.transaction(STORE, "readonly"); + const all = await reqPromise(tx.objectStore(STORE).getAll()); + db.close(); + return all.sort((a, b) => b.updatedAt - a.updatedAt); +} + +/** Snapshots for one course (any engine), sorted fastest-first. */ +export async function listSnapshotsForCourse(courseKey: string): Promise { + const db = await openDB(); + const tx = db.transaction(STORE, "readonly"); + const all = await reqPromise(tx.objectStore(STORE).index("courseKey").getAll(courseKey)); + db.close(); + return all.sort((a, b) => a.lapTimeMs - b.lapTimeMs); +} + +export async function getSnapshot(id: string): Promise { + const db = await openDB(); + const tx = db.transaction(STORE, "readonly"); + const result = await reqPromise(tx.objectStore(STORE).get(id)); + db.close(); + return result ?? null; +} + +/** Save (or replace) a snapshot and notify the sync plugin to push it. */ +export async function saveSnapshot(snap: LapSnapshot): Promise { + await putSnapshotRaw({ ...snap, updatedAt: Date.now() }); + emitGarageChange({ store: STORE, key: snap.id, type: "put" }); +} + +/** + * Write without emitting a garage event or re-stamping — the cloud pull path + * (mirrors the sync engine's `putOne`), so a pulled snapshot doesn't echo back. + */ +export async function putSnapshotRaw(snap: LapSnapshot): Promise { + const db = await openDB(); + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put(snap); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); +} + +/** Delete locally. The cloud copy is untouched (deletes don't propagate). */ +export async function deleteSnapshot(id: string): Promise { + const db = await openDB(); + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(id); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + emitGarageChange({ store: STORE, key: id, type: "delete" }); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 819054d..a909ed9 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -44,6 +44,9 @@ import { useTemplateManager } from "@/hooks/useTemplateManager"; import { useSessionData } from "@/hooks/useSessionData"; import { useLapManagement } from "@/hooks/useLapManagement"; import { useReferenceLap, useExternalReference } from "@/hooks/useReferenceLap"; +import { useLapSnapshots } from "@/hooks/useLapSnapshots"; +import { LapSnapshotControls } from "@/components/LapSnapshotControls"; +import { LapSnapshotPromptDialog } from "@/components/LapSnapshotPromptDialog"; import { useSessionMetadata } from "@/hooks/useSessionMetadata"; import { useVideoSync } from "@/hooks/useVideoSync"; import { useDataLoader } from "@/hooks/useDataLoader"; @@ -148,17 +151,55 @@ export default function Index() { allTracks, gpsCenter, } = dataLoader; + // Lap snapshots: frozen "course fastest lap" captures, loaded as a comparison + // overlay through the same external-reference slot (so they never auto-play or + // appear in the video player). + const loadSnapshotOverlay = useCallback((samples: typeof filteredSamples, label: string) => { + externalRef.setExternalRefSamples(samples); + externalRef.setExternalRefLabel(label); + setReferenceLapNumber(null); + }, [externalRef, setReferenceLapNumber]); + + const snapshots = useLapSnapshots({ + data, + laps, + selection, + selectedLapNumber, + currentFileName, + vehicles: vehicleManager.vehicles, + setups: setupManager.setups, + sessionKartId, + sessionSetupId, + onLoadOverlay: loadSnapshotOverlay, + onClearOverlay: handleClearExternalRef, + }); + // Reference-lap handlers: clear the other side when one is set. const handleSetReferenceWithClear = useCallback((lapNumber: number) => { handleSetReference(lapNumber); externalRef.setExternalRefSamples(null); externalRef.setExternalRefLabel(null); - }, [handleSetReference, externalRef]); + snapshots.setActiveSnapshotId(null); + }, [handleSetReference, externalRef, snapshots]); const handleSelectExternalLapWithClear = useCallback((fileName: string, lapNumber: number) => { handleSelectExternalLap(fileName, lapNumber); setReferenceLapNumber(null); - }, [handleSelectExternalLap, setReferenceLapNumber]); + snapshots.setActiveSnapshotId(null); + }, [handleSelectExternalLap, setReferenceLapNumber, snapshots]); + + // Assigning an engine/setup may set a new course fastest lap → prompt to save. + const handleSaveSessionSetupWithSnapshot = useCallback(async (kartId: string | null, setupId: string | null) => { + await sessionMeta.handleSaveSessionSetup(kartId, setupId); + snapshots.maybePromptOnAssignment(kartId, setupId); + }, [sessionMeta, snapshots]); + + // Clearing the shared reference slot (e.g. the ExternalRefBar X) must also drop + // the active snapshot, since a loaded snapshot rides that same slot. + const handleClearExternalRefWithSnapshot = useCallback(() => { + handleClearExternalRef(); + snapshots.setActiveSnapshotId(null); + }, [handleClearExternalRef, snapshots]); const hasReference = referenceLapNumber !== null || externalRefSamples !== null; @@ -249,13 +290,13 @@ export default function Index() { onLapSelect: handleLapSelect, onSetReference: handleSetReferenceWithClear, onSelectExternalLap: handleSelectExternalLapWithClear, - onClearExternalRef: handleClearExternalRef, + onClearExternalRef: handleClearExternalRefWithSnapshot, onLoadFileForRef: handleLoadFileForRef, onRefreshSavedFiles: refreshSavedFiles, onRangeChange: handleRangeChange, onFieldToggle: sessionData.handleFieldToggle, onWeatherStationResolved: sessionMeta.handleWeatherStationResolved, - onSaveSessionSetup: sessionMeta.handleSaveSessionSetup, + onSaveSessionSetup: handleSaveSessionSetupWithSnapshot, formatRangeLabel, }), [ data, visibleSamples, filteredSamples, referenceSamples, currentSample, fieldMappings, @@ -269,10 +310,10 @@ export default function Index() { vehicleManager.vehicles, setupManager.setups, templateManager.templates, videoSync.state, videoSync.actions, videoSync.handleLoadedMetadata, handleScrub, handleLapSelect, handleSetReferenceWithClear, - handleSelectExternalLapWithClear, handleClearExternalRef, handleLoadFileForRef, + handleSelectExternalLapWithClear, handleClearExternalRefWithSnapshot, handleLoadFileForRef, refreshSavedFiles, handleRangeChange, sessionData.handleFieldToggle, sessionMeta.handleWeatherStationResolved, - sessionMeta.handleSaveSessionSetup, formatRangeLabel, + handleSaveSessionSetupWithSnapshot, formatRangeLabel, ]); // Shared FileManagerDrawer props @@ -309,7 +350,7 @@ export default function Index() { onRemoveVehicleType: templateManager.removeVehicleType, sessionKartId, sessionSetupId, - onSaveSessionSetup: sessionMeta.handleSaveSessionSetup, + onSaveSessionSetup: handleSaveSessionSetupWithSnapshot, }), [ fileManager.isOpen, fileManager.files, fileManager.fileMetadataMap, fileManager.storageUsed, fileManager.storageQuota, fileManager.close, fileManager.loadFile, fileManager.removeFile, fileManager.exportFile, fileManager.saveFile, @@ -319,7 +360,7 @@ export default function Index() { currentFileName, noteManager.notes, noteManager.addNote, noteManager.updateNote, noteManager.removeNote, setupManager.setups, setupManager.addSetup, setupManager.updateSetup, setupManager.removeSetup, setupManager.getLatestForVehicle, - sessionKartId, sessionSetupId, sessionMeta.handleSaveSessionSetup, + sessionKartId, sessionSetupId, handleSaveSessionSetupWithSnapshot, ]); // No data loaded - show import UI @@ -391,6 +432,16 @@ export default function Index() { )} + +
diff --git a/src/plugins/cloud-sync/LapSnapshotsPanel.tsx b/src/plugins/cloud-sync/LapSnapshotsPanel.tsx new file mode 100644 index 0000000..60b91bb --- /dev/null +++ b/src/plugins/cloud-sync/LapSnapshotsPanel.tsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useState } from "react"; +import { Camera, Trash2, AlertTriangle } from "lucide-react"; +import { toast } from "sonner"; +import type { PluginPanelProps } from "@/plugins/panels"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/contexts/AuthContext"; +import { formatLapTime } from "@/lib/lapCalculation"; +import type { LapSnapshot } from "@/lib/lapSnapshot"; +import { deleteSnapshot, listSnapshots } from "@/lib/lapSnapshotStorage"; +import { fetchSnapshotUsage } from "./cloudClient"; +import { deleteCloudSnapshot, listCloudSnapshots, reconcileSnapshots } from "./snapshotSync"; + +function formatDate(ms?: number): string { + if (!ms) return ""; + const d = new Date(ms); + return isNaN(d.getTime()) + ? "" + : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +// Profile-tab panel for lap snapshots. Signed in: manage the snapshots stored in +// YOUR cloud (delete removes the cloud copy; local copies on devices are kept). +// Signed out: manage the snapshots saved on THIS device — the only thing you can +// do with them until you sign in to sync. +export default function LapSnapshotsPanel(_props: PluginPanelProps) { + const { user, loading } = useAuth(); + const [items, setItems] = useState(null); + const [localIds, setLocalIds] = useState>(new Set()); + const [usage, setUsage] = useState<{ usedCount: number; limitCount: number } | null>(null); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(null); + const [alsoLocal, setAlsoLocal] = useState(false); + const [busy, setBusy] = useState(null); + const [syncing, setSyncing] = useState(false); + + const refresh = useCallback(async () => { + try { + const local = await listSnapshots(); + setLocalIds(new Set(local.map((s) => s.id))); + if (user) { + const [cloud, u] = await Promise.all([listCloudSnapshots(user.id), fetchSnapshotUsage()]); + setItems(cloud.map((c) => c.data)); + setUsage(u); + } else { + setItems(local); + setUsage(null); + } + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load snapshots"); + } + }, [user]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + if (loading) return

Loading…

; + + const startConfirm = (id: string) => { + setConfirming(id); + setAlsoLocal(false); + }; + + const handleDelete = async (snap: LapSnapshot) => { + setBusy(snap.id); + try { + if (user) { + await deleteCloudSnapshot(user.id, snap); + if (alsoLocal && localIds.has(snap.id)) await deleteSnapshot(snap.id); + toast.success( + alsoLocal && localIds.has(snap.id) + ? "Deleted snapshot from the cloud and this device." + : "Deleted snapshot from the cloud.", + ); + } else { + await deleteSnapshot(snap.id); + toast.success("Deleted snapshot from this device."); + } + setConfirming(null); + await refresh(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Delete failed"); + } finally { + setBusy(null); + } + }; + + const handleSyncLocal = async () => { + if (!user) return; + setSyncing(true); + try { + const { pushed, skipped } = await reconcileSnapshots(user.id); + if (skipped > 0) toast.error(`${skipped} snapshot${skipped === 1 ? "" : "s"} didn't fit your plan's limit.`); + else if (pushed > 0) toast.success(`Synced ${pushed} snapshot${pushed === 1 ? "" : "s"}.`); + else toast("Everything is already synced."); + await refresh(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Sync failed"); + } finally { + setSyncing(false); + } + }; + + if (error) return

{error}

; + if (!items) return

Loading snapshots…

; + + const unsyncedLocal = user ? localIds.size > items.length : false; + + return ( +
+ {user ? ( +
+

+ {usage ? `${usage.usedCount} of ${usage.limitCount} synced` : "Synced snapshots"} +

+ {unsyncedLocal && ( + + )} +
+ ) : ( +

+ On this device. Sign in to sync snapshots across devices. +

+ )} + + {items.length === 0 ? ( +

+ {user ? "No snapshots in your cloud yet." : "No snapshots saved on this device yet."} +

+ ) : ( +
+ {items.map((snap) => { + const isConfirming = confirming === snap.id; + const onDevice = localIds.has(snap.id); + return ( +
+
+ +
+

+ {snap.engine || "Unknown engine"} + + {formatLapTime(snap.lapTimeMs)} + +

+

+ {snap.trackName} — {snap.courseName} + {formatDate(snap.recordedAt ?? snap.createdAt) && ` · ${formatDate(snap.recordedAt ?? snap.createdAt)}`} + {user && onDevice && " · on this device"} +

+
+ +
+ + {isConfirming && ( +
+

+ + + {user + ? "Permanently delete the cloud copy? This can't be undone." + : "Delete this snapshot from this device? This can't be undone."} + +

+ {user && onDevice && ( +
+ + +
+ )} +
+ + +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/plugins/cloud-sync/autoSync.ts b/src/plugins/cloud-sync/autoSync.ts index 85d633b..7e23192 100644 --- a/src/plugins/cloud-sync/autoSync.ts +++ b/src/plugins/cloud-sync/autoSync.ts @@ -10,9 +10,11 @@ // here; log blobs stay manual/opt-in. import { supabase } from "@/integrations/supabase/client"; +import { STORE_NAMES } from "@/lib/dbUtils"; import { onGarageChange, type GarageChange } from "@/lib/garageEvents"; -import { isQuotaError } from "./cloudClient"; +import { isQuotaError, isSnapshotQuotaError } from "./cloudClient"; import { deleteCloudFile, deleteRecord, pushRecord, reconcileDocs } from "./syncEngine"; +import { clearSnapshotTombstone, pushSnapshot, reconcileSnapshots } from "./snapshotSync"; import { clearPending, listPending, markPending, pendingKeySet } from "./pendingSync"; import { unselectFile } from "./fileSync"; import { pendingId } from "./merge"; @@ -37,6 +39,14 @@ function isOnline(): boolean { } async function pushOne(userId: string, change: GarageChange): Promise { + if (change.store === STORE_NAMES.LAP_SNAPSHOTS) { + // Snapshots always push on save; a local delete never propagates to the + // cloud (the cloud copy is removed only explicitly, from the profile page). + if (change.type === "delete") return; + await clearSnapshotTombstone(change.key); // a fresh save re-enables cloud sync + await pushSnapshot(userId, change.key); + return; + } if (change.store === FILE_STORE) { // Files only ever queue here as a deferred *delete* (a log removed while // offline). Remove the blob + its index, and drop the stale selection. @@ -61,7 +71,9 @@ async function flush(change: GarageChange): Promise { await pushOne(userId, change); await clearPending(change.store, change.key); } catch (err) { - if (isQuotaError(err)) { + if (isSnapshotQuotaError(err)) { + notify("Cloud snapshot limit reached — saved locally. Delete one in Profile to sync.", "error"); + } else if (isQuotaError(err)) { notify( `Cloud ${storageTypeForStore(change.store)} storage is full — saved locally, not synced.`, "error", @@ -98,7 +110,9 @@ async function flushPending(userId: string): Promise { await pushOne(userId, change); await clearPending(change.store, change.key); } catch (err) { - if (isQuotaError(err)) { + if (isSnapshotQuotaError(err)) { + notify("Cloud snapshot limit reached — delete one in Profile to sync.", "error"); + } else if (isQuotaError(err)) { notify( `Cloud ${storageTypeForStore(change.store)} storage is full — some changes didn't sync.`, "error", @@ -119,6 +133,13 @@ async function runReconcile(userId: string): Promise { "error", ); } + const snap = await reconcileSnapshots(userId); + if (snap.skipped > 0) { + notify( + `Cloud snapshot limit reached — ${snap.skipped} snapshot${snap.skipped === 1 ? "" : "s"} didn't sync. Delete one in Profile.`, + "error", + ); + } } catch (err) { if (isQuotaError(err)) { notify("Cloud document storage is full — some items didn't sync.", "error"); diff --git a/src/plugins/cloud-sync/cloudClient.ts b/src/plugins/cloud-sync/cloudClient.ts index 64e57d5..ea12a73 100644 --- a/src/plugins/cloud-sync/cloudClient.ts +++ b/src/plugins/cloud-sync/cloudClient.ts @@ -30,6 +30,34 @@ export function syncRecords() { return untyped.from("sync_records"); } +/** A row in public.lap_snapshots — one frozen lap capture, one per engine+course. */ +export interface LapSnapshotRow { + id?: string; + user_id: string; + course_key: string; + engine_key: string; + data: unknown; + updated_at?: string; +} + +/** Query builder for the lap_snapshots table (a dedicated, count-quota'd type). */ +export function lapSnapshotsTable() { + return untyped.from("lap_snapshots"); +} + +/** Current user's snapshot usage: how many of the tier's count limit are used. */ +export async function fetchSnapshotUsage(): Promise<{ usedCount: number; limitCount: number }> { + const { data, error } = await untyped.rpc("snapshot_usage"); + if (error) throw new Error(`Failed to read snapshot usage: ${error.message}`); + const row = ((data ?? []) as { used_count?: number; limit_count?: number }[])[0]; + return { usedCount: row?.used_count ?? 0, limitCount: row?.limit_count ?? 0 }; +} + +/** True when an error is the server's snapshot count-quota rejection. */ +export function isSnapshotQuotaError(err: unknown): boolean { + return err instanceof Error && /snapshot_quota_exceeded/i.test(err.message); +} + /** Storage API for the private per-user file bucket. */ export function userFiles() { return untyped.storage.from(SYNC_BUCKET); diff --git a/src/plugins/cloud-sync/index.ts b/src/plugins/cloud-sync/index.ts index a04f972..297ae25 100644 --- a/src/plugins/cloud-sync/index.ts +++ b/src/plugins/cloud-sync/index.ts @@ -1,5 +1,5 @@ import { lazy } from "react"; -import { Cloud, ShieldCheck, User } from "lucide-react"; +import { Camera, Cloud, ShieldCheck, User } from "lucide-react"; import { toast } from "sonner"; import type { DataViewerPlugin } from "@/plugins/types"; import { PANELS_POINT, PanelSlot, type PluginPanel } from "@/plugins/panels"; @@ -23,6 +23,7 @@ const DownloadAllCloudLogs = lazy(() => import("./DownloadAllCloudLogs")); // Profile tab panels: storage usage meters + account, and cloud-log management. const StoragePanel = lazy(() => import("./StoragePanel")); const CloudLogsPanel = lazy(() => import("./CloudLogsPanel")); +const LapSnapshotsPanel = lazy(() => import("./LapSnapshotsPanel")); // Profile tab: GDPR self-service — export everything + scheduled account deletion. const DataPrivacyPanel = lazy(() => import("./DataPrivacyPanel")); @@ -94,6 +95,17 @@ const plugin: DataViewerPlugin = { component: StoragePanel, } satisfies PluginPanel); + // Profile tab: view/delete lap snapshots (cloud when signed in, local when + // signed out — the one snapshot feature available before sign-in). + ctx.registry.contribute(PANELS_POINT, { + id: "cloud-sync-snapshots", + title: "Lap snapshots", + slot: PanelSlot.Profile, + order: 5, + icon: Camera, + component: LapSnapshotsPanel, + } satisfies PluginPanel); + // Profile tab: manage (delete) the log files stored in the cloud. ctx.registry.contribute(PANELS_POINT, { id: "cloud-sync-logs", diff --git a/src/plugins/cloud-sync/snapshotSync.ts b/src/plugins/cloud-sync/snapshotSync.ts new file mode 100644 index 0000000..6ccfa29 --- /dev/null +++ b/src/plugins/cloud-sync/snapshotSync.ts @@ -0,0 +1,118 @@ +// Cloud sync for lap snapshots — a dedicated data type (NOT byte document +// storage), enforced by a per-tier COUNT quota server-side. +// +// Sync model (deliberately a hybrid of garage docs and log files): +// • Always push on local save (like garage docs) — snapshots are valuable. +// • A local delete NEVER deletes the cloud copy (like the log menu); the cloud +// row is removed only explicitly here (deleteCloudSnapshot), which tombstones +// the id so reconcile won't resurrect it. +// • One cloud row per (user, course, engine): a faster lap upserts in place and +// never increases the count. + +import type { LapSnapshot } from "@/lib/lapSnapshot"; +import { getSnapshot, listSnapshots, putSnapshotRaw } from "@/lib/lapSnapshotStorage"; +import { isSnapshotQuotaError, lapSnapshotsTable } from "./cloudClient"; +import { + addSnapshotTombstone, clearSnapshotTombstone, snapshotTombstoneSet, +} from "./snapshotTombstones"; + +export interface CloudSnapshot { + data: LapSnapshot; + updatedAt?: string; +} + +interface RawRow { + course_key: string; + engine_key: string; + data: LapSnapshot; + updated_at?: string; +} + +/** Upsert one local snapshot to the cloud (no-op if gone locally or tombstoned). */ +export async function pushSnapshot(userId: string, id: string): Promise { + if ((await snapshotTombstoneSet()).has(id)) return; + const snap = await getSnapshot(id); + if (!snap) return; + const { error } = await lapSnapshotsTable().upsert( + [{ user_id: userId, course_key: snap.courseKey, engine_key: snap.engineKey, data: snap }], + { onConflict: "user_id,course_key,engine_key" }, + ); + if (error) throw new Error(error.message); +} + +/** List the snapshots this user has in the cloud. */ +export async function listCloudSnapshots(userId: string): Promise { + const { data, error } = await lapSnapshotsTable() + .select("course_key,engine_key,data,updated_at") + .eq("user_id", userId); + if (error) throw new Error(`Failed to list cloud snapshots: ${error.message}`); + return ((data ?? []) as RawRow[]) + .map((r) => ({ data: r.data, updatedAt: r.updated_at })) + .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")); +} + +/** + * Delete a snapshot from the cloud only (the local copy, if any, is kept). The id + * is tombstoned so the next reconcile doesn't re-push the surviving local copy. + */ +export async function deleteCloudSnapshot(userId: string, snap: LapSnapshot): Promise { + const { error } = await lapSnapshotsTable() + .delete() + .eq("user_id", userId) + .eq("course_key", snap.courseKey) + .eq("engine_key", snap.engineKey); + if (error) throw new Error(`Failed to delete cloud snapshot: ${error.message}`); + await addSnapshotTombstone(snap.id); +} + +export interface SnapshotReconcileResult { + pulled: number; + pushed: number; + /** Local snapshots that didn't fit under the tier's count limit. */ + skipped: number; +} + +/** + * Two-way snapshot sync. Pull cloud copies down (additive — never deletes local), + * then push local-only snapshots up, skipping tombstoned ids and counting any the + * server rejects for the count quota. Last-write-wins by `updatedAt` on overlap. + */ +export async function reconcileSnapshots(userId: string): Promise { + const tombstones = await snapshotTombstoneSet(); + const cloud = await listCloudSnapshots(userId); + const cloudById = new Map(cloud.map((c) => [c.data.id, c.data])); + + const local = await listSnapshots(); + const localById = new Map(local.map((s) => [s.id, s])); + + // Pull: cloud copy wins when it's newer or absent locally. + let pulled = 0; + for (const c of cloud) { + const localCopy = localById.get(c.data.id); + if (!localCopy || c.data.updatedAt > localCopy.updatedAt) { + await putSnapshotRaw(c.data); + pulled++; + } + } + + // Push: local snapshots the cloud lacks (or has an older copy of), unless the + // user has explicitly removed them from the cloud (tombstoned). + let pushed = 0; + let skipped = 0; + for (const s of local) { + if (tombstones.has(s.id)) continue; + const cloudCopy = cloudById.get(s.id); + if (cloudCopy && cloudCopy.updatedAt >= s.updatedAt) continue; + try { + await pushSnapshot(userId, s.id); + pushed++; + } catch (err) { + if (isSnapshotQuotaError(err)) skipped++; + else throw err; + } + } + + return { pulled, pushed, skipped }; +} + +export { clearSnapshotTombstone }; diff --git a/src/plugins/cloud-sync/snapshotTombstones.ts b/src/plugins/cloud-sync/snapshotTombstones.ts new file mode 100644 index 0000000..e63abc3 --- /dev/null +++ b/src/plugins/cloud-sync/snapshotTombstones.ts @@ -0,0 +1,33 @@ +// Tombstones for snapshots the user explicitly removed from the cloud. +// +// Snapshots always push on local save and a local delete never removes the cloud +// copy (see snapshotSync). The one case that needs memory: when the user deletes +// a snapshot from the *cloud* (in the profile page) but keeps it locally, the +// reconcile pass would otherwise re-push and resurrect it. A tombstone records +// "don't auto-push this id" until the user saves it again (which clears it). + +import { getPluginStore } from "@/plugins/storage"; + +const store = getPluginStore("cloud-sync"); +const KEY = "snapshot-tombstones"; + +async function read(): Promise { + return (await store.get(KEY)) ?? []; +} + +export async function addSnapshotTombstone(id: string): Promise { + const list = await read(); + if (!list.includes(id)) { + list.push(id); + await store.set(KEY, list); + } +} + +export async function clearSnapshotTombstone(id: string): Promise { + const list = await read(); + if (list.includes(id)) await store.set(KEY, list.filter((x) => x !== id)); +} + +export async function snapshotTombstoneSet(): Promise> { + return new Set(await read()); +} diff --git a/supabase/migrations/20260529000000_lap_snapshots.sql b/supabase/migrations/20260529000000_lap_snapshots.sql new file mode 100644 index 0000000..fd90abf --- /dev/null +++ b/supabase/migrations/20260529000000_lap_snapshots.sql @@ -0,0 +1,116 @@ +-- Lap snapshots: frozen "course fastest lap" captures, count-quota'd by tier. +-- +-- A NEW cloud data type (NOT byte document storage). Snapshots are chunky single +-- laps that power cross-session comparison and future AI coaching, so they get +-- their own table and a per-tier COUNT limit (free 5 / plus 10 / premium 20 / +-- pro 50) rather than counting against the documents byte quota. +-- +-- Sync model (mirrored client-side): snapshots always push on save, but a local +-- delete never propagates — the cloud copy is removed only explicitly (profile +-- page). One row per (user, course, engine): a faster lap upserts in place and +-- never increases the count. + +-- ── Table ──────────────────────────────────────────────────────────────────── +create table if not exists public.lap_snapshots ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + course_key text not null, + engine_key text not null, + data jsonb not null, + updated_at timestamptz not null default now(), + unique (user_id, course_key, engine_key) +); + +create index if not exists lap_snapshots_user_idx on public.lap_snapshots (user_id); + +alter table public.lap_snapshots enable row level security; + +drop policy if exists "Users read own snapshots" on public.lap_snapshots; +drop policy if exists "Users insert own snapshots" on public.lap_snapshots; +drop policy if exists "Users update own snapshots" on public.lap_snapshots; +drop policy if exists "Users delete own snapshots" on public.lap_snapshots; + +create policy "Users read own snapshots" + on public.lap_snapshots for select to authenticated using (auth.uid() = user_id); +create policy "Users insert own snapshots" + on public.lap_snapshots for insert to authenticated with check (auth.uid() = user_id); +create policy "Users update own snapshots" + on public.lap_snapshots for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); +create policy "Users delete own snapshots" + on public.lap_snapshots for delete to authenticated using (auth.uid() = user_id); + +-- ── Per-tier snapshot COUNT limit (data-driven, like the byte limits) ──────── +alter table public.subscription_tiers + add column if not exists snapshot_count integer not null default 5; + +update public.subscription_tiers set snapshot_count = 5 where tier = 'free'; +update public.subscription_tiers set snapshot_count = 10 where tier = 'plus'; +update public.subscription_tiers set snapshot_count = 20 where tier = 'premium'; +update public.subscription_tiers set snapshot_count = 50 where tier = 'pro'; + +-- The snapshot count limit for a user, from their effective tier (falls back to +-- free, then to a hard default). SECURITY DEFINER so the trigger can resolve it. +create or replace function public.snapshot_limit(p_user uuid) +returns integer language sql stable security definer set search_path = public as $$ + select coalesce( + (select t.snapshot_count from public.subscription_tiers t where t.tier = public.user_tier(p_user)), + (select t.snapshot_count from public.subscription_tiers t where t.tier = 'free'), + 5); +$$; +grant execute on function public.snapshot_limit(uuid) to authenticated; + +-- ── Count-quota enforcement ────────────────────────────────────────────────── +-- Fires BEFORE INSERT. An upsert that replaces an existing (user, course, engine) +-- row keeps the count, so we exclude that row from the tally — only genuinely new +-- combinations can be blocked. +create or replace function public.enforce_snapshot_quota() +returns trigger language plpgsql as $$ +declare + v_limit integer := public.snapshot_limit(NEW.user_id); + v_count integer; +begin + if v_limit is null then + return NEW; + end if; + + select count(*) into v_count + from public.lap_snapshots + where user_id = NEW.user_id + and not (course_key = NEW.course_key and engine_key = NEW.engine_key); + + if v_count >= v_limit then + raise exception 'snapshot_quota_exceeded: % snapshots used >= % limit', v_count, v_limit + using errcode = 'check_violation'; + end if; + + return NEW; +end; +$$; + +drop trigger if exists lap_snapshots_quota on public.lap_snapshots; +create trigger lap_snapshots_quota + before insert on public.lap_snapshots + for each row execute function public.enforce_snapshot_quota(); + +-- Keep updated_at fresh on every write. +create or replace function public.touch_lap_snapshot() +returns trigger language plpgsql as $$ +begin + NEW.updated_at = now(); + return NEW; +end; +$$; + +drop trigger if exists lap_snapshots_touch on public.lap_snapshots; +create trigger lap_snapshots_touch + before insert or update on public.lap_snapshots + for each row execute function public.touch_lap_snapshot(); + +-- ── Usage readout for the profile meter ────────────────────────────────────── +create or replace function public.snapshot_usage() +returns table(used_count integer, limit_count integer) +language sql stable as $$ + select (select count(*)::integer from public.lap_snapshots where user_id = auth.uid()), + public.snapshot_limit(auth.uid()); +$$; +grant execute on function public.snapshot_usage() to authenticated; From 97f7f1f7bf2c20233d99bf7a6c52f8e58c33f797 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 00:19:44 +0000 Subject: [PATCH 085/121] Changes Co-authored-by: TheAngryRaven <2923950+TheAngryRaven@users.noreply.github.com> --- src/integrations/supabase/types.ts | 146 +++++ ...3_16457d28-551f-4461-8d65-5b560a70e018.sql | 508 ++++++++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 supabase/migrations/20260528001943_16457d28-551f-4461-8d65-5b560a70e018.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index f69474d..94ad957 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,6 +14,24 @@ export type Database = { } public: { Tables: { + account_deletions: { + Row: { + requested_at: string + scheduled_for: string + user_id: string + } + Insert: { + requested_at?: string + scheduled_for: string + user_id: string + } + Update: { + requested_at?: string + scheduled_for?: string + user_id?: string + } + Relationships: [] + } banned_ips: { Row: { banned_at: string | null @@ -154,6 +172,33 @@ export type Database = { }, ] } + lap_snapshots: { + Row: { + course_key: string + data: Json + engine_key: string + id: string + updated_at: string + user_id: string + } + Insert: { + course_key: string + data: Json + engine_key: string + id: string + updated_at?: string + user_id: string + } + Update: { + course_key?: string + data?: Json + engine_key?: string + id?: string + updated_at?: string + user_id?: string + } + Relationships: [] + } login_attempts: { Row: { attempts: number | null @@ -289,6 +334,42 @@ export type Database = { } Relationships: [] } + subscription_tiers: { + Row: { + ai_credits: number + doc_bytes: number + label: string + logs_bytes: number + price_cents: number + snapshot_count: number + sort_order: number + stripe_price_id: string | null + tier: string + } + Insert: { + ai_credits?: number + doc_bytes: number + label: string + logs_bytes: number + price_cents?: number + snapshot_count?: number + sort_order?: number + stripe_price_id?: string | null + tier: string + } + Update: { + ai_credits?: number + doc_bytes?: number + label?: string + logs_bytes?: number + price_cents?: number + snapshot_count?: number + sort_order?: number + stripe_price_id?: string | null + tier?: string + } + Relationships: [] + } sync_records: { Row: { data: Json @@ -372,11 +453,68 @@ export type Database = { } Relationships: [] } + user_subscriptions: { + Row: { + billing_interval: string | null + cancel_at_period_end: boolean + current_period_end: string | null + grace_until: string | null + logs_trimmed_at: string | null + status: string + stripe_customer_id: string | null + stripe_subscription_id: string | null + tier: string + updated_at: string + user_id: string + } + Insert: { + billing_interval?: string | null + cancel_at_period_end?: boolean + current_period_end?: string | null + grace_until?: string | null + logs_trimmed_at?: string | null + status?: string + stripe_customer_id?: string | null + stripe_subscription_id?: string | null + tier?: string + updated_at?: string + user_id: string + } + Update: { + billing_interval?: string | null + cancel_at_period_end?: boolean + current_period_end?: string | null + grace_until?: string | null + logs_trimmed_at?: string | null + status?: string + stripe_customer_id?: string | null + stripe_subscription_id?: string | null + tier?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "user_subscriptions_tier_fkey" + columns: ["tier"] + isOneToOne: false + referencedRelation: "subscription_tiers" + referencedColumns: ["tier"] + }, + ] + } } Views: { [_ in never]: never } Functions: { + due_account_deletions: { + Args: never + Returns: { + user_id: string + }[] + } + encode_uri_component: { Args: { p: string }; Returns: string } has_role: { Args: { _role: Database["public"]["Enums"]["app_role"] @@ -384,6 +522,7 @@ export type Database = { } Returns: boolean } + purge_expired_personal_data: { Args: never; Returns: undefined } random_display_name: { Args: never; Returns: string } sync_record_size: { Args: { p_data: Json; p_store: string } @@ -398,7 +537,14 @@ export type Database = { used_bytes: number }[] } + tier_limit: { + Args: { p_type: string; p_user_id: string } + Returns: number + } + tier_snapshot_count: { Args: { p_user_id: string }; Returns: number } + trim_expired_logs: { Args: never; Returns: undefined } unique_display_name: { Args: { desired: string }; Returns: string } + user_tier: { Args: { p_user_id: string }; Returns: string } } Enums: { app_role: "admin" | "user" diff --git a/supabase/migrations/20260528001943_16457d28-551f-4461-8d65-5b560a70e018.sql b/supabase/migrations/20260528001943_16457d28-551f-4461-8d65-5b560a70e018.sql new file mode 100644 index 0000000..5b98b10 --- /dev/null +++ b/supabase/migrations/20260528001943_16457d28-551f-4461-8d65-5b560a70e018.sql @@ -0,0 +1,508 @@ +-- Cloud sync: per-user storage of telemetry files + garage data. +-- +-- Structured records (file metadata, vehicles/karts, setups, notes, graph prefs, +-- vehicle types, setup templates) are stored one row each in sync_records as a +-- jsonb document keyed by (user_id, store, record_key) — the same keys the +-- client's IndexedDB stores use. Raw session file blobs live in the private +-- user-files Storage bucket under {user_id}/. Everything is scoped to its owner +-- via RLS; there is no cross-user or public read path. + +-- ── Structured records ────────────────────────────────────────────────────── +create table if not exists public.sync_records ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + store text not null, + record_key text not null, + data jsonb not null, + updated_at timestamptz not null default now(), + unique (user_id, store, record_key) +); + +create index if not exists sync_records_user_store_idx + on public.sync_records (user_id, store); + +alter table public.sync_records enable row level security; + +do $$ begin + if not exists (select 1 from pg_policies where schemaname='public' and tablename='sync_records' and policyname='Users read own sync records') then + create policy "Users read own sync records" on public.sync_records for select to authenticated using (auth.uid() = user_id); + end if; + if not exists (select 1 from pg_policies where schemaname='public' and tablename='sync_records' and policyname='Users insert own sync records') then + create policy "Users insert own sync records" on public.sync_records for insert to authenticated with check (auth.uid() = user_id); + end if; + if not exists (select 1 from pg_policies where schemaname='public' and tablename='sync_records' and policyname='Users update own sync records') then + create policy "Users update own sync records" on public.sync_records for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); + end if; + if not exists (select 1 from pg_policies where schemaname='public' and tablename='sync_records' and policyname='Users delete own sync records') then + create policy "Users delete own sync records" on public.sync_records for delete to authenticated using (auth.uid() = user_id); + end if; +end $$; + +grant select, insert, update, delete on public.sync_records to authenticated; +grant all on public.sync_records to service_role; + +-- ── Raw file blobs ────────────────────────────────────────────────────────── +insert into storage.buckets (id, name, public) +values ('user-files', 'user-files', false) +on conflict (id) do nothing; + +do $$ begin + if not exists (select 1 from pg_policies where schemaname='storage' and tablename='objects' and policyname='Users read own files') then + create policy "Users read own files" on storage.objects for select to authenticated using (bucket_id = 'user-files' and (storage.foldername(name))[1] = auth.uid()::text); + end if; + if not exists (select 1 from pg_policies where schemaname='storage' and tablename='objects' and policyname='Users upload own files') then + create policy "Users upload own files" on storage.objects for insert to authenticated with check (bucket_id = 'user-files' and (storage.foldername(name))[1] = auth.uid()::text); + end if; + if not exists (select 1 from pg_policies where schemaname='storage' and tablename='objects' and policyname='Users update own files') then + create policy "Users update own files" on storage.objects for update to authenticated using (bucket_id = 'user-files' and (storage.foldername(name))[1] = auth.uid()::text); + end if; + if not exists (select 1 from pg_policies where schemaname='storage' and tablename='objects' and policyname='Users delete own files') then + create policy "Users delete own files" on storage.objects for delete to authenticated using (bucket_id = 'user-files' and (storage.foldername(name))[1] = auth.uid()::text); + end if; +end $$; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Storage quotas +-- ═══════════════════════════════════════════════════════════════════════════ +create table if not exists public.quota_limits ( + storage_type text primary key, + max_bytes bigint not null +); + +insert into public.quota_limits (storage_type, max_bytes) values + ('documents', 5242880), + ('logs', 20971520) +on conflict (storage_type) do update set max_bytes = excluded.max_bytes; + +alter table public.quota_limits enable row level security; +grant select on public.quota_limits to authenticated; +grant all on public.quota_limits to service_role; + +drop policy if exists "Anyone authenticated reads limits" on public.quota_limits; +create policy "Anyone authenticated reads limits" + on public.quota_limits for select to authenticated + using (true); + +create or replace function public.sync_record_size(p_store text, p_data jsonb) +returns bigint language sql immutable set search_path = public as $$ + select case + when p_store = 'files' then coalesce((p_data->>'size')::bigint, 0) + else octet_length(p_data::text)::bigint + end; +$$; + +create or replace function public.sync_storage_type(p_store text) +returns text language sql immutable set search_path = public as $$ + select case when p_store = 'files' then 'logs' else 'documents' end; +$$; + +create or replace function public.enforce_sync_quota() +returns trigger language plpgsql set search_path = public as $$ +declare + v_type text := public.sync_storage_type(NEW.store); + v_limit bigint; + v_used bigint; + v_new bigint := public.sync_record_size(NEW.store, NEW.data); +begin + select max_bytes into v_limit from public.quota_limits where storage_type = v_type; + if v_limit is null then return NEW; end if; + select coalesce(sum(public.sync_record_size(store, data)), 0) + into v_used + from public.sync_records + where user_id = NEW.user_id + and public.sync_storage_type(store) = v_type + and not (store = NEW.store and record_key = NEW.record_key); + if v_used + v_new > v_limit then + raise exception 'quota_exceeded: % storage over limit (% bytes used + % new > % limit)', + v_type, v_used, v_new, v_limit using errcode = 'check_violation'; + end if; + return NEW; +end; +$$; + +drop trigger if exists sync_records_quota on public.sync_records; +create trigger sync_records_quota + before insert or update on public.sync_records + for each row execute function public.enforce_sync_quota(); + +create or replace function public.sync_storage_usage() +returns table(storage_type text, used_bytes bigint, limit_bytes bigint) +language sql stable set search_path = public as $$ + select q.storage_type, + coalesce(sum(public.sync_record_size(r.store, r.data)), 0)::bigint, + q.max_bytes + from public.quota_limits q + left join public.sync_records r + on r.user_id = auth.uid() + and public.sync_storage_type(r.store) = q.storage_type + group by q.storage_type, q.max_bytes; +$$; + +grant execute on function public.sync_storage_usage() to authenticated; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- User profiles +-- ═══════════════════════════════════════════════════════════════════════════ +create table if not exists public.profiles ( + user_id uuid primary key references auth.users (id) on delete cascade, + display_name text not null unique, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table public.profiles enable row level security; +grant select, insert, update on public.profiles to authenticated; +grant all on public.profiles to service_role; + +drop policy if exists "Profiles readable by authenticated" on public.profiles; +create policy "Profiles readable by authenticated" on public.profiles for select to authenticated using (true); +drop policy if exists "Users insert own profile" on public.profiles; +create policy "Users insert own profile" on public.profiles for insert to authenticated with check (auth.uid() = user_id); +drop policy if exists "Users update own profile" on public.profiles; +create policy "Users update own profile" on public.profiles for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); + +create or replace function public.random_display_name() +returns text language plpgsql set search_path = public as $$ +declare + adjs text[] := array['Speedy','Turbo','Drifty','Nitro','Reckless','Smooth','Apex','Sideways','Greasy','Loose','Sketchy','Mighty','Sneaky','Wobbly','Blazing','Rowdy','Janky','Cosmic','Feral','Zippy']; + nouns text[] := array['Racer','Driver','Pilot','Hooligan','Throttle','Slider','Charger','Rocket','Gremlin','Goblin','Wrench','Piston','Sender','Drifter','Maniac','Comet','Bandit','Cheetah','Noodle','Menace']; + candidate text; +begin + loop + candidate := adjs[1 + floor(random() * array_length(adjs, 1))::int] + || replace(nouns[1 + floor(random() * array_length(nouns, 1))::int], 'e', '3') + || '-' || (100 + floor(random() * 900))::int::text; + exit when not exists (select 1 from public.profiles where display_name = candidate); + end loop; + return candidate; +end; +$$; + +create or replace function public.unique_display_name(desired text) +returns text language plpgsql set search_path = public as $$ +declare + d text := nullif(btrim(coalesce(desired, '')), ''); + candidate text; + tries int := 0; +begin + if d is null then return public.random_display_name(); end if; + candidate := d; + while exists (select 1 from public.profiles where display_name = candidate) loop + tries := tries + 1; + candidate := d || '-' || (100 + floor(random() * 9900))::int::text; + if tries > 50 then + candidate := d || '-' || replace(gen_random_uuid()::text, '-', ''); + exit; + end if; + end loop; + return candidate; +end; +$$; + +create or replace function public.handle_new_user() +returns trigger language plpgsql security definer set search_path = public as $$ +begin + insert into public.profiles (user_id, display_name) + values (new.id, public.unique_display_name(new.raw_user_meta_data->>'display_name')); + return new; +end; +$$; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + +-- Backfill profiles for existing users +insert into public.profiles (user_id, display_name) +select u.id, public.unique_display_name(u.raw_user_meta_data->>'display_name') +from auth.users u +left join public.profiles p on p.user_id = u.id +where p.user_id is null; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Stripe subscriptions +-- ═══════════════════════════════════════════════════════════════════════════ +create table if not exists public.subscription_tiers ( + tier text primary key, + label text not null, + price_cents integer not null default 0, + logs_bytes bigint not null, + doc_bytes bigint not null, + ai_credits integer not null default 0, + stripe_price_id text, + sort_order integer not null default 0 +); + +alter table public.subscription_tiers enable row level security; +grant select on public.subscription_tiers to authenticated, anon; +grant all on public.subscription_tiers to service_role; + +drop policy if exists "Tiers readable by all" on public.subscription_tiers; +create policy "Tiers readable by all" on public.subscription_tiers for select using (true); + +insert into public.subscription_tiers (tier, label, price_cents, logs_bytes, doc_bytes, ai_credits, sort_order) values + ('free', 'Free', 0, 20971520, 5242880, 0, 0), + ('plus', 'Plus', 100, 1572864000, 5242880, 0, 10), + ('pro', 'Pro', 1000, 1073741824, 5242880, 100, 30) +on conflict (tier) do update set + label = excluded.label, + price_cents = excluded.price_cents, + logs_bytes = excluded.logs_bytes, + doc_bytes = excluded.doc_bytes, + ai_credits = excluded.ai_credits, + sort_order = excluded.sort_order; + +create table if not exists public.user_subscriptions ( + user_id uuid primary key references auth.users (id) on delete cascade, + tier text not null default 'free' references public.subscription_tiers(tier), + status text not null default 'inactive', + stripe_customer_id text, + stripe_subscription_id text, + current_period_end timestamptz, + cancel_at_period_end boolean not null default false, + billing_interval text, + updated_at timestamptz not null default now() +); + +alter table public.user_subscriptions enable row level security; +grant select on public.user_subscriptions to authenticated; +grant all on public.user_subscriptions to service_role; + +drop policy if exists "Users read own subscription" on public.user_subscriptions; +create policy "Users read own subscription" on public.user_subscriptions for select to authenticated using (auth.uid() = user_id); + +create or replace function public.user_tier(p_user_id uuid) +returns text language sql stable security definer set search_path = public as $$ + select coalesce( + (select tier from public.user_subscriptions + where user_id = p_user_id + and status in ('active','trialing','past_due')), + 'free'); +$$; + +grant execute on function public.user_tier(uuid) to authenticated; + +create or replace function public.tier_limit(p_user_id uuid, p_type text) +returns bigint language sql stable security definer set search_path = public as $$ + select coalesce( + (select case p_type when 'logs' then logs_bytes when 'documents' then doc_bytes end + from public.subscription_tiers where tier = public.user_tier(p_user_id)), + (select case p_type when 'logs' then logs_bytes when 'documents' then doc_bytes end + from public.subscription_tiers where tier = 'free'), + (select max_bytes from public.quota_limits where storage_type = p_type)); +$$; + +grant execute on function public.tier_limit(uuid, text) to authenticated; + +-- Update quota trigger to use tier_limit +create or replace function public.enforce_sync_quota() +returns trigger language plpgsql set search_path = public as $$ +declare + v_type text := public.sync_storage_type(NEW.store); + v_limit bigint := public.tier_limit(NEW.user_id, v_type); + v_used bigint; + v_new bigint := public.sync_record_size(NEW.store, NEW.data); +begin + if v_limit is null then return NEW; end if; + select coalesce(sum(public.sync_record_size(store, data)), 0) + into v_used + from public.sync_records + where user_id = NEW.user_id + and public.sync_storage_type(store) = v_type + and not (store = NEW.store and record_key = NEW.record_key); + if v_used + v_new > v_limit then + raise exception 'quota_exceeded: % storage over limit (% bytes used + % new > % limit)', + v_type, v_used, v_new, v_limit using errcode = 'check_violation'; + end if; + return NEW; +end; +$$; + +create or replace function public.sync_storage_usage() +returns table(storage_type text, used_bytes bigint, limit_bytes bigint) +language sql stable security definer set search_path = public as $$ + select q.storage_type, + coalesce(sum(public.sync_record_size(r.store, r.data)), 0)::bigint, + public.tier_limit(auth.uid(), q.storage_type) + from public.quota_limits q + left join public.sync_records r + on r.user_id = auth.uid() + and public.sync_storage_type(r.store) = q.storage_type + group by q.storage_type; +$$; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Premium tier +-- ═══════════════════════════════════════════════════════════════════════════ +insert into public.subscription_tiers (tier, label, price_cents, logs_bytes, doc_bytes, ai_credits, sort_order) +values ('premium', 'Premium', 300, 1073741824, 5242880, 0, 20) +on conflict (tier) do update set + label = excluded.label, + price_cents = excluded.price_cents, + logs_bytes = excluded.logs_bytes, + doc_bytes = excluded.doc_bytes, + ai_credits = excluded.ai_credits, + sort_order = excluded.sort_order; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- GDPR compliance +-- ═══════════════════════════════════════════════════════════════════════════ +create table if not exists public.account_deletions ( + user_id uuid primary key references auth.users (id) on delete cascade, + requested_at timestamptz not null default now(), + scheduled_for timestamptz not null +); + +alter table public.account_deletions enable row level security; +grant select, delete on public.account_deletions to authenticated; +grant all on public.account_deletions to service_role; + +drop policy if exists "Users read own deletion" on public.account_deletions; +create policy "Users read own deletion" on public.account_deletions for select to authenticated using (auth.uid() = user_id); +drop policy if exists "Users cancel own deletion" on public.account_deletions; +create policy "Users cancel own deletion" on public.account_deletions for delete to authenticated using (auth.uid() = user_id); + +create or replace function public.encode_uri_component(p text) +returns text language sql immutable as $$ + select string_agg( + case when c ~ '[A-Za-z0-9\-_.!~*''()]' then c + else regexp_replace(encode(convert_to(c,'UTF8'),'hex'),'(..)','%\1','g') end, + '') + from regexp_split_to_table(p, '') as c; +$$; + +create or replace function public.purge_expired_personal_data() +returns void language plpgsql security definer set search_path = public as $$ +begin + update public.submissions set submitted_by_ip = null + where submitted_by_ip is not null and created_at < now() - interval '90 days'; + update public.messages set submitted_by_ip = null + where submitted_by_ip is not null and created_at < now() - interval '90 days'; + delete from public.messages where created_at < now() - interval '1 year'; + delete from public.submissions where reviewed_at is not null and created_at < now() - interval '1 year'; + delete from public.banned_ips where expires_at is not null and expires_at < now(); + delete from public.login_attempts where locked_until is not null and locked_until < now() - interval '30 days'; +end; +$$; + +create or replace function public.due_account_deletions() +returns table(user_id uuid) language sql stable security definer set search_path = public as $$ + select user_id from public.account_deletions where scheduled_for <= now(); +$$; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Subscription grace + trim +-- ═══════════════════════════════════════════════════════════════════════════ +alter table public.user_subscriptions + add column if not exists grace_until timestamptz, + add column if not exists logs_trimmed_at timestamptz; + +create or replace function public.trim_expired_logs() +returns void language plpgsql security definer set search_path = public as $$ +declare + v_user record; + v_free_logs bigint; + v_used bigint; + v_row record; +begin + select logs_bytes into v_free_logs from public.subscription_tiers where tier = 'free'; + for v_user in + select us.user_id from public.user_subscriptions us + where us.grace_until is not null and us.grace_until < now() + and (us.logs_trimmed_at is null or us.logs_trimmed_at < us.grace_until) + loop + select coalesce(sum(public.sync_record_size(store, data)), 0) + into v_used from public.sync_records + where user_id = v_user.user_id and store = 'files'; + for v_row in + select record_key, data from public.sync_records + where user_id = v_user.user_id and store = 'files' + order by updated_at desc + loop + exit when v_used <= v_free_logs; + perform 1 from storage.objects + where bucket_id = 'user-files' + and name = v_user.user_id::text || '/' || public.encode_uri_component(v_row.record_key); + delete from storage.objects + where bucket_id = 'user-files' + and name = v_user.user_id::text || '/' || public.encode_uri_component(v_row.record_key); + delete from public.sync_records + where user_id = v_user.user_id and store = 'files' and record_key = v_row.record_key; + v_used := v_used - public.sync_record_size('files', v_row.data); + end loop; + update public.user_subscriptions + set logs_trimmed_at = now() + where user_id = v_user.user_id; + end loop; +end; +$$; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Lap snapshots +-- ═══════════════════════════════════════════════════════════════════════════ +alter table public.subscription_tiers + add column if not exists snapshot_count integer not null default 5; + +update public.subscription_tiers set snapshot_count = 5 where tier = 'free'; +update public.subscription_tiers set snapshot_count = 10 where tier = 'plus'; +update public.subscription_tiers set snapshot_count = 20 where tier = 'premium'; +update public.subscription_tiers set snapshot_count = 50 where tier = 'pro'; + +create table if not exists public.lap_snapshots ( + id text not null, + user_id uuid not null references auth.users (id) on delete cascade, + course_key text not null, + engine_key text not null, + data jsonb not null, + updated_at timestamptz not null default now(), + primary key (user_id, id) +); + +create index if not exists lap_snapshots_user_course_idx + on public.lap_snapshots (user_id, course_key); +create index if not exists lap_snapshots_user_engine_idx + on public.lap_snapshots (user_id, engine_key); + +alter table public.lap_snapshots enable row level security; +grant select, insert, update, delete on public.lap_snapshots to authenticated; +grant all on public.lap_snapshots to service_role; + +drop policy if exists "Users read own snapshots" on public.lap_snapshots; +create policy "Users read own snapshots" on public.lap_snapshots for select to authenticated using (auth.uid() = user_id); +drop policy if exists "Users insert own snapshots" on public.lap_snapshots; +create policy "Users insert own snapshots" on public.lap_snapshots for insert to authenticated with check (auth.uid() = user_id); +drop policy if exists "Users update own snapshots" on public.lap_snapshots; +create policy "Users update own snapshots" on public.lap_snapshots for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); +drop policy if exists "Users delete own snapshots" on public.lap_snapshots; +create policy "Users delete own snapshots" on public.lap_snapshots for delete to authenticated using (auth.uid() = user_id); + +create or replace function public.tier_snapshot_count(p_user_id uuid) +returns integer language sql stable security definer set search_path = public as $$ + select coalesce( + (select snapshot_count from public.subscription_tiers where tier = public.user_tier(p_user_id)), + (select snapshot_count from public.subscription_tiers where tier = 'free')); +$$; + +grant execute on function public.tier_snapshot_count(uuid) to authenticated; + +create or replace function public.enforce_lap_snapshot_quota() +returns trigger language plpgsql set search_path = public as $$ +declare + v_limit integer := public.tier_snapshot_count(NEW.user_id); + v_count integer; +begin + select count(*) into v_count from public.lap_snapshots + where user_id = NEW.user_id and id <> NEW.id; + if v_count + 1 > v_limit then + raise exception 'snapshot_quota_exceeded: % snapshots over limit (% existing + 1 new > % limit)', + v_limit, v_count, v_limit using errcode = 'check_violation'; + end if; + return NEW; +end; +$$; + +drop trigger if exists lap_snapshots_quota on public.lap_snapshots; +create trigger lap_snapshots_quota + before insert on public.lap_snapshots + for each row execute function public.enforce_lap_snapshot_quota(); From 21a51bc7a3da4599e3a186b2fdabe384067d45c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 01:46:52 +0000 Subject: [PATCH 086/121] Let snapshots be loaded as the reference lap Selecting a snapshot already routed it into the reference-overlay slot, but it wasn't discoverable as "the reference" and had no entry point on the lap page. - The Snapshots menu now reads "Load as reference lap" and selecting a snapshot sets it as the reference comparison. - Add a "Load snapshot as reference" button next to the external-reference loader (Choose Log) on the lap times page, opening the same per-course snapshot picker in load-only mode. Threaded through SessionContext + ExternalRefBar's new trailing slot. https://claude.ai/code/session_01L9h3QDcyTEXmVe6tWMio6T --- CHANGELOG.md | 9 ++--- src/components/ExternalRefBar.tsx | 4 +++ src/components/LapSnapshotControls.tsx | 48 +++++++++++++++----------- src/components/LapTable.tsx | 26 +++++++++++++- src/components/tabs/LapTimesTab.tsx | 6 ++++ src/contexts/SessionContext.tsx | 10 ++++++ src/pages/Index.tsx | 8 +++++ 7 files changed, 86 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 756b8a7..c2fecd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 a comparison overlay only** — never auto-plays, and is excluded - from playback and the video player (it rides the reference-overlay slot, not - the lap selection). Available next to the lap dropdown in both simple and - pro mode. + - **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. - **Local-first & unlimited on-device; cloud-synced with per-tier COUNT limits** (free 5 / plus 10 / premium 20 / pro 50) via a dedicated `lap_snapshots` table — not byte document storage. Snapshots always push on save, but a local 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/LapSnapshotControls.tsx b/src/components/LapSnapshotControls.tsx index a66eda2..04b9d21 100644 --- a/src/components/LapSnapshotControls.tsx +++ b/src/components/LapSnapshotControls.tsx @@ -17,17 +17,21 @@ interface LapSnapshotControlsProps { onLoad: (snap: LapSnapshot) => void; onClear: () => void; onSave: () => 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 a comparison overlay. Loading a snapshot - * NEVER affects playback or the video player — it rides the reference-overlay - * slot, not the lap selection. + * 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, + onLoad, onClear, onSave, triggerLabel = "Snapshots", showSave = true, }: LapSnapshotControlsProps) { const [open, setOpen] = useState(false); if (!hasCourse) return null; @@ -51,7 +55,7 @@ export function LapSnapshotControls({ - {!canSnapshot && ( -

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

+ {showSave && ( + <> + + {!canSnapshot && ( +

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

+ )} + )}

- Compare a snapshot (this course) + Load as reference lap (this course)

{activeSnapshotId && (