Skip to content

Make billing states agree between Settings and the AI functions (T4: QB-02, QB-13, QB-14, QB-15, QB-08, QB-17, SC-03) - #207

Merged
Programcode merged 4 commits into
mainfrom
fix/t4-billing-states
Oct 23, 2026
Merged

Programcode merged 4 commits into
mainfrom
fix/t4-billing-states

Conversation

@Programcode

@Programcode Programcode commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

Batch T4 from the 22 Oct QA report: billing states and entitlement correctness (QB-02, QB-13, QB-14, QB-15, QB-08, QB-17, SC-03). Rebased on main 508f243 (T5 #201, T7 #203 and T2 #202 merged).

One resolver, resolveBillingState in supabase/functions/_shared/billing-state.ts, now decides what plan an account is on. The AI functions call it through entitlements.ts, and the browser calls the same file through src/hooks/use-subscription.ts and the settings store, so the Settings card and the server's refusal always describe the same state. The states are trial, extended access, Pro, Pro set to cancel, payment failed (in grace / after grace), legacy free, trial ended, subscription ended.

Issues

QB-02 (expired trial + override shows "Expired" beside a working usage line)

  • Adam's decision stands, so an override still grants access. The new state extended_access means no running plan and daily_limit_override above 0. The server still serves these accounts. Settings shows the green "Extended access" badge and "Extended access is active: N AI uses a day." with no "Expired" text. Upgrade to Pro is still offered.
  • The extra_limit promo writes an override with no trial; it resolves to the same extended_access state (unit test the extra_limit promo ... is extended access too).
  • An override of 0 grants nothing (refused with trial_ended).

QB-13 (past-due payer told their trial ended, offered a second subscription)

  • invoice.payment_failed no longer rewrites subscription_tier to expired. It records Stripe's status and period and sets subscriptions.payment_failed_at on the first failure. Retries keep that time; invoice.payment_succeeded or an active/trialing update clears it, and a successful payment also re-grants Pro. A failed clear throws so Stripe redelivers the event; only a missing column (migration not applied) is just logged.
  • A past_due subscription keeps Pro for PAYMENT_GRACE_DAYS = 14 from the earlier of payment_failed_at and current_period_start, never from current_period_end. unpaid gets no grace. When Stripe gives up it sends customer.subscription.updated (unpaid) or .deleted, which end access as before.
  • An active or trialing Stripe period now counts as Pro whatever subscription_tier says. The tier can lag: an invoice paid before its subscription.updated arrives, an account the old webhook downgraded, or an admin gift trial given to a payer. Only the webhook (service role) writes subscriptions. RLS allows users SELECT only, and no SECURITY DEFINER function writes it, so this grants nothing a user can forge.
  • src/lib/stores/settings-store.ts decides whether to keep saved Pro-only model choices with the same resolver, so a past-due payer in the grace keeps their choices instead of having them reset to defaults and saved.
  • The grace is bounded by the first failure, so it can't restart each period even if Stripe is set to leave subscriptions past due. Manual check for Cody: in Stripe Dashboard > Billing > Subscriptions and emails > Manage failed payments, the setting should cancel or mark unpaid after the retries. With "leave past due", the account loses access after 14 days but Stripe keeps invoicing.
  • Accounts the old webhook already downgraded (tier expired + a past_due row) resolve to payment_failed too, so the fix applies to them without a data change.
  • Settings shows a red "Payment failed" badge, "Payment failed: update your payment method to keep Pro. Pro access continues until while we retry the payment.", and Manage Subscription (portal). No Upgrade/Checkout button while any Stripe subscription can still bill (active, trialing, past_due, unpaid, paused), the same set create-checkout already refuses with 409.
  • The server refuses with trial_ended, payment_failed or subscription_ended (HTTP 402, with the English reason kept for API callers). The client accepts all three plus the old trial_expired (read as trial_ended) for functions deployed before this change. The limit dialog localizes each in EN/ES/HU; payment_failed offers "Update payment method", which goes to Settings, never to Checkout.

QB-14 (former subscribers told "Your 3-day free trial has ended")

  • New subscription_ended state and code. Settings says "Your Pro subscription ended on . Resubscribe to keep using AI tools." with a Resubscribe button; the AI dialog says "Pro Subscription Ended" with the date. customer.subscription.deleted now stores Stripe's ended_at/canceled_at instead of the webhook's clock.

QB-15 (cancelling Pro looks like renewing Pro, no date)

  • The client reads cancel_at_period_end and shows "Renews on " or "Cancels on " beside the Pro badge (also "Ends on " for trials, promo Pro and legacy grace). Dates use the existing locale formatter (formatEuropeanDate, en-GB/es-ES/hu-HU). No price strings.
  • The client and the AI functions read subscriptions with select('*'), because the frontend ships on merge before the migration. Naming the new column would fail the read until the migration lands.

QB-08 (webhook and promo edge cases wipe grants)

  • Pro activation (checkout completed, subscription updated to active or trialing, a payment that succeeds) no longer writes grace_period_ends_at: null. The resolver ignores a lapsed grant, so leaving it in place is safe.
  • When a promo grant and a Stripe plan overlap, Settings shows the Stripe renewal date. If the plan is set to cancel before the promo ends, it shows the promo instead, so it never says "Cancels on" while Pro continues.
  • customer.subscription.deleted keeps the account Pro when its promo grant ends later than now.
  • discount promos in create-checkout now refuse an expired code (promo_expired) or one at its cap (promo_exhausted) before any Stripe customer is created, the same rules redeem_promo_code_atomic applies to the other promo types. Left open: the discount path never records a redemption (redeem_promo_code_atomic returns before writing user_promos or bumping redemptions_count), so the cap only bites if an admin sets the counter. Recording redemptions needs an atomic increment on checkout.session.completed; I did not add it here.

QB-17 (weekly cap undercounts; trial fallback undocumented)

  • Adam's call, left as is. This change counts our BYOK Anthropic cost toward the $2 WEEKLY_AI_BUDGET_USD. The QA saw cap_cost_usd 0.033 against cost_usd 0.037–0.042 per Haiku call, so the change adds roughly 10–25% cap spend per BYOK call (the missing Anthropic part). If the cap should keep its current reach, raise WEEKLY_AI_BUDGET_USD in supabase/functions/_shared/usage-limits.ts.
  • cap_cost_usd now equals the full logged charge: OpenRouter's fee plus upstream_inference_cost for BYOK Claude calls. Before, the cap counted only OpenRouter's fee (about 0), so Claude on our own Anthropic key barely moved it. A BYOK response without the upstream figure still counts the fee, the known lower bound. cost.test.ts assertions that pinned the old exclusion are rewritten as the new spec, not loosened. docs/architecture.md and usage-limits.ts updated.
  • T5's quota refunds are untouched: refundable doesn't read the cap. The cap logging changes in one case only: a failed BYOK call that reported a real upstream charge now counts toward the weekly cap instead of being logged at 0.
  • The trial fallback rule is written in resolveBillingState's comment. The webhook leaves trial_ends_at alone. A payer who fails a payment keeps Pro through the grace (the grace is checked before the trial). If the grace ends while their own free trial is still running, they fall back to that trial's limits until it ends, because the trial was theirs before they paid.

SC-03 (checkRateLimit(null) returned allowed)

  • Now refuses with unauthenticated / 401 and reserves nothing. Test added.

Migration

  • supabase/migrations/20261023020000_subscription_payment_failed_at.sql: ALTER TABLE public.subscriptions ADD COLUMN IF NOT EXISTS payment_failed_at timestamptz plus a comment. Not destructive, and no backfill. Both the client and the functions work with or without it.

Edge functions to deploy after merge

ai, ai-stream, voice (import _shared/entitlements.ts), stripe-webhook, create-checkout. Apply the migration first. If the column is missing, the webhook logs it and carries on.

Verification

These are local runs. GitHub Actions minutes are used up, so CI did not run. A local pass proves only the local commit.

npm run verify (every step: docs check, lint incl. knip and repo rules, typecheck, unit, collaboration, edge, build with prerender/sitemap/check-site/build boundaries, test:sw, desktop Chromium @smoke e2e) at head c801080, rebased on main 508f243: exit 0.

  • Unit (Vitest): 75 files, 549 tests passed
  • Collaboration: 5 files, 30 tests passed
  • Edge (Deno): 457 passed, 0 failed
  • Build: built; check-site: 48 HTML files, sitemap and llms.txt resolve; no problems
  • test:sw: upgradeActivated, firstVisitedRouteOffline, unvisitedRouteShowsRecovery, intentPrefetchedRouteOffline, offlineLogo all true
  • e2e smoke: 43 passed

The same full verify also passed after each earlier fix round (before the rebase: 494 unit, 448 to 455 edge, 43 smoke).

New or changed tests:

  • supabase/functions/_shared/billing-state.test.ts: the resolver table, one case per state (trial, extended access incl. extra_limit, override 0, Pro renewing, Pro cancelling, past due in grace / after grace / grace from first failure / pre-column rows, unpaid, subscription ended, promo over canceled sub, lifetime promo, legacy free in and after grace, trial fallback after the grace, tier lagging a paid period, cancelling plan inside a longer promo, missing row).
  • supabase/functions/_shared/entitlements.test.ts: each refusal code; extended access served; past-due payer keeps Pro; SC-03 null user refused with no quota used; failed subscriptions read serves only what settings prove.
  • supabase/functions/stripe-webhook/handlers_test.ts: fixture events for invoice.payment_failed, a retried failure, invoice.payment_succeeded, customer.subscription.updated (cancel_at_period_end, with and without a promo), customer.subscription.deleted (with and without a promo), checkout.session.completed keeping a promo, and a failed payment_failed_at clear.
  • supabase/functions/_shared/cost.test.ts: the two BYOK cap tests rewritten to the new rule (the cap equals the full logged cost), not loosened.
  • Client: src/hooks/use-subscription.test.tsx (state from rows, QB-02/13/14/15), src/test/components/SubscriptionStatus.test.tsx (badge, date and the single button for each state), src/test/components/LimitReachedModal.test.tsx (old and new trial codes, payment failed goes to Settings, subscription ended with date), src/test/settings/settings-migration.test.ts (past-due payer keeps Pro model choices), src/test/checkout-security.test.ts (discount expiry and cap).
  • Edge handler fixtures (ai, ai-stream) now stub subscriptions, which the entitlement check reads for every account.

Stack checks, run once the shared Supabase lock freed (details and screenshots in this comment):

  • ./.superset/db-gate.sh: exit 0. pgTAP Files=26, Tests=500, Result: PASS, with the new migration applied. e2e/full-stack and e2e/workspace-collaboration on desktop and mobile Chromium: 71 passed, 1 skipped (mobile-only skip), including billing.spec.ts.
  • verify-debatelab drive recipes/billing-states and recipes/billing on desktop-chromium and mobile-chromium: all passed. EN and HU screenshots at 1280 px, 360 px and Pixel 5 are on pr-assets/t4-billing-states.

Review rounds

Each round was one fresh-context Claude Opus subagent reading the full diff against main.

  • Security hook, before round 1. The grace counted from the billing period, so a subscription Stripe leaves past due would reopen 14 free days each period. Fixed with payment_failed_at (migration above).
  • Round 1. M1: Stripe Pro only counted with tier pro (old-webhook downgrades, gift trials to payers). Fixed: any active or trialing period counts, and a successful payment re-grants Pro. M2 is the QB-17 cap decision (above, Adam's call). Lows fixed: grace start covers pre-column rows; a failed subscriptions read no longer blocks accounts the settings row proves. Lows noted: deploy order, display precedence.
  • Round 2. M-A: the settings store still used the old Pro rule and rewrote a past-due payer's saved model choices. Fixed with the shared resolver and a test. Lows fixed: Stripe renewal shown over an overlapping promo; discount check before customer creation; trialing updates grant Pro and clear the failure. Lows noted: paused (unreachable), deploy window.
  • Round 3. No Medium or higher. Lows fixed: a failed clear of payment_failed_at throws for redelivery; a cancelling plan inside a longer promo shows the promo. Lows noted below (pricing dialog, admin lists).

Left open / manual checks

  • Deploy order on merge day. The frontend ships on merge. Until the functions are deployed, a past-due payer can see "Pro access continues until…" in Settings while the old ai function refuses them with the old trial message. Apply the migration, then deploy stripe-webhook, ai, ai-stream, voice and create-checkout straight after the merge.
  • Stripe failed-payment setting (see QB-13).
  • Discount promo redemptions are not recorded (see QB-08).
  • The admin user lists show the raw subscription_tier, so an extended-access account shows "expired". That is display only and not changed here.
  • paused counts as an open subscription (Settings shows Manage) but is refused as trial_ended. It can't happen today because no Stripe trials or pausing are configured.
  • An extra_limit user whose subscription has lapsed shows "Extended access" rather than a payment prompt; access is correct.
  • A daily_limit_override (e.g. the extra_limit 15) caps a paying Pro account below 50. That was already true on main.
  • The pricing dialog (T6) can still be opened by a past-due payer from the Pro-model dialog; Subscribe there gets the server's 409 "You already have a subscription. Manage it from Settings." toast, in English. Its wording belongs to T6 (QB-06/QB-16).
  • Not proven here: real Stripe Checkout, the real billing portal, real Stripe retries/dunning. Everything here used fixture events and the local stack's fake Stripe.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Settings now show clearer subscription statuses, billing dates, and relevant actions for trials, payment issues, extended access, and ended subscriptions.
    • Pro access can continue for up to 14 days after a failed renewal. If access ends, payment failures direct you to Settings, while ended subscriptions offer a resubscribe option.
    • Checkout now identifies expired or fully redeemed discount codes before proceeding.
  • Updates
    • Weekly AI spending limits now include known charges for calls made with your own Anthropic key.

Programcode and others added 2 commits October 23, 2026 00:56
…QB-02, QB-13, QB-14, QB-15, QB-08, QB-17, SC-03)

One resolver, supabase/functions/_shared/billing-state.ts, decides an
account's plan for the AI functions, the Settings card and the settings
store: trial, extended access, Pro (renewing or set to cancel), payment
failed (in or past a 14-day grace from the first failure), legacy free,
trial ended, subscription ended.

- A failed renewal no longer downgrades the account; the webhook records
  subscriptions.payment_failed_at and Settings offers the billing portal,
  never a second Checkout.
- Refusals carry trial_ended, payment_failed or subscription_ended; the
  client still reads the old trial_expired.
- Pro activation and cancellation keep a promo grant that ends later;
  discount promos check expiry and their redemption cap.
- The weekly cap counts the full logged cost, BYOK Claude included.
- checkRateLimit(null) refuses.

Migration 20261023020000_subscription_payment_failed_at.sql is additive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd 3)

- A failed clear of payment_failed_at throws so Stripe redelivers the
  event; only a missing column (migration not applied) is logged.
- A Stripe plan set to cancel inside a longer promo grant shows the
  promo, not "Cancels on".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 23, 2026 01:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Warning

Review limit reached

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Next included review available in 54 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between e1beacf and 8863fb4.

📒 Files selected for processing: 3
  • src/locales/en.json
  • src/locales/es.json
  • src/locales/hu.json
ℹ️ Recent review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 45788cf and e1beacf.

📒 Files selected for processing: 40
  • .claude/skills/verify-debatelab/features/README.md
  • .claude/skills/verify-debatelab/recipes/billing-states.spec.ts
  • docs/architecture.md
  • e2e/full-stack/billing.spec.ts
  • src/components/features/subscription/LimitReachedModal.tsx
  • src/components/features/subscription/SubscriptionStatus.tsx
  • src/hooks/use-subscription-error.ts
  • src/hooks/use-subscription.test.tsx
  • src/hooks/use-subscription.ts
  • src/lib/ai/features/session-quota.ts
  • src/lib/ai/limit-message.ts
  • src/lib/ai/request.ts
  • src/lib/stores/settings-store.ts
  • src/locales/en.json
  • src/locales/es.json
  • src/locales/hu.json
  • src/pages/SettingsPage.tsx
  • src/test/checkout-security.test.ts
  • src/test/components/LimitReachedModal.test.tsx
  • src/test/components/SubscriptionStatus.test.tsx
  • src/test/settings/settings-migration.test.ts
  • supabase/functions/_shared/billing-state.test.ts
  • supabase/functions/_shared/billing-state.ts
  • supabase/functions/_shared/cost.test.ts
  • supabase/functions/_shared/cost.ts
  • supabase/functions/_shared/entitlements.test.ts
  • supabase/functions/_shared/entitlements.ts
  • supabase/functions/_shared/usage-limits.ts
  • supabase/functions/ai-stream/handler_test.ts
  • supabase/functions/ai-stream/index.ts
  • supabase/functions/ai-stream/index_test.ts
  • supabase/functions/ai/generate-honesty_test.ts
  • supabase/functions/ai/handler-research.test.ts
  • supabase/functions/ai/index.ts
  • supabase/functions/create-checkout/checkout-security.ts
  • supabase/functions/create-checkout/index.ts
  • supabase/functions/stripe-webhook/handlers.ts
  • supabase/functions/stripe-webhook/handlers_test.ts
  • supabase/functions/stripe-webhook/index.ts
  • supabase/migrations/20261023020000_subscription_payment_failed_at.sql

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

