Skip to content

fix(web): refresh sidebar/stat queries on day rollover - #390

Merged
thomasluizon merged 2 commits into
mainfrom
fix/day-rollover-sidebar-stats
Jul 4, 2026
Merged

thomasluizon merged 2 commits into
mainfrom
fix/day-rollover-sidebar-stats

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Root cause

When a day passes with the web app tab left open (app.useorbit.org), the habit list correctly re-updates to the new day but the right sidebar keeps showing the previous day's stats (completion ring, "Restantes hoje", "Sequência", "Nível", "Conquistas") until a manual page refresh.

The Today page (apps/web/app/(app)/page.tsx) owned its own useTodayTick subscription and advanced a local today state at local midnight / on focus, which re-keyed its habit query. The sidebar (apps/web/components/shell/today-rail.tsx) instead froze its day at mount:

const today = useMemo(() => formatAPIDate(new Date()), [])

with an empty dependency array, so it never participated in rollover. Its habit query stayed keyed to the stale day (completion ring + remaining), and the gamification queries (streak / level / achievements) — which are not date-keyed — never refetched at a silent local midnight (their refetchOnWindowFocus only fires on an actual focus event, which does not occur while the tab stays focused across midnight).

Fix

Introduce a single-owner TodayProvider (apps/web/app/(app)/today-provider.tsx) mounted at the app layout. It:

  • holds the current local day (YYYY-MM-DD) and owns the one useTodayTick subscription for the app;
  • on a genuine day change, advances the day (re-keying every date-keyed habit query consumers derive from it) and invalidates gamificationKeys.all so streak / level / XP / achievements refetch;
  • guards against redundant work: a focus/visibility event on the same day neither re-renders nor invalidates.

Both the Today page and the sidebar now read the day via useToday(), so a single rollover trigger refreshes everything date-dependent and the two surfaces can no longer drift.

Cross-platform parity

Mobile is unaffected and needs no change. apps/mobile/app/(tabs)/index.tsx already runs a self-rescheduling midnight timer plus an AppState active (resume) rollover, and its stat surfaces (today-header streak, profile streak/level/achievements) are server-sourced through TanStack Query with refetchOnWindowFocus bridged to AppState — none capture a client-side "today", so there is no frozen-date equivalent to fix.

Tests

  • New apps/web/__tests__/app/today-provider.test.tsx: day advances + gamification invalidated on visibility-change on a new day and at local midnight; no invalidation on same-day focus; useToday throws without a provider.
  • Updated today-page.test.tsx and today-rail.test.tsx to render inside TodayProvider (the page's local rollover ownership moved into the provider).
  • apps/web: type-check, lint, and the affected vitest suites (40 tests) all green.

🤖 Generated with Claude Code

The desktop Today sidebar (TodayRail) captured its notion of "today" once
at mount via `useMemo(() => formatAPIDate(new Date()), [])` and never
subscribed to the day-rollover mechanism. When a day passed with the tab
left open, the habit list re-keyed to the new day (the Today page owned its
own `useTodayTick` subscription) but the rail kept the previous day's
completion ring, remaining-today, streak, level/XP, and achievements until
a manual refresh.

Introduce a single-owner TodayProvider at the app layout that holds the
current local day and owns the one `useTodayTick` subscription. On a genuine
rollover it advances the day (re-keying every date-keyed habit query that
consumers derive from it) and invalidates the gamification queries, which
are not date-keyed and otherwise would not refetch at a silent local
midnight. Both the Today page and the rail now read the day via `useToday`,
so a single trigger refreshes everything date-dependent and the two
surfaces can no longer drift.

Mobile is unaffected: its Today screen already runs a midnight timer plus
an AppState-resume rollover, and its stat surfaces are server-sourced with
refetch-on-foreground rather than a client-captured date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jul 4, 2026 7:14pm

Request Review

@claude claude Bot 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.

Code Review: PR #390 — fix(web): refresh sidebar/stat queries on day rollover

Recommendation: Request changes

Summary

TodayProvider correctly centralizes the app's local-day rollover and invalidates
gamificationKeys.all when the day genuinely changes, fixing the reported symptom
(stale sidebar completion ring / streak / level / achievements until manual refresh).
The mechanism is sound (ref-guarded, single subscription, well-tested). However, the
fix is incomplete on two fronts detailed below.

Findings

High

1. Mobile has the identical silent-midnight staleness bug; cross-platform parity is required (root CLAUDE.md: "Fix a bug in one platform? Check and fix in the other too").

  • File: apps/mobile/app/(tabs)/index.tsx:241-244 (rollover handler), apps/mobile/app/(tabs)/index.tsx:347-374 (midnight timer + AppState listener)
  • If a user leaves the mobile app foregrounded across local midnight (no background→foreground transition), the self-rescheduling setTimeout fires handleTodayRollover, which only does setToday(getTodayDate()) — it never invalidates gamificationKeys.all (already available via useGamificationProfile's invalidate in apps/mobile/hooks/use-gamification.ts:77-79, but not wired into the rollover path). The focusManager bridge only refetches on an actual AppState change event, which doesn't fire in this scenario. Streak/level/achievements on mobile show yesterday's data until the next real background/foreground cycle — the same bug this PR fixes on web.
  • Fix: in apps/mobile/app/(tabs)/index.tsx, have handleTodayRollover also invalidate gamificationKeys.all via useQueryClient(), mirroring today-provider.tsx's handleRollover.

2. The web fix itself misses a second date-dependent stat on the same page: TodayHeader's streak badge.

  • File: apps/web/app/(app)/today-provider.tsx:24-30 (only invalidates gamificationKeys.all); consumed at apps/web/app/(app)/page.tsx:74,422 (useProfile() → <TodayHeader streak={profile?.currentStreak ?? 0} />); source query at apps/web/hooks/use-profile.ts:36 (profileKeys.detail() — a separate TanStack Query cache from gamificationKeys, invalidated only via mutations, not rollover).
  • A silent local-midnight rollover (tab stays open/focused) advances today and invalidates gamificationKeys.all (fixing TodayRail's streak), but leaves profileKeys untouched, so TodayHeader's streak badge keeps showing yesterday's number until an unrelated focus/mutation event happens to invalidate profileKeys. This reproduces the exact bug being fixed, on the same page, in an adjacent component.
  • Fix: also invalidate profileKeys.all (or profileKeys.detail()) in TodayProvider's handleRollover.

Both findings were verified directly against the current file contents (not just inferred from the PR description), including the mobile rollover handler and the separate profileKeys/gamificationKeys query caches.

What's good

  • Clean single-owner pattern: one useTodayTick subscription for the whole app instead of duplicated per-page subscriptions.
  • handleRollover's ref-guard (if (next === todayRef.current) return) correctly avoids redundant re-renders/invalidations on repeated same-day focus events.
  • Solid test coverage for the new provider: day-advance + gamification invalidation on both visibility-change and local-midnight timer paths, no-op on same-day focus, and the useToday-without-provider throw.
  • Comment policy compliant: only JSDoc on the two exported symbols, no narration.
  • No any, no console.log, no dead code left behind.

Not verifiable in this environment

  • orbit-api / contract-aligner: N/A — no packages/shared type or DTO changes; orbit-api not checked out in this job.
  • Lint/type-check/tests: skipped per this workflow's CI adaptation (separate required checks already run Build/Unit Tests/SonarCloud).

Recommendation

Before merge: (1) wire gamificationKeys.all invalidation into mobile's existing rollover handler in apps/mobile/app/(tabs)/index.tsx, and (2) extend TodayProvider's handleRollover to also invalidate profileKeys.all so TodayHeader's streak badge doesn't go stale after the exact rollover this PR targets. Both are scoped follow-ups to the call sites already touched by this change.

Comment on lines +24 to +30
* (`YYYY-MM-DD`) and, when the day actually changes, advances it and refreshes
* date-dependent server state that isn't itself date-keyed (gamification:
* streak, level/XP, achievements). Date-keyed queries (habits) refresh on their
* own once consumers re-key off the new day. Consumers read the day via
* `useToday`; a manually pinned `?date=` param is resolved by the caller.
*/
export function TodayProvider({ children }: Readonly<{ children: ReactNode }>) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

High: This only invalidates gamificationKeys.all, but TodayHeader on the same page reads its streak from useProfile() → profileKeys.detail() (apps/web/app/(app)/page.tsx:74,422) — a separate query cache. A silent midnight rollover (tab left open/focused) will refresh TodayRail's streak but leave TodayHeader's streak badge showing yesterday's value until an unrelated focus/mutation event invalidates profileKeys. Suggest also invalidating profileKeys.all (or profileKeys.detail()) here.

@claude claude Bot 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.

Code Review: fix(web): refresh sidebar/stat queries on day rollover

Scope: PR #390 in thomasluizon/orbit-ui-mobile
Recommendation: NEEDS WORK

Summary

The PR consolidates day-rollover ownership into a single TodayProvider (apps/web/app/(app)/today-provider.tsx) that both the Today page and the sidebar (today-rail.tsx) now read via useToday(), fixing the reported bug where the sidebar froze its date and never invalidated gamification (streak/level/achievements) queries at a silent local midnight. The web-side implementation is correct, well-tested (new today-provider.test.tsx covers visibility-change rollover, midnight-timer rollover, same-day no-op, and the missing-provider throw), and the removed dead code (getTodayDate duplicate in page.tsx) is cleaned up properly. One parity gap survived review: the PR's claim that mobile needs no equivalent fix doesn't hold for the specific defect being fixed (silent-midnight gamification staleness), because mobile's handleTodayRollover has the identical gap.

Findings

Critical

None.

High

[High] Mobile day-rollover doesn't invalidate gamification queries, mirroring the exact bug just fixed on web
· dimension: Parity (#9)
· location: apps/mobile/app/(tabs)/index.tsx:242-246 (handleTodayRollover), also wired at :355 (midnight timer) and :364 (AppState 'active' resume)
· issue: The PR body argues mobile needs no change because it has no frozen client-side "today" (true) and its gamification surfaces read via TanStack Query with `refetchOnWindowFocus` bridged to `AppState` (also true). But the root cause this PR actually fixes is broader than the frozen date: per the PR's own description, "the gamification queries... are not date-keyed... never refetched at a silent local midnight (their refetchOnWindowFocus only fires on an actual focus event...)". Mobile's focus bridge (`apps/mobile/lib/query-client.ts:8-13`, `focusManager.setEventListener` on `AppState` `'change'`) has the identical limitation: it only fires on an actual foreground/background transition. Mobile already has a self-rescheduling midnight timer (`index.tsx:242` `handleTodayRollover`) that runs at exactly the same moment the web fix targets, but it only calls `setToday(...)` — it does not invalidate `gamificationKeys.all` the way the new web `TodayProvider.handleRollover` does (`today-provider.tsx:35-41`). Confirmed no other invalidation path covers this: `apps/mobile/hooks/use-gamification.ts` only calls `invalidateQueries({ queryKey: gamificationKeys.all })` from mutation success handlers (logging/reporting events), never from the rollover path, and there is no `refetchInterval` configured (`apps/mobile/lib/query-client.ts:56-70`).
· risk: A phone/tablet left foregrounded across local midnight without an intervening lock/background transition (e.g., screen-timeout disabled, kiosk/tablet use, "stay awake while charging") keeps showing the previous day's streak/level/achievements on the profile screen and achievements screen (`apps/mobile/app/(tabs)/profile.tsx`, `apps/mobile/app/achievements.tsx`) until the app is backgrounded or a gamification mutation happens — the same stale-stats bug this PR fixes on web, just gated behind a rarer device-stays-active window instead of a common left-open-desktop-tab window.
· fix: In `apps/mobile/app/(tabs)/index.tsx`, add the same `queryClient.invalidateQueries({ queryKey: gamificationKeys.all })` call inside `handleTodayRollover` (mirroring `today-provider.tsx:35-41`), guarded the same way (only on an actual day change, which the mobile `today`/`getTodayDate` comparison already gives for free by only calling `setToday` — add an equality check before invalidating to avoid invalidating on every AppState resume).
· reference: root CLAUDE.md "Cross-platform parity (MANDATORY)" — "Fix a bug in one platform? Check and fix in the other too."; rubric dimension 9 ("PARTIAL is High until proven intentional").

Medium

None (concretely-actionable Medium bar not otherwise met — see Deferred below for what wasn't independently re-derived).

Low / Info

None posted per the rubric's signal gate.

Subagents

Agent Verdict
parity-checker PARTIAL — see High finding above (manual equivalent of the agent's check; not separately spawned since the diff is small and fully read)
i18n-syncer N/A — no user-facing strings added or changed
contract-aligner N/A — no packages/shared/src/types/* or endpoints.ts change, and orbit-api is not part of this diff
security-reviewer N/A — no orbit-api code in this diff

Validation

Check Result
Lint N/A — CI runs this as a separate required check
Type check N/A — CI runs this as a separate required check
Tests N/A — CI runs this as a separate required check (PR body reports 40 affected vitest tests green locally)
Build (api) N/A — orbit-api not touched, not checked out in this job

Deferred — N/A dimensions & files not verdicted

  • DESIGN.md / AI-slop (#8): gated in (touches apps/web/components/shell/today-rail.tsx) but the change there is a pure data-source swap (useMemo(formatAPIDate...) → useToday()) with zero visual/markup change — no AI-slop surface to evaluate.
  • Contract drift + backward-compat (#11): N/A, no shared-type or DTO field changes in this diff.
  • Security (#12) and Backend hard rules (#13): N/A, orbit-api not touched and not checked out in this CI job — not verifiable here regardless.
  • FEATURES.md parity (#14): N/A, this is a bugfix with no user-facing feature/gating/platform change.
  • Every changed file in the diff (today-page.test.tsx, today-provider.test.tsx, today-rail.test.tsx, layout.tsx, page.tsx, today-provider.tsx, today-rail.tsx) was read in full and given a verdict; nothing left unverdicted.

What's good

  • Single-owner TodayProvider is the right fix shape: it removes the duplicate rollover-tracking logic from page.tsx and the frozen useMemo from today-rail.tsx, and both now derive from one source of truth, so they structurally cannot drift from each other again.
  • handleRollover's todayRef equality guard correctly avoids redundant invalidation on same-day focus/visibility events — matches the new test does not refresh gamification when a focus event fires on the same day.
  • Test coverage is genuinely behavioral (dispatches real visibilitychange/focus events and advances fake timers), not implementation-detail assertions, and includes the useToday without-provider throw case.
  • Comment/JSDoc usage on the two new exports is compliant with the lint-enforced comment policy (short paragraph on intent/contract, nothing narrative).

Recommendation

Add the mirrored gamification-invalidation call to apps/mobile/app/(tabs)/index.tsx's handleTodayRollover before merge, so the fix is complete on both platforms per the mandatory parity rule. Everything else in the web-side change is solid and ready to ship once that's added.

@sonarqubecloud

sonarqubecloud Bot commented Jul 4, 2026

Copy link
Copy Markdown

@claude claude Bot 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.

Code Review: fix(web): refresh sidebar/stat queries on day rollover

Scope: PR #390 in thomasluizon/orbit-ui-mobile
Recommendation: NEEDS WORK

Summary

The PR consolidates day-rollover ownership into a single TodayProvider (apps/web/app/(app)/today-provider.tsx) that both the Today page and the sidebar (today-rail.tsx) now read via useToday(), fixing the reported bug where the sidebar froze its date and never invalidated gamification (streak/level/achievements) queries at a silent local midnight. The mechanism is sound (ref-guarded, single subscription, well-tested), and the removed dead code (getTodayDate duplicate in page.tsx) is cleaned up properly. Two gaps survive review — one is the same defect recurring on mobile, the other is the same defect recurring on an adjacent web component that reads a different query cache.

Findings

Critical

None.

High

[High] Mobile day-rollover doesn't invalidate gamification queries, mirroring the exact bug just fixed on web
· dimension: Parity (#9)
· location: apps/mobile/app/(tabs)/index.tsx:242-246 (handleTodayRollover), also wired at :355 (midnight timer) and :364 (AppState 'active' resume)
· issue: The PR body argues mobile needs no change because it has no frozen client-side "today" (true) and its gamification surfaces read via TanStack Query with `refetchOnWindowFocus` bridged to `AppState` (also true). But the root cause this PR actually fixes is broader than the frozen date: the gamification queries are not date-keyed and are never refetched at a silent local midnight, because `refetchOnWindowFocus` only fires on an actual focus event. Mobile's focus bridge (`apps/mobile/lib/query-client.ts:8-13`, `focusManager.setEventListener` on `AppState` `'change'`) has the identical limitation. Mobile already has a self-rescheduling midnight timer (`index.tsx:242`, `handleTodayRollover`) that runs at exactly the same moment the web fix targets, but it only calls `setToday(...)` — it never invalidates `gamificationKeys.all` the way the new web `TodayProvider.handleRollover` does (`today-provider.tsx:35-41`). `apps/mobile/hooks/use-gamification.ts` only invalidates `gamificationKeys.all` from mutation success handlers, never from the rollover path, and there is no `refetchInterval` configured (`apps/mobile/lib/query-client.ts:56-70`).
· risk: A phone/tablet left foregrounded across local midnight without an intervening lock/background transition (screen-timeout disabled, kiosk/tablet use, "stay awake while charging") keeps showing the previous day's streak/level/achievements on the profile and achievements screens until the app is backgrounded or a gamification mutation happens — the same stale-stats bug this PR fixes on web, gated behind a rarer device-stays-active window.
· fix: In `apps/mobile/app/(tabs)/index.tsx`, add `queryClient.invalidateQueries({ queryKey: gamificationKeys.all })` inside `handleTodayRollover`, mirroring `today-provider.tsx:35-41`.
· reference: root CLAUDE.md "Cross-platform parity (MANDATORY)" — "Fix a bug in one platform? Check and fix in the other too."
[High] The web fix itself misses a second date-dependent stat on the same page: TodayHeader's streak badge (profileKeys, not gamificationKeys)
· dimension: Correctness / completeness of the fix (#1, #9)
· location: apps/web/app/(app)/today-provider.tsx:35-41 (handleRollover only invalidates gamificationKeys.all); consumed at apps/web/app/(app)/page.tsx:74,422 (useProfile() -> <TodayHeader streak={profile?.currentStreak ?? 0} />); source query at apps/web/hooks/use-profile.ts:36 (profileKeys.detail(), refetchOnWindowFocus: true) — a separate TanStack Query cache from gamificationKeys, invalidated only via mutations, not rollover.
· issue: The exact scenario this PR fixes is a tab left open and focused across local midnight: use-follow-today-sync.ts's setTimeout fires onRollover() directly with no visibilitychange/focus event, so profile's own refetchOnWindowFocus never fires. TodayProvider's handleRollover advances `today` and invalidates gamificationKeys.all (fixing today-rail.tsx's streak), but never touches profileKeys, so page.tsx's TodayHeader streak badge — reading the same page, same rollover — keeps showing yesterday's number until an unrelated focus/mutation event happens to invalidate profileKeys. This reproduces the bug being fixed, on the same page, in an adjacent component, via the second server-state cache the fix didn't cover. Confirmed unresolved: no functional commit has landed on today-provider.tsx since this was first flagged (only a `main` merge commit, no content change).
· fix: Also invalidate `profileKeys.all` (or `profileKeys.detail()`) in TodayProvider's handleRollover, alongside gamificationKeys.all.

Medium

None (concretely-actionable Medium bar not otherwise met).

Low / Info

None posted per the rubric's signal gate.

Subagents

Agent Verdict
parity-checker PARTIAL — see first High finding above
i18n-syncer N/A — no user-facing strings added or changed
contract-aligner N/A — no packages/shared/src/types/* or endpoints.ts change, and orbit-api is not checked out in this job
security-reviewer N/A — no orbit-api code in this diff

Validation

Check Result
Lint N/A — CI runs this as a separate required check
Type check N/A — CI runs this as a separate required check
Tests N/A — CI runs this as a separate required check
Build (api) N/A — orbit-api not touched, not checked out in this job

Not verifiable in this environment

  • Contract drift + backward-compat (#11): N/A, no shared-type or DTO field changes in this diff.
  • Security (#12) and Backend hard rules (#13): N/A, orbit-api not touched and not checked out in this CI job.
  • FEATURES.md parity (#14): N/A, this is a bugfix with no user-facing feature/gating/platform change.

What's good

  • Single-owner TodayProvider is the right fix shape: it removes the duplicate rollover-tracking logic from page.tsx and the frozen useMemo from today-rail.tsx, so they structurally cannot drift from each other again.
  • handleRollover's todayRef equality guard correctly avoids redundant invalidation on same-day focus/visibility events.
  • Test coverage is genuinely behavioral (dispatches real visibilitychange/focus events, advances fake timers), including the useToday without-provider throw case.
  • Comment/JSDoc usage on the two new exports is compliant with the lint-enforced comment policy.

Recommendation

Before merge: (1) wire gamificationKeys.all invalidation into mobile's existing rollover handler in apps/mobile/app/(tabs)/index.tsx, and (2) extend TodayProvider's handleRollover to also invalidate profileKeys.all so TodayHeader's streak badge doesn't go stale after the exact rollover this PR targets. Both are scoped, one-line follow-ups to call sites already touched by this change.

@thomasluizon
thomasluizon merged commit ba8cba1 into main Jul 4, 2026
10 checks passed
@thomasluizon
thomasluizon deleted the fix/day-rollover-sidebar-stats branch July 4, 2026 19:32
thomasluizon added a commit that referenced this pull request Sep 26, 2026
Main's #390 replay work added a bulk delete test at the same place as this
branch's dropped-delete Undo test; keep both tests.

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.

1 participant