feat(domain): harden v1 time semantics

This commit is contained in:
Ashley Venn 2026-06-24 17:14:45 -07:00
parent 91bdb26b1a
commit 81f137b8c0
19 changed files with 1156 additions and 146 deletions

View file

@ -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 0110, 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

View file

@ -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 |

View file

@ -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:

View file

@ -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

View file

@ -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';

View file

@ -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(

View file

@ -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,

View file

@ -33,7 +33,11 @@ enum DomainValidationCode {
invalidActualInterval,
invalidTimeInterval,
invalidSchedulingWindow,
invalidCivilDate,
invalidClockTime,
blankTimeZoneId,
nonexistentLocalTime,
ambiguousLocalTime,
emptyRecurrence,
invalidLockedBlock,
invalidLockedOverride,

View file

@ -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.

View file

@ -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.

View file

@ -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(

View file

@ -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
View 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,
);
}
}

View file

@ -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,

View file

@ -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;

View file

@ -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',

View file

@ -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();

View file

@ -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', () {

View 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,
);
}
}