feat(domain): enforce v1 invariants

This commit is contained in:
Ashley Venn 2026-06-24 16:56:17 -07:00
parent 656ba0634b
commit 91bdb26b1a
20 changed files with 1020 additions and 146 deletions

View file

@ -149,6 +149,32 @@ Acceptance criteria:
Recommended Codex level: high Recommended Codex level: high
Status: Complete on 2026-06-24.
Implementation outputs:
- Added typed domain validation codes and `DomainValidationException`.
- Enforced non-blank task/project/locked-time identifiers and labels at domain
model boundaries.
- Enforced positive optional durations and valid scheduled, actual,
`TimeInterval`, `SchedulingWindow`, locked block, recurrence, and override
intervals.
- Added explicit nullable clear semantics for task priority, duration, schedule
interval, actual interval, parent ownership, reminder override, and project
default duration.
- Added task `actualStart`, `actualEnd`, and `reminderOverride` fields with
document mapping support.
- Rejected self-parenting and preserved direct-child-only ownership.
- Defensively copied public domain/read-model collections into unmodifiable
snapshots.
Test outputs:
- Added `test/domain_invariants_test.dart` for validation codes, clear paths,
immutable collection boundaries, and locked-time invariants.
- Updated persistence and scheduler tests for the new document fields and
immutable result snapshots.
Tasks: Tasks:
- Enforce non-blank stable IDs, titles, and project IDs at model boundaries. - Enforce non-blank stable IDs, titles, and project IDs at model boundaries.

View file

@ -33,7 +33,7 @@ changes from accidental API drift.
| File | Enums | | File | Enums |
|---|---| |---|---|
| `src/models.dart` | `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` | | `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` |
| `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` | | `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` |
| `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` | | `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
| `src/quick_capture.dart` | `QuickCaptureStatus` | | `src/quick_capture.dart` | `QuickCaptureStatus` |
@ -45,7 +45,7 @@ changes from accidental API drift.
| File | Public API | | File | Public API |
|---|---| |---|---|
| `src/models.dart` | `Task`, `ProjectProfile`, `TimeInterval` | | `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` |
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` | | `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` | | `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` | | `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` |
@ -83,7 +83,8 @@ Current notable model fields:
- `Task`: `id`, `title`, `projectId`, `type`, `status`, `priority`, - `Task`: `id`, `title`, `projectId`, `type`, `status`, `priority`,
`reward`, `difficulty`, `durationMinutes`, `scheduledStart`, `scheduledEnd`, `reward`, `difficulty`, `durationMinutes`, `scheduledStart`, `scheduledEnd`,
`parentTaskId`, `backlogTags`, `createdAt`, `updatedAt`, `stats` `actualStart`, `actualEnd`, `parentTaskId`, `backlogTags`,
`reminderOverride`, `createdAt`, `updatedAt`, `stats`
- `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/ - `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/
difficulty/reminder profile/duration fields difficulty/reminder profile/duration fields
- `TimeInterval`: `start`, `end`, optional `label` - `TimeInterval`: `start`, `end`, optional `label`
@ -94,12 +95,24 @@ Known baseline gaps to preserve as explicit later work:
- `TaskStatus` does not include `pushed` or `skipped`; Chunk 11.2 resolves the - `TaskStatus` does not include `pushed` or `skipped`; Chunk 11.2 resolves the
status/event distinction. status/event distinction.
- `Task` does not yet have explicit completion timestamp, actual interval, - `Task` does not yet have explicit completion timestamp or backlog-entered
backlog-entered timestamp, or task-level reminder override fields. timestamp fields.
- Nullable clearing is limited; broad explicit patch semantics are Chunk 11.3.
- Time is currently `DateTime`-based; civil-date, wall-time, clock, ID, and - Time is currently `DateTime`-based; civil-date, wall-time, clock, ID, and
timezone contracts are Chunk 11.4. timezone contracts are Chunk 11.4.
## Intentional API Changes After Baseline
Chunk 11.3 intentionally changed the public API:
- Added `DomainValidationCode` and `DomainValidationException`.
- Added `Task.actualStart`, `Task.actualEnd`, and `Task.reminderOverride`.
- Added explicit `copyWith` clear flags for nullable task fields.
- Added `ProjectProfile.copyWith(clearDefaultDuration: true)`.
- Changed several public value constructors from `const` to runtime-validating
constructors.
- Public collection fields now expose unmodifiable snapshots instead of caller
mutable collections.
## Repository contracts ## Repository contracts
The current repository surface is pure Dart and in-memory only: The current repository surface is pure Dart and in-memory only:

View file

@ -59,7 +59,7 @@ Status values:
| MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. | | MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. |
| MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction` | `test/required_task_actions_test.dart` | complete | Completion/cancellation timestamps remain in Block 13. | | MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction` | `test/required_task_actions_test.dart` | complete | Completion/cancellation timestamps remain in Block 13. |
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, timeline tokens | `test/timeline_state_test.dart` | incomplete | Scheduling protection and reminder directives remain in Blocks 12 and 13. | | MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, timeline tokens | `test/timeline_state_test.dart` | incomplete | Scheduling protection and reminder directives remain in Blocks 12 and 13. |
| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ReminderProfile` | `test/scheduling_engine_test.dart` | incomplete | Learned/configured precedence and task overrides remain in Chunks 11.2 and Block 13. | | MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart` | incomplete | Task override storage exists; effective resolver, learned suggestions, and reminder directives remain in Block 13. |
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. | | MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, 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` | `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-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `SchedulingChange`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | incomplete | Canonical transition/activity layer remains in Block 13. | | MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `SchedulingChange`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | incomplete | Canonical transition/activity layer remains in Block 13. |

View file

@ -120,12 +120,12 @@ class BacklogStalenessSettings {
/// That keeps backlog display logic out of widgets and avoids duplicating the /// That keeps backlog display logic out of widgets and avoids duplicating the
/// same filtering rules in multiple screens. /// same filtering rules in multiple screens.
class BacklogView { class BacklogView {
const BacklogView({ BacklogView({
required this.tasks, required List<Task> tasks,
required this.now, required this.now,
this.staleAfter = const Duration(days: 31), this.staleAfter = const Duration(days: 31),
this.stalenessSettings = const BacklogStalenessSettings(), this.stalenessSettings = const BacklogStalenessSettings(),
}); }) : tasks = List<Task>.unmodifiable(tasks);
/// Master task list supplied by the caller. Only `status == backlog` items are /// Master task list supplied by the caller. Only `status == backlog` items are
/// shown by this view. /// shown by this view.

View file

@ -83,9 +83,9 @@ class ChildTaskEntry {
/// as "which tasks belong to this parent?" while leaving task placement and /// as "which tasks belong to this parent?" while leaving task placement and
/// completion rules to other domain services. /// completion rules to other domain services.
class ChildTaskView { class ChildTaskView {
const ChildTaskView({ ChildTaskView({
required this.tasks, required List<Task> tasks,
}); }) : tasks = List<Task>.unmodifiable(tasks);
/// Source task list supplied by the caller. /// Source task list supplied by the caller.
final List<Task> tasks; final List<Task> tasks;
@ -165,15 +165,18 @@ class ChildTaskView {
/// Result from child/parent completion propagation. /// Result from child/parent completion propagation.
class ChildTaskCompletionResult { class ChildTaskCompletionResult {
const ChildTaskCompletionResult({ ChildTaskCompletionResult({
required this.tasks, required List<Task> tasks,
required this.changedTaskIds, required List<String> changedTaskIds,
this.completedChildId, this.completedChildId,
this.completedParentId, this.completedParentId,
this.forceCompletedChildIds = const <String>[], List<String> forceCompletedChildIds = const <String>[],
this.autoCompletedParent = false, this.autoCompletedParent = false,
this.canCompleteParentExplicitly = false, this.canCompleteParentExplicitly = false,
}); }) : tasks = List<Task>.unmodifiable(tasks),
changedTaskIds = List<String>.unmodifiable(changedTaskIds),
forceCompletedChildIds =
List<String>.unmodifiable(forceCompletedChildIds);
/// Replacement task list after the completion action. /// Replacement task list after the completion action.
final List<Task> tasks; final List<Task> tasks;

View file

@ -35,6 +35,12 @@ abstract final class PersistenceEnumMapping {
return _decodeEnum(DifficultyLevel.values, value, 'DifficultyLevel'); return _decodeEnum(DifficultyLevel.values, value, 'DifficultyLevel');
} }
static ReminderProfile? decodeNullableReminderProfile(String? value) {
return value == null
? null
: _decodeEnum(ReminderProfile.values, value, 'ReminderProfile');
}
static BacklogTag decodeBacklogTag(String value) { static BacklogTag decodeBacklogTag(String value) {
return _decodeEnum(BacklogTag.values, value, 'BacklogTag'); return _decodeEnum(BacklogTag.values, value, 'BacklogTag');
} }
@ -81,9 +87,13 @@ abstract final class TaskDocumentMapping {
TaskDocumentFields.durationMinutes: task.durationMinutes, TaskDocumentFields.durationMinutes: task.durationMinutes,
TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart), TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart),
TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd),
TaskDocumentFields.actualStart: _dateTimeToStored(task.actualStart),
TaskDocumentFields.actualEnd: _dateTimeToStored(task.actualEnd),
TaskDocumentFields.parentTaskId: task.parentTaskId, TaskDocumentFields.parentTaskId: task.parentTaskId,
TaskDocumentFields.backlogTags: TaskDocumentFields.backlogTags:
task.backlogTags.map((tag) => tag.persistenceName).toList(), task.backlogTags.map((tag) => tag.persistenceName).toList(),
TaskDocumentFields.reminderOverride:
PersistenceEnumMapping.encodeNullable(task.reminderOverride),
TaskDocumentFields.createdAt: TaskDocumentFields.createdAt:
PersistenceDateTimeConvention.toStoredString(task.createdAt), PersistenceDateTimeConvention.toStoredString(task.createdAt),
TaskDocumentFields.updatedAt: TaskDocumentFields.updatedAt:
@ -119,8 +129,13 @@ abstract final class TaskDocumentMapping {
_nullableDateTime(document, TaskDocumentFields.scheduledStart), _nullableDateTime(document, TaskDocumentFields.scheduledStart),
scheduledEnd: scheduledEnd:
_nullableDateTime(document, TaskDocumentFields.scheduledEnd), _nullableDateTime(document, TaskDocumentFields.scheduledEnd),
actualStart: _nullableDateTime(document, TaskDocumentFields.actualStart),
actualEnd: _nullableDateTime(document, TaskDocumentFields.actualEnd),
parentTaskId: _nullableString(document, TaskDocumentFields.parentTaskId), parentTaskId: _nullableString(document, TaskDocumentFields.parentTaskId),
backlogTags: _backlogTagsFromDocument(document), backlogTags: _backlogTagsFromDocument(document),
reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile(
_nullableString(document, TaskDocumentFields.reminderOverride),
),
createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt),
updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt),
stats: TaskStatisticsDocumentMapping.fromDocument( stats: TaskStatisticsDocumentMapping.fromDocument(

View file

@ -33,11 +33,19 @@ enum LockedWeekday {
/// than as one specific timestamp. [ClockTime] stores just the hour/minute part /// than as one specific timestamp. [ClockTime] stores just the hour/minute part
/// and can later be projected onto a concrete date with [onDate]. /// and can later be projected onto a concrete date with [onDate].
class ClockTime { class ClockTime {
const ClockTime({ ClockTime({
required this.hour, required this.hour,
required this.minute, required this.minute,
}) : assert(hour >= 0 && hour < 24, 'hour must be 0-23'), }) {
assert(minute >= 0 && minute < 60, 'minute must be 0-59'); if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) {
throw DomainValidationException(
code: DomainValidationCode.invalidClockTime,
invalidValue: {'hour': hour, 'minute': minute},
name: 'ClockTime',
message: 'Clock time must use hour 0-23 and minute 0-59.',
);
}
}
/// 24-hour clock hour, 0 through 23. /// 24-hour clock hour, 0 through 23.
final int hour; final int hour;
@ -62,9 +70,18 @@ class ClockTime {
/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence /// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence
/// rules can be added later without changing the rest of the locked-time model. /// rules can be added later without changing the rest of the locked-time model.
class LockedBlockRecurrence { class LockedBlockRecurrence {
const LockedBlockRecurrence.weekly({ LockedBlockRecurrence.weekly({
required this.weekdays, required Set<LockedWeekday> weekdays,
}); }) : weekdays = Set<LockedWeekday>.unmodifiable(weekdays) {
if (weekdays.isEmpty) {
throw DomainValidationException(
code: DomainValidationCode.emptyRecurrence,
invalidValue: weekdays,
name: 'weekdays',
message: 'Recurring locked blocks need at least one weekday.',
);
}
}
/// Weekdays when this recurrence should produce an occurrence. /// Weekdays when this recurrence should produce an occurrence.
final Set<LockedWeekday> weekdays; final Set<LockedWeekday> weekdays;
@ -87,9 +104,9 @@ class LockedBlockRecurrence {
/// - one-off, with [date] set and [recurrence] null; or /// - one-off, with [date] set and [recurrence] null; or
/// - recurring, with [recurrence] set and [date] usually null. /// - recurring, with [recurrence] set and [date] usually null.
class LockedBlock { class LockedBlock {
const LockedBlock({ LockedBlock({
required this.id, required String id,
required this.name, required String name,
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
required this.createdAt, required this.createdAt,
@ -98,10 +115,27 @@ class LockedBlock {
this.recurrence, this.recurrence,
this.hiddenByDefault = true, this.hiddenByDefault = true,
this.projectId, this.projectId,
}) : assert( }) : id =
recurrence != null || date != null, _requiredLockedString(id, 'id', DomainValidationCode.blankStableId),
'date is required for one-off locked blocks', name = _requiredLockedString(
); name, 'name', DomainValidationCode.blankName) {
if (recurrence == null && date == null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedBlock,
invalidValue: null,
name: 'date',
message: 'One-off locked blocks need a date.',
);
}
if (!_clockTimeIsBefore(startTime, endTime)) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedBlock,
invalidValue: {'startTime': startTime, 'endTime': endTime},
name: 'startTime/endTime',
message: 'Locked block end time must be after start time.',
);
}
}
/// Stable id for persistence and one-day overrides. /// Stable id for persistence and one-day overrides.
final String id; final String id;
@ -238,19 +272,38 @@ enum LockedBlockOverrideType {
/// - [replace] references a base block and swaps its details for one day. /// - [replace] references a base block and swaps its details for one day.
/// - [add] creates an extra locked occurrence that has no base block. /// - [add] creates an extra locked occurrence that has no base block.
class LockedBlockOverride { class LockedBlockOverride {
const LockedBlockOverride({ LockedBlockOverride({
required this.id, required String id,
required this.date, required this.date,
required this.type, required this.type,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
this.lockedBlockId, String? lockedBlockId,
this.name, String? name,
this.startTime, this.startTime,
this.endTime, this.endTime,
this.hiddenByDefault = true, this.hiddenByDefault = true,
this.projectId, this.projectId,
}); }) : id =
_requiredLockedString(id, 'id', DomainValidationCode.blankStableId),
lockedBlockId = _nullableLockedString(
lockedBlockId,
'lockedBlockId',
DomainValidationCode.blankStableId,
),
name = _nullableLockedString(
name,
'name',
DomainValidationCode.blankName,
) {
_validateOverrideShape(
type: type,
lockedBlockId: this.lockedBlockId,
name: this.name,
startTime: startTime,
endTime: endTime,
);
}
/// Removes a recurring occurrence for one date. /// Removes a recurring occurrence for one date.
factory LockedBlockOverride.remove({ factory LockedBlockOverride.remove({
@ -715,3 +768,123 @@ bool _sameDate(DateTime first, DateTime second) {
first.month == second.month && first.month == second.month &&
first.day == second.day; first.day == second.day;
} }
String _requiredLockedString(
String value,
String name,
DomainValidationCode code,
) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw DomainValidationException(
code: code,
invalidValue: value,
name: name,
message: '$name cannot be blank.',
);
}
return trimmed;
}
String? _nullableLockedString(
String? value,
String name,
DomainValidationCode code,
) {
if (value == null) {
return null;
}
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw DomainValidationException(
code: code,
invalidValue: value,
name: name,
message: '$name cannot be blank.',
);
}
return trimmed;
}
bool _clockTimeIsBefore(ClockTime start, ClockTime end) {
if (start.hour != end.hour) {
return start.hour < end.hour;
}
return start.minute < end.minute;
}
void _validateOverrideShape({
required LockedBlockOverrideType type,
required String? lockedBlockId,
required String? name,
required ClockTime? startTime,
required ClockTime? endTime,
}) {
switch (type) {
case LockedBlockOverrideType.remove:
if (lockedBlockId == null ||
name != null ||
startTime != null ||
endTime != null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: type,
name: 'LockedBlockOverride.remove',
message: 'Remove overrides only reference a locked block and date.',
);
}
case LockedBlockOverrideType.replace:
if (lockedBlockId == null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: type,
name: 'lockedBlockId',
message: 'Replace overrides must reference a locked block.',
);
}
_validateOverrideInterval(startTime: startTime, endTime: endTime);
case LockedBlockOverrideType.add:
if (lockedBlockId != null || name == null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: type,
name: 'LockedBlockOverride.add',
message:
'Add overrides need a name and cannot reference a base block.',
);
}
if (startTime == null || endTime == null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: type,
name: 'startTime/endTime',
message: 'Add overrides need both start and end time.',
);
}
_validateOverrideInterval(startTime: startTime, endTime: endTime);
}
}
void _validateOverrideInterval({
required ClockTime? startTime,
required ClockTime? endTime,
}) {
if ((startTime == null) != (endTime == null)) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: {'startTime': startTime, 'endTime': endTime},
name: 'startTime/endTime',
message: 'Override start and end time must both be present or absent.',
);
}
if (startTime != null &&
endTime != null &&
!_clockTimeIsBefore(startTime, endTime)) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: {'startTime': startTime, 'endTime': endTime},
name: 'startTime/endTime',
message: 'Override end time must be after start time.',
);
}
}

View file

@ -13,6 +13,45 @@
import 'task_statistics.dart'; import 'task_statistics.dart';
/// Stable validation failure categories for domain model boundaries.
///
/// Callers should branch on these codes instead of parsing exception text. The
/// explanatory messages may change, but these enum values are part of the V1
/// backend contract.
enum DomainValidationCode {
blankStableId,
blankTitle,
blankProjectId,
blankName,
blankColorKey,
blankParentTaskId,
selfParent,
nonPositiveDuration,
partialScheduledInterval,
invalidScheduledInterval,
partialActualInterval,
invalidActualInterval,
invalidTimeInterval,
invalidSchedulingWindow,
invalidClockTime,
emptyRecurrence,
invalidLockedBlock,
invalidLockedOverride,
}
/// Typed validation error thrown by domain model constructors.
class DomainValidationException extends ArgumentError {
DomainValidationException({
required this.code,
required Object? invalidValue,
required String name,
required String message,
}) : super.value(invalidValue, name, message);
/// Stable category for application and persistence layers.
final DomainValidationCode code;
}
/// Scheduling behavior category. /// Scheduling behavior category.
/// ///
/// This enum is one of the central concepts in the planner. The type answers /// This enum is one of the central concepts in the planner. The type answers
@ -199,10 +238,10 @@ enum BacklogTag {
/// This is still a starter V1 model, so behavior changes should be explicit and /// This is still a starter V1 model, so behavior changes should be explicit and
/// backed by tests as the product rules settle. /// backed by tests as the product rules settle.
class Task { class Task {
const Task({ Task({
required this.id, required String id,
required this.title, required String title,
required this.projectId, required String projectId,
required this.type, required this.type,
required this.status, required this.status,
required this.createdAt, required this.createdAt,
@ -210,13 +249,71 @@ class Task {
this.priority, this.priority,
this.reward = RewardLevel.notSet, this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet, this.difficulty = DifficultyLevel.notSet,
this.durationMinutes, int? durationMinutes,
this.scheduledStart, this.scheduledStart,
this.scheduledEnd, this.scheduledEnd,
this.parentTaskId, this.actualStart,
this.backlogTags = const <BacklogTag>{}, this.actualEnd,
String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
this.reminderOverride,
this.stats = const TaskStatistics(), this.stats = const TaskStatistics(),
}); }) : id = _requiredTrimmed(
id,
'id',
DomainValidationCode.blankStableId,
'Stable id is required.',
),
title = _requiredTrimmed(
title,
'title',
DomainValidationCode.blankTitle,
'Title is required.',
),
projectId = _requiredTrimmed(
projectId,
'projectId',
DomainValidationCode.blankProjectId,
'Project id is required.',
),
durationMinutes = durationMinutes,
parentTaskId = _nullableTrimmed(
parentTaskId,
'parentTaskId',
DomainValidationCode.blankParentTaskId,
'Parent task id cannot be blank.',
),
backlogTags = Set<BacklogTag>.unmodifiable(backlogTags) {
_validatePositiveDuration(durationMinutes, 'durationMinutes');
_validateOptionalInterval(
start: scheduledStart,
end: scheduledEnd,
partialCode: DomainValidationCode.partialScheduledInterval,
invalidCode: DomainValidationCode.invalidScheduledInterval,
name: 'scheduledStart/scheduledEnd',
partialMessage:
'Scheduled start and scheduled end must both be present or absent.',
invalidMessage: 'Scheduled end must be after scheduled start.',
);
_validateOptionalInterval(
start: actualStart,
end: actualEnd,
partialCode: DomainValidationCode.partialActualInterval,
invalidCode: DomainValidationCode.invalidActualInterval,
name: 'actualStart/actualEnd',
partialMessage:
'Actual start and actual end must both be present or absent.',
invalidMessage: 'Actual end must be after actual start.',
);
if (this.parentTaskId == this.id) {
throw DomainValidationException(
code: DomainValidationCode.selfParent,
invalidValue: parentTaskId,
name: 'parentTaskId',
message: 'A task cannot be its own parent.',
);
}
}
/// Create a minimal captured task without requiring planning details. /// Create a minimal captured task without requiring planning details.
/// ///
@ -240,10 +337,6 @@ class Task {
Set<BacklogTag> backlogTags = const <BacklogTag>{}, Set<BacklogTag> backlogTags = const <BacklogTag>{},
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
if (title.trim().isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
return Task( return Task(
id: id, id: id,
title: title.trim(), title: title.trim(),
@ -294,12 +387,21 @@ class Task {
/// Exclusive scheduled end time. Null means the task is not currently placed. /// Exclusive scheduled end time. Null means the task is not currently placed.
final DateTime? scheduledEnd; final DateTime? scheduledEnd;
/// Actual work start time, when known after the fact.
final DateTime? actualStart;
/// Actual work end time, when known after the fact.
final DateTime? actualEnd;
/// Parent task id when this task is a child/subtask. Null means top-level task. /// Parent task id when this task is a child/subtask. Null means top-level task.
final String? parentTaskId; final String? parentTaskId;
/// Backlog-specific flags, such as wishlist/someday behavior. /// Backlog-specific flags, such as wishlist/someday behavior.
final Set<BacklogTag> backlogTags; final Set<BacklogTag> backlogTags;
/// Optional task-level reminder override.
final ReminderProfile? reminderOverride;
/// Creation timestamp used for age/staleness sorting. /// Creation timestamp used for age/staleness sorting.
final DateTime createdAt; final DateTime createdAt;
@ -334,6 +436,9 @@ class Task {
/// Without it, passing null would be ambiguous: it could mean "do not change" /// Without it, passing null would be ambiguous: it could mean "do not change"
/// or "clear this value." When [clearSchedule] is true, both schedule fields /// or "clear this value." When [clearSchedule] is true, both schedule fields
/// are removed even if [scheduledStart] or [scheduledEnd] are omitted. /// are removed even if [scheduledStart] or [scheduledEnd] are omitted.
///
/// The other `clear*` flags provide the same explicit semantics for nullable
/// fields that the product can remove.
Task copyWith({ Task copyWith({
String? id, String? id,
String? title, String? title,
@ -346,12 +451,20 @@ class Task {
int? durationMinutes, int? durationMinutes,
DateTime? scheduledStart, DateTime? scheduledStart,
DateTime? scheduledEnd, DateTime? scheduledEnd,
DateTime? actualStart,
DateTime? actualEnd,
String? parentTaskId, String? parentTaskId,
Set<BacklogTag>? backlogTags, Set<BacklogTag>? backlogTags,
ReminderProfile? reminderOverride,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
TaskStatistics? stats, TaskStatistics? stats,
bool clearPriority = false,
bool clearDuration = false,
bool clearSchedule = false, bool clearSchedule = false,
bool clearActualInterval = false,
bool clearParentTask = false,
bool clearReminderOverride = false,
}) { }) {
return Task( return Task(
id: id ?? this.id, id: id ?? this.id,
@ -359,15 +472,23 @@ class Task {
projectId: projectId ?? this.projectId, projectId: projectId ?? this.projectId,
type: type ?? this.type, type: type ?? this.type,
status: status ?? this.status, status: status ?? this.status,
priority: priority ?? this.priority, priority: clearPriority ? null : (priority ?? this.priority),
reward: reward ?? this.reward, reward: reward ?? this.reward,
difficulty: difficulty ?? this.difficulty, difficulty: difficulty ?? this.difficulty,
durationMinutes: durationMinutes ?? this.durationMinutes, durationMinutes:
clearDuration ? null : (durationMinutes ?? this.durationMinutes),
scheduledStart: scheduledStart:
clearSchedule ? null : (scheduledStart ?? this.scheduledStart), clearSchedule ? null : (scheduledStart ?? this.scheduledStart),
scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd), scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd),
parentTaskId: parentTaskId ?? this.parentTaskId, actualStart:
clearActualInterval ? null : (actualStart ?? this.actualStart),
actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd),
parentTaskId:
clearParentTask ? null : (parentTaskId ?? this.parentTaskId),
backlogTags: backlogTags ?? this.backlogTags, backlogTags: backlogTags ?? this.backlogTags,
reminderOverride: clearReminderOverride
? null
: (reminderOverride ?? this.reminderOverride),
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
stats: stats ?? this.stats, stats: stats ?? this.stats,
@ -385,16 +506,39 @@ class Task {
/// project defaults separate prevents every scheduling function from needing to /// project defaults separate prevents every scheduling function from needing to
/// know project configuration details. /// know project configuration details.
class ProjectProfile { class ProjectProfile {
const ProjectProfile({ ProjectProfile({
required this.id, required String id,
required this.name, required String name,
required this.colorKey, required String colorKey,
this.defaultPriority = PriorityLevel.medium, this.defaultPriority = PriorityLevel.medium,
this.defaultReward = RewardLevel.notSet, this.defaultReward = RewardLevel.notSet,
this.defaultDifficulty = DifficultyLevel.notSet, this.defaultDifficulty = DifficultyLevel.notSet,
this.defaultReminderProfile = ReminderProfile.gentle, this.defaultReminderProfile = ReminderProfile.gentle,
this.defaultDurationMinutes, int? defaultDurationMinutes,
}); }) : id = _requiredTrimmed(
id,
'id',
DomainValidationCode.blankStableId,
'Stable id is required.',
),
name = _requiredTrimmed(
name,
'name',
DomainValidationCode.blankName,
'Project name is required.',
),
colorKey = _requiredTrimmed(
colorKey,
'colorKey',
DomainValidationCode.blankColorKey,
'Project color key is required.',
),
defaultDurationMinutes = defaultDurationMinutes {
_validatePositiveDuration(
defaultDurationMinutes,
'defaultDurationMinutes',
);
}
/// Stable project id stored on tasks. /// Stable project id stored on tasks.
final String id; final String id;
@ -437,18 +581,16 @@ class ProjectProfile {
int? durationMinutes, int? durationMinutes,
DateTime? scheduledStart, DateTime? scheduledStart,
DateTime? scheduledEnd, DateTime? scheduledEnd,
DateTime? actualStart,
DateTime? actualEnd,
String? parentTaskId, String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{}, Set<BacklogTag> backlogTags = const <BacklogTag>{},
ReminderProfile? reminderOverride,
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
return Task( return Task(
id: id, id: id,
title: trimmedTitle, title: title,
projectId: this.id, projectId: this.id,
type: type, type: type,
status: status, status: status,
@ -458,8 +600,11 @@ class ProjectProfile {
durationMinutes: durationMinutes ?? defaultDurationMinutes, durationMinutes: durationMinutes ?? defaultDurationMinutes,
scheduledStart: scheduledStart, scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd, scheduledEnd: scheduledEnd,
actualStart: actualStart,
actualEnd: actualEnd,
parentTaskId: parentTaskId, parentTaskId: parentTaskId,
backlogTags: backlogTags, backlogTags: backlogTags,
reminderOverride: reminderOverride,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt ?? createdAt, updatedAt: updatedAt ?? createdAt,
); );
@ -475,6 +620,7 @@ class ProjectProfile {
DifficultyLevel? defaultDifficulty, DifficultyLevel? defaultDifficulty,
ReminderProfile? defaultReminderProfile, ReminderProfile? defaultReminderProfile,
int? defaultDurationMinutes, int? defaultDurationMinutes,
bool clearDefaultDuration = false,
}) { }) {
return ProjectProfile( return ProjectProfile(
id: id ?? this.id, id: id ?? this.id,
@ -485,8 +631,9 @@ class ProjectProfile {
defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty, defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty,
defaultReminderProfile: defaultReminderProfile:
defaultReminderProfile ?? this.defaultReminderProfile, defaultReminderProfile ?? this.defaultReminderProfile,
defaultDurationMinutes: defaultDurationMinutes: clearDefaultDuration
defaultDurationMinutes ?? this.defaultDurationMinutes, ? null
: (defaultDurationMinutes ?? this.defaultDurationMinutes),
); );
} }
} }
@ -498,11 +645,20 @@ class ProjectProfile {
/// candidate placements. The interval convention is start-inclusive and /// candidate placements. The interval convention is start-inclusive and
/// end-exclusive, which avoids treating two back-to-back blocks as overlapping. /// end-exclusive, which avoids treating two back-to-back blocks as overlapping.
class TimeInterval { class TimeInterval {
const TimeInterval({ TimeInterval({
required this.start, required this.start,
required this.end, required this.end,
this.label, this.label,
}); }) {
if (!start.isBefore(end)) {
throw DomainValidationException(
code: DomainValidationCode.invalidTimeInterval,
invalidValue: {'start': start, 'end': end},
name: 'TimeInterval',
message: 'Interval end must be after interval start.',
);
}
}
/// Inclusive beginning of the interval. /// Inclusive beginning of the interval.
final DateTime start; final DateTime start;
@ -526,3 +682,83 @@ class TimeInterval {
return start.isBefore(other.end) && end.isAfter(other.start); return start.isBefore(other.end) && end.isAfter(other.start);
} }
} }
String _requiredTrimmed(
String value,
String name,
DomainValidationCode code,
String message,
) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw DomainValidationException(
code: code,
invalidValue: value,
name: name,
message: message,
);
}
return trimmed;
}
String? _nullableTrimmed(
String? value,
String name,
DomainValidationCode code,
String message,
) {
if (value == null) {
return null;
}
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw DomainValidationException(
code: code,
invalidValue: value,
name: name,
message: message,
);
}
return trimmed;
}
void _validatePositiveDuration(int? durationMinutes, String name) {
if (durationMinutes == null) {
return;
}
if (durationMinutes <= 0) {
throw DomainValidationException(
code: DomainValidationCode.nonPositiveDuration,
invalidValue: durationMinutes,
name: name,
message: 'Duration must be positive when present.',
);
}
}
void _validateOptionalInterval({
required DateTime? start,
required DateTime? end,
required DomainValidationCode partialCode,
required DomainValidationCode invalidCode,
required String name,
required String partialMessage,
required String invalidMessage,
}) {
if ((start == null) != (end == null)) {
throw DomainValidationException(
code: partialCode,
invalidValue: {'start': start, 'end': end},
name: name,
message: partialMessage,
);
}
if (start != null && end != null && !start.isBefore(end)) {
throw DomainValidationException(
code: invalidCode,
invalidValue: {'start': start, 'end': end},
name: name,
message: invalidMessage,
);
}
}

View file

@ -51,8 +51,11 @@ abstract final class TaskDocumentFields {
static const durationMinutes = 'durationMinutes'; static const durationMinutes = 'durationMinutes';
static const scheduledStart = 'scheduledStart'; static const scheduledStart = 'scheduledStart';
static const scheduledEnd = 'scheduledEnd'; static const scheduledEnd = 'scheduledEnd';
static const actualStart = 'actualStart';
static const actualEnd = 'actualEnd';
static const parentTaskId = 'parentTaskId'; static const parentTaskId = 'parentTaskId';
static const backlogTags = 'backlogTags'; static const backlogTags = 'backlogTags';
static const reminderOverride = 'reminderOverride';
static const createdAt = 'createdAt'; static const createdAt = 'createdAt';
static const updatedAt = 'updatedAt'; static const updatedAt = 'updatedAt';
static const stats = 'stats'; static const stats = 'stats';
@ -69,8 +72,11 @@ abstract final class TaskDocumentFields {
durationMinutes, durationMinutes,
scheduledStart, scheduledStart,
scheduledEnd, scheduledEnd,
actualStart,
actualEnd,
parentTaskId, parentTaskId,
backlogTags, backlogTags,
reminderOverride,
createdAt, createdAt,
updatedAt, updatedAt,
stats, stats,

View file

@ -31,7 +31,7 @@ enum QuickCaptureStatus {
/// possible, but the user can optionally provide enough detail to immediately /// possible, but the user can optionally provide enough detail to immediately
/// schedule it into the next open flexible slot. /// schedule it into the next open flexible slot.
class QuickCaptureRequest { class QuickCaptureRequest {
const QuickCaptureRequest({ QuickCaptureRequest({
required this.id, required this.id,
required this.title, required this.title,
required this.createdAt, required this.createdAt,
@ -42,8 +42,8 @@ class QuickCaptureRequest {
this.difficulty = DifficultyLevel.notSet, this.difficulty = DifficultyLevel.notSet,
this.type = TaskType.flexible, this.type = TaskType.flexible,
this.durationMinutes, this.durationMinutes,
this.backlogTags = const <BacklogTag>{}, Set<BacklogTag> backlogTags = const <BacklogTag>{},
}); }) : backlogTags = Set<BacklogTag>.unmodifiable(backlogTags);
/// Caller-generated id. Keeping id generation outside this service makes the /// Caller-generated id. Keeping id generation outside this service makes the
/// domain layer independent from persistence/database choices. /// domain layer independent from persistence/database choices.
@ -87,12 +87,12 @@ class QuickCaptureRequest {
/// was attempted, [schedulingResult] exposes the lower-level engine notices and /// was attempted, [schedulingResult] exposes the lower-level engine notices and
/// changes for debugging or timeline updates. /// changes for debugging or timeline updates.
class QuickCaptureResult { class QuickCaptureResult {
const QuickCaptureResult({ QuickCaptureResult({
required this.task, required this.task,
required this.status, required this.status,
this.schedulingResult, this.schedulingResult,
this.messages = const <String>[], List<String> messages = const <String>[],
}); }) : messages = List<String>.unmodifiable(messages);
/// Captured task, scheduled or unscheduled depending on [status]. /// Captured task, scheduled or unscheduled depending on [status].
final Task task; final Task task;
@ -136,7 +136,7 @@ class QuickCaptureService {
SchedulingInput? schedulingInput, SchedulingInput? schedulingInput,
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
final capturedTask = Task.quickCapture( final capturedTaskWithoutDuration = Task.quickCapture(
id: request.id, id: request.id,
title: request.title, title: request.title,
createdAt: request.createdAt, createdAt: request.createdAt,
@ -147,7 +147,19 @@ class QuickCaptureService {
difficulty: request.difficulty, difficulty: request.difficulty,
backlogTags: request.backlogTags, backlogTags: request.backlogTags,
updatedAt: updatedAt, updatedAt: updatedAt,
).copyWith(durationMinutes: request.durationMinutes); );
if (request.durationMinutes != null && request.durationMinutes! <= 0) {
return QuickCaptureResult(
task: capturedTaskWithoutDuration,
status: QuickCaptureStatus.validationError,
messages: const ['Duration must be positive when provided.'],
);
}
final capturedTask = capturedTaskWithoutDuration.copyWith(
durationMinutes: request.durationMinutes,
);
if (!request.addToNextAvailableSlot) { if (!request.addToNextAvailableSlot) {
return QuickCaptureResult( return QuickCaptureResult(

View file

@ -64,18 +64,24 @@ abstract interface class LockedBlockRepository {
/// Snapshot of one scheduling operation or persisted planning state. /// Snapshot of one scheduling operation or persisted planning state.
class SchedulingStateSnapshot { class SchedulingStateSnapshot {
const SchedulingStateSnapshot({ SchedulingStateSnapshot({
required this.id, required this.id,
required this.capturedAt, required this.capturedAt,
required this.window, required this.window,
required this.tasks, required List<Task> tasks,
this.lockedIntervals = const <TimeInterval>[], List<TimeInterval> lockedIntervals = const <TimeInterval>[],
this.requiredVisibleIntervals = const <TimeInterval>[], List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
this.notices = const <SchedulingNotice>[], List<SchedulingNotice> notices = const <SchedulingNotice>[],
this.changes = const <SchedulingChange>[], List<SchedulingChange> changes = const <SchedulingChange>[],
this.overlaps = const <SchedulingOverlap>[], List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
this.operationName, this.operationName,
}); }) : tasks = List<Task>.unmodifiable(tasks),
lockedIntervals = List<TimeInterval>.unmodifiable(lockedIntervals),
requiredVisibleIntervals =
List<TimeInterval>.unmodifiable(requiredVisibleIntervals),
notices = List<SchedulingNotice>.unmodifiable(notices),
changes = List<SchedulingChange>.unmodifiable(changes),
overlaps = List<SchedulingOverlap>.unmodifiable(overlaps);
/// Stable snapshot id. /// Stable snapshot id.
final String id; final String id;

View file

@ -43,10 +43,19 @@ enum SchedulingNoticeType {
/// flexible tasks can be placed. Anything outside this range is treated as out of /// flexible tasks can be placed. Anything outside this range is treated as out of
/// scope for the operation. /// scope for the operation.
class SchedulingWindow { class SchedulingWindow {
const SchedulingWindow({ SchedulingWindow({
required this.start, required this.start,
required this.end, required this.end,
}); }) {
if (!start.isBefore(end)) {
throw DomainValidationException(
code: DomainValidationCode.invalidSchedulingWindow,
invalidValue: {'start': start, 'end': end},
name: 'SchedulingWindow',
message: 'Scheduling window end must be after start.',
);
}
}
/// Inclusive beginning of the scheduling range. /// Inclusive beginning of the scheduling range.
final DateTime start; final DateTime start;
@ -79,12 +88,15 @@ class SchedulingWindow {
/// not know where the data came from: UI state, a database, tests, or generated /// not know where the data came from: UI state, a database, tests, or generated
/// examples can all build this same object. /// examples can all build this same object.
class SchedulingInput { class SchedulingInput {
const SchedulingInput({ SchedulingInput({
required this.tasks, required List<Task> tasks,
required this.window, required this.window,
this.lockedIntervals = const <TimeInterval>[], List<TimeInterval> lockedIntervals = const <TimeInterval>[],
this.requiredVisibleIntervals = const <TimeInterval>[], List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
}); }) : tasks = List<Task>.unmodifiable(tasks),
lockedIntervals = List<TimeInterval>.unmodifiable(lockedIntervals),
requiredVisibleIntervals =
List<TimeInterval>.unmodifiable(requiredVisibleIntervals);
/// All tasks available to this operation. The scheduler returns a replacement /// All tasks available to this operation. The scheduler returns a replacement
/// list rather than mutating this one. /// list rather than mutating this one.
@ -222,12 +234,15 @@ class SchedulingNotice {
/// This keeps the call pattern predictable: always inspect `tasks`, then surface /// This keeps the call pattern predictable: always inspect `tasks`, then surface
/// any `notices`, `changes`, or `overlaps` relevant to the UI. /// any `notices`, `changes`, or `overlaps` relevant to the UI.
class SchedulingResult { class SchedulingResult {
const SchedulingResult({ SchedulingResult({
required this.tasks, required List<Task> tasks,
this.notices = const <SchedulingNotice>[], List<SchedulingNotice> notices = const <SchedulingNotice>[],
this.changes = const <SchedulingChange>[], List<SchedulingChange> changes = const <SchedulingChange>[],
this.overlaps = const <SchedulingOverlap>[], List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
}); }) : tasks = List<Task>.unmodifiable(tasks),
notices = List<SchedulingNotice>.unmodifiable(notices),
changes = List<SchedulingChange>.unmodifiable(changes),
overlaps = List<SchedulingOverlap>.unmodifiable(overlaps);
/// Replacement task list after the operation. /// Replacement task list after the operation.
final List<Task> tasks; final List<Task> tasks;

View file

@ -59,7 +59,7 @@ enum TimelineQuickAction {
/// Display-ready timeline item derived from a domain [Task]. /// Display-ready timeline item derived from a domain [Task].
class TimelineItem { class TimelineItem {
const TimelineItem({ TimelineItem({
required this.id, required this.id,
required this.displayTitle, required this.displayTitle,
required this.taskType, required this.taskType,
@ -68,12 +68,12 @@ class TimelineItem {
required this.rewardIconToken, required this.rewardIconToken,
required this.difficultyIconToken, required this.difficultyIconToken,
required this.showsExplicitTime, required this.showsExplicitTime,
required this.quickActions, required List<TimelineQuickAction> quickActions,
required this.category, required this.category,
this.start, this.start,
this.end, this.end,
this.durationMinutes, this.durationMinutes,
}); }) : quickActions = List<TimelineQuickAction>.unmodifiable(quickActions);
/// Stable id of the source item. /// Stable id of the source item.
final String id; final String id;
@ -164,9 +164,10 @@ class CompactTimelineState {
/// Converts domain tasks into Today timeline items. /// Converts domain tasks into Today timeline items.
class TimelineItemMapper { class TimelineItemMapper {
const TimelineItemMapper({ TimelineItemMapper({
this.projectColorTokensById = const <String, String>{}, Map<String, String> projectColorTokensById = const <String, String>{},
}); }) : projectColorTokensById =
Map<String, String>.unmodifiable(projectColorTokensById);
/// Project id to border color token lookup. /// Project id to border color token lookup.
final Map<String, String> projectColorTokensById; final Map<String, String> projectColorTokensById;

View file

@ -36,8 +36,11 @@ void main() {
expect(restored.durationMinutes, task.durationMinutes); expect(restored.durationMinutes, task.durationMinutes);
expect(restored.scheduledStart, task.scheduledStart); expect(restored.scheduledStart, task.scheduledStart);
expect(restored.scheduledEnd, task.scheduledEnd); expect(restored.scheduledEnd, task.scheduledEnd);
expect(restored.actualStart, task.actualStart);
expect(restored.actualEnd, task.actualEnd);
expect(restored.parentTaskId, task.parentTaskId); expect(restored.parentTaskId, task.parentTaskId);
expect(restored.backlogTags, task.backlogTags); expect(restored.backlogTags, task.backlogTags);
expect(restored.reminderOverride, task.reminderOverride);
expect(restored.createdAt, task.createdAt); expect(restored.createdAt, task.createdAt);
expect(restored.updatedAt, task.updatedAt); expect(restored.updatedAt, task.updatedAt);
expect(restored.stats.manuallyPushedCount, 1); expect(restored.stats.manuallyPushedCount, 1);
@ -143,8 +146,11 @@ Task scheduledTask({
durationMinutes: 30, durationMinutes: 30,
scheduledStart: DateTime.utc(2026, 6, 23, 9), scheduledStart: DateTime.utc(2026, 6, 23, 9),
scheduledEnd: DateTime.utc(2026, 6, 23, 9, 30), scheduledEnd: DateTime.utc(2026, 6, 23, 9, 30),
actualStart: DateTime.utc(2026, 6, 23, 9, 5),
actualEnd: DateTime.utc(2026, 6, 23, 9, 25),
parentTaskId: 'parent-1', parentTaskId: 'parent-1',
backlogTags: const {BacklogTag.wishlist}, backlogTags: const {BacklogTag.wishlist},
reminderOverride: ReminderProfile.persistent,
createdAt: createdAt, createdAt: createdAt,
updatedAt: createdAt.add(const Duration(minutes: 5)), updatedAt: createdAt.add(const Duration(minutes: 5)),
); );

View file

@ -0,0 +1,357 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Domain invariants', () {
final now = DateTime(2026, 6, 24, 9);
test('task rejects blank identifiers titles and project ids with codes',
() {
expectValidationCode(
() => _task(now: now, id: ' '),
DomainValidationCode.blankStableId,
);
expectValidationCode(
() => _task(now: now, title: ' '),
DomainValidationCode.blankTitle,
);
expectValidationCode(
() => _task(now: now, projectId: ' '),
DomainValidationCode.blankProjectId,
);
});
test('task rejects invalid duration and schedule intervals', () {
expectValidationCode(
() => _task(now: now, durationMinutes: 0),
DomainValidationCode.nonPositiveDuration,
);
expectValidationCode(
() => _task(
now: now,
scheduledStart: DateTime(2026, 6, 24, 9),
),
DomainValidationCode.partialScheduledInterval,
);
expectValidationCode(
() => _task(
now: now,
scheduledStart: DateTime(2026, 6, 24, 10),
scheduledEnd: DateTime(2026, 6, 24, 9),
),
DomainValidationCode.invalidScheduledInterval,
);
});
test('task rejects invalid actual intervals and self parenting', () {
expectValidationCode(
() => _task(
now: now,
actualStart: DateTime(2026, 6, 24, 9),
),
DomainValidationCode.partialActualInterval,
);
expectValidationCode(
() => _task(
now: now,
actualStart: DateTime(2026, 6, 24, 10),
actualEnd: DateTime(2026, 6, 24, 9),
),
DomainValidationCode.invalidActualInterval,
);
expectValidationCode(
() => _task(now: now, id: 'task-1', parentTaskId: 'task-1'),
DomainValidationCode.selfParent,
);
});
test('copyWith has explicit clear paths for nullable task fields', () {
final task = _task(
now: now,
priority: PriorityLevel.high,
durationMinutes: 25,
scheduledStart: DateTime(2026, 6, 24, 9),
scheduledEnd: DateTime(2026, 6, 24, 9, 25),
actualStart: DateTime(2026, 6, 24, 9, 5),
actualEnd: DateTime(2026, 6, 24, 9, 20),
parentTaskId: 'parent-1',
reminderOverride: ReminderProfile.strict,
);
final preserved = task.copyWith(title: 'Renamed');
final cleared = task.copyWith(
clearPriority: true,
clearDuration: true,
clearSchedule: true,
clearActualInterval: true,
clearParentTask: true,
clearReminderOverride: true,
);
expect(preserved.priority, PriorityLevel.high);
expect(preserved.durationMinutes, 25);
expect(preserved.scheduledStart, DateTime(2026, 6, 24, 9));
expect(preserved.actualStart, DateTime(2026, 6, 24, 9, 5));
expect(preserved.parentTaskId, 'parent-1');
expect(preserved.reminderOverride, ReminderProfile.strict);
expect(cleared.priority, isNull);
expect(cleared.durationMinutes, isNull);
expect(cleared.scheduledStart, isNull);
expect(cleared.scheduledEnd, isNull);
expect(cleared.actualStart, isNull);
expect(cleared.actualEnd, isNull);
expect(cleared.parentTaskId, isNull);
expect(cleared.reminderOverride, isNull);
});
test('mutable task collections do not leak into domain objects', () {
final tags = {BacklogTag.wishlist};
final task = _task(now: now, backlogTags: tags);
tags.clear();
expect(task.backlogTags, {BacklogTag.wishlist});
expect(() => task.backlogTags.clear(), throwsUnsupportedError);
});
test('quick capture reports invalid durations without throwing', () {
const service = QuickCaptureService();
final result = service.capture(
QuickCaptureRequest(
id: 'capture-1',
title: 'Buy stamps',
createdAt: now,
durationMinutes: -5,
),
);
expect(result.status, QuickCaptureStatus.validationError);
expect(result.task.durationMinutes, isNull);
expect(result.messages.single, contains('positive'));
});
test('project profile validates identifiers and default duration', () {
expectValidationCode(
() => ProjectProfile(id: ' ', name: 'Home', colorKey: 'blue'),
DomainValidationCode.blankStableId,
);
expectValidationCode(
() => ProjectProfile(id: 'home', name: ' ', colorKey: 'blue'),
DomainValidationCode.blankName,
);
expectValidationCode(
() => ProjectProfile(id: 'home', name: 'Home', colorKey: ' '),
DomainValidationCode.blankColorKey,
);
expectValidationCode(
() => ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'blue',
defaultDurationMinutes: -1,
),
DomainValidationCode.nonPositiveDuration,
);
final project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'blue',
defaultDurationMinutes: 20,
);
expect(
project.copyWith(clearDefaultDuration: true).defaultDurationMinutes,
isNull);
});
test('time interval and scheduling window reject non-positive ranges', () {
expectValidationCode(
() => TimeInterval(
start: DateTime(2026, 6, 24, 9),
end: DateTime(2026, 6, 24, 9),
),
DomainValidationCode.invalidTimeInterval,
);
expectValidationCode(
() => SchedulingWindow(
start: DateTime(2026, 6, 24, 10),
end: DateTime(2026, 6, 24, 9),
),
DomainValidationCode.invalidSchedulingWindow,
);
});
});
group('Locked-time invariants', () {
final now = DateTime(2026, 6, 24, 9);
test('clock time and recurrence validate real values', () {
expectValidationCode(
() => ClockTime(hour: 24, minute: 0),
DomainValidationCode.invalidClockTime,
);
expectValidationCode(
() => LockedBlockRecurrence.weekly(weekdays: const {}),
DomainValidationCode.emptyRecurrence,
);
});
test('locked blocks require a source date or recurrence and positive time',
() {
expectValidationCode(
() => LockedBlock(
id: 'locked-1',
name: 'Work',
startTime: ClockTime(hour: 9, minute: 0),
endTime: ClockTime(hour: 10, minute: 0),
createdAt: now,
updatedAt: now,
),
DomainValidationCode.invalidLockedBlock,
);
expectValidationCode(
() => LockedBlock(
id: 'locked-1',
name: 'Work',
startTime: ClockTime(hour: 10, minute: 0),
endTime: ClockTime(hour: 9, minute: 0),
date: now,
createdAt: now,
updatedAt: now,
),
DomainValidationCode.invalidLockedBlock,
);
});
test('locked block overrides enforce valid shapes', () {
expectValidationCode(
() => LockedBlockOverride.add(
id: 'override-1',
date: now,
name: 'Extra work',
startTime: ClockTime(hour: 12, minute: 0),
endTime: ClockTime(hour: 11, minute: 0),
createdAt: now,
),
DomainValidationCode.invalidLockedOverride,
);
expectValidationCode(
() => LockedBlockOverride(
id: 'override-2',
date: now,
type: LockedBlockOverrideType.remove,
name: 'Invalid remove',
createdAt: now,
updatedAt: now,
),
DomainValidationCode.invalidLockedOverride,
);
});
});
group('Collection immutability', () {
final now = DateTime(2026, 6, 24, 9);
test('scheduling input and result copy mutable lists', () {
final task = _task(now: now);
final sourceTasks = [task];
final input = SchedulingInput(
tasks: sourceTasks,
window: SchedulingWindow(
start: DateTime(2026, 6, 24, 9),
end: DateTime(2026, 6, 24, 10),
),
);
final result = SchedulingResult(tasks: sourceTasks);
sourceTasks.clear();
expect(input.tasks, [task]);
expect(result.tasks, [task]);
expect(() => input.tasks.add(task), throwsUnsupportedError);
expect(() => result.tasks.add(task), throwsUnsupportedError);
});
test('view and action result collections are immutable snapshots', () {
final task = _task(now: now);
final tasks = [task];
final backlogView = BacklogView(tasks: tasks, now: now);
final childView = ChildTaskView(tasks: tasks);
final quickResult = QuickCaptureResult(
task: task,
status: QuickCaptureStatus.validationError,
messages: ['Need duration'],
);
final completionResult = ChildTaskCompletionResult(
tasks: tasks,
changedTaskIds: ['task-1'],
);
tasks.clear();
expect(backlogView.tasks, [task]);
expect(childView.tasks, [task]);
expect(quickResult.messages, ['Need duration']);
expect(completionResult.tasks, [task]);
expect(() => backlogView.tasks.add(task), throwsUnsupportedError);
expect(() => childView.tasks.add(task), throwsUnsupportedError);
expect(() => quickResult.messages.add('Other'), throwsUnsupportedError);
expect(() => completionResult.changedTaskIds.add('task-2'),
throwsUnsupportedError);
});
});
}
void expectValidationCode(
Object? Function() callback,
DomainValidationCode code,
) {
expect(
callback,
throwsA(
isA<DomainValidationException>().having(
(error) => error.code,
'code',
code,
),
),
);
}
Task _task({
required DateTime now,
String id = 'task-1',
String title = 'Task',
String projectId = 'project-1',
TaskType type = TaskType.flexible,
TaskStatus status = TaskStatus.planned,
PriorityLevel? priority,
int? durationMinutes,
DateTime? scheduledStart,
DateTime? scheduledEnd,
DateTime? actualStart,
DateTime? actualEnd,
String? parentTaskId,
Set<BacklogTag> backlogTags = const {},
ReminderProfile? reminderOverride,
}) {
return Task(
id: id,
title: title,
projectId: projectId,
type: type,
status: status,
priority: priority,
durationMinutes: durationMinutes,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
actualStart: actualStart,
actualEnd: actualEnd,
parentTaskId: parentTaskId,
backlogTags: backlogTags,
reminderOverride: reminderOverride,
createdAt: now,
updatedAt: now,
);
}

View file

@ -44,7 +44,7 @@ void main() {
test('project profile trims and validates task titles like quick capture', test('project profile trims and validates task titles like quick capture',
() { () {
const project = ProjectProfile( final project = ProjectProfile(
id: 'health', id: 'health',
name: 'Health', name: 'Health',
colorKey: 'green', colorKey: 'green',
@ -562,9 +562,9 @@ void main() {
final block = LockedBlock( final block = LockedBlock(
id: 'work', id: 'work',
name: 'Work', name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0), endTime: ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly( recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.friday}, weekdays: {LockedWeekday.friday},
), ),
createdAt: now, createdAt: now,

View file

@ -23,7 +23,7 @@ void main() {
}); });
test('project ids remain stable across copy and update operations', () { test('project ids remain stable across copy and update operations', () {
const original = ProjectProfile( final original = ProjectProfile(
id: 'project-1', id: 'project-1',
name: 'Project', name: 'Project',
colorKey: 'project-blue', colorKey: 'project-blue',
@ -44,9 +44,9 @@ void main() {
final original = LockedBlock( final original = LockedBlock(
id: 'locked-1', id: 'locked-1',
name: 'Work', name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0), endTime: ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly( recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.monday}, weekdays: {LockedWeekday.monday},
), ),
createdAt: createdAt, createdAt: createdAt,
@ -192,8 +192,11 @@ void main() {
'durationMinutes', 'durationMinutes',
'scheduledStart', 'scheduledStart',
'scheduledEnd', 'scheduledEnd',
'actualStart',
'actualEnd',
'parentTaskId', 'parentTaskId',
'backlogTags', 'backlogTags',
'reminderOverride',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
'stats', 'stats',

View file

@ -38,7 +38,7 @@ void main() {
}); });
test('project repository stores project profiles by stable id', () async { test('project repository stores project profiles by stable id', () async {
const project = ProjectProfile( final project = ProjectProfile(
id: 'home', id: 'home',
name: 'Home', name: 'Home',
colorKey: 'project-home', colorKey: 'project-home',
@ -55,9 +55,9 @@ void main() {
final block = LockedBlock( final block = LockedBlock(
id: 'work', id: 'work',
name: 'Work', name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0), endTime: ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly( recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.monday}, weekdays: {LockedWeekday.monday},
), ),
createdAt: createdAt, createdAt: createdAt,

View file

@ -356,7 +356,7 @@ void main() {
}); });
test('project defaults apply while task overrides remain possible', () { test('project defaults apply while task overrides remain possible', () {
const project = ProjectProfile( final project = ProjectProfile(
id: 'home', id: 'home',
name: 'Home', name: 'Home',
colorKey: 'green', colorKey: 'green',
@ -409,8 +409,8 @@ void main() {
final oneOff = LockedBlock( final oneOff = LockedBlock(
id: 'appointment', id: 'appointment',
name: 'Appointment', name: 'Appointment',
startTime: const ClockTime(hour: 13, minute: 0), startTime: ClockTime(hour: 13, minute: 0),
endTime: const ClockTime(hour: 14, minute: 0), endTime: ClockTime(hour: 14, minute: 0),
date: DateTime(2026, 6, 19), date: DateTime(2026, 6, 19),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -419,9 +419,9 @@ void main() {
final workHours = LockedBlock( final workHours = LockedBlock(
id: 'work-hours', id: 'work-hours',
name: 'Work', name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0), endTime: ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly( recurrence: LockedBlockRecurrence.weekly(
weekdays: { weekdays: {
LockedWeekday.monday, LockedWeekday.monday,
LockedWeekday.tuesday, LockedWeekday.tuesday,
@ -470,7 +470,7 @@ void main() {
test('locked block override modifies one date without recurrence changes', test('locked block override modifies one date without recurrence changes',
() { () {
final recurrence = const LockedBlockRecurrence.weekly( final recurrence = LockedBlockRecurrence.weekly(
weekdays: { weekdays: {
LockedWeekday.monday, LockedWeekday.monday,
LockedWeekday.tuesday, LockedWeekday.tuesday,
@ -482,8 +482,8 @@ void main() {
final workHours = LockedBlock( final workHours = LockedBlock(
id: 'work-hours', id: 'work-hours',
name: 'Work', name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0), endTime: ClockTime(hour: 17, minute: 0),
recurrence: recurrence, recurrence: recurrence,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -492,8 +492,8 @@ void main() {
id: 'half-day', id: 'half-day',
lockedBlockId: 'work-hours', lockedBlockId: 'work-hours',
date: DateTime(2026, 6, 19), date: DateTime(2026, 6, 19),
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 12, minute: 0), endTime: ClockTime(hour: 12, minute: 0),
createdAt: now, createdAt: now,
); );
final interval = halfDay.intervalForDate(DateTime(2026, 6, 19)); final interval = halfDay.intervalForDate(DateTime(2026, 6, 19));
@ -510,8 +510,8 @@ void main() {
id: 'surprise-lock', id: 'surprise-lock',
date: DateTime(2026, 6, 19), date: DateTime(2026, 6, 19),
name: 'Urgent errand', name: 'Urgent errand',
startTime: const ClockTime(hour: 15, minute: 0), startTime: ClockTime(hour: 15, minute: 0),
endTime: const ClockTime(hour: 16, minute: 0), endTime: ClockTime(hour: 16, minute: 0),
createdAt: now, createdAt: now,
projectId: 'home', projectId: 'home',
); );
@ -529,9 +529,9 @@ void main() {
final workHours = LockedBlock( final workHours = LockedBlock(
id: 'work-hours', id: 'work-hours',
name: 'Work', name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0), endTime: ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly( recurrence: LockedBlockRecurrence.weekly(
weekdays: { weekdays: {
LockedWeekday.monday, LockedWeekday.monday,
LockedWeekday.tuesday, LockedWeekday.tuesday,
@ -571,9 +571,9 @@ void main() {
final workHours = LockedBlock( final workHours = LockedBlock(
id: 'work-hours', id: 'work-hours',
name: 'Work', name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0), endTime: ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly( recurrence: LockedBlockRecurrence.weekly(
weekdays: { weekdays: {
LockedWeekday.monday, LockedWeekday.monday,
LockedWeekday.tuesday, LockedWeekday.tuesday,
@ -597,8 +597,8 @@ void main() {
lockedBlockId: 'work-hours', lockedBlockId: 'work-hours',
date: DateTime(2026, 6, 22), date: DateTime(2026, 6, 22),
name: 'Half-day work', name: 'Half-day work',
startTime: const ClockTime(hour: 9, minute: 0), startTime: ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 12, minute: 0), endTime: ClockTime(hour: 12, minute: 0),
hiddenByDefault: false, hiddenByDefault: false,
createdAt: now.add(const Duration(minutes: 1)), createdAt: now.add(const Duration(minutes: 1)),
); );
@ -636,8 +636,8 @@ void main() {
id: 'surprise-lock', id: 'surprise-lock',
date: DateTime(2026, 6, 19), date: DateTime(2026, 6, 19),
name: 'Urgent errand', name: 'Urgent errand',
startTime: const ClockTime(hour: 15, minute: 0), startTime: ClockTime(hour: 15, minute: 0),
endTime: const ClockTime(hour: 16, minute: 0), endTime: ClockTime(hour: 16, minute: 0),
createdAt: now, createdAt: now,
projectId: 'home', projectId: 'home',
); );
@ -1098,7 +1098,8 @@ void main() {
), ),
); );
expect(result.tasks, same(tasks)); expect(result.tasks.single, same(flexible));
expect(() => result.tasks.add(flexible), throwsUnsupportedError);
expect(result.changes, isEmpty); expect(result.changes, isEmpty);
expect(result.overlaps.single.taskId, 'task-1'); expect(result.overlaps.single.taskId, 'task-1');
expect(result.overlaps.single.blockedInterval.label, 'locked-block'); expect(result.overlaps.single.blockedInterval.label, 'locked-block');
@ -1240,9 +1241,9 @@ void main() {
final workHours = LockedBlock( final workHours = LockedBlock(
id: 'work-hours', id: 'work-hours',
name: 'Work', name: 'Work',
startTime: const ClockTime(hour: 13, minute: 0), startTime: ClockTime(hour: 13, minute: 0),
endTime: const ClockTime(hour: 13, minute: 20), endTime: ClockTime(hour: 13, minute: 20),
recurrence: const LockedBlockRecurrence.weekly( recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.friday}, weekdays: {LockedWeekday.friday},
), ),
hiddenByDefault: true, hiddenByDefault: true,
@ -1253,8 +1254,8 @@ void main() {
id: 'longer-work', id: 'longer-work',
lockedBlockId: 'work-hours', lockedBlockId: 'work-hours',
date: DateTime(2026, 6, 19), date: DateTime(2026, 6, 19),
startTime: const ClockTime(hour: 13, minute: 0), startTime: ClockTime(hour: 13, minute: 0),
endTime: const ClockTime(hour: 13, minute: 30), endTime: ClockTime(hour: 13, minute: 30),
createdAt: now, createdAt: now,
); );
final lockedIntervals = lockedSchedulingIntervalsForDay( final lockedIntervals = lockedSchedulingIntervalsForDay(
@ -1351,7 +1352,8 @@ void main() {
updatedAt: now, updatedAt: now,
); );
expect(result.tasks, same(tasks)); expect(result.tasks.single, same(backlogTask));
expect(() => result.tasks.add(backlogTask), throwsUnsupportedError);
expect(result.changes, isEmpty); expect(result.changes, isEmpty);
expect(result.notices.single.type, SchedulingNoticeType.overflow); expect(result.notices.single.type, SchedulingNoticeType.overflow);
expect(result.notices.single.taskId, 'backlog-1'); expect(result.notices.single.taskId, 'backlog-1');

View file

@ -6,12 +6,12 @@ void main() {
final createdAt = DateTime(2026, 6, 20, 8); final createdAt = DateTime(2026, 6, 20, 8);
final start = DateTime(2026, 6, 20, 9); final start = DateTime(2026, 6, 20, 9);
final end = DateTime(2026, 6, 20, 9, 30); final end = DateTime(2026, 6, 20, 9, 30);
const project = ProjectProfile( final project = ProjectProfile(
id: 'home', id: 'home',
name: 'Home', name: 'Home',
colorKey: 'project-home', colorKey: 'project-home',
); );
final mapper = TimelineItemMapper.fromProjects(const [project]); final mapper = TimelineItemMapper.fromProjects([project]);
test('maps task fields to display tokens', () { test('maps task fields to display tokens', () {
final task = scheduledTask( final task = scheduledTask(