feat(domain): enforce v1 invariants
This commit is contained in:
parent
656ba0634b
commit
91bdb26b1a
20 changed files with 1020 additions and 146 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
357
test/domain_invariants_test.dart
Normal file
357
test/domain_invariants_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue