diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b5fde73b..0df53e7b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -10,10 +10,11 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -# contents: write -> publish the badge JSON to the `badges` branch (main only) # pull-requests: write -> post/update the per-PR coverage summary comment +# The live % badge is pushed to a GitHub Gist (see the gist step below) with a +# dedicated GIST_TOKEN secret, so this workflow no longer needs `contents: write` +# and no longer publishes a `badges` branch (which Cloudflare kept trying to deploy). permissions: - contents: write pull-requests: write jobs: @@ -68,22 +69,23 @@ jobs: core.warning(`Could not post coverage comment: ${e.message}`); } - # ---- Live % badge: publish shields.io endpoint JSON to `badges` branch ---- - - name: Generate badge JSON + # ---- Live % badge: push shields.io endpoint fields to a GitHub Gist ---- + # No Git branch is created, so Cloudflare Workers Builds has nothing extra + # to deploy. coverage-badge.mjs computes the message + color and hands them + # over as step outputs. Requires repo secret GIST_TOKEN (PAT with `gist` + # scope) and repo variable COVERAGE_GIST_ID (see README → Coverage badge). + - name: Generate badge fields + id: badge if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' run: npm run coverage:badge - - name: Stage badge for publish + - name: Push badge to coverage gist if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - run: | - mkdir -p badge-publish - cp coverage/coverage-badge.json badge-publish/ - - - name: Publish badge to the `badges` branch - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - uses: peaceiris/actions-gh-pages@v3 + uses: Schneegans/dynamic-badges-action@v1.7.0 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_branch: badges - publish_dir: ./badge-publish - force_orphan: true + auth: ${{ secrets.GIST_TOKEN }} + gistID: ${{ vars.COVERAGE_GIST_ID }} + filename: coverage-badge.json + label: coverage + message: ${{ steps.badge.outputs.message }} + color: ${{ steps.badge.outputs.color }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0c6f9f..09c78004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] - 2026-06-03 + +### Fixed +- **Spurious sign-out on page refresh (and the paid plan card vanishing with + it).** The auth bootstrap awaited a Supabase RPC (`has_role`) *inside* the + `onAuthStateChange` callback. supabase-js holds the GoTrue cross-tab Web Lock + for the duration of that callback, so the awaited call deadlocked token + refresh on reload — signing the user out and, downstream, collapsing the + subscription to the free tier (hiding the paid plan card). The admin-role + lookup is now deferred out of the callback; session/user state is set + synchronously. (Symptom only cleared on a full browser restart, since the + contended Web Lock survives reloads.) + ### Changed +- **Pricing cards now clarify that paid plans only cover cloud backups.** Every + storage line on the plan cards (home + sign-up) carries an asterisk to a new + footnote spelling out that storage on your own device is always unlimited and + free — paid tiers only back your datalogs up to the cloud (and help support + development). Removes the common confusion that you have to pay to keep using + the app or to store logs locally. +- **Coverage badge now publishes to a GitHub Gist instead of a `badges` branch.** + The orphan `badges` branch caused Cloudflare Workers Builds to repeatedly try + (and fail) to deploy a branch with no app in it. The `coverage.yml` workflow + now pushes the badge `%`/color to a gist via `Schneegans/dynamic-badges-action` + (repo secret `GIST_TOKEN` + variable `COVERAGE_GIST_ID`), drops its + `contents: write` permission, and no longer creates a Git branch. See the + README "Coverage badge" section for setup. - **Bumped the optional AI coach plugin (`@perchwerks/eye-in-the-sky`) from `0.3.0` to `0.4.1`, and pinned it to a tilde patch range (`~0.4.1`)** so coach `0.4.x` patch releases are picked up automatically on the next install, while a @@ -531,5 +557,6 @@ open-source project scaffolding and a bundle-size pass. admin, pro view, file-manager drawer, BLE download, and the Leaflet editor off the first-load path. -[Unreleased]: https://github.com/TheAngryRaven/DovesDataViewer/compare/v1.5.0...HEAD +[Unreleased]: https://github.com/TheAngryRaven/DovesDataViewer/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/TheAngryRaven/DovesDataViewer/compare/v1.5.0...v2.0.0 [1.5.0]: https://github.com/TheAngryRaven/DovesDataViewer/compare/V1.0.0...v1.5.0 diff --git a/CLAUDE.md b/CLAUDE.md index 1d9466f4..cabb24e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -659,7 +659,10 @@ 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. +posts a per-PR summary comment, and pushes the % badge fields to a **GitHub Gist** +(repo secret `GIST_TOKEN` + repo variable `COVERAGE_GIST_ID`) — not a Git branch, +so Cloudflare Workers Builds has no badge-only branch to try to deploy. See the +README "Coverage badge" section for the gist wiring. --- diff --git a/README.md b/README.md index 5b3206fb..a1756051 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ [![Typecheck](https://github.com/TheAngryRaven/DovesDataViewer/actions/workflows/typecheck.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataViewer/actions/workflows/typecheck.yml) [![Test](https://github.com/TheAngryRaven/DovesDataViewer/actions/workflows/test.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataViewer/actions/workflows/test.yml) [![Build](https://github.com/TheAngryRaven/DovesDataViewer/actions/workflows/build.yml/badge.svg)](https://github.com/TheAngryRaven/DovesDataViewer/actions/workflows/build.yml) -[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/TheAngryRaven/DovesDataViewer/badges/coverage-badge.json)](https://github.com/TheAngryRaven/DovesDataViewer/actions/workflows/coverage.yml) +[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/TheAngryRaven/9c0c31f9c333c565804b26643a2e3aec/raw/coverage-badge.json)](https://github.com/TheAngryRaven/DovesDataViewer/actions/workflows/coverage.yml) 🌐 **Live Demo:** [HackTheTrack.net](https://hackthetrack.net) 🔧 **Hardware Project:** [DovesDataLogger on GitHub](https://github.com/TheAngryRaven/DovesDataLogger) +**Now officially in BETA status** ---

