From 45b4d649858db15953766775c6845375b6cb0f02 Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:57:59 +0500 Subject: [PATCH 1/3] add fix-today-completed-logical-date OpenSpec --- .../.openspec.yaml | 2 + .../design.md | 66 +++++++++++++ .../proposal.md | 97 +++++++++++++++++++ .../specs/day-boundary/spec.md | 63 ++++++++++++ .../fix-today-completed-logical-date/tasks.md | 20 ++++ 5 files changed, 248 insertions(+) create mode 100644 openspec/changes/fix-today-completed-logical-date/.openspec.yaml create mode 100644 openspec/changes/fix-today-completed-logical-date/design.md create mode 100644 openspec/changes/fix-today-completed-logical-date/proposal.md create mode 100644 openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md create mode 100644 openspec/changes/fix-today-completed-logical-date/tasks.md diff --git a/openspec/changes/fix-today-completed-logical-date/.openspec.yaml b/openspec/changes/fix-today-completed-logical-date/.openspec.yaml new file mode 100644 index 00000000..64105fc9 --- /dev/null +++ b/openspec/changes/fix-today-completed-logical-date/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-14 diff --git a/openspec/changes/fix-today-completed-logical-date/design.md b/openspec/changes/fix-today-completed-logical-date/design.md new file mode 100644 index 00000000..98b134f9 --- /dev/null +++ b/openspec/changes/fix-today-completed-logical-date/design.md @@ -0,0 +1,66 @@ +# Design: fix-today-completed-logical-date + +## Context + +Driven by FR1–FR3 from proposal. Current state (`ActiveTasksPage.tsx:145-150`): + +```ts +const todayCompleted = useMemo(() => { + const logicalToday = getLogicalDate(systemClock, getCachedDayBoundary()); + return completedTasks.filter( + (task) => task.completed_at?.slice(0, 10) === logicalToday, + ); +}, [completedTasks]); +``` + +`logicalToday` is a local logical date; `completed_at.slice(0, 10)` is a UTC calendar date. The comparison mixes frames of reference. Meanwhile `groupCompletedTasks()` (`shared/lib/utils.ts:19`) already solves the same classification correctly for CompletedPage: it builds `startOfToday` as a `Temporal.Instant` (logical today at `dayBoundary` in the user's timezone) and compares instants. + +## Goals / Non-Goals + +**Goals:** +- Instant-based "completed today" classification in ActiveTasksPage (FR1) +- One source of truth shared with CompletedPage (FR2) + +**Non-Goals:** +- Reactivity to day-boundary setting changes without remount (proposal NG1) +- Any change to `groupCompletedTasks` internals (proposal NG3) + +## Decisions + +### D1: Reuse `groupCompletedTasks().todayTasks` instead of writing a local instant comparison + +```ts +const todayCompleted = useMemo( + () => + groupCompletedTasks(completedTasks, undefined, getCachedDayBoundary()) + .todayTasks, + [completedTasks], +); +``` + +**Why**: identical call shape already exists in `CompletedPage.tsx:77-81`, so both pages provably classify the same task the same way (FR2), and the empty-`completed_at` guard (FR3) comes for free (`groupCompletedTasks` routes such tasks to `earlierTasks`). + +**Alternative considered**: local `Temporal.Instant.compare(completedInstant, startOfToday) >= 0` filter inside ActiveTasksPage. Rejected — duplicates the boundary math in a second place, and the two pages could drift apart again. + +**Trade-off accepted**: `groupCompletedTasks` computes all five groups while we need one. Input is the already-filtered list of completed tasks inside a memo; the extra work is a single O(n) pass with four unused array pushes — negligible (NFR-P1). + +### D2: Keep the non-reactive `getCachedDayBoundary()` + `[completedTasks]` deps pattern + +Same pattern as CompletedPage. Making dayBoundary reactive is NG1 and would have to change both pages at once — out of scope. The predecessor change's task 1.3 ("add clock and dayBoundary to useMemo dependencies") was never implementable as written: `systemClock` is a constant import and `getCachedDayBoundary()` is a plain function call, neither is a reactive value. + +### D3: Tests live in `ActiveTasksPage.completed.test.tsx`, component-level + +The bug is in the page's filter wiring, not in `groupCompletedTasks` (which has its own tests). Component tests with mocked `useCompletedTasks` and controlled `completed_at` timestamps pin the wiring. Timezone-sensitive scenarios (delta spec: UTC+5 early morning, UTC-4 evening, custom boundary) require controlling both "now" and the timezone — use the project's `fakeClock` from `@/lib/temporal` (never `vi.setSystemTime`). If `systemClock` inside the page cannot see the fake time, mock `@/hooks/useSettings#getCachedDayBoundary` and `getLogicalDate`'s inputs via module mock of `@/lib/temporal`'s `systemClock` — the existing testSetup already centralizes page mocks; extend it rather than inventing a parallel harness. + +## Risks / Trade-offs + +- [Component tests can't easily fake the system timezone] → mock `systemClock` at module level via `vi.mock("@/lib/temporal", ...)` re-exporting a `fakeClock("...", "Asia/Almaty")`; verify the RED state (test fails against `slice(0,10)` implementation) before applying the fix, per TDD workflow +- [Behavior change visible to users: early-morning completions previously *left* the section at UTC midnight; now the section follows the logical day] → this is exactly the intended fix (proposal U1/U2); no migration needed, data untouched + +## Migration Plan + +Pure client-side filter fix — no data, schema, or sync changes. Rollback = revert the commit. + +## Open Questions + +None. diff --git a/openspec/changes/fix-today-completed-logical-date/proposal.md b/openspec/changes/fix-today-completed-logical-date/proposal.md new file mode 100644 index 00000000..0dd2a6ef --- /dev/null +++ b/openspec/changes/fix-today-completed-logical-date/proposal.md @@ -0,0 +1,97 @@ +# fix-today-completed-logical-date + +Supersedes: fix-date-and-weekly-bugs (FR1 — the fix was incomplete) + +## Why + +The "Completed today" section in ActiveTasksPage compares a timezone-aware logical date (`getLogicalDate`) against a UTC date slice of `completed_at` (`task.completed_at?.slice(0, 10)`). The two sides live in different frames of reference, so whenever the local calendar date differs from the UTC date at completion time, the task never appears in "Completed today". The predecessor change `fix-date-and-weekly-bugs` fixed only the "today" side of the comparison (FR1) and its promised regression test was never added; its own scenario U1 (user in UTC+5 completes a task between 00:00–05:00 local) still fails — and now fails harder than before the fix (the task used to show until 05:00, now it never shows). + +## What Changes + +- **FIX**: `ActiveTasksPage.tsx` — replace the manual `completed_at.slice(0, 10) === logicalToday` filter with `groupCompletedTasks(completedTasks, systemClock, getCachedDayBoundary()).todayTasks`, the instant-based grouping already used by CompletedPage (`shared/lib/utils.ts`) +- **ADDED**: regression tests for todayCompleted in non-UTC timezones and with a custom day boundary — the RED test promised by `fix-date-and-weekly-bugs` task 1.1 that never landed + +## Capabilities + +### New Capabilities + +_(none)_ + +### Modified Capabilities + +- `day-boundary`: the todayCompleted filter requirement changes — the completion day of a task MUST be derived from the `completed_at` instant in the user's timezone with the day boundary applied (instant comparison against start-of-logical-day), never from a UTC string slice + +## Impact + +- `packages/client/src/pages/ActiveTasksPage.tsx` — todayCompleted useMemo +- `packages/client/src/pages/ActiveTasksPage.completed.test.tsx` — new timezone/day-boundary tests +- No API, schema, or dependency changes; `groupCompletedTasks` is reused as-is + +## Goals + +- G1: A task completed "today" in the user's logical day (timezone + day boundary) appears in "Completed today" regardless of the UTC date of its `completed_at` +- G2: ActiveTasksPage and CompletedPage classify the same task into "today" identically (single source of truth: `groupCompletedTasks`) + +## Non-Goals + +- NG1: Making the section reactive to day-boundary setting changes without remount — CompletedPage has the same non-reactive `getCachedDayBoundary()` pattern; changing it is a separate initiative +- NG2: Auto-recalculation of the section on day rollover while the app is open (explicitly excluded by fix-date-and-weekly-bugs NG1 as well) +- NG3: Any changes to `groupCompletedTasks` itself — it is already correct + +## Users & Scenarios + +- U1: User in UTC+5 (Almaty) completes a task at 02:00 local — `completed_at` is 21:00 UTC of the previous day; the task MUST appear in "Completed today" +- U2: User in UTC-4 (New York, summer) completes a task at 21:00 local — `completed_at` is 01:00 UTC of the next day; the task MUST appear in "Completed today" immediately +- U3: User with day boundary "04:00" completes a task at 01:30 local — the completion belongs to the previous logical day; the task MUST appear in "Completed today" (both sides shift together) + +## Requirements + +### Functional + +- FR1: todayCompleted in ActiveTasksPage MUST classify tasks by comparing the `completed_at` instant against the start of the logical day (logical today at `dayBoundary` in the user's timezone), not by comparing date strings +- FR2: todayCompleted MUST reuse `groupCompletedTasks().todayTasks` so ActiveTasksPage and CompletedPage cannot diverge +- FR3: Tasks with empty `completed_at` MUST NOT appear in "Completed today" (preserved behavior) + +### Non-Functional + +#### Performance + +- NFR-P1: The filter stays inside `useMemo` keyed on `completedTasks` — no additional renders versus the current implementation + +#### Accessibility + +_(no changes — list rendering is untouched)_ + +#### Responsive + +_(no changes)_ + +## UX Acceptance Criteria + +- UX1: Completing a task at any local time of the user's logical day shows it in "Completed today" without reload, in every timezone + +## UI States Matrix + +No changes — the section already renders only when non-empty; empty/loading/error handling is untouched. + +## Behavior + +Scenarios are covered by unit tests in `ActiveTasksPage.completed.test.tsx` (component-level, fakeClock-driven); no new Gherkin feature — this is a correctness fix inside an existing behavior, consistent with the predecessor change. + +## Visual Reference + +No visual changes. + +## Affected IA + +No changes. + +## Success Metrics + +- M1: New regression tests (U1, U2, U3 scenarios) pass; the same tests fail against the current implementation (RED verified before GREEN) +- M2: All existing ActiveTasksPage and shared/lib tests keep passing +- M3: Mutation score for the changed filter code >= 95% + +## Open Questions + +- Q1: none diff --git a/openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md b/openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md new file mode 100644 index 00000000..da5cb888 --- /dev/null +++ b/openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md @@ -0,0 +1,63 @@ +# Delta: day-boundary — fix-today-completed-logical-date + +## MODIFIED Requirements + +### Requirement: Logical date computation from day boundary +# implements FR1, FR2 of fix-today-completed-logical-date + +The system SHALL provide a `getLogicalDate(clock, dayBoundary)` function that returns the current logical date. If the current local time is before `dayBoundary`, the function SHALL return the previous calendar day. If the current local time is at or after `dayBoundary`, the function SHALL return the current calendar day. When `dayBoundary` is `"00:00"`, the function SHALL always return the current calendar day (fast path). + +**Changed behavior**: ActiveTasksPage todayCompleted MUST classify a task as "completed today" by comparing the `completed_at` instant against the start of the logical day (logical today at `dayBoundary` in the user's timezone), never by comparing date strings. A UTC string slice of `completed_at` (`slice(0, 10)`) MUST NOT be used on either side of the comparison. ActiveTasksPage MUST obtain the "today" group via `groupCompletedTasks().todayTasks` so that ActiveTasksPage and CompletedPage classify tasks identically. + +#### Scenario: Midnight boundary returns calendar date +- **WHEN** day boundary is "00:00" and current time is any time of day +- **THEN** logical date equals the calendar date from `clock.plainDateISO()` + +#### Scenario: Before boundary returns previous day +- **WHEN** day boundary is "02:00" and current local time is 01:30 on June 5 +- **THEN** logical date is June 4 + +#### Scenario: At boundary returns current day +- **WHEN** day boundary is "02:00" and current local time is 02:00 on June 5 +- **THEN** logical date is June 5 + +#### Scenario: After boundary returns current day +- **WHEN** day boundary is "02:00" and current local time is 14:00 on June 5 +- **THEN** logical date is June 5 + +#### Scenario: Large boundary value +- **WHEN** day boundary is "06:00" and current local time is 05:59 on June 5 +- **THEN** logical date is June 4 + +#### Scenario: Respects current timezone +- **WHEN** day boundary is "02:00" and timezone is "Asia/Tokyo" and current time is 01:00 JST on June 5 +- **THEN** logical date is June 4 (computed in Tokyo timezone) + +#### Scenario: todayCompleted uses logical date in non-UTC timezone +- **WHEN** user timezone is UTC+5, current UTC time is 2026-06-05T01:00:00Z (local 06:00), day boundary is "00:00" +- **AND** task was completed at 2026-06-05T00:30:00Z (local 05:30 on June 5) +- **THEN** todayCompleted includes the task (both are June 5 in local timezone) + +#### Scenario: todayCompleted includes early-morning completion whose UTC date is yesterday +- **WHEN** user timezone is UTC+5 (Asia/Almaty), current UTC time is 2026-06-09T21:30:00Z (local 02:30 June 10), day boundary is "00:00" +- **AND** task was completed at 2026-06-09T21:00:00Z (local 02:00 June 10) +- **THEN** todayCompleted includes the task even though the UTC date of `completed_at` is June 9 + +#### Scenario: todayCompleted includes evening completion whose UTC date is tomorrow +- **WHEN** user timezone is UTC-4 (America/New_York, summer), current UTC time is 2026-06-10T01:30:00Z (local 21:30 June 9), day boundary is "00:00" +- **AND** task was completed at 2026-06-10T01:00:00Z (local 21:00 June 9) +- **THEN** todayCompleted includes the task even though the UTC date of `completed_at` is June 10 + +#### Scenario: todayCompleted respects custom day boundary +- **WHEN** user timezone is UTC+5, day boundary is "04:00", current UTC time is 2026-06-09T21:30:00Z (local 02:30 June 10 — before boundary, logical day is June 9) +- **AND** task was completed at 2026-06-09T05:00:00Z (local 10:00 June 9) +- **THEN** todayCompleted includes the task (completed after the June 9 logical day started at 04:00 local) + +#### Scenario: todayCompleted excludes yesterday's tasks +- **WHEN** user timezone is UTC+5, current UTC time is 2026-06-05T01:00:00Z (local 06:00 June 5) +- **AND** task was completed at 2026-06-04T10:00:00Z (local 15:00 June 4) +- **THEN** todayCompleted does NOT include the task + +#### Scenario: todayCompleted excludes tasks without completed_at +- **WHEN** a task has `is_completed = true` and `completed_at = ""` +- **THEN** todayCompleted does NOT include the task diff --git a/openspec/changes/fix-today-completed-logical-date/tasks.md b/openspec/changes/fix-today-completed-logical-date/tasks.md new file mode 100644 index 00000000..fef29284 --- /dev/null +++ b/openspec/changes/fix-today-completed-logical-date/tasks.md @@ -0,0 +1,20 @@ +## 1. Regression tests (RED) — FR1, FR2, FR3 + +- [ ] 1.1 Extend `activeTasksPage.testSetup.tsx` to allow faking clock/timezone for the page (module mock of `systemClock` per design D3) and mocking `getCachedDayBoundary` — no behavior change for existing tests +- [ ] 1.2 Write failing test: task completed at 2026-06-09T21:00:00Z, timezone UTC+5, now local 02:30 June 10 → appears in "Completed today" (FR1, delta scenario "early-morning completion whose UTC date is yesterday") +- [ ] 1.3 Write failing test: task completed at 2026-06-10T01:00:00Z, timezone UTC-4, now local 21:30 June 9 → appears in "Completed today" (FR1, delta scenario "evening completion whose UTC date is tomorrow") +- [ ] 1.4 Write failing test: day boundary "04:00", timezone UTC+5, now local 02:30 June 10, task completed local 10:00 June 9 → appears in "Completed today" (FR1, delta scenario "respects custom day boundary") +- [ ] 1.5 Run `npx vitest run src/pages/ActiveTasksPage.completed.test.tsx` — confirm new tests FAIL against current implementation, existing tests pass; show output + +## 2. Fix (GREEN) — FR1, FR2, FR3 + +- [ ] 2.1 Replace the manual filter in `ActiveTasksPage.tsx` todayCompleted with `groupCompletedTasks(completedTasks, undefined, getCachedDayBoundary()).todayTasks` (design D1); remove now-unused `getLogicalDate`/`systemClock` imports if nothing else uses them +- [ ] 2.2 Add JSDoc traceability on the memo: `Implements FR1, FR2 of fix-today-completed-logical-date` +- [ ] 2.3 Run `npx vitest run src/pages/ActiveTasksPage.completed.test.tsx` — all tests green; show output + +## 3. Verification + +- [ ] 3.1 Run `npx vitest run src/pages/ActiveTasksPage` — full page suite green (M2) +- [ ] 3.2 Run `npx vitest run src/shared/lib` — groupCompletedTasks suite untouched and green (M2) +- [ ] 3.3 Call `get_file_problems` for changed files via JetBrains MCP; run `pnpm run build` +- [ ] 3.4 Scoped mutation run: `cd packages/client && npx stryker run --mutate 'src/pages/ActiveTasksPage.tsx'`; analyze report at `packages/client/reports/mutation/mutation-report.json` — filter-related mutants killed (M3); pre-existing JSX survivors out of scope From c72ae3cd804c81b074bc32fb5d9e2bf4a5457f8e Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:52:18 +0500 Subject: [PATCH 2/3] implement fix-today-completed-logical-date OpenSpec --- .../specs/day-boundary/spec.md | 5 + .../fix-today-completed-logical-date/tasks.md | 24 ++--- .../pages/ActiveTasksPage.completed.test.tsx | 102 ++++++++++++++++++ packages/client/src/pages/ActiveTasksPage.tsx | 16 +-- .../src/pages/activeTasksPage.testSetup.tsx | 68 +++++++++--- .../client/src/test/mocks/settingsMocks.ts | 19 +++- 6 files changed, 198 insertions(+), 36 deletions(-) diff --git a/openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md b/openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md index da5cb888..d020ecca 100644 --- a/openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md +++ b/openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md @@ -48,6 +48,11 @@ The system SHALL provide a `getLogicalDate(clock, dayBoundary)` function that re - **AND** task was completed at 2026-06-10T01:00:00Z (local 21:00 June 9) - **THEN** todayCompleted includes the task even though the UTC date of `completed_at` is June 10 +#### Scenario: todayCompleted includes post-boundary completion whose UTC date is yesterday (custom boundary) +- **WHEN** user timezone is UTC+5, day boundary is "04:00", current UTC time is 2026-06-10T07:00:00Z (local 12:00 June 10 — after boundary, logical day is June 10) +- **AND** task was completed at 2026-06-09T23:30:00Z (local 04:30 June 10 — after the 04:00 boundary, UTC date June 9) +- **THEN** todayCompleted includes the task (completed after the June 10 logical day started at 04:00 local, even though the UTC date of `completed_at` is June 9) + #### Scenario: todayCompleted respects custom day boundary - **WHEN** user timezone is UTC+5, day boundary is "04:00", current UTC time is 2026-06-09T21:30:00Z (local 02:30 June 10 — before boundary, logical day is June 9) - **AND** task was completed at 2026-06-09T05:00:00Z (local 10:00 June 9) diff --git a/openspec/changes/fix-today-completed-logical-date/tasks.md b/openspec/changes/fix-today-completed-logical-date/tasks.md index fef29284..4a51914c 100644 --- a/openspec/changes/fix-today-completed-logical-date/tasks.md +++ b/openspec/changes/fix-today-completed-logical-date/tasks.md @@ -1,20 +1,20 @@ ## 1. Regression tests (RED) — FR1, FR2, FR3 -- [ ] 1.1 Extend `activeTasksPage.testSetup.tsx` to allow faking clock/timezone for the page (module mock of `systemClock` per design D3) and mocking `getCachedDayBoundary` — no behavior change for existing tests -- [ ] 1.2 Write failing test: task completed at 2026-06-09T21:00:00Z, timezone UTC+5, now local 02:30 June 10 → appears in "Completed today" (FR1, delta scenario "early-morning completion whose UTC date is yesterday") -- [ ] 1.3 Write failing test: task completed at 2026-06-10T01:00:00Z, timezone UTC-4, now local 21:30 June 9 → appears in "Completed today" (FR1, delta scenario "evening completion whose UTC date is tomorrow") -- [ ] 1.4 Write failing test: day boundary "04:00", timezone UTC+5, now local 02:30 June 10, task completed local 10:00 June 9 → appears in "Completed today" (FR1, delta scenario "respects custom day boundary") -- [ ] 1.5 Run `npx vitest run src/pages/ActiveTasksPage.completed.test.tsx` — confirm new tests FAIL against current implementation, existing tests pass; show output +- [x] 1.1 Extend `activeTasksPage.testSetup.tsx` to allow faking clock/timezone for the page (module mock of `systemClock` per design D3) and mocking `getCachedDayBoundary` — no behavior change for existing tests +- [x] 1.2 Write failing test: task completed at 2026-06-09T21:00:00Z, timezone UTC+5, now local 02:30 June 10 → appears in "Completed today" (FR1, delta scenario "early-morning completion whose UTC date is yesterday") +- [x] 1.3 Write failing test: task completed at 2026-06-10T01:00:00Z, timezone UTC-4, now local 21:30 June 9 → appears in "Completed today" (FR1, delta scenario "evening completion whose UTC date is tomorrow") +- [x] 1.4 Fix test-harness bug (`settingsMocks.ts` duplicate `useSettings` mock overrides testSetup's `pageConfig.dayBoundary`); write failing test: day boundary "04:00", timezone UTC+5, now local 12:00 June 10, task completed local 04:30 June 10 (UTC 2026-06-09T23:30:00Z) → appears in "Completed today" (FR1, delta scenario "post-boundary completion whose UTC date is yesterday"); keep the original "respects custom day boundary" scenario as a green-only regression guard +- [x] 1.5 Run `npx vitest run src/pages/ActiveTasksPage.completed.test.tsx` — confirm the three RED tests (1.2, 1.3, 1.4) FAIL against current implementation, the green guard and existing tests pass; show output ## 2. Fix (GREEN) — FR1, FR2, FR3 -- [ ] 2.1 Replace the manual filter in `ActiveTasksPage.tsx` todayCompleted with `groupCompletedTasks(completedTasks, undefined, getCachedDayBoundary()).todayTasks` (design D1); remove now-unused `getLogicalDate`/`systemClock` imports if nothing else uses them -- [ ] 2.2 Add JSDoc traceability on the memo: `Implements FR1, FR2 of fix-today-completed-logical-date` -- [ ] 2.3 Run `npx vitest run src/pages/ActiveTasksPage.completed.test.tsx` — all tests green; show output +- [x] 2.1 Replace the manual filter in `ActiveTasksPage.tsx` todayCompleted with `groupCompletedTasks(completedTasks, undefined, getCachedDayBoundary()).todayTasks` (design D1); remove now-unused `getLogicalDate`/`systemClock` imports if nothing else uses them +- [x] 2.2 Add JSDoc traceability on the memo: `Implements FR1, FR2 of fix-today-completed-logical-date` +- [x] 2.3 Run `npx vitest run src/pages/ActiveTasksPage.completed.test.tsx` — all tests green; show output ## 3. Verification -- [ ] 3.1 Run `npx vitest run src/pages/ActiveTasksPage` — full page suite green (M2) -- [ ] 3.2 Run `npx vitest run src/shared/lib` — groupCompletedTasks suite untouched and green (M2) -- [ ] 3.3 Call `get_file_problems` for changed files via JetBrains MCP; run `pnpm run build` -- [ ] 3.4 Scoped mutation run: `cd packages/client && npx stryker run --mutate 'src/pages/ActiveTasksPage.tsx'`; analyze report at `packages/client/reports/mutation/mutation-report.json` — filter-related mutants killed (M3); pre-existing JSX survivors out of scope +- [x] 3.1 Run `npx vitest run src/pages/ActiveTasksPage` — full page suite green (M2) +- [x] 3.2 Run `npx vitest run src/shared/lib` — groupCompletedTasks suite untouched and green (M2) +- [x] 3.3 Call `get_file_problems` for changed files via JetBrains MCP; run `pnpm run build` +- [x] 3.4 Scoped mutation run: `cd packages/client && npx stryker run --mutate 'src/pages/ActiveTasksPage.tsx'`; analyze report at `packages/client/reports/mutation/mutation-report.json` — filter-related mutants killed (M3); pre-existing JSX survivors out of scope diff --git a/packages/client/src/pages/ActiveTasksPage.completed.test.tsx b/packages/client/src/pages/ActiveTasksPage.completed.test.tsx index fda7b0f0..5ca67738 100644 --- a/packages/client/src/pages/ActiveTasksPage.completed.test.tsx +++ b/packages/client/src/pages/ActiveTasksPage.completed.test.tsx @@ -5,8 +5,10 @@ import { buildCompletedTasksHook, buildTask, mockUseCompletedTasks, + pageConfig, renderPage, resetDefaultMocks, + setFakeClock, } from "./activeTasksPage.testSetup"; describe("ActiveTasksPage — completed tasks", () => { @@ -73,6 +75,106 @@ describe("ActiveTasksPage — completed tasks", () => { expect(screen.queryByText(/No date task/)).not.toBeInTheDocument(); }); + // FR1 of fix-today-completed-logical-date: completion day is derived from the + // completed_at instant in the user's timezone, never from a UTC date slice + it("should show early-morning completion in completed today when its UTC date is yesterday", () => { + // Arrange: UTC+5 (Asia/Almaty), now 2026-06-09T21:30:00Z = 02:30 June 10 local, + // day boundary "00:00" (default); task completed 21:00Z June 9 = 02:00 June 10 local + setFakeClock("2026-06-09T21:30:00Z", "Asia/Almaty"); + const completedTasks = [ + buildTask({ + name: "Early morning task", + is_completed: true, + completed_at: "2026-06-09T21:00:00.000Z", + }), + ]; + mockUseCompletedTasks.mockReturnValue( + buildCompletedTasksHook({ completedTasks }), + ); + // Act + renderPage(); + // Assert: task belongs to logical today (June 10 local) despite UTC date June 9 + expect(screen.getByText(/Early morning task/)).toBeInTheDocument(); + }); + + // FR1 of fix-today-completed-logical-date: completion day is derived from the + // completed_at instant in the user's timezone, never from a UTC date slice + it("should show evening completion in completed today when its UTC date is tomorrow", () => { + // Arrange: UTC-4 (America/New_York, summer), now 2026-06-10T01:30:00Z = 21:30 June 9 local, + // day boundary "00:00" (default); task completed 01:00Z June 10 = 21:00 June 9 local + setFakeClock("2026-06-10T01:30:00Z", "America/New_York"); + const completedTasks = [ + buildTask({ + name: "Evening task", + is_completed: true, + completed_at: "2026-06-10T01:00:00.000Z", + }), + ]; + mockUseCompletedTasks.mockReturnValue( + buildCompletedTasksHook({ completedTasks }), + ); + // Act + renderPage(); + // Assert: task belongs to logical today (June 9 local) despite UTC date June 10 + expect(screen.getByText(/Evening task/)).toBeInTheDocument(); + }); + + // FR1 of fix-today-completed-logical-date: completion day is derived from the + // completed_at instant compared against the logical day start at the custom + // day boundary, never from a UTC date slice + it("should show post-boundary completion in completed today when custom boundary applies and its UTC date is yesterday", () => { + // Arrange: UTC+5 (Asia/Almaty), day boundary "04:00", + // now 2026-06-10T07:00:00Z = 12:00 June 10 local — after the boundary, + // so the logical day is June 10; + // task completed 23:30Z June 9 = 04:30 June 10 local — after the June 10 + // logical day started at 04:00 local, while the UTC date of completed_at + // is still June 9 + pageConfig.dayBoundary = "04:00"; + setFakeClock("2026-06-10T07:00:00Z", "Asia/Almaty"); + const completedTasks = [ + buildTask({ + name: "Post-boundary task", + is_completed: true, + completed_at: "2026-06-09T23:30:00.000Z", + }), + ]; + mockUseCompletedTasks.mockReturnValue( + buildCompletedTasksHook({ completedTasks }), + ); + // Act + renderPage(); + // Assert: task belongs to logical today (June 10) despite UTC date June 9 + expect(screen.getByText(/Post-boundary task/)).toBeInTheDocument(); + }); + + // FR1 of fix-today-completed-logical-date: green-only regression guard — + // expected to pass both before and after the implementation fix; pins that + // the custom day boundary shifts logical "today" for the completed section + // (fails if pageConfig.dayBoundary stops reaching the page) + it("should show daytime completion in completed today when custom boundary keeps logical day on previous date", () => { + // Arrange: UTC+5 (Asia/Almaty), day boundary "04:00", + // now 2026-06-09T21:30:00Z = 02:30 June 10 local — before the boundary, + // so the logical day is still June 9; + // task completed 05:00Z June 9 = 10:00 June 9 local, + // after the June 9 logical day started at 04:00 local + pageConfig.dayBoundary = "04:00"; + setFakeClock("2026-06-09T21:30:00Z", "Asia/Almaty"); + const completedTasks = [ + buildTask({ + name: "Daytime task", + is_completed: true, + completed_at: "2026-06-09T05:00:00.000Z", + }), + ]; + mockUseCompletedTasks.mockReturnValue( + buildCompletedTasksHook({ completedTasks }), + ); + // Act + renderPage(); + // Assert: task belongs to logical today (June 9) under the "04:00" boundary + expect(screen.getByText(/Daytime task/)).toBeInTheDocument(); + }); + // FR-2: completed today section label is correct it("should render completed today section with correct label", () => { const todayDate = Temporal.Now.plainDateISO().toString(); diff --git a/packages/client/src/pages/ActiveTasksPage.tsx b/packages/client/src/pages/ActiveTasksPage.tsx index 762dcce2..e51ff3a6 100644 --- a/packages/client/src/pages/ActiveTasksPage.tsx +++ b/packages/client/src/pages/ActiveTasksPage.tsx @@ -35,9 +35,8 @@ import { useTargetBox } from "@/hooks/useTargetBox"; import { useTaskCompletion } from "@/hooks/useTaskCompletion"; import { useTaskSelection } from "@/hooks/useTaskSelection"; import { useTasks } from "@/hooks/useTasks"; -import { systemClock } from "@/lib/temporal"; +import { groupCompletedTasks } from "@/shared/lib/utils"; import type { BoxFilter } from "@/types/common"; -import { getLogicalDate } from "@/utils/getLogicalDate"; const BOX_FILTER_ICONS: Record = { today: TodayBoxIcon, @@ -142,12 +141,13 @@ export default function ActiveTasksPage() { onExpand: selection.handleTaskExpand, }; - const todayCompleted = useMemo(() => { - const logicalToday = getLogicalDate(systemClock, getCachedDayBoundary()); - return completedTasks.filter( - (task) => task.completed_at?.slice(0, 10) === logicalToday, - ); - }, [completedTasks]); + /** Implements FR1, FR2 of fix-today-completed-logical-date */ + const todayCompleted = useMemo( + () => + groupCompletedTasks(completedTasks, undefined, getCachedDayBoundary()) + .todayTasks, + [completedTasks], + ); const sections = [ { diff --git a/packages/client/src/pages/activeTasksPage.testSetup.tsx b/packages/client/src/pages/activeTasksPage.testSetup.tsx index 7f1447b3..ee1f4b83 100644 --- a/packages/client/src/pages/activeTasksPage.testSetup.tsx +++ b/packages/client/src/pages/activeTasksPage.testSetup.tsx @@ -10,18 +10,69 @@ import { } from "@/test/builders/hookBuilders"; import { buildTask } from "@/test/factories/taskFactory"; import "@/test/mocks/taskPageMocks"; +import { DEFAULT_DAY_BOUNDARY } from "@/constants"; import { useCategories } from "@/hooks/useCategories"; import { useCompletedTasks } from "@/hooks/useCompletedTasks"; import { useContexts } from "@/hooks/useContexts"; import { useGoals } from "@/hooks/useGoals"; import { useTasks } from "@/hooks/useTasks"; +import { type Clock, fakeClock } from "@/lib/temporal"; +import { settingsMockState } from "@/test/mocks/settingsMocks"; import ActiveTasksPage from "./ActiveTasksPage"; +/** + * `defaultBox` and `dayBoundary` delegate to `settingsMockState`, the state + * behind the `@/hooks/useSettings` mock registered by settingsMocks (imported + * via taskPageMocks). A local `vi.mock("@/hooks/useSettings")` here would be + * silently overridden by that registration and never reach the page. + */ export const pageConfig = { filterBarPosition: "bottom", - defaultBox: "today", + get defaultBox() { + return settingsMockState.defaultBox; + }, + set defaultBox(box: string) { + settingsMockState.defaultBox = box; + }, + get dayBoundary() { + return settingsMockState.dayBoundary; + }, + set dayBoundary(boundary: string) { + settingsMockState.dayBoundary = boundary; + }, }; +const clockState = vi.hoisted(() => ({ + fakeClockOverride: null as Clock | null, +})); + +vi.mock("@/lib/temporal", async (importOriginal) => { + const actualTemporal = + await importOriginal(); + const resolveClock = () => + clockState.fakeClockOverride ?? actualTemporal.systemClock; + return { + ...actualTemporal, + systemClock: { + instant: () => resolveClock().instant(), + plainDateISO: () => resolveClock().plainDateISO(), + timeZoneId: () => resolveClock().timeZoneId(), + } satisfies Clock, + }; +}); + +/** + * Makes the page's `systemClock` import resolve to a fixed instant + * in the given timezone. Reset via `resetFakeClock()` or `resetDefaultMocks()`. + */ +export function setFakeClock(isoTimestamp: string, timeZone?: string) { + clockState.fakeClockOverride = fakeClock(isoTimestamp, timeZone); +} + +export function resetFakeClock() { + clockState.fakeClockOverride = null; +} + vi.mock("@/hooks/useFilterBarPosition", () => ({ useFilterBarPosition: () => ({ filterBarPosition: pageConfig.filterBarPosition, @@ -29,19 +80,6 @@ vi.mock("@/hooks/useFilterBarPosition", () => ({ }), })); -vi.mock("@/hooks/useSettings", () => ({ - useSettings: () => ({ - get defaultBox() { - return pageConfig.defaultBox; - }, - accentColor: "green", - isLoading: false, - setDefaultBox: vi.fn(), - setAccentColor: vi.fn(), - }), - getCachedDayBoundary: () => "00:00", -})); - vi.mock("@/hooks/useHandedness", () => ({ useHandedness: () => ({ handedness: "right", @@ -89,6 +127,8 @@ export function setupAllBoxTasks() { export function resetDefaultMocks() { pageConfig.defaultBox = "today"; pageConfig.filterBarPosition = "bottom"; + pageConfig.dayBoundary = DEFAULT_DAY_BOUNDARY; + resetFakeClock(); mockUseTasks.mockReturnValue(buildTasksHook()); mockUseGoals.mockReturnValue(buildGoalsHook()); mockUseContexts.mockReturnValue(buildContextsHook()); diff --git a/packages/client/src/test/mocks/settingsMocks.ts b/packages/client/src/test/mocks/settingsMocks.ts index cfff0d0d..2d786f95 100644 --- a/packages/client/src/test/mocks/settingsMocks.ts +++ b/packages/client/src/test/mocks/settingsMocks.ts @@ -1,4 +1,17 @@ import { vi } from "vitest"; +import { DEFAULT_DAY_BOUNDARY } from "@/constants"; + +/** + * Shared mutable state behind the `@/hooks/useSettings` mock below. + * Test setups (e.g. activeTasksPage.testSetup) mutate these fields to + * control what the page under test sees; defaults match the previous + * hardcoded mock values, so importers that never touch this state keep + * the exact same behavior. + */ +export const settingsMockState = { + defaultBox: "today", + dayBoundary: DEFAULT_DAY_BOUNDARY, +}; vi.mock("@/hooks/useShowHidden", () => ({ useShowHidden: () => ({ @@ -9,13 +22,15 @@ vi.mock("@/hooks/useShowHidden", () => ({ vi.mock("@/hooks/useSettings", () => ({ useSettings: () => ({ - defaultBox: "today", + get defaultBox() { + return settingsMockState.defaultBox; + }, accentColor: "green", isLoading: false, setDefaultBox: vi.fn(), setAccentColor: vi.fn(), }), - getCachedDayBoundary: () => "00:00", + getCachedDayBoundary: () => settingsMockState.dayBoundary, })); vi.mock("@/hooks/useFilterBarPosition", () => ({ From 9dd9e6d3a60b88620206a826f5e9258ea6e0dbc7 Mon Sep 17 00:00:00 2001 From: oinsio <8233537+oinsio@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:54:40 +0500 Subject: [PATCH 3/3] archive fix-today-completed-logical-date OpenSpec --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/day-boundary/spec.md | 0 .../tasks.md | 0 openspec/specs/day-boundary/spec.md | 32 ++++++++++++++++--- 6 files changed, 28 insertions(+), 4 deletions(-) rename openspec/changes/{fix-today-completed-logical-date => archive/2026/07/2026-07-14-fix-today-completed-logical-date}/.openspec.yaml (100%) rename openspec/changes/{fix-today-completed-logical-date => archive/2026/07/2026-07-14-fix-today-completed-logical-date}/design.md (100%) rename openspec/changes/{fix-today-completed-logical-date => archive/2026/07/2026-07-14-fix-today-completed-logical-date}/proposal.md (100%) rename openspec/changes/{fix-today-completed-logical-date => archive/2026/07/2026-07-14-fix-today-completed-logical-date}/specs/day-boundary/spec.md (100%) rename openspec/changes/{fix-today-completed-logical-date => archive/2026/07/2026-07-14-fix-today-completed-logical-date}/tasks.md (100%) diff --git a/openspec/changes/fix-today-completed-logical-date/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/.openspec.yaml similarity index 100% rename from openspec/changes/fix-today-completed-logical-date/.openspec.yaml rename to openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/.openspec.yaml diff --git a/openspec/changes/fix-today-completed-logical-date/design.md b/openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/design.md similarity index 100% rename from openspec/changes/fix-today-completed-logical-date/design.md rename to openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/design.md diff --git a/openspec/changes/fix-today-completed-logical-date/proposal.md b/openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/proposal.md similarity index 100% rename from openspec/changes/fix-today-completed-logical-date/proposal.md rename to openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/proposal.md diff --git a/openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md b/openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/specs/day-boundary/spec.md similarity index 100% rename from openspec/changes/fix-today-completed-logical-date/specs/day-boundary/spec.md rename to openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/specs/day-boundary/spec.md diff --git a/openspec/changes/fix-today-completed-logical-date/tasks.md b/openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/tasks.md similarity index 100% rename from openspec/changes/fix-today-completed-logical-date/tasks.md rename to openspec/changes/archive/2026/07/2026-07-14-fix-today-completed-logical-date/tasks.md diff --git a/openspec/specs/day-boundary/spec.md b/openspec/specs/day-boundary/spec.md index 70f16f61..a8d68928 100644 --- a/openspec/specs/day-boundary/spec.md +++ b/openspec/specs/day-boundary/spec.md @@ -7,11 +7,11 @@ Logical date computation based on a configurable day boundary time. Allows users ## Requirements ### Requirement: Logical date computation from day boundary -# implements FR3 of day-boundary +# implements FR1, FR2 of fix-today-completed-logical-date The system SHALL provide a `getLogicalDate(clock, dayBoundary)` function that returns the current logical date. If the current local time is before `dayBoundary`, the function SHALL return the previous calendar day. If the current local time is at or after `dayBoundary`, the function SHALL return the current calendar day. When `dayBoundary` is `"00:00"`, the function SHALL always return the current calendar day (fast path). -**Changed behavior**: ActiveTasksPage todayCompleted filter MUST use `getLogicalDate(clock, dayBoundary)` instead of `new Date().toISOString().slice(0, 10)` for determining the current day. This aligns it with `groupCompletedTasks()` which already uses `getLogicalDate`. +**Changed behavior**: ActiveTasksPage todayCompleted MUST classify a task as "completed today" by comparing the `completed_at` instant against the start of the logical day (logical today at `dayBoundary` in the user's timezone), never by comparing date strings. A UTC string slice of `completed_at` (`slice(0, 10)`) MUST NOT be used on either side of the comparison. ActiveTasksPage MUST obtain the "today" group via `groupCompletedTasks().todayTasks` so that ActiveTasksPage and CompletedPage classify tasks identically. #### Scenario: Midnight boundary returns calendar date - **WHEN** day boundary is "00:00" and current time is any time of day @@ -40,12 +40,36 @@ The system SHALL provide a `getLogicalDate(clock, dayBoundary)` function that re #### Scenario: todayCompleted uses logical date in non-UTC timezone - **WHEN** user timezone is UTC+5, current UTC time is 2026-06-05T01:00:00Z (local 06:00), day boundary is "00:00" - **AND** task was completed at 2026-06-05T00:30:00Z (local 05:30 on June 5) -- **THEN** todayCompleted filter includes the task (both are June 5 in local timezone) +- **THEN** todayCompleted includes the task (both are June 5 in local timezone) + +#### Scenario: todayCompleted includes early-morning completion whose UTC date is yesterday +- **WHEN** user timezone is UTC+5 (Asia/Almaty), current UTC time is 2026-06-09T21:30:00Z (local 02:30 June 10), day boundary is "00:00" +- **AND** task was completed at 2026-06-09T21:00:00Z (local 02:00 June 10) +- **THEN** todayCompleted includes the task even though the UTC date of `completed_at` is June 9 + +#### Scenario: todayCompleted includes evening completion whose UTC date is tomorrow +- **WHEN** user timezone is UTC-4 (America/New_York, summer), current UTC time is 2026-06-10T01:30:00Z (local 21:30 June 9), day boundary is "00:00" +- **AND** task was completed at 2026-06-10T01:00:00Z (local 21:00 June 9) +- **THEN** todayCompleted includes the task even though the UTC date of `completed_at` is June 10 + +#### Scenario: todayCompleted includes post-boundary completion whose UTC date is yesterday (custom boundary) +- **WHEN** user timezone is UTC+5, day boundary is "04:00", current UTC time is 2026-06-10T07:00:00Z (local 12:00 June 10 — after boundary, logical day is June 10) +- **AND** task was completed at 2026-06-09T23:30:00Z (local 04:30 June 10 — after the 04:00 boundary, UTC date June 9) +- **THEN** todayCompleted includes the task (completed after the June 10 logical day started at 04:00 local, even though the UTC date of `completed_at` is June 9) + +#### Scenario: todayCompleted respects custom day boundary +- **WHEN** user timezone is UTC+5, day boundary is "04:00", current UTC time is 2026-06-09T21:30:00Z (local 02:30 June 10 — before boundary, logical day is June 9) +- **AND** task was completed at 2026-06-09T05:00:00Z (local 10:00 June 9) +- **THEN** todayCompleted includes the task (completed after the June 9 logical day started at 04:00 local) #### Scenario: todayCompleted excludes yesterday's tasks - **WHEN** user timezone is UTC+5, current UTC time is 2026-06-05T01:00:00Z (local 06:00 June 5) - **AND** task was completed at 2026-06-04T10:00:00Z (local 15:00 June 4) -- **THEN** todayCompleted filter does NOT include the task +- **THEN** todayCompleted does NOT include the task + +#### Scenario: todayCompleted excludes tasks without completed_at +- **WHEN** a task has `is_completed = true` and `completed_at = ""` +- **THEN** todayCompleted does NOT include the task ### Requirement: Day boundary validation # implements FR11 of day-boundary