This change adds shared billing-state resolution and Stripe payment-failure tracking. It updates subscription access checks and billing displays, includes known BYOK provider charges in weekly AI spend, and validates discount promos before checkout.

Changes: Subscription billing

Layer / File(s) Summary
Resolve billing states and enforce access
supabase/functions/_shared/billing-state.ts, supabase/functions/_shared/entitlements.ts, src/hooks/use-subscription.ts, src/lib/ai/*, src/lib/stores/settings-store.ts, related tests
A shared resolver determines access, refusal codes, plan limits, billing dates, and open-subscription status. Server entitlements and client subscription state use it. Subscription refusal codes pass through client error handling.
Persist Stripe payment and subscription events
supabase/migrations/*subscription_payment_failed_at.sql, supabase/functions/stripe-webhook/*, e2e/full-stack/billing.spec.ts
Webhook handlers update subscription records, record the first payment-failure timestamp, clear it on recovery, and update Pro status for subscription events. The migration adds payment_failed_at.
Display billing states and actions
src/components/features/subscription/*, src/pages/SettingsPage.tsx, src/locales/*.json, src/test/components/*, .claude/skills/verify-debatelab/*
Settings displays billing-state badges, dates, descriptions, and actions through SubscriptionStatus. LimitReachedModal handles trial end, payment failure, and subscription end. Tests cover UI states and billing-state scenarios in English and Hungarian.

Weekly AI spend accounting

Layer / File(s) Summary
Count known provider charges in weekly spend
supabase/functions/_shared/cost.ts, supabase/functions/_shared/cost.test.ts, supabase/functions/_shared/usage-limits.ts, supabase/functions/ai-stream/index.ts, supabase/functions/ai/index.ts, docs/architecture.md
Weekly-cap cost includes known BYOK upstream charges with OpenRouter fees. If upstream cost is unavailable, the cap uses the known fee. Tests and documentation describe this accounting.

Discount promo validation

Layer / File(s) Summary
Validate discount promos before checkout
supabase/functions/create-checkout/checkout-security.ts, supabase/functions/create-checkout/index.ts, src/test/checkout-security.test.ts
Checkout checks active discount promos for expiry, redemption limits, and prior redemption before customer lookup or creation. Expired and exhausted promos return specific errors.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Stripe
  participant WebhookEntry as stripe-webhook
  participant Handlers as createWebhookHandlers
  participant Database as Supabase
  Stripe->>WebhookEntry: Send invoice.payment_failed event
  WebhookEntry->>Handlers: Dispatch event with handlers.handle
  Handlers->>Stripe: Retrieve subscription
  Stripe-->>Handlers: Return current subscription status
  Handlers->>Database: Update subscription and payment_failed_at
Loading
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@Programcode

Copy link
Copy Markdown
Owner Author

Stack checks at c801080 (previously pending)

All local, on the shared Supabase stack once the lock freed. All of them passed.

  • ./.superset/db-gate.sh: exit 0. pgTAP and the database tests passed (Files=26, Tests=500, Result: PASS); migration 20261023020000_subscription_payment_failed_at.sql applied with the rest. The gate's browser run covered e2e/full-stack and e2e/workspace-collaboration on desktop and mobile Chromium: 71 passed, 1 skipped (workspace evidence, team AI review, and practice survive a teammate reload on mobile-chromium, which the suite skips there). It includes billing.spec.ts on both projects. The signed invoice.payment_failed step kept the account on Pro, Settings showed "Payment failed" with Manage Subscription and no Upgrade, and after deletion it showed "Your Pro subscription ended on..." with Resubscribe.
  • verify-debatelab drive recipes/billing-states: passed on desktop-chromium and mobile-chromium.
  • drive recipes/billing (runs billing-states and billing): 2 passed on each project.
  • The stack was stopped afterwards (down exit 0, gate cleanup "Stopped supabase local development setup").

Screenshots (desktop 1280 px, 360 px, and Pixel 5)

Extended access (QB-02), EN desktop and HU 360 px:

desktop-en-extended-access.png hu-extended-access-360.png

Payment failed in grace (QB-13), EN desktop and HU 360 px, and the AI refusal once the grace is over:

desktop-en-past-due-grace.png hu-past-due-grace-360.png

desktop-ai-payment-failed-refused.png

Pro set to cancel (QB-15), EN desktop and HU Pixel 5:

desktop-en-pro-canceling.png pixel5-hu-pro-canceling.png

Subscription ended (QB-14), HU desktop and EN 360 px:

desktop-hu-subscription-ended.png en-subscription-ended-360.png

All 26 images, covering each state in EN and HU at 1280 px, 360 px and Pixel 5, are on pr-assets/t4-billing-states (not for merge).

@Programcode
Programcode merged commit b0b2ae7 into main Oct 23, 2026
Programcode added a commit that referenced this pull request Oct 23, 2026
…02, PF-04, PF-05, PF-06, PF-09) (#215)

* Cache every public icon file for 30 days

C3 (#199) added icon-1024.png and the 192/512/maskable manifest icons,
which still fell back to revalidating on every request. The test now
reads public/ so a new icon size can't miss the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Load only the active locale and keep app-only code off public pages

- Spanish and Hungarian strings are their own chunks, fetched when chosen;
  English stays in the entry as the default and fallback. The first render
  waits for the chosen language, and a later choice wins over one still
  loading.
- AppLayout (header, navigation, debate dialogs) loads only under /app.
  mountApp starts the page chunk and the layout while the entry starts,
  instead of when React first reaches them.
- The landing reveal is a CSS transition, so / no longer loads
  framer-motion's 118 KB chunk.
- The session check and layout load show the route's skeleton instead of a
  full-screen spinner.
- check-build-boundaries fails if the es/hu catalogs or AppLayout re-enter
  the entry or the landing, login, home, generate or attack graphs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open the main app pages offline and keep each page's own HTML

The build writes a precache list into sw.js from the Vite manifest: the
shell's scripts and styles, the signed-in layout, Home, Library, History,
Timer and Settings, the es/hu strings and Inter's Latin fonts. A missing
manifest key fails the build.

Offline, app routes get the precached app-shell.html (the paths
vercel.json rewrites) and a public page gets only its own HTML, stored
under its own path. Before, every navigation was stored as /index.html,
so any route could come back as the last page visited. /auth/callback,
Supabase paths and /_vercel/ stay network-only. Caches carry the build's
id and the next build's worker deletes them.

vite preview applies the vercel.json rewrites, so the offline test loads
what production serves. The test's async waitForFunction calls passed at
once without checking (Playwright treats the returned Promise as truthy);
they now poll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold Supabase reads while offline instead of retrying them

Offline, supabase-js retried every failed read three more times, so
opening Settings sent about 70 doomed requests in 30 seconds (user_roles
four times, user_settings 18). REST, Functions and Storage reads now wait
for the 'online' event and go out once; an abort still ends them. Writes
fail at once as before, and Auth is left alone because auth-js holds its
session lock during a user lookup.

TanStack Query also starts with the browser's real online state, so a page
opened offline pauses its queries instead of running them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Don't reload for a missing chunk while offline

Choosing a language whose strings were never cached, offline, failed its
import and triggered the stale-deploy reload, which without a service
worker replaces the page with the browser's offline error. Offline the
chunk is unreachable, not stale, so the error stands and the app keeps
the current language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Treat /expocit/sources as a public page offline, and always leave the startup shell

/expocit/sources is prerendered and Vercel serves that file before the
rewrite, so offline it now gets its own stored HTML instead of the shell
(whose page chunk is not precached). vite preview serves prerendered
pages before the rewrites too. The non-prerendered first render now also
runs if waiting for the language rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disable Upgrade Now while offline and say why

Checkout needs the network. Offline the button stays disabled with a short
notice under it (one new string, pricingModal.offlineUpgrade, in all three
catalogs) and re-enables on the 'online' event. Done here now that T4
(#207) has merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Load every language before the flowchart opens

T9 (#206) recognises flowchart starter text saved in any language by
comparing it with each catalog. With Spanish and Hungarian loaded only on
demand, a chart saved in Hungarian and opened in English kept its
Hungarian starter text. The flowchart route's loader now fetches every
catalog before the page renders. Only that page (and a hover prefetch of
it) pays for them, and they are precached for offline.

The flowchart tests now load through the route loader; without this
change four of them fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review round 1: stop retries offline instead of holding reads

- Offline Supabase reads are no longer held until 'online'. A held read kept
  the token of whoever started it and could replay after sign-out, and a
  browser that wrongly reports offline would never send it. Every request
  is now sent; one that fails while the browser is offline ends as an
  abort, which postgrest-js does not retry. TanStack Query is no longer
  seeded from navigator.onLine; failed queries refetch on 'online' instead.
- A language chosen offline before its strings were cached is applied when
  the connection returns.
- The first render waits at most 3 s for the language's strings.
- The service worker's build id also covers the unhashed public files it
  serves cache-first (theme-init.js, icons), and an offline public page
  prefers the copy saved on the last visit over the one stored at install.
- The landing page's code is precached with its HTML, so / opened offline
  is not replaced by the error page.
- test:sw now visits /auth/callback before checking it is never cached,
  and checks the precached files sit in the install-time shell cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review round 2: load layout and page together after sign-in

- After a client-side navigation into /app (sign-in, the callback), the
  page chunk now starts with the layout chunk instead of after it.
- The offline no-retry rule also matches reads through the dev:preview
  staging proxy path.
- hero-visual.jpg and placeholder.svg also feed the worker's build id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants