forked from eva/focus-flow
feat(domain): harden v1 time semantics
This commit is contained in:
parent
91bdb26b1a
commit
81f137b8c0
19 changed files with 1156 additions and 146 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# V1 Block 11 — Backend Baseline and Domain Contracts
|
||||
|
||||
Status: Planned
|
||||
Status: Complete on 2026-06-24.
|
||||
|
||||
Purpose: Re-establish a trustworthy executable baseline after the archived
|
||||
Blocks 01–10, resolve remaining V1 specification ambiguities, and harden the
|
||||
|
|
@ -220,6 +220,37 @@ clock contracts.
|
|||
|
||||
Recommended Codex level: extra high
|
||||
|
||||
Status: Complete on 2026-06-24.
|
||||
|
||||
Implementation outputs:
|
||||
|
||||
- Added clock, ID, civil-date, wall-time, timezone resolver, DST policy, and
|
||||
fixed-offset resolver contracts in `lib/src/time_contracts.dart`.
|
||||
- Exported the time contracts from the public `scheduler_core.dart` entry point.
|
||||
- Replaced hidden `DateTime.now()` fallbacks in scheduling, action, surprise
|
||||
logging, and child-completion services with injected `Clock` boundaries.
|
||||
- Added optional ID-generator convenience wrappers for quick capture and
|
||||
surprise logging while preserving explicit request APIs.
|
||||
- Migrated locked block dates and override dates to `CivilDate`; kept
|
||||
`ClockTime` as a backwards-compatible alias of `WallTime`.
|
||||
- Required locked-time expansion and override interval conversion to receive an
|
||||
explicit `timeZoneId` and `TimeZoneResolver`.
|
||||
- Documented and tested safe overnight locked-rule behavior: reject non-positive
|
||||
and overnight wall-time intervals instead of splitting silently.
|
||||
- Added civil-date and wall-time persistence conventions that store date-only
|
||||
and wall-time strings without timezone conversion.
|
||||
- Updated architecture notes and the public API baseline for the intentional
|
||||
time-contract changes.
|
||||
|
||||
Test outputs:
|
||||
|
||||
- Added `test/time_contracts_test.dart` for civil-date/wall-time parsing,
|
||||
midnight/month/year/leap-day boundaries, fixed offset conversion, injected
|
||||
clocks/IDs, DST gap/repeat policy, reject policy codes, and overnight rejection.
|
||||
- Updated locked-time, scheduling, persistence, repository, and invariant tests
|
||||
to use explicit civil dates and timezone resolver boundaries.
|
||||
- Current verification result: `dart test` passed with 174 tests.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Introduce injectable clock and ID-generation contracts for application/domain
|
||||
|
|
@ -45,7 +45,7 @@ runtime database dependency.
|
|||
|
||||
| Order | Plan | Status | Backend/UI |
|
||||
|---|---|---|---|
|
||||
| 11 | `V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md` | Planned | Backend |
|
||||
| 11 | `../Archived plans/V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md` | Complete, archived | Backend |
|
||||
| 12 | `V1_BLOCK_12_Occupancy_Scheduling_Correctness.md` | Planned | Backend |
|
||||
| 13 | `V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md` | Planned | Backend |
|
||||
| 14 | `V1_BLOCK_14_Application_Use_Cases_Read_Models.md` | Planned | Backend |
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ following source files:
|
|||
- `src/scheduling_engine.dart`
|
||||
- `src/task_actions.dart`
|
||||
- `src/task_statistics.dart`
|
||||
- `src/time_contracts.dart`
|
||||
- `src/timeline_state.dart`
|
||||
|
||||
This baseline is used by later chunks to distinguish intentional breaking
|
||||
|
|
@ -39,6 +40,7 @@ changes from accidental API drift.
|
|||
| `src/quick_capture.dart` | `QuickCaptureStatus` |
|
||||
| `src/scheduling_engine.dart` | `SchedulingNoticeType` |
|
||||
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
|
||||
| `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` |
|
||||
| `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` |
|
||||
|
||||
## Public classes and top-level functions
|
||||
|
|
@ -56,6 +58,7 @@ changes from accidental API drift.
|
|||
| `src/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` |
|
||||
| `src/task_actions.dart` | `FlexibleTaskActionResult`, `PushDestinationResult`, `RequiredTaskActionResult`, `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, `FlexibleTaskActionService`, `RequiredTaskActionService`, `SurpriseTaskLogService` |
|
||||
| `src/task_statistics.dart` | `TaskStatistics` |
|
||||
| `src/time_contracts.dart` | `Clock`, `SystemClock`, `FixedClock`, `IdGenerator`, `SequentialIdGenerator`, `CivilDate`, `WallTime`, `TimeZoneResolutionOptions`, `ResolvedLocalDateTime`, `TimeZoneResolver`, `FixedOffsetTimeZoneResolver` |
|
||||
| `src/timeline_state.dart` | `TimelineItem`, `CompactTimelineState`, `TimelineItemMapper` |
|
||||
|
||||
## Current model baseline
|
||||
|
|
@ -97,8 +100,9 @@ Known baseline gaps to preserve as explicit later work:
|
|||
status/event distinction.
|
||||
- `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.
|
||||
- Instant ranges still use `DateTime`; locked recurrence dates, wall-clock
|
||||
rules, clocks, IDs, and timezone resolution are explicit Chunk 11.4
|
||||
contracts.
|
||||
|
||||
## Intentional API Changes After Baseline
|
||||
|
||||
|
|
@ -113,6 +117,22 @@ Chunk 11.3 intentionally changed the public API:
|
|||
- Public collection fields now expose unmodifiable snapshots instead of caller
|
||||
mutable collections.
|
||||
|
||||
Chunk 11.4 intentionally changed the public API:
|
||||
|
||||
- Added clock, ID, civil-date, wall-time, timezone resolver, DST policy, and
|
||||
fixed-offset resolver contracts in `src/time_contracts.dart`.
|
||||
- Exported `src/time_contracts.dart` from `scheduler_core.dart`.
|
||||
- Added `nonexistentLocalTime` and `ambiguousLocalTime` validation codes.
|
||||
- Changed `ClockTime` to a backwards-compatible alias of `WallTime`.
|
||||
- Changed locked block dates and override dates from `DateTime` to `CivilDate`.
|
||||
- Changed locked-time expansion helpers and override interval conversion to
|
||||
require explicit `timeZoneId` and `TimeZoneResolver`.
|
||||
- Added injected `Clock` boundaries to scheduling/action/child completion
|
||||
services and optional `IdGenerator` convenience wrappers for quick capture and
|
||||
surprise logging.
|
||||
- Added civil-date and wall-time persistence conventions that store strings
|
||||
without timezone conversion.
|
||||
|
||||
## Repository contracts
|
||||
|
||||
The current repository surface is pure Dart and in-memory only:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,20 @@ a proxy for completion time, actual work time, missed time, or backlog-entry
|
|||
time. Blocks 12, 13, and 15 add those fields explicitly where the product needs
|
||||
them.
|
||||
|
||||
Recurring locked time uses civil dates and wall-clock times, not implicit local
|
||||
`DateTime` values. The scheduling core converts a civil date plus wall time into
|
||||
an instant only through an explicit time-zone resolver supplied by the
|
||||
application boundary. Daylight-saving behavior is deterministic: nonexistent
|
||||
local times shift forward by default, repeated local times choose the earlier
|
||||
instant by default, and callers may request rejection or the later repeated
|
||||
instant. Overnight locked rules are rejected instead of silently split or
|
||||
normalized.
|
||||
|
||||
Persistence should keep these concepts separate. Instants are stored as UTC
|
||||
ISO-8601 strings, civil dates as `YYYY-MM-DD`, and wall times as `HH:MM`; date
|
||||
and wall-time fields must not be converted through UTC in a way that can shift
|
||||
the user's intended local day.
|
||||
|
||||
## Persistence direction
|
||||
|
||||
MongoDB is the committed persistence target. The V1 scheduling core should still
|
||||
|
|
@ -71,11 +85,11 @@ interfaces should be designed so a later MongoDB adapter can persist document-sh
|
|||
models without importing MongoDB APIs into scheduling logic.
|
||||
|
||||
Current V1 persistence preparation includes pure Dart repository interfaces,
|
||||
in-memory fakes for tests, UTC DateTime conventions, stable enum names, and
|
||||
MongoDB-friendly document-shaped map helpers. It does not include a MongoDB
|
||||
driver, adapter implementation, connection string, Atlas/cloud setup, local
|
||||
MongoDB server requirement, user accounts, network sync, background sync, or
|
||||
mobile background reconciliation.
|
||||
in-memory fakes for tests, UTC DateTime conventions, civil-date and wall-time
|
||||
string conventions, stable enum names, and MongoDB-friendly document-shaped map
|
||||
helpers. It does not include a MongoDB driver, adapter implementation,
|
||||
connection string, Atlas/cloud setup, local MongoDB server requirement, user
|
||||
accounts, network sync, background sync, or mobile background reconciliation.
|
||||
|
||||
Sync remains future work and should only be added if an active plan explicitly
|
||||
asks for it. The core should continue to operate in-memory for tests and local
|
||||
|
|
|
|||
|
|
@ -27,4 +27,5 @@ export 'src/repositories.dart';
|
|||
export 'src/scheduling_engine.dart';
|
||||
export 'src/task_actions.dart';
|
||||
export 'src/task_statistics.dart';
|
||||
export 'src/time_contracts.dart';
|
||||
export 'src/timeline_state.dart';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
// traversal, or UI-specific state.
|
||||
|
||||
import 'models.dart';
|
||||
import 'time_contracts.dart';
|
||||
|
||||
/// Row-style input for creating one child task.
|
||||
///
|
||||
|
|
@ -206,7 +207,12 @@ class ChildTaskCompletionResult {
|
|||
/// dependency graphs, and it only completes sibling children when the caller
|
||||
/// explicitly completes the parent.
|
||||
class ChildTaskCompletionService {
|
||||
const ChildTaskCompletionService();
|
||||
const ChildTaskCompletionService({
|
||||
this.clock = const SystemClock(),
|
||||
});
|
||||
|
||||
/// Clock boundary used when a completion timestamp is not supplied.
|
||||
final Clock clock;
|
||||
|
||||
/// Complete one child and auto-complete its parent only when all children are complete.
|
||||
ChildTaskCompletionResult completeChild({
|
||||
|
|
@ -214,7 +220,7 @@ class ChildTaskCompletionService {
|
|||
required String childTaskId,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final now = updatedAt ?? DateTime.now();
|
||||
final now = updatedAt ?? clock.now();
|
||||
final child = _taskById(tasks, childTaskId);
|
||||
if (child == null) {
|
||||
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
||||
|
|
@ -269,7 +275,7 @@ class ChildTaskCompletionService {
|
|||
required String parentTaskId,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final now = updatedAt ?? DateTime.now();
|
||||
final now = updatedAt ?? clock.now();
|
||||
final parent = _taskById(tasks, parentTaskId);
|
||||
if (parent == null) {
|
||||
throw ArgumentError.value(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// concrete `TimeInterval`s that the scheduler can avoid.
|
||||
|
||||
import 'models.dart';
|
||||
import 'time_contracts.dart';
|
||||
|
||||
/// Weekday value using DateTime's Monday-first convention.
|
||||
///
|
||||
|
|
@ -27,41 +28,8 @@ enum LockedWeekday {
|
|||
final int dateTimeValue;
|
||||
}
|
||||
|
||||
/// Time of day without a calendar date.
|
||||
///
|
||||
/// Locked blocks are often defined as "every weekday from 8:00 to 17:00" rather
|
||||
/// 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 {
|
||||
ClockTime({
|
||||
required this.hour,
|
||||
required this.minute,
|
||||
}) {
|
||||
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;
|
||||
|
||||
/// Minute within the hour, 0 through 59.
|
||||
final int minute;
|
||||
|
||||
/// Combine this time-of-day with [date].
|
||||
///
|
||||
/// Only the year, month, and day from [date] are used. Seconds and smaller
|
||||
/// units are intentionally reset because locked blocks in this starter project
|
||||
/// operate at minute precision.
|
||||
DateTime onDate(DateTime date) {
|
||||
return DateTime(date.year, date.month, date.day, hour, minute);
|
||||
}
|
||||
}
|
||||
/// Backwards-compatible name for explicit wall-clock time.
|
||||
typedef ClockTime = WallTime;
|
||||
|
||||
/// Recurrence rule for locked time.
|
||||
///
|
||||
|
|
@ -87,7 +55,7 @@ class LockedBlockRecurrence {
|
|||
final Set<LockedWeekday> weekdays;
|
||||
|
||||
/// Whether this recurrence applies to [date].
|
||||
bool occursOn(DateTime date) {
|
||||
bool occursOn(CivilDate date) {
|
||||
return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday);
|
||||
}
|
||||
}
|
||||
|
|
@ -150,7 +118,7 @@ class LockedBlock {
|
|||
final ClockTime endTime;
|
||||
|
||||
/// Calendar date for one-off locked blocks. Recurring blocks leave this null.
|
||||
final DateTime? date;
|
||||
final CivilDate? date;
|
||||
|
||||
/// Optional weekly recurrence. Null means this is a one-off block.
|
||||
final LockedBlockRecurrence? recurrence;
|
||||
|
|
@ -177,7 +145,7 @@ class LockedBlock {
|
|||
String? name,
|
||||
ClockTime? startTime,
|
||||
ClockTime? endTime,
|
||||
DateTime? date,
|
||||
CivilDate? date,
|
||||
LockedBlockRecurrence? recurrence,
|
||||
bool? hiddenByDefault,
|
||||
String? projectId,
|
||||
|
|
@ -309,7 +277,7 @@ class LockedBlockOverride {
|
|||
factory LockedBlockOverride.remove({
|
||||
required String id,
|
||||
required String lockedBlockId,
|
||||
required DateTime date,
|
||||
required CivilDate date,
|
||||
required DateTime createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
|
|
@ -327,7 +295,7 @@ class LockedBlockOverride {
|
|||
factory LockedBlockOverride.replace({
|
||||
required String id,
|
||||
required String lockedBlockId,
|
||||
required DateTime date,
|
||||
required CivilDate date,
|
||||
required ClockTime startTime,
|
||||
required ClockTime endTime,
|
||||
required DateTime createdAt,
|
||||
|
|
@ -354,7 +322,7 @@ class LockedBlockOverride {
|
|||
/// Adds surprise locked time for one date.
|
||||
factory LockedBlockOverride.add({
|
||||
required String id,
|
||||
required DateTime date,
|
||||
required CivilDate date,
|
||||
required String name,
|
||||
required ClockTime startTime,
|
||||
required ClockTime endTime,
|
||||
|
|
@ -383,8 +351,8 @@ class LockedBlockOverride {
|
|||
/// Base block id affected by remove/replace overrides. Null for add overrides.
|
||||
final String? lockedBlockId;
|
||||
|
||||
/// Date the override applies to. Time components are ignored by date checks.
|
||||
final DateTime date;
|
||||
/// Date the override applies to.
|
||||
final CivilDate date;
|
||||
|
||||
/// Kind of override operation.
|
||||
final LockedBlockOverrideType type;
|
||||
|
|
@ -413,7 +381,7 @@ class LockedBlockOverride {
|
|||
/// Whether this override targets [blockId] on [occurrenceDate].
|
||||
bool appliesTo({
|
||||
required String blockId,
|
||||
required DateTime occurrenceDate,
|
||||
required CivilDate occurrenceDate,
|
||||
}) {
|
||||
return lockedBlockId == blockId && _sameDate(date, occurrenceDate);
|
||||
}
|
||||
|
|
@ -423,7 +391,13 @@ class LockedBlockOverride {
|
|||
///
|
||||
/// Remove overrides intentionally return null because they do not create a new
|
||||
/// interval. Replacement/add overrides need both [startTime] and [endTime].
|
||||
TimeInterval? intervalForDate(DateTime targetDate) {
|
||||
TimeInterval? intervalForDate({
|
||||
required CivilDate targetDate,
|
||||
required String timeZoneId,
|
||||
required TimeZoneResolver timeZoneResolver,
|
||||
TimeZoneResolutionOptions resolutionOptions =
|
||||
const TimeZoneResolutionOptions(),
|
||||
}) {
|
||||
final start = startTime;
|
||||
final end = endTime;
|
||||
|
||||
|
|
@ -431,10 +405,14 @@ class LockedBlockOverride {
|
|||
return null;
|
||||
}
|
||||
|
||||
return TimeInterval(
|
||||
start: start.onDate(targetDate),
|
||||
end: end.onDate(targetDate),
|
||||
return _resolvedInterval(
|
||||
date: targetDate,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
label: name,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -451,7 +429,7 @@ class LockedScheduleExpansion {
|
|||
});
|
||||
|
||||
/// Calendar date represented by this expansion, normalized to year/month/day.
|
||||
final DateTime date;
|
||||
final CivilDate date;
|
||||
|
||||
/// Concrete locked occurrences active on [date].
|
||||
final List<LockedBlockOccurrence> occurrences;
|
||||
|
|
@ -476,7 +454,11 @@ class LockedScheduleExpansion {
|
|||
LockedScheduleExpansion expandLockedBlocksForDay({
|
||||
required List<LockedBlock> blocks,
|
||||
required List<LockedBlockOverride> overrides,
|
||||
required DateTime date,
|
||||
required CivilDate date,
|
||||
required String timeZoneId,
|
||||
required TimeZoneResolver timeZoneResolver,
|
||||
TimeZoneResolutionOptions resolutionOptions =
|
||||
const TimeZoneResolutionOptions(),
|
||||
}) {
|
||||
final occurrences = <LockedBlockOccurrence>[];
|
||||
final sortedOverrides = [...overrides]..sort((a, b) {
|
||||
|
|
@ -510,11 +492,20 @@ LockedScheduleExpansion expandLockedBlocksForDay({
|
|||
|
||||
occurrences.add(
|
||||
replacement == null
|
||||
? _occurrenceFromBlock(block, date)
|
||||
? _occurrenceFromBlock(
|
||||
block,
|
||||
date,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
)
|
||||
: _occurrenceFromReplacement(
|
||||
block: block,
|
||||
override: replacement,
|
||||
date: date,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -525,7 +516,13 @@ LockedScheduleExpansion expandLockedBlocksForDay({
|
|||
continue;
|
||||
}
|
||||
|
||||
final occurrence = _occurrenceFromAddedOverride(override, date);
|
||||
final occurrence = _occurrenceFromAddedOverride(
|
||||
override,
|
||||
date,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
);
|
||||
if (occurrence != null) {
|
||||
occurrences.add(occurrence);
|
||||
}
|
||||
|
|
@ -541,7 +538,7 @@ LockedScheduleExpansion expandLockedBlocksForDay({
|
|||
});
|
||||
|
||||
return LockedScheduleExpansion(
|
||||
date: DateTime(date.year, date.month, date.day),
|
||||
date: date,
|
||||
occurrences: List<LockedBlockOccurrence>.unmodifiable(occurrences),
|
||||
);
|
||||
}
|
||||
|
|
@ -566,12 +563,19 @@ LockedBlockOverride? _lastReplacementOverride(
|
|||
List<TimeInterval> lockedSchedulingIntervalsForDay({
|
||||
required List<LockedBlock> blocks,
|
||||
required List<LockedBlockOverride> overrides,
|
||||
required DateTime date,
|
||||
required CivilDate date,
|
||||
required String timeZoneId,
|
||||
required TimeZoneResolver timeZoneResolver,
|
||||
TimeZoneResolutionOptions resolutionOptions =
|
||||
const TimeZoneResolutionOptions(),
|
||||
}) {
|
||||
return expandLockedBlocksForDay(
|
||||
blocks: blocks,
|
||||
overrides: overrides,
|
||||
date: date,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
).schedulingIntervals;
|
||||
}
|
||||
|
||||
|
|
@ -656,7 +660,7 @@ int completedDuringLockedHoursMinutes({
|
|||
}
|
||||
|
||||
/// Whether a base block should produce an occurrence on [date].
|
||||
bool _blockOccursOn(LockedBlock block, DateTime date) {
|
||||
bool _blockOccursOn(LockedBlock block, CivilDate date) {
|
||||
final recurrence = block.recurrence;
|
||||
if (recurrence != null) {
|
||||
return recurrence.occursOn(date);
|
||||
|
|
@ -668,15 +672,23 @@ bool _blockOccursOn(LockedBlock block, DateTime date) {
|
|||
/// Convert a base block rule into a concrete occurrence for [date].
|
||||
LockedBlockOccurrence _occurrenceFromBlock(
|
||||
LockedBlock block,
|
||||
DateTime date,
|
||||
) {
|
||||
CivilDate date, {
|
||||
required String timeZoneId,
|
||||
required TimeZoneResolver timeZoneResolver,
|
||||
TimeZoneResolutionOptions resolutionOptions =
|
||||
const TimeZoneResolutionOptions(),
|
||||
}) {
|
||||
return LockedBlockOccurrence(
|
||||
lockedBlockId: block.id,
|
||||
name: block.name,
|
||||
interval: TimeInterval(
|
||||
start: block.startTime.onDate(date),
|
||||
end: block.endTime.onDate(date),
|
||||
interval: _resolvedInterval(
|
||||
date: date,
|
||||
startTime: block.startTime,
|
||||
endTime: block.endTime,
|
||||
label: block.name,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
),
|
||||
hiddenByDefault: block.hiddenByDefault,
|
||||
projectId: block.projectId,
|
||||
|
|
@ -690,7 +702,11 @@ LockedBlockOccurrence _occurrenceFromBlock(
|
|||
LockedBlockOccurrence _occurrenceFromReplacement({
|
||||
required LockedBlock block,
|
||||
required LockedBlockOverride override,
|
||||
required DateTime date,
|
||||
required CivilDate date,
|
||||
required String timeZoneId,
|
||||
required TimeZoneResolver timeZoneResolver,
|
||||
TimeZoneResolutionOptions resolutionOptions =
|
||||
const TimeZoneResolutionOptions(),
|
||||
}) {
|
||||
final startTime = override.startTime ?? block.startTime;
|
||||
final endTime = override.endTime ?? block.endTime;
|
||||
|
|
@ -700,10 +716,14 @@ LockedBlockOccurrence _occurrenceFromReplacement({
|
|||
lockedBlockId: block.id,
|
||||
overrideId: override.id,
|
||||
name: name,
|
||||
interval: TimeInterval(
|
||||
start: startTime.onDate(date),
|
||||
end: endTime.onDate(date),
|
||||
interval: _resolvedInterval(
|
||||
date: date,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
label: name,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
),
|
||||
hiddenByDefault: override.hiddenByDefault,
|
||||
projectId: override.projectId ?? block.projectId,
|
||||
|
|
@ -713,9 +733,18 @@ LockedBlockOccurrence _occurrenceFromReplacement({
|
|||
/// Convert an add override into a concrete occurrence when it is complete.
|
||||
LockedBlockOccurrence? _occurrenceFromAddedOverride(
|
||||
LockedBlockOverride override,
|
||||
DateTime date,
|
||||
) {
|
||||
final interval = override.intervalForDate(date);
|
||||
CivilDate date, {
|
||||
required String timeZoneId,
|
||||
required TimeZoneResolver timeZoneResolver,
|
||||
TimeZoneResolutionOptions resolutionOptions =
|
||||
const TimeZoneResolutionOptions(),
|
||||
}) {
|
||||
final interval = override.intervalForDate(
|
||||
targetDate: date,
|
||||
timeZoneId: timeZoneId,
|
||||
timeZoneResolver: timeZoneResolver,
|
||||
resolutionOptions: resolutionOptions,
|
||||
);
|
||||
final name = override.name;
|
||||
|
||||
if (interval == null || name == null) {
|
||||
|
|
@ -762,13 +791,37 @@ DateTime _latest(DateTime first, DateTime second) {
|
|||
return first.isAfter(second) ? first : second;
|
||||
}
|
||||
|
||||
/// Compare only the calendar date parts of two timestamps.
|
||||
bool _sameDate(DateTime first, DateTime second) {
|
||||
return first.year == second.year &&
|
||||
first.month == second.month &&
|
||||
first.day == second.day;
|
||||
TimeInterval _resolvedInterval({
|
||||
required CivilDate date,
|
||||
required ClockTime startTime,
|
||||
required ClockTime endTime,
|
||||
required String? label,
|
||||
required String timeZoneId,
|
||||
required TimeZoneResolver timeZoneResolver,
|
||||
required TimeZoneResolutionOptions resolutionOptions,
|
||||
}) {
|
||||
final start = timeZoneResolver.resolve(
|
||||
date: date,
|
||||
wallTime: startTime,
|
||||
timeZoneId: timeZoneId,
|
||||
options: resolutionOptions,
|
||||
);
|
||||
final end = timeZoneResolver.resolve(
|
||||
date: date,
|
||||
wallTime: endTime,
|
||||
timeZoneId: timeZoneId,
|
||||
options: resolutionOptions,
|
||||
);
|
||||
|
||||
return TimeInterval(
|
||||
start: start.instant,
|
||||
end: end.instant,
|
||||
label: label,
|
||||
);
|
||||
}
|
||||
|
||||
bool _sameDate(CivilDate first, CivilDate second) => first == second;
|
||||
|
||||
String _requiredLockedString(
|
||||
String value,
|
||||
String name,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ enum DomainValidationCode {
|
|||
invalidActualInterval,
|
||||
invalidTimeInterval,
|
||||
invalidSchedulingWindow,
|
||||
invalidCivilDate,
|
||||
invalidClockTime,
|
||||
blankTimeZoneId,
|
||||
nonexistentLocalTime,
|
||||
ambiguousLocalTime,
|
||||
emptyRecurrence,
|
||||
invalidLockedBlock,
|
||||
invalidLockedOverride,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
// code should use. It deliberately does not serialize domain models or import a
|
||||
// MongoDB client.
|
||||
|
||||
import 'time_contracts.dart';
|
||||
|
||||
/// DateTime storage convention for future document persistence.
|
||||
abstract final class PersistenceDateTimeConvention {
|
||||
/// Human-readable convention kept close to the helper for tests and docs.
|
||||
|
|
@ -26,6 +28,40 @@ abstract final class PersistenceDateTimeConvention {
|
|||
}
|
||||
}
|
||||
|
||||
/// Civil-date storage convention for future document persistence.
|
||||
abstract final class PersistenceCivilDateConvention {
|
||||
/// Human-readable convention kept close to the helper for tests and docs.
|
||||
static const description =
|
||||
'Store CivilDate values as YYYY-MM-DD strings without timezone conversion.';
|
||||
|
||||
/// Convert a CivilDate into the persisted string convention.
|
||||
static String toStoredString(CivilDate value) {
|
||||
return value.toIsoString();
|
||||
}
|
||||
|
||||
/// Parse a persisted CivilDate string without applying a timezone.
|
||||
static CivilDate fromStoredString(String value) {
|
||||
return CivilDate.fromIsoString(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wall-time storage convention for future document persistence.
|
||||
abstract final class PersistenceWallTimeConvention {
|
||||
/// Human-readable convention kept close to the helper for tests and docs.
|
||||
static const description =
|
||||
'Store WallTime values as HH:MM strings without date or timezone data.';
|
||||
|
||||
/// Convert a WallTime into the persisted string convention.
|
||||
static String toStoredString(WallTime value) {
|
||||
return value.toIsoString();
|
||||
}
|
||||
|
||||
/// Parse a persisted WallTime string without applying a date or timezone.
|
||||
static WallTime fromStoredString(String value) {
|
||||
return WallTime.fromIsoString(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable enum-name mapping plan for future document persistence.
|
||||
extension PersistenceEnumName on Enum {
|
||||
/// Persist enum values by their Dart enum name.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import 'models.dart';
|
||||
import 'scheduling_engine.dart';
|
||||
import 'time_contracts.dart';
|
||||
|
||||
/// Outcome of a quick-capture request.
|
||||
///
|
||||
|
|
@ -117,12 +118,22 @@ class QuickCaptureResult {
|
|||
/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand
|
||||
/// every scheduler precondition.
|
||||
class QuickCaptureService {
|
||||
const QuickCaptureService({this.engine = const SchedulingEngine()});
|
||||
const QuickCaptureService({
|
||||
this.engine = const SchedulingEngine(),
|
||||
this.clock = const SystemClock(),
|
||||
this.idGenerator,
|
||||
});
|
||||
|
||||
/// Scheduling dependency. Defaults to the starter engine but can be swapped in
|
||||
/// tests or future implementations.
|
||||
final SchedulingEngine engine;
|
||||
|
||||
/// Clock boundary used by convenience capture entry points.
|
||||
final Clock clock;
|
||||
|
||||
/// Optional ID boundary for convenience capture entry points.
|
||||
final IdGenerator? idGenerator;
|
||||
|
||||
/// Capture a task and optionally place it into the next available slot.
|
||||
///
|
||||
/// Flow:
|
||||
|
|
@ -219,6 +230,47 @@ class QuickCaptureService {
|
|||
schedulingResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience outer-boundary wrapper that supplies id and creation time.
|
||||
QuickCaptureResult captureNew({
|
||||
required String title,
|
||||
bool addToNextAvailableSlot = false,
|
||||
String projectId = 'inbox',
|
||||
PriorityLevel priority = PriorityLevel.medium,
|
||||
RewardLevel reward = RewardLevel.notSet,
|
||||
DifficultyLevel difficulty = DifficultyLevel.notSet,
|
||||
TaskType type = TaskType.flexible,
|
||||
int? durationMinutes,
|
||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
SchedulingInput? schedulingInput,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final generator = idGenerator;
|
||||
if (generator == null) {
|
||||
throw StateError('Quick capture needs an id generator.');
|
||||
}
|
||||
|
||||
final now = createdAt ?? updatedAt ?? clock.now();
|
||||
|
||||
return capture(
|
||||
QuickCaptureRequest(
|
||||
id: generator.nextId(),
|
||||
title: title,
|
||||
createdAt: now,
|
||||
addToNextAvailableSlot: addToNextAvailableSlot,
|
||||
projectId: projectId,
|
||||
priority: priority,
|
||||
reward: reward,
|
||||
difficulty: difficulty,
|
||||
type: type,
|
||||
durationMinutes: durationMinutes,
|
||||
backlogTags: backlogTags,
|
||||
),
|
||||
schedulingInput: schedulingInput,
|
||||
updatedAt: updatedAt ?? now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a task in a returned scheduling result.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
// 4. Private apply helpers: convert plans into updated tasks and notices.
|
||||
|
||||
import 'models.dart';
|
||||
import 'time_contracts.dart';
|
||||
|
||||
/// Category for scheduler notices.
|
||||
///
|
||||
|
|
@ -276,7 +277,12 @@ class SchedulingResult {
|
|||
/// - `queue` is the ordered set of flexible tasks that may be placed or shifted.
|
||||
/// - `placement` is a map from task id to the interval chosen by the planner.
|
||||
class SchedulingEngine {
|
||||
const SchedulingEngine();
|
||||
const SchedulingEngine({
|
||||
this.clock = const SystemClock(),
|
||||
});
|
||||
|
||||
/// Clock boundary used when an operation timestamp is not supplied.
|
||||
final Clock clock;
|
||||
|
||||
/// Insert a backlog task into the earliest available slot today.
|
||||
///
|
||||
|
|
@ -352,7 +358,7 @@ class SchedulingEngine {
|
|||
input: input,
|
||||
insertedTask: task,
|
||||
placement: placement,
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? clock.now(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -440,7 +446,7 @@ class SchedulingEngine {
|
|||
pushedTask: task,
|
||||
placement: placement,
|
||||
pushedTaskMessage: 'Flexible task pushed to next available slot.',
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? clock.now(),
|
||||
countPushedTaskAsManual: countAsManualPush,
|
||||
);
|
||||
}
|
||||
|
|
@ -515,7 +521,7 @@ class SchedulingEngine {
|
|||
pushedTask: task,
|
||||
placement: placement,
|
||||
pushedTaskMessage: 'Flexible task moved to tomorrow.',
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? clock.now(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -600,7 +606,7 @@ class SchedulingEngine {
|
|||
input: input,
|
||||
rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(),
|
||||
placement: placement,
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? clock.now(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -657,7 +663,7 @@ class SchedulingEngine {
|
|||
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
|
||||
return task.copyWith(
|
||||
status: TaskStatus.backlog,
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? clock.now(),
|
||||
stats: task.stats.incrementMovedToBacklog(),
|
||||
clearSchedule: true,
|
||||
);
|
||||
|
|
@ -669,7 +675,7 @@ class SchedulingEngine {
|
|||
/// actual scheduled slot should change.
|
||||
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
|
||||
return task.copyWith(
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? clock.now(),
|
||||
stats: task.stats.incrementManualPush(),
|
||||
);
|
||||
}
|
||||
|
|
@ -681,7 +687,7 @@ class SchedulingEngine {
|
|||
/// or time block that cannot simply be rescheduled automatically.
|
||||
Task markMissed(Task task, {DateTime? updatedAt}) {
|
||||
final nextStats = task.stats.incrementMissed();
|
||||
final now = updatedAt ?? DateTime.now();
|
||||
final now = updatedAt ?? clock.now();
|
||||
|
||||
if (task.type == TaskType.critical) {
|
||||
return task.copyWith(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import 'models.dart';
|
||||
import 'scheduling_engine.dart';
|
||||
import 'time_contracts.dart';
|
||||
|
||||
/// Quick actions available from a flexible task card.
|
||||
///
|
||||
|
|
@ -218,11 +219,15 @@ class SurpriseTaskLogResult {
|
|||
class FlexibleTaskActionService {
|
||||
const FlexibleTaskActionService({
|
||||
this.schedulingEngine = const SchedulingEngine(),
|
||||
this.clock = const SystemClock(),
|
||||
});
|
||||
|
||||
/// Scheduling dependency used for actions that need timeline changes.
|
||||
final SchedulingEngine schedulingEngine;
|
||||
|
||||
/// Clock boundary used when an action timestamp is not supplied.
|
||||
final Clock clock;
|
||||
|
||||
/// Apply the first-stage quick action.
|
||||
///
|
||||
/// Direct actions (`done`, `backlog`) return a changed task. `push` returns the
|
||||
|
|
@ -240,11 +245,12 @@ class FlexibleTaskActionService {
|
|||
|
||||
switch (action) {
|
||||
case FlexibleTaskQuickAction.done:
|
||||
final now = updatedAt ?? clock.now();
|
||||
return FlexibleTaskActionResult(
|
||||
action: action,
|
||||
task: task.copyWith(
|
||||
status: TaskStatus.completed,
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
case FlexibleTaskQuickAction.push:
|
||||
|
|
@ -258,9 +264,10 @@ class FlexibleTaskActionService {
|
|||
],
|
||||
);
|
||||
case FlexibleTaskQuickAction.backlog:
|
||||
final now = updatedAt ?? clock.now();
|
||||
return FlexibleTaskActionResult(
|
||||
action: action,
|
||||
task: schedulingEngine.moveToBacklog(task, updatedAt: updatedAt),
|
||||
task: schedulingEngine.moveToBacklog(task, updatedAt: now),
|
||||
);
|
||||
case FlexibleTaskQuickAction.breakUp:
|
||||
return FlexibleTaskActionResult(
|
||||
|
|
@ -281,23 +288,24 @@ class FlexibleTaskActionService {
|
|||
required String taskId,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final now = updatedAt ?? clock.now();
|
||||
final result = switch (destination) {
|
||||
PushDestination.nextAvailableSlot =>
|
||||
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
||||
input: input,
|
||||
taskId: taskId,
|
||||
updatedAt: updatedAt,
|
||||
updatedAt: now,
|
||||
),
|
||||
PushDestination.tomorrowTopOfQueue =>
|
||||
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
|
||||
input: input,
|
||||
taskId: taskId,
|
||||
updatedAt: updatedAt,
|
||||
updatedAt: now,
|
||||
),
|
||||
PushDestination.backlog => _moveTaskToBacklog(
|
||||
input: input,
|
||||
taskId: taskId,
|
||||
updatedAt: updatedAt,
|
||||
updatedAt: now,
|
||||
),
|
||||
};
|
||||
|
||||
|
|
@ -386,11 +394,15 @@ class FlexibleTaskActionService {
|
|||
class RequiredTaskActionService {
|
||||
const RequiredTaskActionService({
|
||||
this.schedulingEngine = const SchedulingEngine(),
|
||||
this.clock = const SystemClock(),
|
||||
});
|
||||
|
||||
/// Scheduling dependency used for required missed behavior.
|
||||
final SchedulingEngine schedulingEngine;
|
||||
|
||||
/// Clock boundary used when an action timestamp is not supplied.
|
||||
final Clock clock;
|
||||
|
||||
/// Apply one required-card state transition.
|
||||
RequiredTaskActionResult apply({
|
||||
required Task task,
|
||||
|
|
@ -405,7 +417,7 @@ class RequiredTaskActionService {
|
|||
);
|
||||
}
|
||||
|
||||
final now = updatedAt ?? DateTime.now();
|
||||
final now = updatedAt ?? clock.now();
|
||||
|
||||
return switch (action) {
|
||||
RequiredTaskAction.done => RequiredTaskActionResult(
|
||||
|
|
@ -467,18 +479,26 @@ class RequiredTaskActionService {
|
|||
class SurpriseTaskLogService {
|
||||
const SurpriseTaskLogService({
|
||||
this.schedulingEngine = const SchedulingEngine(),
|
||||
this.clock = const SystemClock(),
|
||||
this.idGenerator,
|
||||
});
|
||||
|
||||
/// Scheduling dependency used to move overlapping flexible tasks.
|
||||
final SchedulingEngine schedulingEngine;
|
||||
|
||||
/// Clock boundary used when a log timestamp is not supplied.
|
||||
final Clock clock;
|
||||
|
||||
/// Optional ID boundary for outer convenience entry points.
|
||||
final IdGenerator? idGenerator;
|
||||
|
||||
/// Log one surprise task against a scheduling snapshot.
|
||||
SurpriseTaskLogResult log({
|
||||
required SurpriseTaskLogRequest request,
|
||||
required SchedulingInput input,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final now = updatedAt ?? DateTime.now();
|
||||
final now = updatedAt ?? clock.now();
|
||||
final surpriseTask = _createSurpriseTask(request, updatedAt: now);
|
||||
final surpriseInterval = _scheduledIntervalForTask(surpriseTask);
|
||||
var currentTasks = <Task>[surpriseTask, ...input.tasks];
|
||||
|
|
@ -570,6 +590,43 @@ class SurpriseTaskLogService {
|
|||
);
|
||||
}
|
||||
|
||||
/// Convenience outer-boundary wrapper that supplies id and creation time.
|
||||
SurpriseTaskLogResult logNew({
|
||||
required String title,
|
||||
required SchedulingInput input,
|
||||
DateTime? startedAt,
|
||||
int? timeUsedMinutes,
|
||||
String projectId = 'inbox',
|
||||
PriorityLevel? priority,
|
||||
RewardLevel reward = RewardLevel.notSet,
|
||||
DifficultyLevel difficulty = DifficultyLevel.notSet,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final generator = idGenerator;
|
||||
if (generator == null) {
|
||||
throw StateError('Surprise task logging needs an id generator.');
|
||||
}
|
||||
|
||||
final now = createdAt ?? updatedAt ?? clock.now();
|
||||
|
||||
return log(
|
||||
request: SurpriseTaskLogRequest(
|
||||
id: generator.nextId(),
|
||||
title: title,
|
||||
createdAt: now,
|
||||
startedAt: startedAt,
|
||||
timeUsedMinutes: timeUsedMinutes,
|
||||
projectId: projectId,
|
||||
priority: priority,
|
||||
reward: reward,
|
||||
difficulty: difficulty,
|
||||
),
|
||||
input: input,
|
||||
updatedAt: updatedAt ?? now,
|
||||
);
|
||||
}
|
||||
|
||||
Task _createSurpriseTask(
|
||||
SurpriseTaskLogRequest request, {
|
||||
required DateTime updatedAt,
|
||||
|
|
|
|||
293
lib/src/time_contracts.dart
Normal file
293
lib/src/time_contracts.dart
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import 'models.dart';
|
||||
|
||||
/// Deterministic source of the current instant.
|
||||
abstract interface class Clock {
|
||||
DateTime now();
|
||||
}
|
||||
|
||||
/// Production clock boundary. Domain services should receive this through
|
||||
/// constructors rather than reading wall time directly.
|
||||
class SystemClock implements Clock {
|
||||
const SystemClock();
|
||||
|
||||
@override
|
||||
DateTime now() => DateTime.now();
|
||||
}
|
||||
|
||||
/// Test/application clock with a fixed instant.
|
||||
class FixedClock implements Clock {
|
||||
const FixedClock(this.instant);
|
||||
|
||||
final DateTime instant;
|
||||
|
||||
@override
|
||||
DateTime now() => instant;
|
||||
}
|
||||
|
||||
/// Deterministic ID source for application/domain convenience entry points.
|
||||
abstract interface class IdGenerator {
|
||||
String nextId();
|
||||
}
|
||||
|
||||
/// Simple deterministic ID generator useful for tests and local wiring.
|
||||
class SequentialIdGenerator implements IdGenerator {
|
||||
SequentialIdGenerator({
|
||||
this.prefix = 'id',
|
||||
int start = 1,
|
||||
}) : _next = start;
|
||||
|
||||
final String prefix;
|
||||
int _next;
|
||||
|
||||
@override
|
||||
String nextId() {
|
||||
final id = '$prefix-$_next';
|
||||
_next += 1;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calendar date without a time or timezone.
|
||||
class CivilDate implements Comparable<CivilDate> {
|
||||
CivilDate(this.year, this.month, this.day) {
|
||||
final normalized = DateTime.utc(year, month, day);
|
||||
if (normalized.year != year ||
|
||||
normalized.month != month ||
|
||||
normalized.day != day) {
|
||||
throw DomainValidationException(
|
||||
code: DomainValidationCode.invalidCivilDate,
|
||||
invalidValue: {'year': year, 'month': month, 'day': day},
|
||||
name: 'CivilDate',
|
||||
message: 'Civil date must be a valid calendar date.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
factory CivilDate.fromDateTime(DateTime value) {
|
||||
return CivilDate(value.year, value.month, value.day);
|
||||
}
|
||||
|
||||
factory CivilDate.fromIsoString(String value) {
|
||||
if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) {
|
||||
throw DomainValidationException(
|
||||
code: DomainValidationCode.invalidCivilDate,
|
||||
invalidValue: value,
|
||||
name: 'CivilDate',
|
||||
message: 'Civil date must use YYYY-MM-DD format.',
|
||||
);
|
||||
}
|
||||
final parts = value.split('-');
|
||||
return CivilDate(
|
||||
int.parse(parts[0]),
|
||||
int.parse(parts[1]),
|
||||
int.parse(parts[2]),
|
||||
);
|
||||
}
|
||||
|
||||
final int year;
|
||||
final int month;
|
||||
final int day;
|
||||
|
||||
int get weekday => DateTime.utc(year, month, day).weekday;
|
||||
|
||||
CivilDate addDays(int days) {
|
||||
final shifted = DateTime.utc(year, month, day).add(Duration(days: days));
|
||||
return CivilDate(shifted.year, shifted.month, shifted.day);
|
||||
}
|
||||
|
||||
String toIsoString() {
|
||||
final monthText = month.toString().padLeft(2, '0');
|
||||
final dayText = day.toString().padLeft(2, '0');
|
||||
return '$year-$monthText-$dayText';
|
||||
}
|
||||
|
||||
@override
|
||||
int compareTo(CivilDate other) {
|
||||
final yearComparison = year.compareTo(other.year);
|
||||
if (yearComparison != 0) {
|
||||
return yearComparison;
|
||||
}
|
||||
final monthComparison = month.compareTo(other.month);
|
||||
if (monthComparison != 0) {
|
||||
return monthComparison;
|
||||
}
|
||||
return day.compareTo(other.day);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is CivilDate &&
|
||||
other.year == year &&
|
||||
other.month == month &&
|
||||
other.day == day;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(year, month, day);
|
||||
|
||||
@override
|
||||
String toString() => toIsoString();
|
||||
}
|
||||
|
||||
/// Wall-clock time without a date or timezone.
|
||||
class WallTime implements Comparable<WallTime> {
|
||||
WallTime({
|
||||
required this.hour,
|
||||
required this.minute,
|
||||
}) {
|
||||
if (hour < 0 || hour >= 24 || minute < 0 || minute >= 60) {
|
||||
throw DomainValidationException(
|
||||
code: DomainValidationCode.invalidClockTime,
|
||||
invalidValue: {'hour': hour, 'minute': minute},
|
||||
name: 'WallTime',
|
||||
message: 'Wall time must use hour 0-23 and minute 0-59.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
factory WallTime.fromIsoString(String value) {
|
||||
if (!RegExp(r'^\d{2}:\d{2}$').hasMatch(value)) {
|
||||
throw DomainValidationException(
|
||||
code: DomainValidationCode.invalidClockTime,
|
||||
invalidValue: value,
|
||||
name: 'WallTime',
|
||||
message: 'Wall time must use HH:MM format.',
|
||||
);
|
||||
}
|
||||
final parts = value.split(':');
|
||||
return WallTime(hour: int.parse(parts[0]), minute: int.parse(parts[1]));
|
||||
}
|
||||
|
||||
final int hour;
|
||||
final int minute;
|
||||
|
||||
int get minutesSinceMidnight => hour * 60 + minute;
|
||||
|
||||
String toIsoString() {
|
||||
return '${hour.toString().padLeft(2, '0')}:'
|
||||
'${minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
int compareTo(WallTime other) {
|
||||
return minutesSinceMidnight.compareTo(other.minutesSinceMidnight);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is WallTime && other.hour == hour && other.minute == minute;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(hour, minute);
|
||||
|
||||
@override
|
||||
String toString() => toIsoString();
|
||||
}
|
||||
|
||||
enum LocalTimeResolution {
|
||||
exact,
|
||||
shiftedForward,
|
||||
repeatedEarlier,
|
||||
repeatedLater,
|
||||
}
|
||||
|
||||
enum NonexistentLocalTimePolicy {
|
||||
/// Move through a daylight-saving gap to the first valid local instant after it.
|
||||
shiftForward,
|
||||
|
||||
/// Reject local times that do not exist in the requested timezone.
|
||||
reject,
|
||||
}
|
||||
|
||||
enum RepeatedLocalTimePolicy {
|
||||
/// Use the first instant for a repeated local time during a fall-back transition.
|
||||
earlier,
|
||||
|
||||
/// Use the second instant for a repeated local time during a fall-back transition.
|
||||
later,
|
||||
|
||||
/// Reject local times that occur more than once in the requested timezone.
|
||||
reject,
|
||||
}
|
||||
|
||||
class TimeZoneResolutionOptions {
|
||||
const TimeZoneResolutionOptions({
|
||||
this.nonexistentLocalTimePolicy = NonexistentLocalTimePolicy.shiftForward,
|
||||
this.repeatedLocalTimePolicy = RepeatedLocalTimePolicy.earlier,
|
||||
});
|
||||
|
||||
final NonexistentLocalTimePolicy nonexistentLocalTimePolicy;
|
||||
final RepeatedLocalTimePolicy repeatedLocalTimePolicy;
|
||||
}
|
||||
|
||||
class ResolvedLocalDateTime {
|
||||
const ResolvedLocalDateTime({
|
||||
required this.date,
|
||||
required this.wallTime,
|
||||
required this.timeZoneId,
|
||||
required this.instant,
|
||||
required this.resolution,
|
||||
required this.utcOffset,
|
||||
});
|
||||
|
||||
final CivilDate date;
|
||||
final WallTime wallTime;
|
||||
final String timeZoneId;
|
||||
final DateTime instant;
|
||||
final LocalTimeResolution resolution;
|
||||
final Duration utcOffset;
|
||||
}
|
||||
|
||||
/// Boundary for converting local civil date/time values to instants.
|
||||
abstract interface class TimeZoneResolver {
|
||||
ResolvedLocalDateTime resolve({
|
||||
required CivilDate date,
|
||||
required WallTime wallTime,
|
||||
required String timeZoneId,
|
||||
TimeZoneResolutionOptions options,
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolver for zones whose offset is already known by the application boundary.
|
||||
class FixedOffsetTimeZoneResolver implements TimeZoneResolver {
|
||||
const FixedOffsetTimeZoneResolver.utc() : offset = Duration.zero;
|
||||
|
||||
const FixedOffsetTimeZoneResolver({required this.offset});
|
||||
|
||||
final Duration offset;
|
||||
|
||||
@override
|
||||
ResolvedLocalDateTime resolve({
|
||||
required CivilDate date,
|
||||
required WallTime wallTime,
|
||||
required String timeZoneId,
|
||||
TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(),
|
||||
}) {
|
||||
final trimmedZone = timeZoneId.trim();
|
||||
if (trimmedZone.isEmpty) {
|
||||
throw DomainValidationException(
|
||||
code: DomainValidationCode.blankTimeZoneId,
|
||||
invalidValue: timeZoneId,
|
||||
name: 'timeZoneId',
|
||||
message: 'Time-zone id is required.',
|
||||
);
|
||||
}
|
||||
final localAsUtc = DateTime.utc(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
wallTime.hour,
|
||||
wallTime.minute,
|
||||
);
|
||||
|
||||
return ResolvedLocalDateTime(
|
||||
date: date,
|
||||
wallTime: wallTime,
|
||||
timeZoneId: trimmedZone,
|
||||
instant: localAsUtc.subtract(offset),
|
||||
resolution: LocalTimeResolution.exact,
|
||||
utcOffset: offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -216,7 +216,7 @@ void main() {
|
|||
name: 'Work',
|
||||
startTime: ClockTime(hour: 10, minute: 0),
|
||||
endTime: ClockTime(hour: 9, minute: 0),
|
||||
date: now,
|
||||
date: CivilDate.fromDateTime(now),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
|
|
@ -228,7 +228,7 @@ void main() {
|
|||
expectValidationCode(
|
||||
() => LockedBlockOverride.add(
|
||||
id: 'override-1',
|
||||
date: now,
|
||||
date: CivilDate.fromDateTime(now),
|
||||
name: 'Extra work',
|
||||
startTime: ClockTime(hour: 12, minute: 0),
|
||||
endTime: ClockTime(hour: 11, minute: 0),
|
||||
|
|
@ -239,7 +239,7 @@ void main() {
|
|||
expectValidationCode(
|
||||
() => LockedBlockOverride(
|
||||
id: 'override-2',
|
||||
date: now,
|
||||
date: CivilDate.fromDateTime(now),
|
||||
type: LockedBlockOverrideType.remove,
|
||||
name: 'Invalid remove',
|
||||
createdAt: now,
|
||||
|
|
|
|||
|
|
@ -557,6 +557,8 @@ void main() {
|
|||
|
||||
group('Block 06.3 locked-time regressions', () {
|
||||
final now = DateTime(2026, 6, 19, 12);
|
||||
const utcTimeZoneId = 'UTC';
|
||||
const utcResolver = FixedOffsetTimeZoneResolver.utc();
|
||||
|
||||
test('locked occurrence exposes overlay data without becoming a task', () {
|
||||
final block = LockedBlock(
|
||||
|
|
@ -574,7 +576,9 @@ void main() {
|
|||
final expansion = expandLockedBlocksForDay(
|
||||
blocks: [block],
|
||||
overrides: const [],
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
final occurrence = expansion.occurrences.single;
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,29 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('civil dates and wall times persist without timezone conversion', () {
|
||||
final date = CivilDate(2026, 3, 8);
|
||||
final wallTime = WallTime(hour: 2, minute: 30);
|
||||
|
||||
final storedDate = PersistenceCivilDateConvention.toStoredString(date);
|
||||
final storedWallTime =
|
||||
PersistenceWallTimeConvention.toStoredString(wallTime);
|
||||
|
||||
expect(
|
||||
PersistenceCivilDateConvention.description, contains('YYYY-MM-DD'));
|
||||
expect(PersistenceWallTimeConvention.description, contains('HH:MM'));
|
||||
expect(storedDate, '2026-03-08');
|
||||
expect(storedWallTime, '02:30');
|
||||
expect(
|
||||
PersistenceCivilDateConvention.fromStoredString(storedDate),
|
||||
date,
|
||||
);
|
||||
expect(
|
||||
PersistenceWallTimeConvention.fromStoredString(storedWallTime),
|
||||
wallTime,
|
||||
);
|
||||
});
|
||||
|
||||
test('nullable task fields are preserved unless explicitly cleared', () {
|
||||
final original = scheduledTask(
|
||||
id: 'nullable-task',
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ void main() {
|
|||
final override = LockedBlockOverride.remove(
|
||||
id: 'holiday',
|
||||
lockedBlockId: block.id,
|
||||
date: DateTime(2026, 6, 22),
|
||||
date: CivilDate(2026, 6, 22),
|
||||
createdAt: createdAt,
|
||||
);
|
||||
final repository = InMemoryLockedBlockRepository();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:test/test.dart';
|
|||
void main() {
|
||||
group('Domain model', () {
|
||||
final now = DateTime(2026, 6, 19, 12);
|
||||
const utcTimeZoneId = 'UTC';
|
||||
const utcResolver = FixedOffsetTimeZoneResolver.utc();
|
||||
|
||||
test('core enum values are importable', () {
|
||||
expect(TaskType.locked, TaskType.locked);
|
||||
|
|
@ -411,7 +413,7 @@ void main() {
|
|||
name: 'Appointment',
|
||||
startTime: ClockTime(hour: 13, minute: 0),
|
||||
endTime: ClockTime(hour: 14, minute: 0),
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
projectId: 'health',
|
||||
|
|
@ -439,15 +441,15 @@ void main() {
|
|||
expect(oneOff.projectId, 'health');
|
||||
expect(workHours.isRecurring, isTrue);
|
||||
expect(workHours.hiddenByDefault, isTrue);
|
||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue);
|
||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 20)), isFalse);
|
||||
expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 19)), isTrue);
|
||||
expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 20)), isFalse);
|
||||
});
|
||||
|
||||
test('locked block override removes only one date', () {
|
||||
final override = LockedBlockOverride.remove(
|
||||
id: 'friday-off',
|
||||
lockedBlockId: 'work-hours',
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
createdAt: now,
|
||||
);
|
||||
|
||||
|
|
@ -455,14 +457,14 @@ void main() {
|
|||
expect(
|
||||
override.appliesTo(
|
||||
blockId: 'work-hours',
|
||||
occurrenceDate: DateTime(2026, 6, 19),
|
||||
occurrenceDate: CivilDate(2026, 6, 19),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
override.appliesTo(
|
||||
blockId: 'work-hours',
|
||||
occurrenceDate: DateTime(2026, 6, 20),
|
||||
occurrenceDate: CivilDate(2026, 6, 20),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
|
|
@ -491,38 +493,46 @@ void main() {
|
|||
final halfDay = LockedBlockOverride.replace(
|
||||
id: 'half-day',
|
||||
lockedBlockId: 'work-hours',
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
startTime: ClockTime(hour: 9, minute: 0),
|
||||
endTime: ClockTime(hour: 12, minute: 0),
|
||||
createdAt: now,
|
||||
);
|
||||
final interval = halfDay.intervalForDate(DateTime(2026, 6, 19));
|
||||
final interval = halfDay.intervalForDate(
|
||||
targetDate: CivilDate(2026, 6, 19),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
|
||||
expect(interval, isNotNull);
|
||||
expect(interval!.start, DateTime(2026, 6, 19, 9));
|
||||
expect(interval.end, DateTime(2026, 6, 19, 12));
|
||||
expect(interval!.start, DateTime.utc(2026, 6, 19, 9));
|
||||
expect(interval.end, DateTime.utc(2026, 6, 19, 12));
|
||||
expect(workHours.recurrence, same(recurrence));
|
||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue);
|
||||
expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 19)), isTrue);
|
||||
});
|
||||
|
||||
test('locked block override can add surprise locked time', () {
|
||||
final surprise = LockedBlockOverride.add(
|
||||
id: 'surprise-lock',
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
name: 'Urgent errand',
|
||||
startTime: ClockTime(hour: 15, minute: 0),
|
||||
endTime: ClockTime(hour: 16, minute: 0),
|
||||
createdAt: now,
|
||||
projectId: 'home',
|
||||
);
|
||||
final interval = surprise.intervalForDate(DateTime(2026, 6, 19));
|
||||
final interval = surprise.intervalForDate(
|
||||
targetDate: CivilDate(2026, 6, 19),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
|
||||
expect(surprise.type, LockedBlockOverrideType.add);
|
||||
expect(surprise.lockedBlockId, isNull);
|
||||
expect(surprise.projectId, 'home');
|
||||
expect(interval, isNotNull);
|
||||
expect(interval!.start, DateTime(2026, 6, 19, 15));
|
||||
expect(interval.end, DateTime(2026, 6, 19, 16));
|
||||
expect(interval!.start, DateTime.utc(2026, 6, 19, 15));
|
||||
expect(interval.end, DateTime.utc(2026, 6, 19, 16));
|
||||
});
|
||||
|
||||
test('locked block expansion returns weekday occurrences', () {
|
||||
|
|
@ -547,21 +557,25 @@ void main() {
|
|||
final fridayExpansion = expandLockedBlocksForDay(
|
||||
blocks: [workHours],
|
||||
overrides: const [],
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
final saturdayExpansion = expandLockedBlocksForDay(
|
||||
blocks: [workHours],
|
||||
overrides: const [],
|
||||
date: DateTime(2026, 6, 20),
|
||||
date: CivilDate(2026, 6, 20),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
final occurrence = fridayExpansion.occurrences.single;
|
||||
|
||||
expect(fridayExpansion.date, DateTime(2026, 6, 19));
|
||||
expect(fridayExpansion.date, CivilDate(2026, 6, 19));
|
||||
expect(occurrence.lockedBlockId, 'work-hours');
|
||||
expect(occurrence.overrideId, isNull);
|
||||
expect(occurrence.name, 'Work');
|
||||
expect(occurrence.interval.start, DateTime(2026, 6, 19, 9));
|
||||
expect(occurrence.interval.end, DateTime(2026, 6, 19, 17));
|
||||
expect(occurrence.interval.start, DateTime.utc(2026, 6, 19, 9));
|
||||
expect(occurrence.interval.end, DateTime.utc(2026, 6, 19, 17));
|
||||
expect(occurrence.hiddenByDefault, isTrue);
|
||||
expect(occurrence.schedulingInterval.label, 'Work');
|
||||
expect(saturdayExpansion.occurrences, isEmpty);
|
||||
|
|
@ -589,13 +603,13 @@ void main() {
|
|||
final fridayOff = LockedBlockOverride.remove(
|
||||
id: 'friday-off',
|
||||
lockedBlockId: 'work-hours',
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
createdAt: now,
|
||||
);
|
||||
final halfDay = LockedBlockOverride.replace(
|
||||
id: 'half-day',
|
||||
lockedBlockId: 'work-hours',
|
||||
date: DateTime(2026, 6, 22),
|
||||
date: CivilDate(2026, 6, 22),
|
||||
name: 'Half-day work',
|
||||
startTime: ClockTime(hour: 9, minute: 0),
|
||||
endTime: ClockTime(hour: 12, minute: 0),
|
||||
|
|
@ -606,17 +620,23 @@ void main() {
|
|||
final removed = expandLockedBlocksForDay(
|
||||
blocks: [workHours],
|
||||
overrides: [fridayOff, halfDay],
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
final modified = expandLockedBlocksForDay(
|
||||
blocks: [workHours],
|
||||
overrides: [fridayOff, halfDay],
|
||||
date: DateTime(2026, 6, 22),
|
||||
date: CivilDate(2026, 6, 22),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
final normal = expandLockedBlocksForDay(
|
||||
blocks: [workHours],
|
||||
overrides: [fridayOff, halfDay],
|
||||
date: DateTime(2026, 6, 23),
|
||||
date: CivilDate(2026, 6, 23),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
final halfDayOccurrence = modified.occurrences.single;
|
||||
|
||||
|
|
@ -624,17 +644,20 @@ void main() {
|
|||
expect(halfDayOccurrence.lockedBlockId, 'work-hours');
|
||||
expect(halfDayOccurrence.overrideId, 'half-day');
|
||||
expect(halfDayOccurrence.name, 'Half-day work');
|
||||
expect(halfDayOccurrence.interval.start, DateTime(2026, 6, 22, 9));
|
||||
expect(halfDayOccurrence.interval.end, DateTime(2026, 6, 22, 12));
|
||||
expect(halfDayOccurrence.interval.start, DateTime.utc(2026, 6, 22, 9));
|
||||
expect(halfDayOccurrence.interval.end, DateTime.utc(2026, 6, 22, 12));
|
||||
expect(halfDayOccurrence.hiddenByDefault, isFalse);
|
||||
expect(normal.occurrences.single.interval.end, DateTime(2026, 6, 23, 17));
|
||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 22)), isTrue);
|
||||
expect(
|
||||
normal.occurrences.single.interval.end,
|
||||
DateTime.utc(2026, 6, 23, 17),
|
||||
);
|
||||
expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 22)), isTrue);
|
||||
});
|
||||
|
||||
test('locked block expansion includes surprise locked time overrides', () {
|
||||
final surprise = LockedBlockOverride.add(
|
||||
id: 'surprise-lock',
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
name: 'Urgent errand',
|
||||
startTime: ClockTime(hour: 15, minute: 0),
|
||||
endTime: ClockTime(hour: 16, minute: 0),
|
||||
|
|
@ -645,7 +668,9 @@ void main() {
|
|||
final expansion = expandLockedBlocksForDay(
|
||||
blocks: const [],
|
||||
overrides: [surprise],
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
final occurrence = expansion.occurrences.single;
|
||||
|
||||
|
|
@ -653,8 +678,8 @@ void main() {
|
|||
expect(occurrence.overrideId, 'surprise-lock');
|
||||
expect(occurrence.name, 'Urgent errand');
|
||||
expect(occurrence.projectId, 'home');
|
||||
expect(occurrence.interval.start, DateTime(2026, 6, 19, 15));
|
||||
expect(occurrence.interval.end, DateTime(2026, 6, 19, 16));
|
||||
expect(occurrence.interval.start, DateTime.utc(2026, 6, 19, 15));
|
||||
expect(occurrence.interval.end, DateTime.utc(2026, 6, 19, 16));
|
||||
});
|
||||
|
||||
test(
|
||||
|
|
@ -1020,6 +1045,8 @@ void main() {
|
|||
|
||||
group('SchedulingEngine starter behavior', () {
|
||||
final now = DateTime(2026, 6, 19, 12);
|
||||
const utcTimeZoneId = 'UTC';
|
||||
const utcResolver = FixedOffsetTimeZoneResolver.utc();
|
||||
const engine = SchedulingEngine();
|
||||
|
||||
Task flexibleTask() {
|
||||
|
|
@ -1253,7 +1280,7 @@ void main() {
|
|||
final replacement = LockedBlockOverride.replace(
|
||||
id: 'longer-work',
|
||||
lockedBlockId: 'work-hours',
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
startTime: ClockTime(hour: 13, minute: 0),
|
||||
endTime: ClockTime(hour: 13, minute: 30),
|
||||
createdAt: now,
|
||||
|
|
@ -1261,15 +1288,17 @@ void main() {
|
|||
final lockedIntervals = lockedSchedulingIntervalsForDay(
|
||||
blocks: [workHours],
|
||||
overrides: [replacement],
|
||||
date: DateTime(2026, 6, 19),
|
||||
date: CivilDate(2026, 6, 19),
|
||||
timeZoneId: utcTimeZoneId,
|
||||
timeZoneResolver: utcResolver,
|
||||
);
|
||||
|
||||
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
|
||||
input: SchedulingInput(
|
||||
tasks: [backlogTask],
|
||||
window: SchedulingWindow(
|
||||
start: DateTime(2026, 6, 19, 13),
|
||||
end: DateTime(2026, 6, 19, 14),
|
||||
start: DateTime.utc(2026, 6, 19, 13),
|
||||
end: DateTime.utc(2026, 6, 19, 14),
|
||||
),
|
||||
lockedIntervals: lockedIntervals,
|
||||
),
|
||||
|
|
@ -1278,10 +1307,10 @@ void main() {
|
|||
);
|
||||
final inserted = result.tasks.single;
|
||||
|
||||
expect(lockedIntervals.single.start, DateTime(2026, 6, 19, 13));
|
||||
expect(lockedIntervals.single.end, DateTime(2026, 6, 19, 13, 30));
|
||||
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 30));
|
||||
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
|
||||
expect(lockedIntervals.single.start, DateTime.utc(2026, 6, 19, 13));
|
||||
expect(lockedIntervals.single.end, DateTime.utc(2026, 6, 19, 13, 30));
|
||||
expect(inserted.scheduledStart, DateTime.utc(2026, 6, 19, 13, 30));
|
||||
expect(inserted.scheduledEnd, DateTime.utc(2026, 6, 19, 13, 45));
|
||||
});
|
||||
|
||||
test('insert backlog task does not move inflexible or critical blocks', () {
|
||||
|
|
|
|||
381
test/time_contracts_test.dart
Normal file
381
test/time_contracts_test.dart
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('Civil date and wall-time contracts', () {
|
||||
test('civil dates preserve date-only boundaries', () {
|
||||
expect(CivilDate(2024, 2, 29).addDays(1), CivilDate(2024, 3, 1));
|
||||
expect(CivilDate(2026, 12, 31).addDays(1), CivilDate(2027, 1, 1));
|
||||
expect(CivilDate.fromIsoString('2026-01-02'), CivilDate(2026, 1, 2));
|
||||
expect(CivilDate(2026, 6, 24).toIsoString(), '2026-06-24');
|
||||
});
|
||||
|
||||
test('civil date and wall-time parsing reject loose timestamp shapes', () {
|
||||
expectValidationCode(
|
||||
() => CivilDate.fromIsoString('2026-2-29'),
|
||||
DomainValidationCode.invalidCivilDate,
|
||||
);
|
||||
expectValidationCode(
|
||||
() => CivilDate.fromIsoString('2026-02-29'),
|
||||
DomainValidationCode.invalidCivilDate,
|
||||
);
|
||||
expectValidationCode(
|
||||
() => WallTime.fromIsoString('9:05'),
|
||||
DomainValidationCode.invalidClockTime,
|
||||
);
|
||||
expectValidationCode(
|
||||
() => WallTime.fromIsoString('24:00'),
|
||||
DomainValidationCode.invalidClockTime,
|
||||
);
|
||||
});
|
||||
|
||||
test('wall times preserve midnight and end-of-day values', () {
|
||||
expect(WallTime(hour: 0, minute: 0).toIsoString(), '00:00');
|
||||
expect(WallTime(hour: 23, minute: 59).toIsoString(), '23:59');
|
||||
expect(WallTime.fromIsoString('09:05').minutesSinceMidnight, 545);
|
||||
});
|
||||
|
||||
test('overnight locked rules are rejected instead of normalized', () {
|
||||
final now = DateTime.utc(2026, 6, 24, 12);
|
||||
|
||||
expectValidationCode(
|
||||
() => LockedBlock(
|
||||
id: 'overnight',
|
||||
name: 'Overnight work',
|
||||
startTime: WallTime(hour: 23, minute: 0),
|
||||
endTime: WallTime(hour: 1, minute: 0),
|
||||
date: CivilDate(2026, 6, 24),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
DomainValidationCode.invalidLockedBlock,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Clock and id boundaries', () {
|
||||
test('scheduling engine uses injected clock when updatedAt is omitted', () {
|
||||
final stamp = DateTime.utc(2026, 6, 24, 18);
|
||||
final engine = SchedulingEngine(clock: FixedClock(stamp));
|
||||
final task = Task.quickCapture(
|
||||
id: 'task',
|
||||
title: 'Inbox item',
|
||||
createdAt: DateTime.utc(2026, 6, 24, 9),
|
||||
);
|
||||
|
||||
final moved = engine.moveToBacklog(task);
|
||||
|
||||
expect(moved.updatedAt, stamp);
|
||||
});
|
||||
|
||||
test('quick capture convenience wrapper uses injected id and clock', () {
|
||||
final stamp = DateTime.utc(2026, 6, 24, 18);
|
||||
final service = QuickCaptureService(
|
||||
clock: FixedClock(stamp),
|
||||
idGenerator: SequentialIdGenerator(prefix: 'capture', start: 7),
|
||||
);
|
||||
|
||||
final result = service.captureNew(title: ' Pay bill ');
|
||||
|
||||
expect(result.status, QuickCaptureStatus.addedToBacklog);
|
||||
expect(result.task.id, 'capture-7');
|
||||
expect(result.task.title, 'Pay bill');
|
||||
expect(result.task.createdAt, stamp);
|
||||
expect(result.task.updatedAt, stamp);
|
||||
});
|
||||
|
||||
test('surprise log convenience wrapper uses injected id and clock', () {
|
||||
final stamp = DateTime.utc(2026, 6, 24, 18);
|
||||
final service = SurpriseTaskLogService(
|
||||
clock: FixedClock(stamp),
|
||||
idGenerator: SequentialIdGenerator(prefix: 'surprise'),
|
||||
);
|
||||
final input = SchedulingInput(
|
||||
tasks: const [],
|
||||
window: SchedulingWindow(
|
||||
start: DateTime.utc(2026, 6, 24, 8),
|
||||
end: DateTime.utc(2026, 6, 24, 20),
|
||||
),
|
||||
);
|
||||
|
||||
final result = service.logNew(
|
||||
title: 'Unexpected call',
|
||||
input: input,
|
||||
);
|
||||
|
||||
expect(result.surpriseTask.id, 'surprise-1');
|
||||
expect(result.surpriseTask.createdAt, stamp);
|
||||
expect(result.surpriseTask.updatedAt, stamp);
|
||||
expect(result.surpriseTask.status, TaskStatus.completed);
|
||||
});
|
||||
});
|
||||
|
||||
group('Time-zone resolution boundary', () {
|
||||
test('fixed offset resolver converts local wall time to UTC instant', () {
|
||||
const resolver = FixedOffsetTimeZoneResolver(
|
||||
offset: Duration(hours: 5, minutes: 30),
|
||||
);
|
||||
|
||||
final resolved = resolver.resolve(
|
||||
date: CivilDate(2026, 6, 24),
|
||||
wallTime: WallTime(hour: 9, minute: 0),
|
||||
timeZoneId: 'Asia/Kolkata',
|
||||
);
|
||||
|
||||
expect(resolved.instant, DateTime.utc(2026, 6, 24, 3, 30));
|
||||
expect(resolved.resolution, LocalTimeResolution.exact);
|
||||
expect(resolved.utcOffset, const Duration(hours: 5, minutes: 30));
|
||||
});
|
||||
|
||||
test('blank time-zone ids are rejected', () {
|
||||
const resolver = FixedOffsetTimeZoneResolver.utc();
|
||||
|
||||
expectValidationCode(
|
||||
() => resolver.resolve(
|
||||
date: CivilDate(2026, 6, 24),
|
||||
wallTime: WallTime(hour: 9, minute: 0),
|
||||
timeZoneId: ' ',
|
||||
),
|
||||
DomainValidationCode.blankTimeZoneId,
|
||||
);
|
||||
});
|
||||
|
||||
test('DST gap policy shifts nonexistent local time forward by default', () {
|
||||
final resolver = _LosAngelesDstFixtureResolver();
|
||||
final block = LockedBlock(
|
||||
id: 'sleep-boundary',
|
||||
name: 'Sleep boundary',
|
||||
startTime: WallTime(hour: 2, minute: 30),
|
||||
endTime: WallTime(hour: 3, minute: 30),
|
||||
recurrence: LockedBlockRecurrence.weekly(
|
||||
weekdays: {LockedWeekday.sunday},
|
||||
),
|
||||
createdAt: DateTime.utc(2026, 3, 1),
|
||||
updatedAt: DateTime.utc(2026, 3, 1),
|
||||
);
|
||||
|
||||
final expansion = expandLockedBlocksForDay(
|
||||
blocks: [block],
|
||||
overrides: const [],
|
||||
date: CivilDate(2026, 3, 8),
|
||||
timeZoneId: 'America/Los_Angeles',
|
||||
timeZoneResolver: resolver,
|
||||
);
|
||||
final directResolution = resolver.resolve(
|
||||
date: CivilDate(2026, 3, 8),
|
||||
wallTime: WallTime(hour: 2, minute: 30),
|
||||
timeZoneId: 'America/Los_Angeles',
|
||||
);
|
||||
|
||||
expect(directResolution.resolution, LocalTimeResolution.shiftedForward);
|
||||
expect(expansion.occurrences.single.interval.start,
|
||||
DateTime.utc(2026, 3, 8, 10));
|
||||
expect(expansion.occurrences.single.interval.end,
|
||||
DateTime.utc(2026, 3, 8, 10, 30));
|
||||
});
|
||||
|
||||
test('DST repeated local time uses earlier instant by default', () {
|
||||
final resolver = _LosAngelesDstFixtureResolver();
|
||||
final block = LockedBlock(
|
||||
id: 'quiet-hour',
|
||||
name: 'Quiet hour',
|
||||
startTime: WallTime(hour: 1, minute: 30),
|
||||
endTime: WallTime(hour: 2, minute: 30),
|
||||
date: CivilDate(2026, 11, 1),
|
||||
createdAt: DateTime.utc(2026, 11, 1),
|
||||
updatedAt: DateTime.utc(2026, 11, 1),
|
||||
);
|
||||
|
||||
final expansion = expandLockedBlocksForDay(
|
||||
blocks: [block],
|
||||
overrides: const [],
|
||||
date: CivilDate(2026, 11, 1),
|
||||
timeZoneId: 'America/Los_Angeles',
|
||||
timeZoneResolver: resolver,
|
||||
);
|
||||
|
||||
expect(expansion.occurrences.single.interval.start,
|
||||
DateTime.utc(2026, 11, 1, 8, 30));
|
||||
expect(expansion.occurrences.single.interval.end,
|
||||
DateTime.utc(2026, 11, 1, 10, 30));
|
||||
});
|
||||
|
||||
test('DST repeated local time can choose the later instant', () {
|
||||
final resolver = _LosAngelesDstFixtureResolver();
|
||||
final block = LockedBlock(
|
||||
id: 'quiet-hour',
|
||||
name: 'Quiet hour',
|
||||
startTime: WallTime(hour: 1, minute: 30),
|
||||
endTime: WallTime(hour: 2, minute: 30),
|
||||
date: CivilDate(2026, 11, 1),
|
||||
createdAt: DateTime.utc(2026, 11, 1),
|
||||
updatedAt: DateTime.utc(2026, 11, 1),
|
||||
);
|
||||
|
||||
final expansion = expandLockedBlocksForDay(
|
||||
blocks: [block],
|
||||
overrides: const [],
|
||||
date: CivilDate(2026, 11, 1),
|
||||
timeZoneId: 'America/Los_Angeles',
|
||||
timeZoneResolver: resolver,
|
||||
resolutionOptions: const TimeZoneResolutionOptions(
|
||||
repeatedLocalTimePolicy: RepeatedLocalTimePolicy.later,
|
||||
),
|
||||
);
|
||||
|
||||
expect(expansion.occurrences.single.interval.start,
|
||||
DateTime.utc(2026, 11, 1, 9, 30));
|
||||
expect(expansion.occurrences.single.interval.end,
|
||||
DateTime.utc(2026, 11, 1, 10, 30));
|
||||
});
|
||||
|
||||
test('DST reject policies return typed validation codes', () {
|
||||
final resolver = _LosAngelesDstFixtureResolver();
|
||||
|
||||
expectValidationCode(
|
||||
() => resolver.resolve(
|
||||
date: CivilDate(2026, 3, 8),
|
||||
wallTime: WallTime(hour: 2, minute: 30),
|
||||
timeZoneId: 'America/Los_Angeles',
|
||||
options: const TimeZoneResolutionOptions(
|
||||
nonexistentLocalTimePolicy: NonexistentLocalTimePolicy.reject,
|
||||
),
|
||||
),
|
||||
DomainValidationCode.nonexistentLocalTime,
|
||||
);
|
||||
expectValidationCode(
|
||||
() => resolver.resolve(
|
||||
date: CivilDate(2026, 11, 1),
|
||||
wallTime: WallTime(hour: 1, minute: 30),
|
||||
timeZoneId: 'America/Los_Angeles',
|
||||
options: const TimeZoneResolutionOptions(
|
||||
repeatedLocalTimePolicy: RepeatedLocalTimePolicy.reject,
|
||||
),
|
||||
),
|
||||
DomainValidationCode.ambiguousLocalTime,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void expectValidationCode(
|
||||
Object? Function() body,
|
||||
DomainValidationCode expectedCode,
|
||||
) {
|
||||
expect(
|
||||
body,
|
||||
throwsA(
|
||||
isA<DomainValidationException>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
expectedCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LosAngelesDstFixtureResolver implements TimeZoneResolver {
|
||||
@override
|
||||
ResolvedLocalDateTime resolve({
|
||||
required CivilDate date,
|
||||
required WallTime wallTime,
|
||||
required String timeZoneId,
|
||||
TimeZoneResolutionOptions options = const TimeZoneResolutionOptions(),
|
||||
}) {
|
||||
if (timeZoneId.trim() != 'America/Los_Angeles') {
|
||||
throw DomainValidationException(
|
||||
code: DomainValidationCode.blankTimeZoneId,
|
||||
invalidValue: timeZoneId,
|
||||
name: 'timeZoneId',
|
||||
message: 'Fixture resolver only supports America/Los_Angeles.',
|
||||
);
|
||||
}
|
||||
|
||||
if (date == CivilDate(2026, 3, 8) && wallTime.hour == 2) {
|
||||
if (options.nonexistentLocalTimePolicy ==
|
||||
NonexistentLocalTimePolicy.reject) {
|
||||
throw DomainValidationException(
|
||||
code: DomainValidationCode.nonexistentLocalTime,
|
||||
invalidValue: {'date': date, 'wallTime': wallTime},
|
||||
name: 'wallTime',
|
||||
message: 'Local time does not exist in this time-zone.',
|
||||
);
|
||||
}
|
||||
|
||||
return _resolved(
|
||||
date: date,
|
||||
wallTime: WallTime(hour: 3, minute: 0),
|
||||
timeZoneId: timeZoneId,
|
||||
offset: const Duration(hours: -7),
|
||||
resolution: LocalTimeResolution.shiftedForward,
|
||||
);
|
||||
}
|
||||
|
||||
if (date == CivilDate(2026, 11, 1) && wallTime.hour == 1) {
|
||||
if (options.repeatedLocalTimePolicy == RepeatedLocalTimePolicy.reject) {
|
||||
throw DomainValidationException(
|
||||
code: DomainValidationCode.ambiguousLocalTime,
|
||||
invalidValue: {'date': date, 'wallTime': wallTime},
|
||||
name: 'wallTime',
|
||||
message: 'Local time is repeated in this time-zone.',
|
||||
);
|
||||
}
|
||||
|
||||
final useLater =
|
||||
options.repeatedLocalTimePolicy == RepeatedLocalTimePolicy.later;
|
||||
return _resolved(
|
||||
date: date,
|
||||
wallTime: wallTime,
|
||||
timeZoneId: timeZoneId,
|
||||
offset: Duration(hours: useLater ? -8 : -7),
|
||||
resolution: useLater
|
||||
? LocalTimeResolution.repeatedLater
|
||||
: LocalTimeResolution.repeatedEarlier,
|
||||
);
|
||||
}
|
||||
|
||||
final offset = _offsetFor(date, wallTime);
|
||||
return _resolved(
|
||||
date: date,
|
||||
wallTime: wallTime,
|
||||
timeZoneId: timeZoneId,
|
||||
offset: offset,
|
||||
resolution: LocalTimeResolution.exact,
|
||||
);
|
||||
}
|
||||
|
||||
Duration _offsetFor(CivilDate date, WallTime wallTime) {
|
||||
if (date == CivilDate(2026, 3, 8) && wallTime.hour >= 3) {
|
||||
return const Duration(hours: -7);
|
||||
}
|
||||
if (date == CivilDate(2026, 11, 1) && wallTime.hour >= 2) {
|
||||
return const Duration(hours: -8);
|
||||
}
|
||||
return const Duration(hours: -8);
|
||||
}
|
||||
|
||||
ResolvedLocalDateTime _resolved({
|
||||
required CivilDate date,
|
||||
required WallTime wallTime,
|
||||
required String timeZoneId,
|
||||
required Duration offset,
|
||||
required LocalTimeResolution resolution,
|
||||
}) {
|
||||
final localAsUtc = DateTime.utc(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
wallTime.hour,
|
||||
wallTime.minute,
|
||||
);
|
||||
|
||||
return ResolvedLocalDateTime(
|
||||
date: date,
|
||||
wallTime: wallTime,
|
||||
timeZoneId: timeZoneId,
|
||||
instant: localAsUtc.subtract(offset),
|
||||
resolution: resolution,
|
||||
utcOffset: offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue