feat(app): add today state read model

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

View file

@ -32,7 +32,7 @@ passed. The current suite has 143 passing tests.
| Core task model | Task types, lifecycle statuses, basic metadata, immutable copy pattern, strong invariants, explicit nullable clearing, completion timestamp, task reminder override, typed validation | Backlog-entered time and remaining persistence/query support | 14, 15 |
| Status semantics | Planned/active/completed/missed/cancelled/no-longer-relevant/backlog | Reconcile the human specifications `pushed` and `skipped` labels with event/stat semantics without importing V2 burnout behavior | 11, 13 |
| Time model | DateTime-based schedule intervals and recurring wall-clock helpers | Civil-date/wall-time/time-zone semantics, DST policy, deterministic clocks, and safe date-only persistence | 11 |
| 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 |

View file

@ -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

View file

@ -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.

View file

@ -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. |

View file

@ -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';

View file

@ -304,6 +304,12 @@ abstract interface class ApplicationUnitOfWork {
ApplicationUnitOfWorkRepositories repositories,
) action,
});
Future<ApplicationResult<T>> read<T>({
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
});
}
/// 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 {

View file

@ -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<Task?>()
.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()}';

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

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

559
test/today_state_test.dart Normal file
View file

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