diff --git a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md index ee0baf9..5695b4d 100644 --- a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md @@ -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 | | 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 | -| 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 | | 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 | @@ -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 | | 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 | -| 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 | -| 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 | | 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 | diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md b/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md index 36961af..102d889 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md @@ -79,6 +79,8 @@ command orchestration. Recommended Codex level: extra high +Status: Complete + Tasks: - Add a `GetTodayState`-style query that loads the requested local day, app @@ -116,6 +118,36 @@ Acceptance criteria: - Read queries have no side effects. - 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 Recommended Codex level: extra high diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index e5d1f02..6fccc8a 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -31,6 +31,7 @@ following source files: - `src/task_lifecycle.dart` - `src/task_statistics.dart` - `src/time_contracts.dart` +- `src/today_state.dart` - `src/timeline_state.dart` 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_lifecycle.dart` | `TaskTransitionCode`, `TaskActivityCode`, `TaskTransitionOutcomeCode` | | `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` | +| `src/today_state.dart` | `TodayTimelineItemSource` | | `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` | ## 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_statistics.dart` | `TaskStatistics` | | `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` | ## 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 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 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`. - Application transaction behavior is represented by `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 not import MongoDB APIs into the scheduling core. diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index fa745ff..53e37a8 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -70,6 +70,13 @@ Latest Block 14.1 verification result on 2026-06-25: - `dart test`: passed, 249 tests - `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: - `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-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-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-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-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. | `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-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-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. | @@ -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-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-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-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. | diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 7e70c67..d56c423 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -34,4 +34,5 @@ export 'src/task_actions.dart'; export 'src/task_lifecycle.dart'; export 'src/task_statistics.dart'; export 'src/time_contracts.dart'; +export 'src/today_state.dart'; export 'src/timeline_state.dart'; diff --git a/lib/src/application_layer.dart b/lib/src/application_layer.dart index 450c274..0fbaa0e 100644 --- a/lib/src/application_layer.dart +++ b/lib/src/application_layer.dart @@ -304,6 +304,12 @@ abstract interface class ApplicationUnitOfWork { ApplicationUnitOfWorkRepositories repositories, ) action, }); + + Future> read({ + required Future> Function( + ApplicationUnitOfWorkRepositories repositories, + ) action, + }); } /// Repository loader for common scheduler input assembly. @@ -484,6 +490,39 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { ); } } + + @override + Future> read({ + required Future> 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 { diff --git a/lib/src/timeline_state.dart b/lib/src/timeline_state.dart index 9c662a5..1134988 100644 --- a/lib/src/timeline_state.dart +++ b/lib/src/timeline_state.dart @@ -6,6 +6,7 @@ import 'locked_time.dart'; import 'models.dart'; +import 'time_contracts.dart'; /// Broad display category for an item placed on the Today timeline. enum TimelineItemCategory { @@ -210,13 +211,14 @@ class TimelineItemMapper { TimelineItem? fromLockedOccurrence( LockedBlockOccurrence occurrence, { required bool revealHiddenLockedBlocks, + CivilDate? occurrenceDate, }) { if (occurrence.hiddenByDefault && !revealHiddenLockedBlocks) { return null; } return TimelineItem( - id: _lockedOccurrenceId(occurrence), + id: _lockedOccurrenceId(occurrence, occurrenceDate: occurrenceDate), displayTitle: occurrence.name, taskType: TaskType.locked, projectColorToken: _projectColorTokenFor(occurrence.projectId), @@ -269,7 +271,9 @@ class TimelineItemMapper { final nextFlexibleTask = includeNextFlexibleTask ? timelineTasks .where((task) { - return task.isFlexible && _startsAtOrAfter(task, now); + return task.isFlexible && + task.id != currentTask?.id && + _startsAtOrAfter(task, now); }) .cast() .firstWhere( @@ -328,14 +332,19 @@ class TimelineItemMapper { 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; if (lockedBlockId != null) { - return 'locked-block:$lockedBlockId'; + return 'locked-block:$lockedBlockId${datePart ?? ''}'; } final overrideId = occurrence.overrideId; if (overrideId != null) { - return 'locked-override:$overrideId'; + return 'locked-override:$overrideId${datePart ?? ''}'; } return 'locked:${occurrence.name}:' '${occurrence.interval.start.toIso8601String()}'; diff --git a/lib/src/today_state.dart b/lib/src/today_state.dart new file mode 100644 index 0000000..46d2e5b --- /dev/null +++ b/lib/src/today_state.dart @@ -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 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 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 parameters, + this.taskId, + this.issueCode, + this.movementCode, + this.conflictCode, + }) : parameters = Map.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 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 timelineItems, + required this.compactState, + required List pendingNotices, + this.currentItem, + this.nextRequiredItem, + this.nextFlexibleItem, + }) : timelineItems = List.unmodifiable(timelineItems), + pendingNotices = List.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 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 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> 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 = [ + 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 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 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 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 items, + String? id, + ) { + if (id == null) { + return null; + } + + return _firstOrNull(items.where((item) => item.id == id)); + } + + static List _pendingNoticesFromSnapshots( + List 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 = []; + + 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.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(Iterable values) { + final iterator = values.iterator; + if (!iterator.moveNext()) { + return null; + } + + return iterator.current; +} diff --git a/test/today_state_test.dart b/test/today_state_test.dart new file mode 100644 index 0000000..1430001 --- /dev/null +++ b/test/today_state_test.dart @@ -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); + } +}