@@ -283,6 +284,24 @@ Open [http://localhost:8080](http://localhost:8080) in your browser. | `npm test` | Run Vitest in watch mode | | `npm run test:run` | Run Vitest once (CI-style) | +### Coverage badge + +The live coverage badge is a [shields.io endpoint](https://shields.io/badges/endpoint-badge) +backed by a **GitHub Gist** (not a Git branch — that kept Cloudflare Workers +Builds trying to deploy a badge-only branch). The `coverage.yml` workflow runs +`npm run coverage:badge` (which computes the `%` + color from the Vitest summary) +and pushes those fields to the gist on every push to `main`. To wire it up on a +fork: + +1. Create a **public** gist with a single file named `coverage-badge.json` + (any placeholder contents) and copy its ID from the URL + (`gist.github.com//`). +2. Create a fine-grained/classic **PAT with the `gist` scope** and add it as the + repo secret **`GIST_TOKEN`** (Settings → Secrets and variables → Actions). +3. Add the gist ID as the repo **variable `COVERAGE_GIST_ID`** (same page → Variables). +4. Replace `COVERAGE_GIST_ID` in the Coverage badge URL at the top of this README + with your gist ID. + --- ## Deployment diff --git a/package-lock.json b/package-lock.json index 354f2697..efe0c9d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doves-dataviewer", - "version": "1.5.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doves-dataviewer", - "version": "1.5.0", + "version": "2.0.0", "license": "GPL-3.0-or-later", "dependencies": { "@lovable.dev/cloud-auth-js": "^1.1.2", diff --git a/package.json b/package.json index 694afb71..5c617e69 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "doves-dataviewer", "private": true, - "version": "1.5.0", + "version": "2.0.0", "description": "Open-source, offline-first motorsport telemetry viewer (Dove's DataViewer / HackTheTrack).", "license": "GPL-3.0-or-later", "author": "TheAngryRaven", diff --git a/scripts/coverage-badge.mjs b/scripts/coverage-badge.mjs index cf45f84a..321e649f 100644 --- a/scripts/coverage-badge.mjs +++ b/scripts/coverage-badge.mjs @@ -1,7 +1,10 @@ // Reads the Vitest json-summary and emits a shields.io endpoint badge JSON. -// No third-party service involved — the JSON is published to the `badges` -// branch by the Coverage workflow and rendered by img.shields.io/endpoint. -import { readFileSync, writeFileSync } from "node:fs"; +// The Coverage workflow pushes this badge's fields to a GitHub Gist (rendered +// by img.shields.io/endpoint), so it never touches a Git branch — keeping +// Cloudflare Workers Builds from trying to deploy a badge-only branch. +// This script stays the single source of truth for the color thresholds: it +// also exports `message`/`color` as GitHub Actions step outputs when run in CI. +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; const summary = JSON.parse( readFileSync("coverage/coverage-summary.json", "utf8"), @@ -26,3 +29,11 @@ const badge = { writeFileSync("coverage/coverage-badge.json", JSON.stringify(badge) + "\n"); console.log("coverage badge:", JSON.stringify(badge)); + +// Hand the rendered fields to the Coverage workflow's gist-update step. +if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + `message=${badge.message}\ncolor=${badge.color}\n`, + ); +} diff --git a/src/components/PricingCards.tsx b/src/components/PricingCards.tsx index d6af87c5..39644850 100644 --- a/src/components/PricingCards.tsx +++ b/src/components/PricingCards.tsx @@ -81,7 +81,7 @@ function onlineCard(variant: Variant): FreeTier { price: "$0", slug: "free", inherits: variant === "register" ? "Everything included with offline mode" : "Everything in Free, plus", - features: [CLOUD_SYNC_FEATURE, "Fastest laps & synced setups — always free", "50 MB cloud storage"], + features: [CLOUD_SYNC_FEATURE, "Fastest laps & synced setups — always free", "50 MB cloud storage*"], }; } @@ -96,7 +96,7 @@ const PAID_TIERS: PaidTier[] = [ highlight: true, inherits: "Everything in Free online, plus", features: [ - "10 GB cloud storage", + "10 GB cloud storage*", "Video uploads & sharing (coming soon)", "You're helping support the project ❤️", ], @@ -106,14 +106,14 @@ const PAID_TIERS: PaidTier[] = [ blurb: "Max storage", slug: "premium", inherits: "Everything in Plus, plus", - features: ["100 GB cloud storage"], + features: ["100 GB cloud storage*"], }, { name: "Pro", blurb: "With AI coaching", slug: "pro", inherits: "Everything in Premium, plus", - features: ["500 GB cloud storage", "AI coaching (coming soon)"], + features: ["500 GB cloud storage*", "AI coaching (coming soon)"], }, ]; @@ -364,6 +364,12 @@ export function PricingCards({ className, variant = "home" }: { className?: stri ); })} +

+ * Storage on your own device is always unlimited and free. + Paid plans only cover cloud backups of your datalogs — + so you can dump as many logs as you like and keep them synced across devices. Upgrading mostly just helps + support development ❤️ +

); } diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index e5a67e49..af13b84c 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -26,27 +26,36 @@ export function AuthProvider({ children }: { children: ReactNode }) { let cancelled = false; let initialResolved = false; - const updateAuth = async (s: Session | null) => { - if (cancelled) return; + // Resolve the admin role. MUST run outside the onAuthStateChange callback: + // supabase-js holds the GoTrue Web Lock (navigator.locks) for the duration + // of that callback, and any awaited Supabase call inside it needs the same + // lock — which deadlocks token refresh and spuriously signs the user out on + // reload. So updateAuth sets session/user synchronously and defers this. + const resolveRole = async (s: Session | null) => { + if (!s?.user) { + if (!cancelled) setIsAdmin(false); + return; + } try { - setSession(s); - setUser(s?.user ?? null); - if (s?.user) { - const { data } = await supabase.rpc('has_role', { - _user_id: s.user.id, - _role: 'admin', - }); - if (!cancelled) setIsAdmin(!!data); - } else { - setIsAdmin(false); - } + const { data } = await supabase.rpc('has_role', { + _user_id: s.user.id, + _role: 'admin', + }); + if (!cancelled) setIsAdmin(!!data); } catch { if (!cancelled) setIsAdmin(false); } - if (!cancelled) { - initialResolved = true; - setLoading(false); - } + }; + + const updateAuth = (s: Session | null) => { + if (cancelled) return; + setSession(s); + setUser(s?.user ?? null); + if (!s?.user) setIsAdmin(false); + initialResolved = true; + setLoading(false); + // Deferred so we never await a Supabase call while the auth lock is held. + setTimeout(() => { void resolveRole(s); }, 0); }; const { data: { subscription } } = supabase.auth.onAuthStateChange(