feat(app): add today state read model

This commit is contained in:
Ashley Venn 2026-06-25 10:28:30 -07:00
parent 2463fd7817
commit 11f6eff85b
9 changed files with 1287 additions and 12 deletions

View file

@ -32,7 +32,7 @@ passed. The current suite has 143 passing tests.
| Core task model | Task types, lifecycle statuses, basic metadata, immutable copy pattern, strong invariants, explicit nullable clearing, completion timestamp, task reminder override, typed validation | Backlog-entered time and remaining persistence/query support | 14, 15 | | Core task model | Task types, lifecycle statuses, basic metadata, immutable copy pattern, strong invariants, explicit nullable clearing, completion timestamp, task reminder override, typed validation | Backlog-entered time and remaining persistence/query support | 14, 15 |
| Status semantics | Planned/active/completed/missed/cancelled/no-longer-relevant/backlog | Reconcile the human specifications `pushed` and `skipped` labels with event/stat semantics without importing V2 burnout behavior | 11, 13 | | Status semantics | Planned/active/completed/missed/cancelled/no-longer-relevant/backlog | Reconcile the human specifications `pushed` and `skipped` labels with event/stat semantics without importing V2 burnout behavior | 11, 13 |
| Time model | DateTime-based schedule intervals and recurring wall-clock helpers | Civil-date/wall-time/time-zone semantics, DST policy, deterministic clocks, and safe date-only persistence | 11 | | Time model | DateTime-based schedule intervals and recurring wall-clock helpers | Civil-date/wall-time/time-zone semantics, DST policy, deterministic clocks, and safe date-only persistence | 11 |
| Scheduling engine | Flexible insertion, next-slot push, tomorrow push, backlog push, rollover, overlap analysis, centralized occupancy, free-slot protection, surprise/actual occupancy, structured non-localized result codes, invariant coverage | Application-facing orchestration and final backend acceptance scenarios | 14, 17 | | Scheduling engine | Flexible insertion, next-slot push, tomorrow push, backlog push, rollover, overlap analysis, centralized occupancy, free-slot protection, surprise/actual occupancy, structured non-localized result codes, invariant coverage, Today pending-notice read model | Application-facing command orchestration and final backend acceptance scenarios | 14, 17 |
| Locked time | Recurring blocks, one-day remove/replace/add overrides, hidden overlay mapping | Strong override validation, date/time-zone safety, complete persistence mapping, date-scoped repository queries | 11, 15 | | Locked time | Recurring blocks, one-day remove/replace/add overrides, hidden overlay mapping | Strong override validation, date/time-zone safety, complete persistence mapping, date-scoped repository queries | 11, 15 |
| Free slots | Task type, timeline token, creation/update rules, protection from flexible scheduling, reminder suppression policy, and overlap tests | Application use cases that persist resulting changes atomically | 12, 13, 14 | | Free slots | Task type, timeline token, creation/update rules, protection from flexible scheduling, reminder suppression policy, and overlap tests | Application use cases that persist resulting changes atomically | 12, 13, 14 |
| Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 | | Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 |
@ -41,9 +41,9 @@ passed. The current suite has 143 passing tests.
| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 13, 14 | | Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 13, 14 |
| Project defaults | Configured static defaults, reminder profile on project, per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution, and effective reminder-profile resolver | Application-layer persistence/query orchestration | 13, 14 | | Project defaults | Configured static defaults, reminder profile on project, per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution, and effective reminder-profile resolver | Application-layer persistence/query orchestration | 13, 14 |
| Reminder profiles | Gentle/persistent/strict/silent enum on project, task override, effective-profile resolver, typed reminder directives, and Free Slot suppression policy | Platform delivery remains outside the pure core | 13 | | Reminder profiles | Gentle/persistent/strict/silent enum on project, task override, effective-profile resolver, typed reminder directives, and Free Slot suppression policy | Platform delivery remains outside the pure core | 13 |
| Timeline state | UI-independent item mapper, locked overlay state, compact selection | Complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, rollover notices | 14 | | Timeline state | UI-independent item mapper, locked overlay state, compact selection, complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, and pending notice projection | Flutter rendering and later management/read-model queries | 14, 18 |
| Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 | | Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 |
| Application layer | Application operation context, typed result/failure contracts, centralized scheduler input loader, and in-memory atomic unit-of-work foundation | Concrete Today/read-model queries and V1 command use cases that invoke domain rules and return UI-ready results | 14 | | Application layer | Application operation context, typed result/failure contracts, centralized scheduler input loader, side-effect-free read boundary, complete Today query/read model, and in-memory atomic unit-of-work foundation | V1 command use cases that invoke domain rules and return UI-ready mutation results | 14 |
| Repository contracts | Basic task/project/locked/snapshot interfaces, activity/settings/project-statistics/operation repository contracts, and in-memory staged unit of work | Date/project/parent queries, archive/delete behavior, revisions, complete persistence codecs, and adapter conformance suite | 14, 15 | | Repository contracts | Basic task/project/locked/snapshot interfaces, activity/settings/project-statistics/operation repository contracts, and in-memory staged unit of work | Date/project/parent queries, archive/delete behavior, revisions, complete persistence codecs, and adapter conformance suite | 14, 15 |
| Document mapping | Task, task-statistics, and project-statistics map round trips; field-name constants for other entities | Complete codecs for every repository entity, explicit stable codes, schema version, civil-time encoding, migration fixtures | 15 | | Document mapping | Task, task-statistics, and project-statistics map round trips; field-name constants for other entities | Complete codecs for every repository entity, explicit stable codes, schema version, civil-time encoding, migration fixtures | 15 |
| MongoDB runtime | Committed target documented; no driver/runtime yet | Trusted runtime decision, actual adapter, indexes, transactions/optimistic concurrency, integration tests, secret handling | 16 | | MongoDB runtime | Committed target documented; no driver/runtime yet | Trusted runtime decision, actual adapter, indexes, transactions/optimistic concurrency, integration tests, secret handling | 16 |

View file

@ -79,6 +79,8 @@ command orchestration.
Recommended Codex level: extra high Recommended Codex level: extra high
Status: Complete
Tasks: Tasks:
- Add a `GetTodayState`-style query that loads the requested local day, app - Add a `GetTodayState`-style query that loads the requested local day, app
@ -116,6 +118,36 @@ Acceptance criteria:
- Read queries have no side effects. - Read queries have no side effects.
- Today query tests cover all V1 task types and relevant lifecycle states. - Today query tests cover all V1 task types and relevant lifecycle states.
Completed implementation:
- Added `src/today_state.dart` with `GetTodayStateQuery`, request/result
objects, source-tagged timeline items, compact projection, and pending
scheduling notice read models.
- Added a read-only application boundary through `ApplicationUnitOfWork.read`
so queries do not create operation records, activities, statistics, or
rollover changes.
- Loaded owner settings, project color tokens, scheduled tasks, locked blocks,
one-day overrides, and stored scheduling snapshot notices for the requested
local day.
- Expanded locked occurrences with the explicit `TimeZoneResolver` and
owner time-zone context, including DST fixture coverage.
- Produced date-stable locked overlay IDs by passing the local date into
`TimelineItemMapper.fromLockedOccurrence`.
- Built current, next required, optional next flexible, full timeline, and
compact projections from the same sorted Today item list.
- Corrected compact next-flexible selection so the current flexible item is not
duplicated as the next flexible item.
- Added tests for empty days, mixed V1 item types, current boundaries, compact
mode, reveal mode, stable cross-day locked IDs, DST day expansion, and
deterministic equal-start ordering.
Verification:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 257 tests
- `git diff --check`: passed
## Chunk 14.3 — Atomic V1 command use cases ## Chunk 14.3 — Atomic V1 command use cases
Recommended Codex level: extra high Recommended Codex level: extra high

View file

@ -31,6 +31,7 @@ following source files:
- `src/task_lifecycle.dart` - `src/task_lifecycle.dart`
- `src/task_statistics.dart` - `src/task_statistics.dart`
- `src/time_contracts.dart` - `src/time_contracts.dart`
- `src/today_state.dart`
- `src/timeline_state.dart` - `src/timeline_state.dart`
This baseline is used by later chunks to distinguish intentional breaking This baseline is used by later chunks to distinguish intentional breaking
@ -53,6 +54,7 @@ changes from accidental API drift.
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` | | `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
| `src/task_lifecycle.dart` | `TaskTransitionCode`, `TaskActivityCode`, `TaskTransitionOutcomeCode` | | `src/task_lifecycle.dart` | `TaskTransitionCode`, `TaskActivityCode`, `TaskTransitionOutcomeCode` |
| `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` | | `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` |
| `src/today_state.dart` | `TodayTimelineItemSource` |
| `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` | | `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` |
## Public classes and top-level functions ## Public classes and top-level functions
@ -77,6 +79,7 @@ changes from accidental API drift.
| `src/task_lifecycle.dart` | `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `TaskTransitionResult`, `TaskTransitionService` | | `src/task_lifecycle.dart` | `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `TaskTransitionResult`, `TaskTransitionService` |
| `src/task_statistics.dart` | `TaskStatistics` | | `src/task_statistics.dart` | `TaskStatistics` |
| `src/time_contracts.dart` | `Clock`, `SystemClock`, `FixedClock`, `IdGenerator`, `SequentialIdGenerator`, `CivilDate`, `WallTime`, `TimeZoneResolutionOptions`, `ResolvedLocalDateTime`, `TimeZoneResolver`, `FixedOffsetTimeZoneResolver` | | `src/time_contracts.dart` | `Clock`, `SystemClock`, `FixedClock`, `IdGenerator`, `SequentialIdGenerator`, `CivilDate`, `WallTime`, `TimeZoneResolutionOptions`, `ResolvedLocalDateTime`, `TimeZoneResolver`, `FixedOffsetTimeZoneResolver` |
| `src/today_state.dart` | `GetTodayStateRequest`, `TodayTimelineItem`, `TodayCompactState`, `TodayPendingNotice`, `TodayState`, `GetTodayStateQuery` |
| `src/timeline_state.dart` | `TimelineItem`, `CompactTimelineState`, `TimelineItemMapper` | | `src/timeline_state.dart` | `TimelineItem`, `CompactTimelineState`, `TimelineItemMapper` |
## Current model baseline ## Current model baseline
@ -272,6 +275,20 @@ Chunk 14.1 intentionally changed the public API:
- Added in-memory staged transaction behavior with exactly-once operation - Added in-memory staged transaction behavior with exactly-once operation
records and rollback on typed failures or persistence failures. records and rollback on typed failures or persistence failures.
Chunk 14.2 intentionally changed the public API:
- Added `src/today_state.dart` and exported it from `scheduler_core.dart`.
- Added `GetTodayStateQuery`, `GetTodayStateRequest`, `TodayState`,
`TodayTimelineItem`, `TodayCompactState`, and `TodayPendingNotice`.
- Added `TodayTimelineItemSource` for stable task-vs-overlay source metadata.
- Added `ApplicationUnitOfWork.read` so application queries can access
repositories without creating operation records.
- Added an optional `occurrenceDate` parameter to
`TimelineItemMapper.fromLockedOccurrence` so Today read models can produce
date-stable locked overlay IDs.
- Updated compact next-flexible selection so the current flexible item is not
also returned as `nextFlexibleItem`.
## Repository contracts ## Repository contracts
The current repository surface is pure Dart and in-memory only: The current repository surface is pure Dart and in-memory only:
@ -284,6 +301,8 @@ The current repository surface is pure Dart and in-memory only:
`InMemorySchedulingSnapshotRepository`. `InMemorySchedulingSnapshotRepository`.
- Application transaction behavior is represented by - Application transaction behavior is represented by
`InMemoryApplicationUnitOfWork`. `InMemoryApplicationUnitOfWork`.
- Application read-query behavior uses `ApplicationUnitOfWork.read`, which
stages repository access without committing operation records.
Future MongoDB adapter work must remain behind repository interfaces and must Future MongoDB adapter work must remain behind repository interfaces and must
not import MongoDB APIs into the scheduling core. not import MongoDB APIs into the scheduling core.

View file

@ -70,6 +70,13 @@ Latest Block 14.1 verification result on 2026-06-25:
- `dart test`: passed, 249 tests - `dart test`: passed, 249 tests
- `git diff --check`: passed - `git diff --check`: passed
Latest Block 14.2 verification result on 2026-06-25:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 257 tests
- `git diff --check`: passed
Status values: Status values:
- `complete`: Current backend APIs and tests cover the requirement. - `complete`: Current backend APIs and tests cover the requirement.
@ -88,11 +95,11 @@ Status values:
| MVP-AC-02 | Store quick-capture tasks in Backlog with neutral defaults. | `Task.quickCapture`, `QuickCaptureService.capture`, `TaskStatus.backlog`, `PriorityLevel.medium`, `RewardLevel.notSet`, inbox project default | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Backlog-entered timestamp remains incomplete; see Blocks 11 and 15. | | MVP-AC-02 | Store quick-capture tasks in Backlog with neutral defaults. | `Task.quickCapture`, `QuickCaptureService.capture`, `TaskStatus.backlog`, `PriorityLevel.medium`, `RewardLevel.notSet`, inbox project default | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Backlog-entered timestamp remains incomplete; see Blocks 11 and 15. |
| MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot avoidance, structured no-slot outcomes, and invariant coverage. | | MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot avoidance, structured no-slot outcomes, and invariant coverage. |
| MVP-AC-04 | Create recurring hidden locked blocks and one-day overrides. | `LockedBlock`, `LockedBlockRecurrence.weekly`, `LockedBlockOverride.remove`, `LockedBlockOverride.replace`, `LockedBlockOverride.add`, `expandLockedBlocksForDay` | `test/scheduling_engine_test.dart`, `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Date-only/time-zone hardening remains in Chunk 11.4 and Block 15. | | MVP-AC-04 | Create recurring hidden locked blocks and one-day overrides. | `LockedBlock`, `LockedBlockRecurrence.weekly`, `LockedBlockOverride.remove`, `LockedBlockOverride.replace`, `LockedBlockOverride.add`, `expandLockedBlocksForDay` | `test/scheduling_engine_test.dart`, `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Date-only/time-zone hardening remains in Chunk 11.4 and Block 15. |
| MVP-AC-05 | Render Today as a timeline with project border, task-type background, task name, reward icon, and difficulty icon. | `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart` | incomplete | Backend read-model tokens exist; actual Flutter rendering is Block 18. | | MVP-AC-05 | Render Today as a timeline with project border, task-type background, task name, reward icon, and difficulty icon. | `GetTodayStateQuery`, `TodayState`, `TodayTimelineItem`, `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart`, `test/today_state_test.dart` | incomplete | Backend Today read model and tokens are complete; actual Flutter rendering is Block 18. |
| MVP-AC-06 | Use compact Today mode manually. | `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart` | complete | Complete at backend/read-model level; Flutter UI remains Block 18. | | MVP-AC-06 | Use compact Today mode manually. | `GetTodayStateQuery`, `TodayCompactState`, `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart`, `test/today_state_test.dart` | complete | Complete at backend/read-model level, including current/next deduplication; Flutter UI remains Block 18. |
| MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods, `TaskActivityCode` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 adds internal activity records; exactly-once statistics/accounting continues in Chunk 13.2. | | MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods, `TaskActivityCode` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 adds internal activity records; exactly-once statistics/accounting continues in Chunk 13.2. |
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot blockers, structured outcomes, and invariant coverage. | | MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot blockers, structured outcomes, and invariant coverage. |
| MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Domain movement and structured rollover notices exist; durable next-open rollover notice/read model remains in Block 14. | | MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks`, `SchedulingNotice`, `TodayPendingNotice`, `GetTodayStateQuery` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart`, `test/today_state_test.dart` | incomplete | Domain movement, structured rollover notices, and Today pending-notice read model exist; explicit durable app-open rollover orchestration remains in Chunk 14.5. |
| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunks 13.1-13.2 add completion timestamps and surprise completion accounting/locked-hour statistics; application use cases remain in Block 14. | | MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunks 13.1-13.2 add completion timestamps and surprise completion accounting/locked-hour statistics; application use cases remain in Block 14. |
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart` | complete | Chunk 13.3 adds deterministic break-up mutation results; persistence transaction wiring remains in Block 14. | | MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart` | complete | Chunk 13.3 adds deterministic break-up mutation results; persistence transaction wiring remains in Block 14. |
| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers, `TaskActivity` | `test/child_tasks_test.dart` | complete | Chunk 13.3 routes parent/child completion through lifecycle activities and preserves already-terminal child facts. | | MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers, `TaskActivity` | `test/child_tasks_test.dart` | complete | Chunk 13.3 routes parent/child completion through lifecycle activities and preserves already-terminal child facts. |
@ -110,7 +117,7 @@ Status values:
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, `ReminderPolicyService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart`, `test/reminder_policy_test.dart` | complete | Blocks 12 and 13 cover scheduling protection, explicit required conflicts, and reminder suppression/directives at the backend policy level. | | MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, `ReminderPolicyService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart`, `test/reminder_policy_test.dart` | complete | Blocks 12 and 13 cover scheduling protection, explicit required conflicts, and reminder suppression/directives at the backend policy level. |
| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ProjectStatistics`, `ProjectSuggestionService`, `ProjectDefaultResolution`, `ReminderPolicyService`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart`, `test/project_statistics_test.dart`, `test/reminder_policy_test.dart` | complete | Chunk 13.5 adds effective reminder resolution without using learned suggestions as silent configuration. | | MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ProjectStatistics`, `ProjectSuggestionService`, `ProjectDefaultResolution`, `ReminderPolicyService`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart`, `test/project_statistics_test.dart`, `test/reminder_policy_test.dart` | complete | Chunk 13.5 adds effective reminder resolution without using learned suggestions as silent configuration. |
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, `ApplicationUnitOfWork`, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart`, `test/application_layer_test.dart` | incomplete | Chunk 14.1 adds pure Dart application transaction contracts and staged in-memory unit-of-work behavior; complete codecs, revisions, and runtime adapter remain in Blocks 15 and 16. | | MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, `ApplicationUnitOfWork`, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart`, `test/application_layer_test.dart` | incomplete | Chunk 14.1 adds pure Dart application transaction contracts and staged in-memory unit-of-work behavior; complete codecs, revisions, and runtime adapter remain in Blocks 15 and 16. |
| MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. | | MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence`, `GetTodayStateQuery`, `TodayTimelineItem` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart`, `test/today_state_test.dart` | complete | Chunk 14.2 adds hidden-by-default Today omission, explicit reveal mode, and date-stable locked overlay IDs. |
| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, `ApplicationUnitOfWork`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; Chunk 14.1 adds atomic transaction contracts; concrete action use-case persistence remains in Chunk 14.3. | | MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, `ApplicationUnitOfWork`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; Chunk 14.1 adds atomic transaction contracts; concrete action use-case persistence remains in Chunk 14.3. |
| MVP-SUP-10 | The human spec lists `pushed` and `skipped` as statuses. | `TaskStatus` excludes both; movement and burnout compatibility are modeled as activity/stat metadata. | `test/domain_contracts_test.dart` | contradictory | Resolved by `V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md`; the contradiction is kept visible so later chunks do not add movement labels as statuses. | | MVP-SUP-10 | The human spec lists `pushed` and `skipped` as statuses. | `TaskStatus` excludes both; movement and burnout compatibility are modeled as activity/stat metadata. | `test/domain_contracts_test.dart` | contradictory | Resolved by `V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md`; the contradiction is kept visible so later chunks do not add movement labels as statuses. |

View file

@ -34,4 +34,5 @@ export 'src/task_actions.dart';
export 'src/task_lifecycle.dart'; export 'src/task_lifecycle.dart';
export 'src/task_statistics.dart'; export 'src/task_statistics.dart';
export 'src/time_contracts.dart'; export 'src/time_contracts.dart';
export 'src/today_state.dart';
export 'src/timeline_state.dart'; export 'src/timeline_state.dart';

View file

@ -304,6 +304,12 @@ abstract interface class ApplicationUnitOfWork {
ApplicationUnitOfWorkRepositories repositories, ApplicationUnitOfWorkRepositories repositories,
) action, ) action,
}); });
Future<ApplicationResult<T>> read<T>({
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
});
} }
/// Repository loader for common scheduler input assembly. /// Repository loader for common scheduler input assembly.
@ -484,6 +490,39 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
); );
} }
} }
@override
Future<ApplicationResult<T>> read<T>({
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
}) async {
final staged = _state.copy();
final repositories = _InMemoryApplicationRepositories(staged);
try {
return await action(repositories);
} on ApplicationFailureException catch (error) {
return ApplicationResult.failure(error.failure);
} on DomainValidationException catch (error) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.validation,
detailCode: error.code.name,
),
);
} on ApplicationPersistenceException {
return ApplicationResult.failure(
const ApplicationFailure(
code: ApplicationFailureCode.persistenceFailure,
),
);
} catch (_) {
return ApplicationResult.failure(
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
);
}
}
} }
class _InMemoryApplicationState { class _InMemoryApplicationState {

View file

@ -6,6 +6,7 @@
import 'locked_time.dart'; import 'locked_time.dart';
import 'models.dart'; import 'models.dart';
import 'time_contracts.dart';
/// Broad display category for an item placed on the Today timeline. /// Broad display category for an item placed on the Today timeline.
enum TimelineItemCategory { enum TimelineItemCategory {
@ -210,13 +211,14 @@ class TimelineItemMapper {
TimelineItem? fromLockedOccurrence( TimelineItem? fromLockedOccurrence(
LockedBlockOccurrence occurrence, { LockedBlockOccurrence occurrence, {
required bool revealHiddenLockedBlocks, required bool revealHiddenLockedBlocks,
CivilDate? occurrenceDate,
}) { }) {
if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) { if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) {
return null; return null;
} }
return TimelineItem( return TimelineItem(
id: _lockedOccurrenceId(occurrence), id: _lockedOccurrenceId(occurrence, occurrenceDate: occurrenceDate),
displayTitle: occurrence.name, displayTitle: occurrence.name,
taskType: TaskType.locked, taskType: TaskType.locked,
projectColorToken: _projectColorTokenFor(occurrence.projectId), projectColorToken: _projectColorTokenFor(occurrence.projectId),
@ -269,7 +271,9 @@ class TimelineItemMapper {
final nextFlexibleTask = includeNextFlexibleTask final nextFlexibleTask = includeNextFlexibleTask
? timelineTasks ? timelineTasks
.where((task) { .where((task) {
return task.isFlexible && _startsAtOrAfter(task, now); return task.isFlexible &&
task.id != currentTask?.id &&
_startsAtOrAfter(task, now);
}) })
.cast<Task?>() .cast<Task?>()
.firstWhere( .firstWhere(
@ -328,14 +332,19 @@ class TimelineItemMapper {
return leftStart.compareTo(rightStart); return leftStart.compareTo(rightStart);
} }
String _lockedOccurrenceId(LockedBlockOccurrence occurrence) { String _lockedOccurrenceId(
LockedBlockOccurrence occurrence, {
CivilDate? occurrenceDate,
}) {
final datePart =
occurrenceDate == null ? null : ':${occurrenceDate.toIsoString()}';
final lockedBlockId = occurrence.lockedBlockId; final lockedBlockId = occurrence.lockedBlockId;
if (lockedBlockId != null) { if (lockedBlockId != null) {
return 'locked-block:$lockedBlockId'; return 'locked-block:$lockedBlockId${datePart ?? ''}';
} }
final overrideId = occurrence.overrideId; final overrideId = occurrence.overrideId;
if (overrideId != null) { if (overrideId != null) {
return 'locked-override:$overrideId'; return 'locked-override:$overrideId${datePart ?? ''}';
} }
return 'locked:${occurrence.name}:' return 'locked:${occurrence.name}:'
'${occurrence.interval.start.toIso8601String()}'; '${occurrence.interval.start.toIso8601String()}';

609
lib/src/today_state.dart Normal file
View file

@ -0,0 +1,609 @@
// Application-level Today read model.
//
// This module builds UI-independent state for the Today and compact views. It
// is read-only: it does not run rollover, create activities, update statistics,
// or persist operation records.
import 'application_layer.dart';
import 'locked_time.dart';
import 'models.dart';
import 'repositories.dart';
import 'scheduling_engine.dart';
import 'timeline_state.dart';
import 'time_contracts.dart';
/// Source category for one Today timeline item.
enum TodayTimelineItemSource {
task,
lockedOccurrence,
}
/// Request for the V1 Today read model.
class GetTodayStateRequest {
const GetTodayStateRequest({
required this.context,
required this.date,
this.revealHiddenLockedBlocks = false,
this.includeNextFlexibleItem = true,
this.fullTimelineExpanded = false,
});
/// Operation/read context. [ApplicationOperationContext.now] is the read
/// instant and is never recomputed during the query.
final ApplicationOperationContext context;
/// Owner-local day to read.
final CivilDate date;
/// Whether hidden locked-time overlays should be present in the item list.
final bool revealHiddenLockedBlocks;
/// Whether compact projection should expose a next flexible task.
final bool includeNextFlexibleItem;
/// Whether compact mode is currently showing the full timeline.
final bool fullTimelineExpanded;
}
/// One item in the Today read model, with task status/source metadata.
class TodayTimelineItem {
TodayTimelineItem({
required this.item,
required this.source,
this.taskStatus,
this.taskId,
this.lockedBlockId,
this.lockedOverrideId,
this.hiddenByDefault = false,
this.isCurrent = false,
this.isNextRequired = false,
this.isNextFlexible = false,
});
/// Display-ready framework-neutral item.
final TimelineItem item;
/// Repository/domain source for this item.
final TodayTimelineItemSource source;
/// Source task status. Null for locked occurrences.
final TaskStatus? taskStatus;
/// Source task id, when [source] is [TodayTimelineItemSource.task].
final String? taskId;
/// Source locked block id, when this is a locked occurrence.
final String? lockedBlockId;
/// Source override id, when this occurrence was replaced or added.
final String? lockedOverrideId;
/// Whether the locked source is normally hidden.
final bool hiddenByDefault;
/// Whether this item is the current selected item for the read instant.
final bool isCurrent;
/// Whether this item is the next required item.
final bool isNextRequired;
/// Whether this item is the optional next flexible item.
final bool isNextFlexible;
/// Stable item id.
String get id => item.id;
/// Timeline start.
DateTime? get start => item.start;
/// Timeline end.
DateTime? get end => item.end;
/// Source task behavior type.
TaskType get taskType => item.taskType;
/// Project/border color token.
String get projectColorToken => item.projectColorToken;
/// Structured quick actions available for this item.
List<TimelineQuickAction> get quickActions => item.quickActions;
/// Return a copy with selection flags changed.
TodayTimelineItem copyWith({
bool? isCurrent,
bool? isNextRequired,
bool? isNextFlexible,
}) {
return TodayTimelineItem(
item: item,
source: source,
taskStatus: taskStatus,
taskId: taskId,
lockedBlockId: lockedBlockId,
lockedOverrideId: lockedOverrideId,
hiddenByDefault: hiddenByDefault,
isCurrent: isCurrent ?? this.isCurrent,
isNextRequired: isNextRequired ?? this.isNextRequired,
isNextFlexible: isNextFlexible ?? this.isNextFlexible,
);
}
}
/// Compact projection derived from the same Today item list.
class TodayCompactState {
const TodayCompactState({
required this.manualCompactModeEnabled,
required this.fullTimelineExpanded,
this.currentItem,
this.nextRequiredItem,
this.nextFlexibleItem,
});
/// User-controlled compact mode flag.
final bool manualCompactModeEnabled;
/// Whether the full timeline is expanded while compact mode is active.
final bool fullTimelineExpanded;
/// Current visible item, if one occupies the read instant.
final TodayTimelineItem? currentItem;
/// Next critical or inflexible item.
final TodayTimelineItem? nextRequiredItem;
/// Optional next flexible item.
final TodayTimelineItem? nextFlexibleItem;
/// Whether callers should show the full timeline.
bool get fullTimelineVisible {
return !manualCompactModeEnabled || fullTimelineExpanded;
}
/// Items selected for compact rendering.
List<TodayTimelineItem> get compactItems {
if (!manualCompactModeEnabled) {
return const [];
}
return [
if (currentItem != null) currentItem!,
if (nextRequiredItem != null && nextRequiredItem!.id != currentItem?.id)
nextRequiredItem!,
if (nextFlexibleItem != null &&
nextFlexibleItem!.id != currentItem?.id &&
nextFlexibleItem!.id != nextRequiredItem?.id)
nextFlexibleItem!,
];
}
}
/// Pending notice data surfaced through Today state.
class TodayPendingNotice {
TodayPendingNotice({
required this.snapshotId,
required this.capturedAt,
required this.message,
required this.type,
required Map<String, Object?> parameters,
this.taskId,
this.issueCode,
this.movementCode,
this.conflictCode,
}) : parameters = Map<String, Object?>.unmodifiable(parameters);
/// Source snapshot id.
final String snapshotId;
/// Snapshot capture time.
final DateTime capturedAt;
/// Existing neutral scheduler message. UI may localize from codes later.
final String message;
/// Notice category.
final SchedulingNoticeType type;
/// Related task id, if any.
final String? taskId;
/// Stable issue code, if any.
final SchedulingIssueCode? issueCode;
/// Stable movement code, if any.
final SchedulingMovementCode? movementCode;
/// Stable conflict code, if any.
final SchedulingConflictCode? conflictCode;
/// Structured notice parameters.
final Map<String, Object?> parameters;
}
/// Complete V1 Today state for one owner-local date.
class TodayState {
TodayState({
required this.date,
required this.ownerId,
required this.timeZoneId,
required this.readAt,
required this.dayStart,
required this.dayEnd,
required this.ownerSettings,
required List<TodayTimelineItem> timelineItems,
required this.compactState,
required List<TodayPendingNotice> pendingNotices,
this.currentItem,
this.nextRequiredItem,
this.nextFlexibleItem,
}) : timelineItems = List<TodayTimelineItem>.unmodifiable(timelineItems),
pendingNotices = List<TodayPendingNotice>.unmodifiable(pendingNotices);
/// Owner-local date represented by this state.
final CivilDate date;
/// Authorization-neutral owner scope.
final String ownerId;
/// Time-zone id used for local-day expansion.
final String timeZoneId;
/// Read instant from the request context.
final DateTime readAt;
/// Resolved start instant for [date].
final DateTime dayStart;
/// Resolved end instant for [date].
final DateTime dayEnd;
/// Effective owner settings used by the query.
final OwnerSettings ownerSettings;
/// Full sorted UI-independent timeline projection.
final List<TodayTimelineItem> timelineItems;
/// Current item, if one occupies [readAt].
final TodayTimelineItem? currentItem;
/// Next required item after [readAt].
final TodayTimelineItem? nextRequiredItem;
/// Optional next flexible item after [readAt].
final TodayTimelineItem? nextFlexibleItem;
/// Compact-mode projection from [timelineItems].
final TodayCompactState compactState;
/// Pending scheduling notices stored for the day.
final List<TodayPendingNotice> pendingNotices;
}
/// Builds Today state from application repositories.
class GetTodayStateQuery {
const GetTodayStateQuery({
required this.applicationStore,
required this.timeZoneResolver,
this.resolutionOptions = const TimeZoneResolutionOptions(),
});
/// Read boundary for repository access.
final ApplicationUnitOfWork applicationStore;
/// Explicit local-time resolver.
final TimeZoneResolver timeZoneResolver;
/// DST/nonexistent/repeated local time policy.
final TimeZoneResolutionOptions resolutionOptions;
/// Execute the read-only Today query.
Future<ApplicationResult<TodayState>> execute(
GetTodayStateRequest request,
) {
return applicationStore.read(
action: (repositories) async {
final context = request.context;
final ownerId = context.ownerTimeZone.ownerId;
final storedSettings =
await repositories.ownerSettings.findByOwnerId(ownerId);
final settings = storedSettings ??
OwnerSettings(
ownerId: ownerId,
timeZoneId: context.ownerTimeZone.timeZoneId,
);
final timeZoneId = settings.timeZoneId;
final dayStart = _resolve(
date: request.date,
wallTime: settings.dayStart,
timeZoneId: timeZoneId,
);
final dayEnd = _resolve(
date: request.date,
wallTime: settings.dayEnd,
timeZoneId: timeZoneId,
);
final window = SchedulingWindow(start: dayStart, end: dayEnd);
final projects = await repositories.projects.findAll();
final tasks = await repositories.tasks.findScheduledInWindow(window);
final blocks = await repositories.lockedBlocks.findAllBlocks();
final overrides = await repositories.lockedBlocks.findAllOverrides();
final snapshots =
await repositories.schedulingSnapshots.findInWindow(window);
final lockedExpansion = expandLockedBlocksForDay(
blocks: blocks,
overrides: overrides,
date: request.date,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
final mapper = TimelineItemMapper.fromProjects(projects);
final baseItems = <TodayTimelineItem>[
for (final task in tasks)
if (_taskAppearsInToday(task, request.revealHiddenLockedBlocks))
TodayTimelineItem(
item: mapper.fromTask(task),
source: TodayTimelineItemSource.task,
taskStatus: task.status,
taskId: task.id,
hiddenByDefault: task.isLocked,
),
for (final occurrence in lockedExpansion.occurrences)
if (mapper.fromLockedOccurrence(
occurrence,
revealHiddenLockedBlocks: request.revealHiddenLockedBlocks,
occurrenceDate: request.date,
)
case final lockedItem?)
TodayTimelineItem(
item: lockedItem,
source: TodayTimelineItemSource.lockedOccurrence,
lockedBlockId: occurrence.lockedBlockId,
lockedOverrideId: occurrence.overrideId,
hiddenByDefault: occurrence.hiddenByDefault,
),
]..sort(_compareTodayItems);
final current = _currentItem(baseItems, context.now);
final nextRequired = _nextRequiredItem(baseItems, context.now);
final nextFlexible = request.includeNextFlexibleItem
? _nextFlexibleItem(
baseItems,
context.now,
currentTaskId: current?.taskId,
)
: null;
final selectedItems = baseItems
.map(
(item) => item.copyWith(
isCurrent: item.id == current?.id,
isNextRequired: item.id == nextRequired?.id,
isNextFlexible: item.id == nextFlexible?.id,
),
)
.toList(growable: false);
final selectedCurrent = _findById(selectedItems, current?.id);
final selectedNextRequired = _findById(selectedItems, nextRequired?.id);
final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id);
final pendingNotices = _pendingNoticesFromSnapshots(snapshots);
return ApplicationResult.success(
TodayState(
date: request.date,
ownerId: ownerId,
timeZoneId: timeZoneId,
readAt: context.now,
dayStart: dayStart,
dayEnd: dayEnd,
ownerSettings: settings,
timelineItems: selectedItems,
currentItem: selectedCurrent,
nextRequiredItem: selectedNextRequired,
nextFlexibleItem: selectedNextFlexible,
compactState: TodayCompactState(
manualCompactModeEnabled: settings.compactModeEnabled,
fullTimelineExpanded: request.fullTimelineExpanded,
currentItem: selectedCurrent,
nextRequiredItem: selectedNextRequired,
nextFlexibleItem: selectedNextFlexible,
),
pendingNotices: pendingNotices,
),
);
},
);
}
DateTime _resolve({
required CivilDate date,
required WallTime wallTime,
required String timeZoneId,
}) {
return timeZoneResolver
.resolve(
date: date,
wallTime: wallTime,
timeZoneId: timeZoneId,
options: resolutionOptions,
)
.instant;
}
static bool _taskAppearsInToday(Task task, bool revealHiddenLockedBlocks) {
if (task.status == TaskStatus.backlog ||
task.status == TaskStatus.cancelled ||
task.status == TaskStatus.noLongerRelevant) {
return false;
}
if (task.isLocked && !revealHiddenLockedBlocks) {
return false;
}
return task.scheduledStart != null && task.scheduledEnd != null;
}
static TodayTimelineItem? _currentItem(
List<TodayTimelineItem> items,
DateTime now,
) {
final active = _firstOrNull(
items.where((item) {
return item.source == TodayTimelineItemSource.task &&
item.taskStatus == TaskStatus.active;
}),
);
if (active != null) {
return active;
}
return _firstOrNull(
items.where((item) {
return item.source == TodayTimelineItemSource.task &&
item.taskStatus == TaskStatus.planned &&
_containsTime(item, now);
}),
);
}
static TodayTimelineItem? _nextRequiredItem(
List<TodayTimelineItem> items,
DateTime now,
) {
return _firstOrNull(
items.where((item) {
return item.source == TodayTimelineItemSource.task &&
_isActionable(item.taskStatus) &&
_isRequired(item.taskType) &&
_startsAtOrAfter(item, now);
}),
);
}
static TodayTimelineItem? _nextFlexibleItem(
List<TodayTimelineItem> items,
DateTime now, {
required String? currentTaskId,
}) {
return _firstOrNull(
items.where((item) {
return item.source == TodayTimelineItemSource.task &&
item.taskId != currentTaskId &&
_isActionable(item.taskStatus) &&
item.taskType == TaskType.flexible &&
_startsAtOrAfter(item, now);
}),
);
}
static bool _isActionable(TaskStatus? status) {
return status == TaskStatus.planned || status == TaskStatus.active;
}
static bool _isRequired(TaskType type) {
return type == TaskType.inflexible || type == TaskType.critical;
}
static bool _containsTime(TodayTimelineItem item, DateTime now) {
final start = item.start;
final end = item.end;
if (start == null || end == null) {
return false;
}
return !now.isBefore(start) && now.isBefore(end);
}
static bool _startsAtOrAfter(TodayTimelineItem item, DateTime now) {
final start = item.start;
return start != null && !start.isBefore(now);
}
static TodayTimelineItem? _findById(
List<TodayTimelineItem> items,
String? id,
) {
if (id == null) {
return null;
}
return _firstOrNull(items.where((item) => item.id == id));
}
static List<TodayPendingNotice> _pendingNoticesFromSnapshots(
List<SchedulingStateSnapshot> snapshots,
) {
final sortedSnapshots = [...snapshots]..sort((a, b) {
final capturedComparison = a.capturedAt.compareTo(b.capturedAt);
if (capturedComparison != 0) {
return capturedComparison;
}
return a.id.compareTo(b.id);
});
final notices = <TodayPendingNotice>[];
for (final snapshot in sortedSnapshots) {
for (final notice in snapshot.notices) {
notices.add(
TodayPendingNotice(
snapshotId: snapshot.id,
capturedAt: snapshot.capturedAt,
message: notice.message,
type: notice.type,
taskId: notice.taskId,
issueCode: notice.issueCode,
movementCode: notice.movementCode,
conflictCode: notice.conflictCode,
parameters: notice.parameters,
),
);
}
}
return List<TodayPendingNotice>.unmodifiable(notices);
}
}
int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) {
final startComparison = _compareNullableInstants(left.start, right.start);
if (startComparison != 0) {
return startComparison;
}
final endComparison = _compareNullableInstants(left.end, right.end);
if (endComparison != 0) {
return endComparison;
}
final sourceComparison = left.source.index.compareTo(right.source.index);
if (sourceComparison != 0) {
return sourceComparison;
}
return left.id.compareTo(right.id);
}
int _compareNullableInstants(DateTime? left, DateTime? right) {
if (left == null && right == null) {
return 0;
}
if (left == null) {
return 1;
}
if (right == null) {
return -1;
}
return left.compareTo(right);
}
T? _firstOrNull<T>(Iterable<T> values) {
final iterator = values.iterator;
if (!iterator.moveNext()) {
return null;
}
return iterator.current;
}

559
test/today_state_test.dart Normal file
View file

@ -0,0 +1,559 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('GetTodayStateQuery', () {
final day = CivilDate(2026, 6, 25);
final createdAt = DateTime.utc(2026, 6, 20, 8);
final project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'project-home',
);
test('returns an empty side-effect-free day state', () async {
final store = InMemoryApplicationUnitOfWork(
initialProjects: [project],
);
final query = todayQuery(store);
final result = await query.execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-empty',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
),
);
final state = result.requireValue;
expect(state.date, day);
expect(state.ownerId, 'owner-1');
expect(state.timeZoneId, 'UTC');
expect(state.timelineItems, isEmpty);
expect(state.currentItem, isNull);
expect(state.nextRequiredItem, isNull);
expect(state.nextFlexibleItem, isNull);
expect(state.pendingNotices, isEmpty);
expect(store.currentOperations, isEmpty);
expect(store.currentTaskActivities, isEmpty);
expect(store.currentProjectStatistics, isEmpty);
});
test(
'builds one sorted model for tasks, Free Slots, locked overlays, and notices',
() async {
final dayStart = DateTime.utc(2026, 6, 25);
final dayEnd = DateTime.utc(2026, 6, 25, 23, 59);
final flexible = task(
id: 'flexible',
title: 'Flexible work',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
createdAt: createdAt,
);
final freeSlot = task(
id: 'rest',
title: 'Rest',
type: TaskType.freeSlot,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
createdAt: createdAt,
);
final required = task(
id: 'appointment',
title: 'Appointment',
type: TaskType.inflexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 11),
end: DateTime.utc(2026, 6, 25, 11, 30),
createdAt: createdAt,
);
final surprise = task(
id: 'surprise',
title: 'Surprise done',
type: TaskType.surprise,
status: TaskStatus.completed,
start: DateTime.utc(2026, 6, 25, 12),
end: DateTime.utc(2026, 6, 25, 12, 20),
createdAt: createdAt,
completedAt: DateTime.utc(2026, 6, 25, 12, 20),
);
final visibleLocked = LockedBlock(
id: 'visible-lock',
name: 'Visible locked',
startTime: WallTime(hour: 13, minute: 0),
endTime: WallTime(hour: 14, minute: 0),
date: day,
hiddenByDefault: false,
projectId: project.id,
createdAt: createdAt,
updatedAt: createdAt,
);
final snapshot = SchedulingStateSnapshot(
id: 'rollover-notice',
capturedAt: DateTime.utc(2026, 6, 25, 7),
window: SchedulingWindow(start: dayStart, end: dayEnd),
tasks: const [],
notices: [
SchedulingNotice(
'Moved 2 unfinished tasks to today.',
type: SchedulingNoticeType.moved,
movementCode:
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
parameters: const {'count': 2},
),
],
);
final store = InMemoryApplicationUnitOfWork(
initialProjects: [project],
initialTasks: [surprise, required, freeSlot, flexible],
initialLockedBlocks: [visibleLocked],
initialSchedulingSnapshots: [snapshot],
);
final state = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-mixed',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
),
))
.requireValue;
expect(state.timelineItems.map((item) => item.id), [
'flexible',
'rest',
'appointment',
'surprise',
'locked-block:visible-lock:2026-06-25',
]);
expect(state.timelineItems.first.taskStatus, TaskStatus.planned);
expect(state.timelineItems.first.quickActions, [
TimelineQuickAction.done,
TimelineQuickAction.push,
TimelineQuickAction.backlog,
TimelineQuickAction.breakUp,
]);
expect(
state.timelineItems.singleWhere((item) => item.id == 'rest').taskType,
TaskType.freeSlot,
);
expect(
state.timelineItems
.singleWhere((item) => item.id == 'rest')
.quickActions,
isEmpty,
);
expect(
state.timelineItems
.singleWhere((item) => item.id == 'surprise')
.taskStatus,
TaskStatus.completed,
);
final locked = state.timelineItems.last;
expect(locked.source, TodayTimelineItemSource.lockedOccurrence);
expect(locked.taskType, TaskType.locked);
expect(locked.projectColorToken, 'project-home');
expect(state.pendingNotices.single.movementCode,
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver);
expect(state.pendingNotices.single.parameters['count'], 2);
});
test('uses start-inclusive end-exclusive boundaries for current selection',
() async {
final ended = task(
id: 'ended',
title: 'Ended',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 10),
createdAt: createdAt,
);
final starting = task(
id: 'starting',
title: 'Starting',
type: TaskType.inflexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
createdAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialProjects: [project],
initialTasks: [ended, starting],
);
final state = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-boundary',
now: DateTime.utc(2026, 6, 25, 10),
),
date: day,
),
))
.requireValue;
expect(state.currentItem!.id, 'starting');
expect(state.currentItem!.id, isNot('ended'));
expect(state.nextRequiredItem!.id, 'starting');
});
test(
'builds compact projection without duplicating the current flexible task',
() async {
final current = task(
id: 'current-flex',
title: 'Current flexible',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
createdAt: createdAt,
);
final nextRequired = task(
id: 'next-required',
title: 'Next required',
type: TaskType.critical,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
createdAt: createdAt,
);
final laterFlexible = task(
id: 'later-flex',
title: 'Later flexible',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 11),
end: DateTime.utc(2026, 6, 25, 11, 30),
createdAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialProjects: [project],
initialTasks: [laterFlexible, nextRequired, current],
initialOwnerSettings: [
OwnerSettings(
ownerId: 'owner-1',
timeZoneId: 'UTC',
compactModeEnabled: true,
),
],
);
final state = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-compact',
now: DateTime.utc(2026, 6, 25, 9),
),
date: day,
),
))
.requireValue;
expect(state.currentItem!.id, 'current-flex');
expect(state.nextRequiredItem!.id, 'next-required');
expect(state.nextFlexibleItem!.id, 'later-flex');
expect(state.compactState.fullTimelineVisible, isFalse);
expect(state.compactState.compactItems.map((item) => item.id), [
'current-flex',
'next-required',
'later-flex',
]);
});
test('keeps hidden locked details absent unless reveal mode is requested',
() async {
final hidden = LockedBlock(
id: 'work-hours',
name: 'Work hours',
startTime: WallTime(hour: 9, minute: 0),
endTime: WallTime(hour: 17, minute: 0),
recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.thursday},
),
hiddenByDefault: true,
createdAt: createdAt,
updatedAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialLockedBlocks: [hidden],
);
final defaultState = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-hidden-default',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
),
))
.requireValue;
final revealedState = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-hidden-revealed',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
revealHiddenLockedBlocks: true,
),
))
.requireValue;
expect(defaultState.timelineItems, isEmpty);
expect(revealedState.timelineItems.single.id,
'locked-block:work-hours:2026-06-25');
expect(revealedState.timelineItems.single.hiddenByDefault, isTrue);
});
test('uses date-stable locked occurrence ids across days', () async {
final block = LockedBlock(
id: 'daily-lock',
name: 'Daily lock',
startTime: WallTime(hour: 9, minute: 0),
endTime: WallTime(hour: 10, minute: 0),
recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.thursday, LockedWeekday.friday},
),
hiddenByDefault: false,
createdAt: createdAt,
updatedAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialLockedBlocks: [block],
);
final query = todayQuery(store);
final thursday = (await query.execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-thursday',
now: DateTime.utc(2026, 6, 25, 8),
),
date: CivilDate(2026, 6, 25),
),
))
.requireValue;
final friday = (await query.execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-friday',
now: DateTime.utc(2026, 6, 26, 8),
),
date: CivilDate(2026, 6, 26),
),
))
.requireValue;
expect(thursday.timelineItems.single.id,
'locked-block:daily-lock:2026-06-25');
expect(
friday.timelineItems.single.id, 'locked-block:daily-lock:2026-06-26');
expect(thursday.timelineItems.single.id,
isNot(friday.timelineItems.single.id));
});
test('expands locked occurrences through explicit DST resolver context',
() async {
final dstDay = CivilDate(2026, 3, 8);
final block = LockedBlock(
id: 'dst-lock',
name: 'DST lock',
startTime: WallTime(hour: 1, minute: 30),
endTime: WallTime(hour: 3, minute: 30),
date: dstDay,
hiddenByDefault: false,
createdAt: createdAt,
updatedAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialLockedBlocks: [block],
initialOwnerSettings: [
OwnerSettings(
ownerId: 'owner-1',
timeZoneId: 'America/Los_Angeles',
),
],
);
final query = GetTodayStateQuery(
applicationStore: store,
timeZoneResolver: _LosAngelesDstFixtureResolver(),
);
final state = (await query.execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-dst',
now: DateTime.utc(2026, 3, 8, 8),
timeZoneId: 'America/Los_Angeles',
),
date: dstDay,
),
))
.requireValue;
final item = state.timelineItems.single;
expect(state.dayStart, DateTime.utc(2026, 3, 8, 8));
expect(state.dayEnd, DateTime.utc(2026, 3, 9, 6, 59));
expect(item.id, 'locked-block:dst-lock:2026-03-08');
expect(item.start, DateTime.utc(2026, 3, 8, 9, 30));
expect(item.end, DateTime.utc(2026, 3, 8, 10, 30));
});
test('uses deterministic ordering for equal-start items', () async {
final bTask = task(
id: 'b-task',
title: 'B task',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
createdAt: createdAt,
);
final aTask = task(
id: 'a-task',
title: 'A task',
type: TaskType.flexible,
status: TaskStatus.planned,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
createdAt: createdAt,
);
final lock = LockedBlock(
id: 'same-start',
name: 'Same start',
startTime: WallTime(hour: 9, minute: 0),
endTime: WallTime(hour: 9, minute: 30),
date: day,
hiddenByDefault: false,
createdAt: createdAt,
updatedAt: createdAt,
);
final store = InMemoryApplicationUnitOfWork(
initialTasks: [bTask, aTask],
initialLockedBlocks: [lock],
);
final state = (await todayQuery(store).execute(
GetTodayStateRequest(
context: appContext(
operationId: 'read-ordering',
now: DateTime.utc(2026, 6, 25, 8),
),
date: day,
),
))
.requireValue;
expect(state.timelineItems.map((item) => item.id), [
'a-task',
'b-task',
'locked-block:same-start:2026-06-25',
]);
});
});
}
GetTodayStateQuery todayQuery(InMemoryApplicationUnitOfWork store) {
return GetTodayStateQuery(
applicationStore: store,
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
);
}
ApplicationOperationContext appContext({
required String operationId,
required DateTime now,
String timeZoneId = 'UTC',
}) {
return ApplicationOperationContext.start(
operationId: operationId,
ownerTimeZone: OwnerTimeZoneContext(
ownerId: 'owner-1',
timeZoneId: timeZoneId,
),
clock: FixedClock(now),
idGenerator: SequentialIdGenerator(prefix: 'generated'),
);
}
Task task({
required String id,
required String title,
required TaskType type,
required TaskStatus status,
required DateTime start,
required DateTime end,
required DateTime createdAt,
DateTime? completedAt,
}) {
return Task(
id: id,
title: title,
projectId: 'home',
type: type,
status: status,
durationMinutes: end.difference(start).inMinutes,
scheduledStart: start,
scheduledEnd: end,
completedAt: completedAt,
createdAt: createdAt,
updatedAt: createdAt,
);
}
class _LosAngelesDstFixtureResolver implements TimeZoneResolver {
@override
ResolvedLocalDateTime resolve({
required CivilDate date,
required WallTime wallTime,
required String timeZoneId,
TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(),
}) {
if (timeZoneId.trim() != 'America/Los_Angeles') {
throw DomainValidationException(
code: DomainValidationCode.blankTimeZoneId,
invalidValue: timeZoneId,
name: 'timeZoneId',
message: 'Fixture resolver only supports America/Los_Angeles.',
);
}
final offset = _offsetFor(date, wallTime);
final localAsUtc = DateTime.utc(
date.year,
date.month,
date.day,
wallTime.hour,
wallTime.minute,
);
return ResolvedLocalDateTime(
date: date,
wallTime: wallTime,
timeZoneId: timeZoneId,
instant: localAsUtc.subtract(offset),
resolution: LocalTimeResolution.exact,
utcOffset: offset,
);
}
Duration _offsetFor(CivilDate date, WallTime wallTime) {
if (date == CivilDate(2026, 3, 8) && wallTime.hour >= 3) {
return const Duration(hours: -7);
}
return const Duration(hours: -8);
}
}