Skip to content

cloud/C9: CloudUsagePanel with 60s polling#225

Closed
blueberrycongee wants to merge 7 commits intoloop/cloud-C5from
loop/cloud-C9
Closed

cloud/C9: CloudUsagePanel with 60s polling#225
blueberrycongee wants to merge 7 commits intoloop/cloud-C5from
loop/cloud-C9

Conversation

@blueberrycongee
Copy link
Copy Markdown
Owner

What

Adds the cloud-usage panel: shows "X / Y tokens used this month, resets on YYYY-MM-DD" when the user has a valid license, polls getUsage every 60s while mounted, and degrades gracefully on network failure.

Stacking note

C9 needs both C4 (useLicenseStore) and C5 (getUsage) — those are independent stacks both forking from C1. This branch merges loop/cloud-C4 into loop/cloud-C5 and adds C9 on top. Merge order in this PR's diff:

loop/cloud-C5 (base, contains C1+C5)
└── merge: bring loop/cloud-C4 into C9 stack   ← brings C2 + C4
    └── cloud/C9: CloudUsagePanel with 60s polling
        └── cloud/C9: mark C9 done in TASKS.md

Merge to main order: #217 (C1) → #218 (C2) → #220 (C4) → #221 (C5) → this PR (C9). When you merge them in order, the merge commit here becomes a no-op.

Behavior

  • License missing / invalid status → renders null. No empty-state flash, per the spec.
  • License valid + first fetch succeeds → shows the formatted usage line.
  • License valid + first fetch fails (no cached value) → shows "Could not fetch usage. Retrying…" rather than blanking.
  • License valid + later poll fails → keeps the last successful value, appends a small "Retrying…" hint.
  • CleanupclearInterval on unmount; no leaked timers.

Acceptance criteria

  • When license valid: fetch client.getUsage() on mount + every 60s while open.
  • When license missing: render nothing (no empty-state flash). (also covered: status='invalid' renders nothing.)
  • On network error: show last successful value with a quiet "Retrying…" indicator; do not throw.
  • Tests cover three states (loading, success, error-with-cache).

How I tested

  • npm run typecheck: pass.
  • npm test -- --run src/components/settings/CloudUsagePanel.test.tsx: 7/7 pass.

Coverage: license-absent (1), license-invalid (1), loading→success (1), success→error-with-cache (1), cold error (1), 60s poll cadence (1), unmount cleanup (1).

Touched files outside src/services/luminaCloud/

  • New: src/components/settings/CloudUsagePanel.tsx + test — both inside the PRD §3 allow-list.
  • cloud/TASKS.md — marked C9 [x] and appended Done-log entry.

Notes for Lead

  • Vitest's vi.useFakeTimers is scoped to setInterval/clearInterval only — the testing-library waitFor + act flow needs real microtask handling for the mount fetch to settle. Mixing both worked once the fake timers were narrowed.
  • Token counts use toLocaleString('en-US')12345 renders as 12,345. If you want locale-aware formatting later, swap in useIntl or similar.
  • formatResetDate slices to YYYY-MM-DD; the contract's period_end is ISO 8601 UTC so the date is always valid. The Date.parse → ISO round-trip is defensive in case the server ever returns a non-canonical form.

blueberrycongee and others added 7 commits April 28, 2026 12:18
verifyLicense decodes the two-part license string per CONTRACT.md §1.3,
checks the Ed25519 signature against the bundled public key, and returns
the parsed payload or null. Never throws — every malformed input path
returns null (empty, missing dot, non-base64, wrong byte lengths,
non-UTF-8 payload, non-object JSON).

canonical-json.ts provides the simple JCS subset required by §1.2
(sorted keys, no whitespace, undefined drop). Sufficient for the
license payload shape (only strings, integers, booleans, string arrays,
null) — would need extending for non-integer numbers.

verify.ts wires synchronous SHA-512 once at module load via
@noble/hashes/sha2, so verifyLicense is sync as the contract pseudocode
shows. An optional second arg (publicKeyB64) defaults to PUBLIC_KEY_B64
and exists for tests + multi-key scenarios.

Tests cover: valid fixture round-trip, payload tampering, signature
tampering, wrong public key, eight malformed-input shapes, no-throw
guarantee, and that the bundled placeholder key is the wired default.

Deps: @noble/ed25519@^3.1.0 (per task), @noble/hashes@^2.2.0 (needed
to wire the sync sha512 slot left empty by ed25519 v3).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Holds the verified license string + decoded payload + lifecycle status.
setLicense verifies via luminaCloud.verifyLicense and persists through
saveLicense; persistence failures degrade gracefully (in-memory state
stays valid for the session, just not across restarts).
clearLicense flips back to idle. refreshFromKeychain hydrates on app
start — empty / unreadable keychain → idle, stored token that no
longer verifies → invalid, valid token → valid.

Tests use vi.hoisted to mock the four luminaCloud touchpoints, then
subscribe to capture status sequences. Covers the four transitions
called out in C4 acceptance: idle→loading, loading→valid,
loading→invalid, valid→idle. 9 tests, all pass.

Booting hookup is the consumer's job — App should call
useLicenseStore.getState().refreshFromKeychain() once after IPC bridge
is ready. Wiring lands in C10 (Account tab mount).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Renders "X / Y tokens used. Resets on YYYY-MM-DD." when the user has
a valid license, polls every 60s while mounted, returns null when no
license is present (no empty-state flash). On network failure, keeps
the last successful value and shows a quiet "Retrying…" hint instead
of throwing or blanking the panel.

Tests cover the three states the spec lists (loading / success /
error-with-cache) plus four extras: invisible when status='invalid',
the cold-start error path (no cache → "Could not fetch usage.
Retrying…"), 60s poll cadence, and cleanup on unmount. 7 tests.

Vitest fake timers are scoped to setInterval/clearInterval only —
the React-Testing-Library waitFor + act flow needs real microtask
handling for the initial mount fetch to settle. Mixing both worked
once the fake timers were narrowed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d3c1b7889

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +63 to +64
async refreshFromKeychain() {
set({ status: 'loading' });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Invoke keychain hydration during app startup

refreshFromKeychain is implemented, but there is no production call path that runs it on boot (repo-wide refreshFromKeychain usage is only in tests), so the store always starts as idle with license: null and ignores any token previously saved in keychain. That means persisted licenses do not rehydrate automatically and cloud-gated UI remains hidden until some later manual action invokes hydration.

Useful? React with 👍 / 👎.

Comment on lines +42 to +44
if (!payload) {
set({ license: null, payload: null, status: 'invalid' });
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear persisted token when new license fails verification

When setLicense receives an invalid token, this branch clears only in-memory state and returns without touching keychain storage. If a previous valid token was saved, restarting the app can silently restore that old token, which conflicts with the immediate invalid result users just saw. This path should either preserve the current valid token in-memory or explicitly remove/replace persisted storage on failed replacement.

Useful? React with 👍 / 👎.

@blueberrycongee
Copy link
Copy Markdown
Owner Author

Closing — re-issued cleanly by integrator.

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.

1 participant