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
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:
- 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 |
|---|---|
| `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/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
| `src/quick_capture.dart` | `QuickCaptureStatus` |
@ -45,7 +45,7 @@ changes from accidental API drift.
| File | Public API |
|---|---|
| `src/models.dart` | `Task`, `ProjectProfile`, `TimeInterval` |
| `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` |
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` |
@ -83,7 +83,8 @@ Current notable model fields:
- `Task`: `id`, `title`, `projectId`, `type`, `status`, `priority`,
`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/
difficulty/reminder profile/duration fields
- `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
status/event distinction.
- `Task` does not yet have explicit completion timestamp, actual interval,
backlog-entered timestamp, or task-level reminder override fields.
- Nullable clearing is limited; broad explicit patch semantics are Chunk 11.3.
- `Task` does not yet have explicit completion timestamp or backlog-entered
timestamp fields.
- Time is currently `DateTime`-based; civil-date, wall-time, clock, ID, and
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
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-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-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-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. |

View file

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

View file

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

View file

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

View file

@ -33,11 +33,19 @@ enum LockedWeekday {
/// than as one specific timestamp. [ClockTime] stores just the hour/minute part
/// and can later be projected onto a concrete date with [onDate].
class ClockTime {
const ClockTime({
ClockTime({
required this.hour,
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.
final int hour;
@ -62,9 +70,18 @@ class ClockTime {
/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence
/// rules can be added later without changing the rest of the locked-time model.
class LockedBlockRecurrence {
const LockedBlockRecurrence.weekly({
required this.weekdays,
});
LockedBlockRecurrence.weekly({
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.
final Set<LockedWeekday> weekdays;
@ -87,9 +104,9 @@ class LockedBlockRecurrence {
/// - one-off, with [date] set and [recurrence] null; or
/// - recurring, with [recurrence] set and [date] usually null.
class LockedBlock {
const LockedBlock({
required this.id,
required this.name,
LockedBlock({
required String id,
required String name,
required this.startTime,
required this.endTime,
required this.createdAt,
@ -98,10 +115,27 @@ class LockedBlock {
this.recurrence,
this.hiddenByDefault = true,
this.projectId,
}) : assert(
recurrence != null || date != null,
'date is required for one-off locked blocks',
);
}) : id =
_requiredLockedString(id, 'id', DomainValidationCode.blankStableId),
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.
final String id;
@ -238,19 +272,38 @@ enum LockedBlockOverrideType {
/// - [replace] references a base block and swaps its details for one day.
/// - [add] creates an extra locked occurrence that has no base block.
class LockedBlockOverride {
const LockedBlockOverride({
required this.id,
LockedBlockOverride({
required String id,
required this.date,
required this.type,
required this.createdAt,
required this.updatedAt,
this.lockedBlockId,
this.name,
String? lockedBlockId,
String? name,
this.startTime,
this.endTime,
this.hiddenByDefault = true,
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.
factory LockedBlockOverride.remove({
@ -715,3 +768,123 @@ bool _sameDate(DateTime first, DateTime second) {
first.month == second.month &&
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';
/// 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.
///
/// 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
/// backed by tests as the product rules settle.
class Task {
const Task({
required this.id,
required this.title,
required this.projectId,
Task({
required String id,
required String title,
required String projectId,
required this.type,
required this.status,
required this.createdAt,
@ -210,13 +249,71 @@ class Task {
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
this.durationMinutes,
int? durationMinutes,
this.scheduledStart,
this.scheduledEnd,
this.parentTaskId,
this.backlogTags = const <BacklogTag>{},
this.actualStart,
this.actualEnd,
String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
this.reminderOverride,
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.
///
@ -240,10 +337,6 @@ class Task {
Set<BacklogTag> backlogTags = const <BacklogTag>{},
DateTime? updatedAt,
}) {
if (title.trim().isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
return Task(
id: id,
title: title.trim(),
@ -294,12 +387,21 @@ class Task {
/// Exclusive scheduled end time. Null means the task is not currently placed.
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.
final String? parentTaskId;
/// Backlog-specific flags, such as wishlist/someday behavior.
final Set<BacklogTag> backlogTags;
/// Optional task-level reminder override.
final ReminderProfile? reminderOverride;
/// Creation timestamp used for age/staleness sorting.
final DateTime createdAt;
@ -334,6 +436,9 @@ class Task {
/// Without it, passing null would be ambiguous: it could mean "do not change"
/// or "clear this value." When [clearSchedule] is true, both schedule fields
/// 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({
String? id,
String? title,
@ -346,12 +451,20 @@ class Task {
int? durationMinutes,
DateTime? scheduledStart,
DateTime? scheduledEnd,
DateTime? actualStart,
DateTime? actualEnd,
String? parentTaskId,
Set<BacklogTag>? backlogTags,
ReminderProfile? reminderOverride,
DateTime? createdAt,
DateTime? updatedAt,
TaskStatistics? stats,
bool clearPriority = false,
bool clearDuration = false,
bool clearSchedule = false,
bool clearActualInterval = false,
bool clearParentTask = false,
bool clearReminderOverride = false,
}) {
return Task(
id: id ?? this.id,
@ -359,15 +472,23 @@ class Task {
projectId: projectId ?? this.projectId,
type: type ?? this.type,
status: status ?? this.status,
priority: priority ?? this.priority,
priority: clearPriority ? null : (priority ?? this.priority),
reward: reward ?? this.reward,
difficulty: difficulty ?? this.difficulty,
durationMinutes: durationMinutes ?? this.durationMinutes,
durationMinutes:
clearDuration ? null : (durationMinutes ?? this.durationMinutes),
scheduledStart:
clearSchedule ? null : (scheduledStart ?? this.scheduledStart),
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,
reminderOverride: clearReminderOverride
? null
: (reminderOverride ?? this.reminderOverride),
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
stats: stats ?? this.stats,
@ -385,16 +506,39 @@ class Task {
/// project defaults separate prevents every scheduling function from needing to
/// know project configuration details.
class ProjectProfile {
const ProjectProfile({
required this.id,
required this.name,
required this.colorKey,
ProjectProfile({
required String id,
required String name,
required String colorKey,
this.defaultPriority = PriorityLevel.medium,
this.defaultReward = RewardLevel.notSet,
this.defaultDifficulty = DifficultyLevel.notSet,
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.
final String id;
@ -437,18 +581,16 @@ class ProjectProfile {
int? durationMinutes,
DateTime? scheduledStart,
DateTime? scheduledEnd,
DateTime? actualStart,
DateTime? actualEnd,
String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
ReminderProfile? reminderOverride,
DateTime? updatedAt,
}) {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
return Task(
id: id,
title: trimmedTitle,
title: title,
projectId: this.id,
type: type,
status: status,
@ -458,8 +600,11 @@ class ProjectProfile {
durationMinutes: durationMinutes ?? defaultDurationMinutes,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
actualStart: actualStart,
actualEnd: actualEnd,
parentTaskId: parentTaskId,
backlogTags: backlogTags,
reminderOverride: reminderOverride,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
@ -475,6 +620,7 @@ class ProjectProfile {
DifficultyLevel? defaultDifficulty,
ReminderProfile? defaultReminderProfile,
int? defaultDurationMinutes,
bool clearDefaultDuration = false,
}) {
return ProjectProfile(
id: id ?? this.id,
@ -485,8 +631,9 @@ class ProjectProfile {
defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty,
defaultReminderProfile:
defaultReminderProfile ?? this.defaultReminderProfile,
defaultDurationMinutes:
defaultDurationMinutes ?? this.defaultDurationMinutes,
defaultDurationMinutes: clearDefaultDuration
? null
: (defaultDurationMinutes ?? this.defaultDurationMinutes),
);
}
}
@ -498,11 +645,20 @@ class ProjectProfile {
/// candidate placements. The interval convention is start-inclusive and
/// end-exclusive, which avoids treating two back-to-back blocks as overlapping.
class TimeInterval {
const TimeInterval({
TimeInterval({
required this.start,
required this.end,
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.
final DateTime start;
@ -526,3 +682,83 @@ class TimeInterval {
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 scheduledStart = 'scheduledStart';
static const scheduledEnd = 'scheduledEnd';
static const actualStart = 'actualStart';
static const actualEnd = 'actualEnd';
static const parentTaskId = 'parentTaskId';
static const backlogTags = 'backlogTags';
static const reminderOverride = 'reminderOverride';
static const createdAt = 'createdAt';
static const updatedAt = 'updatedAt';
static const stats = 'stats';
@ -69,8 +72,11 @@ abstract final class TaskDocumentFields {
durationMinutes,
scheduledStart,
scheduledEnd,
actualStart,
actualEnd,
parentTaskId,
backlogTags,
reminderOverride,
createdAt,
updatedAt,
stats,

View file

@ -31,7 +31,7 @@ enum QuickCaptureStatus {
/// possible, but the user can optionally provide enough detail to immediately
/// schedule it into the next open flexible slot.
class QuickCaptureRequest {
const QuickCaptureRequest({
QuickCaptureRequest({
required this.id,
required this.title,
required this.createdAt,
@ -42,8 +42,8 @@ class QuickCaptureRequest {
this.difficulty = DifficultyLevel.notSet,
this.type = TaskType.flexible,
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
/// domain layer independent from persistence/database choices.
@ -87,12 +87,12 @@ class QuickCaptureRequest {
/// was attempted, [schedulingResult] exposes the lower-level engine notices and
/// changes for debugging or timeline updates.
class QuickCaptureResult {
const QuickCaptureResult({
QuickCaptureResult({
required this.task,
required this.status,
this.schedulingResult,
this.messages = const <String>[],
});
List<String> messages = const <String>[],
}) : messages = List<String>.unmodifiable(messages);
/// Captured task, scheduled or unscheduled depending on [status].
final Task task;
@ -136,7 +136,7 @@ class QuickCaptureService {
SchedulingInput? schedulingInput,
DateTime? updatedAt,
}) {
final capturedTask = Task.quickCapture(
final capturedTaskWithoutDuration = Task.quickCapture(
id: request.id,
title: request.title,
createdAt: request.createdAt,
@ -147,7 +147,19 @@ class QuickCaptureService {
difficulty: request.difficulty,
backlogTags: request.backlogTags,
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) {
return QuickCaptureResult(

View file

@ -64,18 +64,24 @@ abstract interface class LockedBlockRepository {
/// Snapshot of one scheduling operation or persisted planning state.
class SchedulingStateSnapshot {
const SchedulingStateSnapshot({
SchedulingStateSnapshot({
required this.id,
required this.capturedAt,
required this.window,
required this.tasks,
this.lockedIntervals = const <TimeInterval>[],
this.requiredVisibleIntervals = const <TimeInterval>[],
this.notices = const <SchedulingNotice>[],
this.changes = const <SchedulingChange>[],
this.overlaps = const <SchedulingOverlap>[],
required List<Task> tasks,
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
List<SchedulingNotice> notices = const <SchedulingNotice>[],
List<SchedulingChange> changes = const <SchedulingChange>[],
List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
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.
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
/// scope for the operation.
class SchedulingWindow {
const SchedulingWindow({
SchedulingWindow({
required this.start,
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.
final DateTime start;
@ -79,12 +88,15 @@ class SchedulingWindow {
/// not know where the data came from: UI state, a database, tests, or generated
/// examples can all build this same object.
class SchedulingInput {
const SchedulingInput({
required this.tasks,
SchedulingInput({
required List<Task> tasks,
required this.window,
this.lockedIntervals = const <TimeInterval>[],
this.requiredVisibleIntervals = const <TimeInterval>[],
});
List<TimeInterval> lockedIntervals = 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
/// list rather than mutating this one.
@ -222,12 +234,15 @@ class SchedulingNotice {
/// This keeps the call pattern predictable: always inspect `tasks`, then surface
/// any `notices`, `changes`, or `overlaps` relevant to the UI.
class SchedulingResult {
const SchedulingResult({
required this.tasks,
this.notices = const <SchedulingNotice>[],
this.changes = const <SchedulingChange>[],
this.overlaps = const <SchedulingOverlap>[],
});
SchedulingResult({
required List<Task> tasks,
List<SchedulingNotice> notices = const <SchedulingNotice>[],
List<SchedulingChange> changes = const <SchedulingChange>[],
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.
final List<Task> tasks;

View file

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

View file

@ -36,8 +36,11 @@ void main() {
expect(restored.durationMinutes, task.durationMinutes);
expect(restored.scheduledStart, task.scheduledStart);
expect(restored.scheduledEnd, task.scheduledEnd);
expect(restored.actualStart, task.actualStart);
expect(restored.actualEnd, task.actualEnd);
expect(restored.parentTaskId, task.parentTaskId);
expect(restored.backlogTags, task.backlogTags);
expect(restored.reminderOverride, task.reminderOverride);
expect(restored.createdAt, task.createdAt);
expect(restored.updatedAt, task.updatedAt);
expect(restored.stats.manuallyPushedCount, 1);
@ -143,8 +146,11 @@ Task scheduledTask({
durationMinutes: 30,
scheduledStart: DateTime.utc(2026, 6, 23, 9),
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',
backlogTags: const {BacklogTag.wishlist},
reminderOverride: ReminderProfile.persistent,
createdAt: createdAt,
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',
() {
const project = ProjectProfile(
final project = ProjectProfile(
id: 'health',
name: 'Health',
colorKey: 'green',
@ -562,9 +562,9 @@ void main() {
final block = LockedBlock(
id: 'work',
name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly(
startTime: ClockTime(hour: 9, minute: 0),
endTime: ClockTime(hour: 17, minute: 0),
recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.friday},
),
createdAt: now,

View file

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

View file

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

View file

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

View file

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