diff --git a/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/.openspec.yaml b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/.openspec.yaml new file mode 100644 index 00000000..1c37182e --- /dev/null +++ b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-05 diff --git a/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/design.md b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/design.md new file mode 100644 index 00000000..e3b73337 --- /dev/null +++ b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/design.md @@ -0,0 +1,66 @@ +## Context + +Driven by FR1–FR8 of this change. "Today"-relative UI surfaces compute the logical date imperatively at render/`useMemo` time with no dependency on the passage of time. When the app stays mounted across a day boundary, the logical date the UI used is never recomputed, so five surfaces go stale (see proposal Impact): `ActiveTasksPage` completed-today (`ActiveTasksPage.tsx:145`), `CompletedPage` grouping (`CompletedPage.tsx:77`), `TaskItem` label (`TaskItem.tsx:244`), `GoalItem` label (`GoalItem.tsx:99`), `TaskDetailsTab` next-date label (`TaskDetailsTab.tsx:311`). + +Current constraints: +- The only existing day-boundary timer lives inside `useHiddenTasksReveal.ts:37-62` (`scheduleNextBoundary`), but it only triggers hidden-task reveal — it never publishes a date to render state. +- The repo already has the exact reactive pattern needed: `stores/menuOrderStore.ts` (module-level store: `getSnapshot`/`subscribe`/setter) consumed via `useSyncExternalStore` in `hooks/useMenuOrder.ts`. React 18.3, no external state library. +- Time is always accessed through the `Clock` abstraction (`lib/temporal.ts`): `systemClock` in prod, `fakeClock` in tests. `vi.useFakeTimers`/`Date.now` are forbidden. + +## Goals / Non-Goals + +**Goals:** +- Provide one shared reactive logical-date source so all five surfaces update on day rollover without a remount (FR1, FR3–FR7). +- Keep exactly one boundary timer + one set of re-arm listeners app-wide (NFR-P1, FR2). +- Extract the boundary-timer math into a shared helper reused by `useHiddenTasksReveal` with zero behavior change (FR8). + +**Non-Goals:** +- Changing `getLogicalDate` or the day-boundary setting UI (NG1). +- Changing reveal behavior (NG2) — pure refactor of its timer math. +- Reactivity for authoring-time date defaults (NG3). + +## Decisions + +### D1: Components subscribe directly to a module-level store (not prop-drilling) + +`todayCompleted`/grouping consumers add the reactive date to their `useMemo` deps; leaf label components (`TaskItem`, `GoalItem`, `TaskDetailsTab`) call `useLogicalToday()` directly. + +**Why over prop-drilling the date from list parents:** a module-level store means the number of subscribers is decoupled from the number of timers — one timer regardless of list length (NFR-P1). Prop-drilling would still need the timer somewhere, and would additionally require threading a prop through `TaskList→TaskItem`, `GoalList→GoalItem`, and into `TaskDetailsTab` — five render paths, easy to miss one. Direct subscription has every consumer self-register, so no path can be forgotten. + +**Alternative considered — event-only (`dispatchEvent` on boundary, each consumer bumps local state):** rejected because it duplicates subscription bookkeeping in every component and has no single snapshot; `useSyncExternalStore` gives a consistent snapshot and tearing-free reads for free. + +### D2: Implement as `logicalTodayStore` + `useLogicalToday`, mirroring `menuOrderStore`/`useMenuOrder` + +- `stores/logicalTodayStore.ts`: holds `currentSnapshot: ISODate`, a `Set`, and exports `getSnapshot()`, `subscribe(listener)`, plus internal recompute. Snapshot recomputed via `getLogicalDate(clock, getCachedDayBoundary())`; `emitChange()` only fires when the date string actually changes (avoids needless renders on spurious re-arms). +- `hooks/useLogicalToday.ts`: `useSyncExternalStore(subscribe, getSnapshot)` — a one-line hook, exactly like `useMenuOrder`. +- Testability: export `_resetForTesting()` and allow injecting a `Clock` (default `systemClock`) so `fakeClock` drives the store in tests — same seam the reveal tests use. + +### D3: Ref-counted lazy timer lifecycle (resolves proposal Q1) + +The store starts its boundary timer and attaches re-arm listeners (`visibilitychange`, `pageshow`, `DAY_BOUNDARY_CHANGED_EVENT`) when the **first** subscriber registers, and tears them down when the **last** unsubscribes. + +**Why over always-on from module load:** cleaner in tests (no dangling timer between cases), no work when no "today" surface is mounted, and it matches `useSyncExternalStore`'s subscribe/unsubscribe lifecycle. Trade-off: slightly more bookkeeping than a module-load singleton — acceptable and covered by unit tests. + +### D4: Extract `scheduleNextBoundary(clock, dayBoundary, onFire)` into a shared helper + +Move the pure `nextBoundary`/`msUntilBoundary` math (currently `useHiddenTasksReveal.ts:37-62`, incl. `BOUNDARY_BUFFER_MS`) into a shared module. `useHiddenTasksReveal` calls it with `onFire = revealTasks`; the store calls it with `onFire = recompute`. The helper self-reschedules via the caller re-invoking it inside `onFire` (same shape as today). No observable change to reveal (FR8) — guarded by the existing `useHiddenTasksReveal.midnight.test.ts`. + +### D5: Consumer wiring + +- `ActiveTasksPage`/`CompletedPage`: `const logicalToday = useLogicalToday();` added to the `useMemo` deps alongside `completedTasks`. +- `TaskItem`/`GoalItem`/`TaskDetailsTab`: `useLogicalToday()` called (value used to key the label or simply to force re-render); label functions keep reading `getCachedDayBoundary()` as today. Because these are list leaves, subscribing them directly is what makes the labels reactive. + +## Risks / Trade-offs + +- **Every `TaskItem` in a long list subscribes to the store** → With a module-level store this is cheap (Set insert + one shared snapshot); still, re-render on rollover touches all mounted items at once. Mitigation: `emitChange` fires only on an actual date-string change (≤ once/day), so the mass re-render happens at most once per boundary. +- **Refactoring `useHiddenTasksReveal`'s timer could regress reveal timing** → Mitigation: extraction is behavior-preserving and covered by `useHiddenTasksReveal.midnight.test.ts`; run it before/after. +- **Timer drift / suspended tabs** (a backgrounded tab may not fire the timeout on time) → Mitigation: `visibilitychange`→visible and `pageshow` re-arm recompute immediately on return, so a missed timeout self-heals (U3). +- **Two independent timers (reveal + store) firing near the same boundary** → Acceptable; they do different work and both use the shared helper with the same buffer. No shared mutable state between them. + +## Migration Plan + +Additive, no data migration. Deploy the store/hook/helper and rewire consumers together. Rollback = revert the change; no persisted state or schema is touched. No feature flag needed (client-only behavior, covered by tests). + +## Open Questions + +None — Q1 from the proposal is resolved by D3 (ref-counted lazy lifecycle). diff --git a/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/proposal.md b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/proposal.md new file mode 100644 index 00000000..f4b899ab --- /dev/null +++ b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/proposal.md @@ -0,0 +1,102 @@ +# Fix completed-today stale on day rollover + +## Why + +When the app stays open across a day boundary, "today"-relative surfaces freeze on the previous day: the "completed today" section on the Active Tasks page keeps showing yesterday's completed tasks until the user closes/reopens the app or navigates away and back. The logical date is computed imperatively inside `useMemo`/render with no dependency on the passage of time, so nothing forces a recompute when a new day begins. Users who leave the PWA open overnight see incorrect data every morning. + +## What Changes + +- **ADDED**: a single shared reactive source of the current logical date (`useLogicalToday`, backed by a module-level store consumed via `useSyncExternalStore`, mirroring the existing `menuOrderStore`/`useMenuOrder` pattern). It updates React state exactly when the logical day changes while the app stays mounted. +- **ADDED**: an extracted `scheduleNextBoundary(clock, dayBoundary, onFire)` helper carrying the day-boundary timer math currently inlined in `useHiddenTasksReveal`, reused by both the reveal hook and the new store (single timer per app, ref-counted). +- **MODIFIED**: `ActiveTasksPage` "completed today" section, `CompletedPage` grouping, `TaskItem` completed-at label, `GoalItem` date label, and `TaskDetailsTab` next-date label now recompute/re-render on logical-day change without a remount. +- **ADDED**: an integration test that reproduces the bug (list frozen on yesterday) by advancing a fake clock across the boundary while mounted, then verifies the fix. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `day-boundary`: adds a requirement that "today"-relative displays react to the logical day changing while the app stays mounted (not only on remount), via a shared reactive logical-date source and a single re-armed boundary timer. + +## Impact + +- **Code**: new `src/stores/logicalTodayStore.ts` + `src/hooks/useLogicalToday.ts`; extracted boundary-timer helper (from `src/hooks/useHiddenTasksReveal.ts`); edits to `src/pages/ActiveTasksPage.tsx`, `src/pages/CompletedPage.tsx`, `src/components/tasks/TaskItem.tsx`, `src/components/goals/GoalItem.tsx`, `src/components/tasks/TaskDetailsTab.tsx`. +- **Behavior**: no data model, sync, or storage changes. No breaking changes. `useHiddenTasksReveal` behavior is preserved (pure refactor of its timer math). +- **Tests**: new integration test crossing the boundary while mounted; unit tests for the store/hook and extracted helper; mutation testing on new logic. + +## Goals + +- **G1**: When the logical day changes while the app is open, the "completed today" section clears yesterday's tasks within one day-boundary tick (≤ boundary + buffer) — no user action, no remount. +- **G2**: All five "today"-relative display surfaces stay correct across a day boundary without a remount. +- **G3**: Exactly one boundary timer and one set of re-arm listeners exist regardless of how many list items are mounted. + +## Non-Goals + +- **NG1**: Changing how the logical date itself is computed (`getLogicalDate`) or the day-boundary setting UI. +- **NG2**: Changing the reveal-of-hidden-tasks behavior in `useHiddenTasksReveal` (only its timer math is extracted, behavior unchanged). +- **NG3**: Reactivity for authoring-time date defaults (e.g. `RepeatRuleSelector`, `HideTaskPanel`) — those are not "today" labels and are out of scope. + +## Users & Scenarios + +- **U1**: A user leaves the PWA open overnight. In the morning, without touching anything, the "completed today" section shows an empty (or newly-started) today, not yesterday's completions. +- **U2**: A user with a custom day boundary (e.g. `04:00`) sees the section roll over at 04:00, not at midnight. +- **U3**: A user returns to a backgrounded tab after the boundary passed; on becoming visible, the "today" surfaces are already correct. + +## Requirements + +### Functional + +- **FR1**: The system SHALL provide a shared reactive current-logical-date source that exposes the logical date (computed via `getLogicalDate` in the user's timezone and day boundary) as React state, updating when the logical day changes while the app stays mounted. +- **FR2**: The reactive source SHALL schedule a single day-boundary timer (via the extracted `scheduleNextBoundary` helper) that self-reschedules, and SHALL re-arm/recompute on `visibilitychange` → visible, `pageshow` (persisted), and `DAY_BOUNDARY_CHANGED_EVENT`. +- **FR3**: `ActiveTasksPage` "completed today" section SHALL recompute when the logical date changes, so tasks completed on the previous logical day leave the section at the boundary without a remount. +- **FR4**: `CompletedPage` grouping SHALL recompute its Today/Yesterday/Week/Month buckets when the logical date changes without a remount. +- **FR5**: `TaskItem` completed-at label ("Completed today/yesterday …") SHALL re-render to the correct label when the logical date changes without a remount. +- **FR6**: `GoalItem` date label ("Today/Yesterday …") SHALL re-render to the correct label when the logical date changes without a remount. +- **FR7**: `TaskDetailsTab` next-date label ("Today/Tomorrow …") SHALL re-render to the correct label when the logical date changes without a remount. +- **FR8**: The day-boundary timer math SHALL be extracted into a reusable helper and reused by `useHiddenTasksReveal` with no change to its observable reveal behavior. + +### Non-Functional + +#### Performance + +- **NFR-P1**: Regardless of the number of mounted list items, the reactive source SHALL maintain at most one boundary timer and one set of global re-arm listeners (module-level store, ref-counted subscriptions). + +#### Accessibility + +- **NFR-A1**: The rollover update SHALL cause no accessibility regressions; axe-core checks on the affected pages SHALL remain green. + +## UX Acceptance Criteria + +- **UX1**: With the app open and a completed-today task visible, when the clock crosses the day boundary, the task disappears from the "completed today" section automatically within one boundary tick. +- **UX2**: Yesterday's completed task never reappears in "completed today" after the rollover, even without navigation. +- **UX3**: Custom day boundary is respected — the rollover happens at the configured time, not midnight. + +## UI States Matrix + +| Network | Data | UI | +|---------|--------------------------------------------|-----------------------------------------------------------------| +| any | before boundary, has today-completed tasks | tasks shown in "completed today" | +| any | boundary crosses, no new completions | "completed today" empty state | +| any | boundary crosses, list unchanged otherwise | grouping/labels update; other sections unaffected | +| offline | boundary crosses | rollover still occurs (timer is client-side, no network needed) | + +## Behavior + +See `features/day_boundary_rollover.feature` (tags `@fix-completed-today-stale-on-day-rollover`). + +## Affected IA + +No changes. + +## Success Metrics + +- **M1**: An integration test reproduces the bug on the pre-fix code (fails: yesterday's task still shown after the clock crosses the boundary while mounted) and passes after the fix. +- **M2**: After the boundary crosses while mounted, zero previous-logical-day completed tasks remain in the "completed today" section. +- **M3**: Mutation score on new store/hook/helper code ≥ 90% (target ≥ 95%). + +## Open Questions + +- **Q1**: Timer lifecycle — ref-counted lazy start/stop (start on first subscriber, stop on last) vs. always-on from module load. Leaning ref-counted for cleaner tests and background behavior. Resolved in `design.md`. diff --git a/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/specs/day-boundary/spec.md b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/specs/day-boundary/spec.md new file mode 100644 index 00000000..1ee0538f --- /dev/null +++ b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/specs/day-boundary/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Reactive current logical date source +# implements FR1, FR2 of fix-completed-today-stale-on-day-rollover + +The system SHALL provide a shared reactive source of the current logical date that exposes the logical date (computed via `getLogicalDate` in the user's timezone and configured day boundary) to React as state via `useSyncExternalStore`. The source SHALL update its value when the logical day changes while the app stays mounted, by scheduling a single self-rescheduling day-boundary timer. It SHALL also recompute on `visibilitychange` → visible, on `pageshow` when persisted, and on `DAY_BOUNDARY_CHANGED_EVENT`. On return to visibility (`visibilitychange` → visible and persisted `pageshow`) and on `DAY_BOUNDARY_CHANGED_EVENT` the source SHALL additionally re-arm (clear and reschedule) the day-boundary timer, because the timezone or configured boundary may have changed while the app was inactive, leaving the timer aimed at a stale instant. This mirrors the re-arm behavior of `useHiddenTasksReveal`, the other consumer of `scheduleNextBoundary`. It SHALL emit a change to subscribers only when the logical date value actually changes. + +#### Scenario: Value updates when boundary is crossed while mounted +- **WHEN** a component subscribes to the source, current logical date is June 4, and the clock advances past the day boundary into June 5 +- **THEN** the source emits and its value becomes June 5 without any remount + +#### Scenario: No emit when re-armed but date unchanged +- **WHEN** the source recomputes (e.g. on visibilitychange) but the logical date is unchanged +- **THEN** no change is emitted to subscribers + +#### Scenario: Recompute on return to visibility after boundary passed +- **WHEN** the tab was hidden while the clock crossed the boundary and then becomes visible +- **THEN** the source recomputes immediately and reflects the new logical date + +#### Scenario: Re-arm boundary timer on return to visibility after timezone change +- **WHEN** the tab was hidden while the timezone changed and then becomes visible (or a persisted `pageshow` fires) +- **THEN** the source clears the existing boundary timer and reschedules it against the current timezone and boundary, so the next boundary fires at the correct instant + +#### Scenario: Recompute on day boundary setting change +- **WHEN** `DAY_BOUNDARY_CHANGED_EVENT` fires and the new boundary changes the current logical date +- **THEN** the source recomputes and emits the new logical date + +### Requirement: Single boundary timer regardless of subscriber count +# implements NFR-P1, FR2 of fix-completed-today-stale-on-day-rollover + +The reactive logical-date source SHALL maintain at most one day-boundary timer and one set of global re-arm listeners regardless of how many components are subscribed. The timer and listeners SHALL be created when the first subscriber registers and torn down when the last subscriber unsubscribes. + +#### Scenario: Many subscribers share one timer +- **WHEN** many list items subscribe to the source +- **THEN** exactly one boundary timer and one set of re-arm listeners exist + +#### Scenario: Teardown on last unsubscribe +- **WHEN** the last subscriber unsubscribes +- **THEN** the boundary timer is cleared and the re-arm listeners are removed + +### Requirement: Today-relative displays react to day rollover without remount +# implements FR3, FR4, FR5, FR6, FR7 of fix-completed-today-stale-on-day-rollover + +Every "today"-relative display surface SHALL recompute or re-render when the reactive logical date changes, without requiring a remount. This applies to: the ActiveTasksPage "completed today" section, the CompletedPage grouping, the TaskItem completed-at label, the GoalItem date label, and the TaskDetailsTab next-date label. + +#### Scenario: Completed-today section clears yesterday's tasks at boundary +- **WHEN** the ActiveTasksPage is mounted showing a task completed on the previous logical day, and the clock crosses the day boundary +- **THEN** the task is removed from the "completed today" section without a remount + +#### Scenario: Completed page grouping rebuckets at boundary +- **WHEN** the CompletedPage is mounted and the clock crosses the day boundary +- **THEN** a task previously in the "Today" group moves to the "Yesterday" group without a remount + +#### Scenario: TaskItem label updates at boundary +- **WHEN** a TaskItem shows a "completed today" label and the clock crosses the day boundary +- **THEN** the label re-renders to the "completed yesterday" (previous-day) form without a remount + +#### Scenario: GoalItem label updates at boundary +- **WHEN** a GoalItem shows a "Today" date label and the clock crosses the day boundary +- **THEN** the label re-renders to the "Yesterday" form without a remount + +#### Scenario: TaskDetailsTab next-date label updates at boundary +- **WHEN** a TaskDetailsTab shows a next-date "Today"/"Tomorrow" label and the clock crosses the day boundary +- **THEN** the label re-renders to the correct relative form without a remount + +#### Scenario: Custom day boundary respected for rollover +- **WHEN** the day boundary is "04:00" and the app is mounted with a task completed at 10:00 on June 4, and the clock advances to 04:00 on June 5 +- **THEN** the task leaves the "completed today" section exactly at the 04:00 rollover, not at midnight + +### Requirement: Shared boundary-timer helper reused by hidden-task reveal +# implements FR8 of fix-completed-today-stale-on-day-rollover + +The day-boundary timer math SHALL be extracted into a reusable `scheduleNextBoundary(clock, dayBoundary, onFire)` helper and reused by both `useHiddenTasksReveal` and the reactive logical-date source. The extraction SHALL NOT change the observable reveal behavior of `useHiddenTasksReveal`. + +#### Scenario: Reveal behavior unchanged after extraction +- **WHEN** the hidden-task reveal runs across a day boundary using the extracted helper +- **THEN** hidden tasks are revealed at the boundary exactly as before the refactor + +#### Scenario: Helper schedules next boundary for non-midnight time +- **WHEN** `scheduleNextBoundary` is called with day boundary "02:00" and current time 23:00 +- **THEN** the scheduled fire time is the next 02:00 occurrence (plus the boundary buffer) diff --git a/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/tasks.md b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/tasks.md new file mode 100644 index 00000000..d2e5f677 --- /dev/null +++ b/openspec/changes/archive/2026/08/2026-08-07-fix-completed-today-stale-on-day-rollover/tasks.md @@ -0,0 +1,41 @@ +## 1. Reproduce the bug (RED integration test) + +- [x] 1.1 Add a failing integration test that mounts `ActiveTasksPage` with a task completed on the previous logical day, advances a `fakeClock` across the day boundary WITHOUT remount/rerender, drives the boundary timer, and asserts the task is gone from "completed today" (FR3, M1). Confirm it FAILS on current code. +- [x] 1.2 Add a parallel RED case for a custom day boundary ("04:00") verifying rollover happens at 04:00, not midnight (UX3, FR3). Confirm it FAILS. + +## 2. Extract shared boundary-timer helper (FR8) + +- [x] 2.1 RED: write unit tests for `scheduleNextBoundary(clock, dayBoundary, onFire)` covering next-boundary-today vs next-day and the boundary buffer, using `fakeClock` (FR8). +- [x] 2.2 GREEN: extract the `nextBoundary`/`msUntilBoundary` math and `BOUNDARY_BUFFER_MS` from `useHiddenTasksReveal.ts` into a shared `src/utils/scheduleNextBoundary.ts` helper, imported by path per the `src/utils/` convention (no barrel `index.ts`) (FR8). +- [x] 2.3 Refactor `useHiddenTasksReveal` to call the helper; run `useHiddenTasksReveal.midnight.test.ts` to prove reveal behavior is unchanged (FR8). + +## 3. Reactive logical-date store (FR1, FR2, NFR-P1) + +- [x] 3.1 RED: unit tests for `logicalTodayStore` — `getSnapshot` returns current logical date; `subscribe` triggers on boundary cross; emits only on actual date change; ref-counted timer/listener setup on first subscribe and teardown on last unsubscribe; recompute on `visibilitychange`/`pageshow`/`DAY_BOUNDARY_CHANGED_EVENT`; `_resetForTesting` + injectable `Clock` (FR1, FR2, NFR-P1). +- [x] 3.2 GREEN: implement `src/stores/logicalTodayStore.ts` mirroring `menuOrderStore.ts`, using the extracted helper and `getLogicalDate(clock, getCachedDayBoundary())` (FR1, FR2, NFR-P1). +- [x] 3.3 Implement `src/hooks/useLogicalToday.ts` via `useSyncExternalStore(subscribe, getSnapshot)`, mirroring `useMenuOrder.ts` (FR1). + +## 4. Wire consumers to the reactive date + +- [x] 4.1 `ActiveTasksPage.tsx`: add `useLogicalToday()` to `todayCompleted` `useMemo` deps (FR3). +- [x] 4.2 `CompletedPage.tsx`: add `useLogicalToday()` to the grouping `useMemo` deps (FR4). +- [x] 4.3 `TaskItem.tsx`: subscribe via `useLogicalToday()` so the completed-at label re-renders on rollover (FR5). +- [x] 4.4 `GoalItem.tsx`: subscribe via `useLogicalToday()` so the date label re-renders on rollover (FR6). +- [x] 4.5 `TaskDetailsTab.tsx`: subscribe via `useLogicalToday()` so the next-date label re-renders on rollover (FR7). + +## 5. Turn the bug tests GREEN + +- [x] 5.1 Run the section-1 integration tests; confirm they now PASS (M1, M2). +- [x] 5.2 Add integration assertions for `CompletedPage` rebucketing and for a `TaskItem`/`GoalItem` label flip across the boundary while mounted (FR4, FR5, FR6). +- [x] 5.3 Add a `TaskDetailsTab` next-date label flip test (`TaskDetailsTab.dayRollover.test.tsx`): mount with a repeating task whose `next_date` is tomorrow, cross the boundary while mounted, assert the label flips "завтра" → "сегодня" without remount (FR7). + +## 6. BDD behavior spec + +- [x] 6.1 Author `features/day_boundary_rollover.feature` with `@fix-completed-today-stale-on-day-rollover` scenarios mirroring the spec (rollover clears completed-today, custom boundary, no-emit-when-unchanged) (FR3, FR2). +- [x] 6.2 Implement step definitions using `fakeClock` (no `vi.useFakeTimers`) (FR3). + +## 7. Verification + +- [x] 7.1 axe-core check on `ActiveTasksPage` and `CompletedPage` remains green after rollover (NFR-A1). E2e scenarios cross the day boundary in place (`page.clock` + `visibilitychange`) and scan the POST-rollover DOM — the active page after the completed-today task leaves, and the completed page after the task regroups into "yesterday" — not only the pre-rollover state. +- [x] 7.2 `get_file_problems` on all changed files; fix errors; run `pnpm run build`. +- [x] 7.3 Mutation testing (scoped) on `logicalTodayStore.ts`, `useLogicalToday.ts`, and the extracted helper; add tests to kill survivors; target ≥95%, minimum ≥90% (M3). Full Stryker run is delegated to the user. diff --git a/openspec/specs/day-boundary/spec.md b/openspec/specs/day-boundary/spec.md index a8d68928..c0f71e6e 100644 --- a/openspec/specs/day-boundary/spec.md +++ b/openspec/specs/day-boundary/spec.md @@ -192,3 +192,83 @@ The Settings page SHALL include a "Day start time" section with an ` ({ + useIsUnsynced: vi.fn().mockReturnValue(false), +})); +vi.mock("@/hooks/useAttachmentCount", () => ({ + useAttachmentCount: vi.fn().mockReturnValue({ + attachmentCount: 0, + hasUnsyncedAttachments: false, + isLoading: false, + }), +})); +vi.mock("@/hooks/usePanelSide", () => ({ + usePanelSide: vi + .fn() + .mockReturnValue({ panelSide: "right", setPanelSide: vi.fn() }), +})); +vi.mock("@/hooks/useFileUrl", () => ({ + useFileUrl: vi.fn().mockReturnValue({ url: null }), +})); + +const clockState = vi.hoisted(() => ({ + fakeClockOverride: null as unknown, +})); + +vi.mock("@/lib/temporal", async (importOriginal) => { + const actualTemporal = + await importOriginal(); + const resolveClock = () => + (clockState.fakeClockOverride as typeof actualTemporal.systemClock) ?? + actualTemporal.systemClock; + return { + ...actualTemporal, + systemClock: { + instant: () => resolveClock().instant(), + plainDateISO: () => resolveClock().plainDateISO(), + timeZoneId: () => resolveClock().timeZoneId(), + }, + }; +}); + +function setFakeClock(isoTimestamp: string, timeZone?: string) { + clockState.fakeClockOverride = fakeClock( + isoTimestamp, + timeZone, + ) as unknown as Clock; +} + +function renderGoalItem(overrides = {}) { + const goal = buildGoal(); + const props = { + goal, + taskCount: 0, + onNavigate: vi.fn(), + ...overrides, + }; + render( + + + , + ); + return props; +} + +/** + * Reproduces FR6 of fix-completed-today-stale-on-day-rollover: the finished + * goal's date label must re-render with the correct relative day when the + * logical day changes while the item stays mounted, not only on remount. + */ +describe("GoalItem — day boundary rollover", () => { + // implements FR6 of fix-completed-today-stale-on-day-rollover + it("should flip the finished-at label from today to yesterday when the day boundary is crossed while mounted", async () => { + setFakeClock("2026-06-04T20:00:00Z", "UTC"); + const goal = buildGoal({ + status: "completed", + updated_at: "2026-06-04T10:00:00.000Z", + }); + renderGoalItem({ goal }); + + expect(screen.getByTestId("goal-item-finished-at")).toHaveTextContent( + /Сегодня/, + ); + + setFakeClock("2026-06-05T00:30:00Z", "UTC"); + act(() => { + window.dispatchEvent(new CustomEvent(DAY_BOUNDARY_CHANGED_EVENT)); + }); + + await waitFor(() => { + expect(screen.getByTestId("goal-item-finished-at")).toHaveTextContent( + /Вчера/, + ); + }); + }); +}); diff --git a/packages/client/src/components/goals/GoalItem.tsx b/packages/client/src/components/goals/GoalItem.tsx index ab963692..13a92dea 100644 --- a/packages/client/src/components/goals/GoalItem.tsx +++ b/packages/client/src/components/goals/GoalItem.tsx @@ -4,6 +4,7 @@ import defaultCoverSvg from "@/assets/default-goal-cover.svg"; import { ENTITY_TYPE, FINISHED_GOAL_STATUSES } from "@/constants"; import { useAttachmentCount } from "@/hooks/useAttachmentCount"; import { useFileUrl } from "@/hooks/useFileUrl"; +import { useLogicalToday } from "@/hooks/useLogicalToday"; import { usePanelSide } from "@/hooks/usePanelSide"; import { getCachedDayBoundary } from "@/hooks/useSettings"; import { cn } from "@/shared/lib/cn"; @@ -45,6 +46,8 @@ export function GoalItem({ ); const { panelSide } = usePanelSide(); const { url: coverUrl } = useFileUrl(goal.cover_hash); + // implements FR6 of fix-completed-today-stale-on-day-rollover + useLogicalToday(); return (
  • ({ + ...(await importOriginal()), + useSettings: () => ({ defaultBox: "today" }), +})); + +vi.mock("@/hooks/useRepeatRuleChangeDialog", () => ({ + useRepeatRuleChangeDialog: () => ({ + pendingRuleChange: null, + handleRepeatChange: vi.fn(), + handleRuleChangeConfirm: vi.fn(), + handleRuleChangeCancel: vi.fn(), + }), +})); + +vi.mock("@/components/ui/EditableDescription", () => ({ + EditableDescription: () =>