Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
29 changes: 28 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
---

<p align="center">
Expand Down Expand Up @@ -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/<user>/<THIS_IS_THE_ID>`).
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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
17 changes: 14 additions & 3 deletions scripts/coverage-badge.mjs
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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`,
);
}
14 changes: 10 additions & 4 deletions src/components/PricingCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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*"],
};
}

Expand All @@ -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 ❤️",
],
Expand All @@ -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)"],
},
];

Expand Down Expand Up @@ -364,6 +364,12 @@ export function PricingCards({ className, variant = "home" }: { className?: stri
);
})}
</div>
<p className="mt-4 text-center text-xs text-muted-foreground">
* Storage on your own device is always <span className="font-medium text-foreground">unlimited and free</span>.
Paid plans only cover <span className="font-medium text-foreground">cloud backups of your datalogs</span> —
so you can dump as many logs as you like and keep them synced across devices. Upgrading mostly just helps
support development ❤️
</p>
</section>
);
}
43 changes: 26 additions & 17 deletions src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading