feat(app): add today state read model
This commit is contained in:
parent
2463fd7817
commit
11f6eff85b
9 changed files with 1287 additions and 12 deletions
|
|
@ -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 specification’s `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 specification’s `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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
609
lib/src/today_state.dart
Normal 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
559
test/today_state_test.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue