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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
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',
|
||||
() {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue