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
|
# 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
|
Purpose: Re-establish a trustworthy executable baseline after the archived
|
||||||
Blocks 01–10, resolve remaining V1 specification ambiguities, and harden the
|
Blocks 01–10, resolve remaining V1 specification ambiguities, and harden the
|
||||||
|
|
@ -220,6 +220,37 @@ clock contracts.
|
||||||
|
|
||||||
Recommended Codex level: extra high
|
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:
|
Tasks:
|
||||||
|
|
||||||
- Introduce injectable clock and ID-generation contracts for application/domain
|
- Introduce injectable clock and ID-generation contracts for application/domain
|
||||||
|
|
@ -45,7 +45,7 @@ runtime database dependency.
|
||||||
|
|
||||||
| Order | Plan | Status | Backend/UI |
|
| 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 |
|
| 12 | `V1_BLOCK_12_Occupancy_Scheduling_Correctness.md` | Planned | Backend |
|
||||||
| 13 | `V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.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 |
|
| 14 | `V1_BLOCK_14_Application_Use_Cases_Read_Models.md` | Planned | Backend |
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ following source files:
|
||||||
- `src/scheduling_engine.dart`
|
- `src/scheduling_engine.dart`
|
||||||
- `src/task_actions.dart`
|
- `src/task_actions.dart`
|
||||||
- `src/task_statistics.dart`
|
- `src/task_statistics.dart`
|
||||||
|
- `src/time_contracts.dart`
|
||||||
- `src/timeline_state.dart`
|
- `src/timeline_state.dart`
|
||||||
|
|
||||||
This baseline is used by later chunks to distinguish intentional breaking
|
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/quick_capture.dart` | `QuickCaptureStatus` |
|
||||||
| `src/scheduling_engine.dart` | `SchedulingNoticeType` |
|
| `src/scheduling_engine.dart` | `SchedulingNoticeType` |
|
||||||
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
|
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
|
||||||
|
| `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` |
|
||||||
| `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` |
|
| `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` |
|
||||||
|
|
||||||
## Public classes and top-level functions
|
## 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/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` |
|
||||||
| `src/task_actions.dart` | `FlexibleTaskActionResult`, `PushDestinationResult`, `RequiredTaskActionResult`, `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, `FlexibleTaskActionService`, `RequiredTaskActionService`, `SurpriseTaskLogService` |
|
| `src/task_actions.dart` | `FlexibleTaskActionResult`, `PushDestinationResult`, `RequiredTaskActionResult`, `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, `FlexibleTaskActionService`, `RequiredTaskActionService`, `SurpriseTaskLogService` |
|
||||||
| `src/task_statistics.dart` | `TaskStatistics` |
|
| `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` |
|
| `src/timeline_state.dart` | `TimelineItem`, `CompactTimelineState`, `TimelineItemMapper` |
|
||||||
|
|
||||||
## Current model baseline
|
## Current model baseline
|
||||||
|
|
@ -97,8 +100,9 @@ Known baseline gaps to preserve as explicit later work:
|
||||||
status/event distinction.
|
status/event distinction.
|
||||||
- `Task` does not yet have explicit completion timestamp or backlog-entered
|
- `Task` does not yet have explicit completion timestamp or backlog-entered
|
||||||
timestamp fields.
|
timestamp fields.
|
||||||
- Time is currently `DateTime`-based; civil-date, wall-time, clock, ID, and
|
- Instant ranges still use `DateTime`; locked recurrence dates, wall-clock
|
||||||
timezone contracts are Chunk 11.4.
|
rules, clocks, IDs, and timezone resolution are explicit Chunk 11.4
|
||||||
|
contracts.
|
||||||
|
|
||||||
## Intentional API Changes After Baseline
|
## 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
|
- Public collection fields now expose unmodifiable snapshots instead of caller
|
||||||
mutable collections.
|
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
|
## Repository contracts
|
||||||
|
|
||||||
The current repository surface is pure Dart and in-memory only:
|
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
|
time. Blocks 12, 13, and 15 add those fields explicitly where the product needs
|
||||||
them.
|
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
|
## Persistence direction
|
||||||
|
|
||||||
MongoDB is the committed persistence target. The V1 scheduling core should still
|
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.
|
models without importing MongoDB APIs into scheduling logic.
|
||||||
|
|
||||||
Current V1 persistence preparation includes pure Dart repository interfaces,
|
Current V1 persistence preparation includes pure Dart repository interfaces,
|
||||||
in-memory fakes for tests, UTC DateTime conventions, stable enum names, and
|
in-memory fakes for tests, UTC DateTime conventions, civil-date and wall-time
|
||||||
MongoDB-friendly document-shaped map helpers. It does not include a MongoDB
|
string conventions, stable enum names, and MongoDB-friendly document-shaped map
|
||||||
driver, adapter implementation, connection string, Atlas/cloud setup, local
|
helpers. It does not include a MongoDB driver, adapter implementation,
|
||||||
MongoDB server requirement, user accounts, network sync, background sync, or
|
connection string, Atlas/cloud setup, local MongoDB server requirement, user
|
||||||
mobile background reconciliation.
|
accounts, network sync, background sync, or mobile background reconciliation.
|
||||||
|
|
||||||
Sync remains future work and should only be added if an active plan explicitly
|
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
|
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/scheduling_engine.dart';
|
||||||
export 'src/task_actions.dart';
|
export 'src/task_actions.dart';
|
||||||
export 'src/task_statistics.dart';
|
export 'src/task_statistics.dart';
|
||||||
|
export 'src/time_contracts.dart';
|
||||||
export 'src/timeline_state.dart';
|
export 'src/timeline_state.dart';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
// traversal, or UI-specific state.
|
// traversal, or UI-specific state.
|
||||||
|
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
|
import 'time_contracts.dart';
|
||||||
|
|
||||||
/// Row-style input for creating one child task.
|
/// 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
|
/// dependency graphs, and it only completes sibling children when the caller
|
||||||
/// explicitly completes the parent.
|
/// explicitly completes the parent.
|
||||||
class ChildTaskCompletionService {
|
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.
|
/// Complete one child and auto-complete its parent only when all children are complete.
|
||||||
ChildTaskCompletionResult completeChild({
|
ChildTaskCompletionResult completeChild({
|
||||||
|
|
@ -214,7 +220,7 @@ class ChildTaskCompletionService {
|
||||||
required String childTaskId,
|
required String childTaskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? DateTime.now();
|
final now = updatedAt ?? clock.now();
|
||||||
final child = _taskById(tasks, childTaskId);
|
final child = _taskById(tasks, childTaskId);
|
||||||
if (child == null) {
|
if (child == null) {
|
||||||
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
|
||||||
|
|
@ -269,7 +275,7 @@ class ChildTaskCompletionService {
|
||||||
required String parentTaskId,
|
required String parentTaskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? DateTime.now();
|
final now = updatedAt ?? clock.now();
|
||||||
final parent = _taskById(tasks, parentTaskId);
|
final parent = _taskById(tasks, parentTaskId);
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
throw ArgumentError.value(
|
throw ArgumentError.value(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
// concrete `TimeInterval`s that the scheduler can avoid.
|
// concrete `TimeInterval`s that the scheduler can avoid.
|
||||||
|
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
|
import 'time_contracts.dart';
|
||||||
|
|
||||||
/// Weekday value using DateTime's Monday-first convention.
|
/// Weekday value using DateTime's Monday-first convention.
|
||||||
///
|
///
|
||||||
|
|
@ -27,41 +28,8 @@ enum LockedWeekday {
|
||||||
final int dateTimeValue;
|
final int dateTimeValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Time of day without a calendar date.
|
/// Backwards-compatible name for explicit wall-clock time.
|
||||||
///
|
typedef ClockTime = WallTime;
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recurrence rule for locked time.
|
/// Recurrence rule for locked time.
|
||||||
///
|
///
|
||||||
|
|
@ -87,7 +55,7 @@ class LockedBlockRecurrence {
|
||||||
final Set<LockedWeekday> weekdays;
|
final Set<LockedWeekday> weekdays;
|
||||||
|
|
||||||
/// Whether this recurrence applies to [date].
|
/// Whether this recurrence applies to [date].
|
||||||
bool occursOn(DateTime date) {
|
bool occursOn(CivilDate date) {
|
||||||
return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday);
|
return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +118,7 @@ class LockedBlock {
|
||||||
final ClockTime endTime;
|
final ClockTime endTime;
|
||||||
|
|
||||||
/// Calendar date for one-off locked blocks. Recurring blocks leave this null.
|
/// 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.
|
/// Optional weekly recurrence. Null means this is a one-off block.
|
||||||
final LockedBlockRecurrence? recurrence;
|
final LockedBlockRecurrence? recurrence;
|
||||||
|
|
@ -177,7 +145,7 @@ class LockedBlock {
|
||||||
String? name,
|
String? name,
|
||||||
ClockTime? startTime,
|
ClockTime? startTime,
|
||||||
ClockTime? endTime,
|
ClockTime? endTime,
|
||||||
DateTime? date,
|
CivilDate? date,
|
||||||
LockedBlockRecurrence? recurrence,
|
LockedBlockRecurrence? recurrence,
|
||||||
bool? hiddenByDefault,
|
bool? hiddenByDefault,
|
||||||
String? projectId,
|
String? projectId,
|
||||||
|
|
@ -309,7 +277,7 @@ class LockedBlockOverride {
|
||||||
factory LockedBlockOverride.remove({
|
factory LockedBlockOverride.remove({
|
||||||
required String id,
|
required String id,
|
||||||
required String lockedBlockId,
|
required String lockedBlockId,
|
||||||
required DateTime date,
|
required CivilDate date,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -327,7 +295,7 @@ class LockedBlockOverride {
|
||||||
factory LockedBlockOverride.replace({
|
factory LockedBlockOverride.replace({
|
||||||
required String id,
|
required String id,
|
||||||
required String lockedBlockId,
|
required String lockedBlockId,
|
||||||
required DateTime date,
|
required CivilDate date,
|
||||||
required ClockTime startTime,
|
required ClockTime startTime,
|
||||||
required ClockTime endTime,
|
required ClockTime endTime,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
|
|
@ -354,7 +322,7 @@ class LockedBlockOverride {
|
||||||
/// Adds surprise locked time for one date.
|
/// Adds surprise locked time for one date.
|
||||||
factory LockedBlockOverride.add({
|
factory LockedBlockOverride.add({
|
||||||
required String id,
|
required String id,
|
||||||
required DateTime date,
|
required CivilDate date,
|
||||||
required String name,
|
required String name,
|
||||||
required ClockTime startTime,
|
required ClockTime startTime,
|
||||||
required ClockTime endTime,
|
required ClockTime endTime,
|
||||||
|
|
@ -383,8 +351,8 @@ class LockedBlockOverride {
|
||||||
/// Base block id affected by remove/replace overrides. Null for add overrides.
|
/// Base block id affected by remove/replace overrides. Null for add overrides.
|
||||||
final String? lockedBlockId;
|
final String? lockedBlockId;
|
||||||
|
|
||||||
/// Date the override applies to. Time components are ignored by date checks.
|
/// Date the override applies to.
|
||||||
final DateTime date;
|
final CivilDate date;
|
||||||
|
|
||||||
/// Kind of override operation.
|
/// Kind of override operation.
|
||||||
final LockedBlockOverrideType type;
|
final LockedBlockOverrideType type;
|
||||||
|
|
@ -413,7 +381,7 @@ class LockedBlockOverride {
|
||||||
/// Whether this override targets [blockId] on [occurrenceDate].
|
/// Whether this override targets [blockId] on [occurrenceDate].
|
||||||
bool appliesTo({
|
bool appliesTo({
|
||||||
required String blockId,
|
required String blockId,
|
||||||
required DateTime occurrenceDate,
|
required CivilDate occurrenceDate,
|
||||||
}) {
|
}) {
|
||||||
return lockedBlockId == blockId && _sameDate(date, 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
|
/// Remove overrides intentionally return null because they do not create a new
|
||||||
/// interval. Replacement/add overrides need both [startTime] and [endTime].
|
/// 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 start = startTime;
|
||||||
final end = endTime;
|
final end = endTime;
|
||||||
|
|
||||||
|
|
@ -431,10 +405,14 @@ class LockedBlockOverride {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TimeInterval(
|
return _resolvedInterval(
|
||||||
start: start.onDate(targetDate),
|
date: targetDate,
|
||||||
end: end.onDate(targetDate),
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
label: name,
|
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.
|
/// Calendar date represented by this expansion, normalized to year/month/day.
|
||||||
final DateTime date;
|
final CivilDate date;
|
||||||
|
|
||||||
/// Concrete locked occurrences active on [date].
|
/// Concrete locked occurrences active on [date].
|
||||||
final List<LockedBlockOccurrence> occurrences;
|
final List<LockedBlockOccurrence> occurrences;
|
||||||
|
|
@ -476,7 +454,11 @@ class LockedScheduleExpansion {
|
||||||
LockedScheduleExpansion expandLockedBlocksForDay({
|
LockedScheduleExpansion expandLockedBlocksForDay({
|
||||||
required List<LockedBlock> blocks,
|
required List<LockedBlock> blocks,
|
||||||
required List<LockedBlockOverride> overrides,
|
required List<LockedBlockOverride> overrides,
|
||||||
required DateTime date,
|
required CivilDate date,
|
||||||
|
required String timeZoneId,
|
||||||
|
required TimeZoneResolver timeZoneResolver,
|
||||||
|
TimeZoneResolutionOptions resolutionOptions =
|
||||||
|
const TimeZoneResolutionOptions(),
|
||||||
}) {
|
}) {
|
||||||
final occurrences = <LockedBlockOccurrence>[];
|
final occurrences = <LockedBlockOccurrence>[];
|
||||||
final sortedOverrides = [...overrides]..sort((a, b) {
|
final sortedOverrides = [...overrides]..sort((a, b) {
|
||||||
|
|
@ -510,11 +492,20 @@ LockedScheduleExpansion expandLockedBlocksForDay({
|
||||||
|
|
||||||
occurrences.add(
|
occurrences.add(
|
||||||
replacement == null
|
replacement == null
|
||||||
? _occurrenceFromBlock(block, date)
|
? _occurrenceFromBlock(
|
||||||
|
block,
|
||||||
|
date,
|
||||||
|
timeZoneId: timeZoneId,
|
||||||
|
timeZoneResolver: timeZoneResolver,
|
||||||
|
resolutionOptions: resolutionOptions,
|
||||||
|
)
|
||||||
: _occurrenceFromReplacement(
|
: _occurrenceFromReplacement(
|
||||||
block: block,
|
block: block,
|
||||||
override: replacement,
|
override: replacement,
|
||||||
date: date,
|
date: date,
|
||||||
|
timeZoneId: timeZoneId,
|
||||||
|
timeZoneResolver: timeZoneResolver,
|
||||||
|
resolutionOptions: resolutionOptions,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -525,7 +516,13 @@ LockedScheduleExpansion expandLockedBlocksForDay({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final occurrence = _occurrenceFromAddedOverride(override, date);
|
final occurrence = _occurrenceFromAddedOverride(
|
||||||
|
override,
|
||||||
|
date,
|
||||||
|
timeZoneId: timeZoneId,
|
||||||
|
timeZoneResolver: timeZoneResolver,
|
||||||
|
resolutionOptions: resolutionOptions,
|
||||||
|
);
|
||||||
if (occurrence != null) {
|
if (occurrence != null) {
|
||||||
occurrences.add(occurrence);
|
occurrences.add(occurrence);
|
||||||
}
|
}
|
||||||
|
|
@ -541,7 +538,7 @@ LockedScheduleExpansion expandLockedBlocksForDay({
|
||||||
});
|
});
|
||||||
|
|
||||||
return LockedScheduleExpansion(
|
return LockedScheduleExpansion(
|
||||||
date: DateTime(date.year, date.month, date.day),
|
date: date,
|
||||||
occurrences: List<LockedBlockOccurrence>.unmodifiable(occurrences),
|
occurrences: List<LockedBlockOccurrence>.unmodifiable(occurrences),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -566,12 +563,19 @@ LockedBlockOverride? _lastReplacementOverride(
|
||||||
List<TimeInterval> lockedSchedulingIntervalsForDay({
|
List<TimeInterval> lockedSchedulingIntervalsForDay({
|
||||||
required List<LockedBlock> blocks,
|
required List<LockedBlock> blocks,
|
||||||
required List<LockedBlockOverride> overrides,
|
required List<LockedBlockOverride> overrides,
|
||||||
required DateTime date,
|
required CivilDate date,
|
||||||
|
required String timeZoneId,
|
||||||
|
required TimeZoneResolver timeZoneResolver,
|
||||||
|
TimeZoneResolutionOptions resolutionOptions =
|
||||||
|
const TimeZoneResolutionOptions(),
|
||||||
}) {
|
}) {
|
||||||
return expandLockedBlocksForDay(
|
return expandLockedBlocksForDay(
|
||||||
blocks: blocks,
|
blocks: blocks,
|
||||||
overrides: overrides,
|
overrides: overrides,
|
||||||
date: date,
|
date: date,
|
||||||
|
timeZoneId: timeZoneId,
|
||||||
|
timeZoneResolver: timeZoneResolver,
|
||||||
|
resolutionOptions: resolutionOptions,
|
||||||
).schedulingIntervals;
|
).schedulingIntervals;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -656,7 +660,7 @@ int completedDuringLockedHoursMinutes({
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a base block should produce an occurrence on [date].
|
/// 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;
|
final recurrence = block.recurrence;
|
||||||
if (recurrence != null) {
|
if (recurrence != null) {
|
||||||
return recurrence.occursOn(date);
|
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].
|
/// Convert a base block rule into a concrete occurrence for [date].
|
||||||
LockedBlockOccurrence _occurrenceFromBlock(
|
LockedBlockOccurrence _occurrenceFromBlock(
|
||||||
LockedBlock block,
|
LockedBlock block,
|
||||||
DateTime date,
|
CivilDate date, {
|
||||||
) {
|
required String timeZoneId,
|
||||||
|
required TimeZoneResolver timeZoneResolver,
|
||||||
|
TimeZoneResolutionOptions resolutionOptions =
|
||||||
|
const TimeZoneResolutionOptions(),
|
||||||
|
}) {
|
||||||
return LockedBlockOccurrence(
|
return LockedBlockOccurrence(
|
||||||
lockedBlockId: block.id,
|
lockedBlockId: block.id,
|
||||||
name: block.name,
|
name: block.name,
|
||||||
interval: TimeInterval(
|
interval: _resolvedInterval(
|
||||||
start: block.startTime.onDate(date),
|
date: date,
|
||||||
end: block.endTime.onDate(date),
|
startTime: block.startTime,
|
||||||
|
endTime: block.endTime,
|
||||||
label: block.name,
|
label: block.name,
|
||||||
|
timeZoneId: timeZoneId,
|
||||||
|
timeZoneResolver: timeZoneResolver,
|
||||||
|
resolutionOptions: resolutionOptions,
|
||||||
),
|
),
|
||||||
hiddenByDefault: block.hiddenByDefault,
|
hiddenByDefault: block.hiddenByDefault,
|
||||||
projectId: block.projectId,
|
projectId: block.projectId,
|
||||||
|
|
@ -690,7 +702,11 @@ LockedBlockOccurrence _occurrenceFromBlock(
|
||||||
LockedBlockOccurrence _occurrenceFromReplacement({
|
LockedBlockOccurrence _occurrenceFromReplacement({
|
||||||
required LockedBlock block,
|
required LockedBlock block,
|
||||||
required LockedBlockOverride override,
|
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 startTime = override.startTime ?? block.startTime;
|
||||||
final endTime = override.endTime ?? block.endTime;
|
final endTime = override.endTime ?? block.endTime;
|
||||||
|
|
@ -700,10 +716,14 @@ LockedBlockOccurrence _occurrenceFromReplacement({
|
||||||
lockedBlockId: block.id,
|
lockedBlockId: block.id,
|
||||||
overrideId: override.id,
|
overrideId: override.id,
|
||||||
name: name,
|
name: name,
|
||||||
interval: TimeInterval(
|
interval: _resolvedInterval(
|
||||||
start: startTime.onDate(date),
|
date: date,
|
||||||
end: endTime.onDate(date),
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
label: name,
|
label: name,
|
||||||
|
timeZoneId: timeZoneId,
|
||||||
|
timeZoneResolver: timeZoneResolver,
|
||||||
|
resolutionOptions: resolutionOptions,
|
||||||
),
|
),
|
||||||
hiddenByDefault: override.hiddenByDefault,
|
hiddenByDefault: override.hiddenByDefault,
|
||||||
projectId: override.projectId ?? block.projectId,
|
projectId: override.projectId ?? block.projectId,
|
||||||
|
|
@ -713,9 +733,18 @@ LockedBlockOccurrence _occurrenceFromReplacement({
|
||||||
/// Convert an add override into a concrete occurrence when it is complete.
|
/// Convert an add override into a concrete occurrence when it is complete.
|
||||||
LockedBlockOccurrence? _occurrenceFromAddedOverride(
|
LockedBlockOccurrence? _occurrenceFromAddedOverride(
|
||||||
LockedBlockOverride override,
|
LockedBlockOverride override,
|
||||||
DateTime date,
|
CivilDate date, {
|
||||||
) {
|
required String timeZoneId,
|
||||||
final interval = override.intervalForDate(date);
|
required TimeZoneResolver timeZoneResolver,
|
||||||
|
TimeZoneResolutionOptions resolutionOptions =
|
||||||
|
const TimeZoneResolutionOptions(),
|
||||||
|
}) {
|
||||||
|
final interval = override.intervalForDate(
|
||||||
|
targetDate: date,
|
||||||
|
timeZoneId: timeZoneId,
|
||||||
|
timeZoneResolver: timeZoneResolver,
|
||||||
|
resolutionOptions: resolutionOptions,
|
||||||
|
);
|
||||||
final name = override.name;
|
final name = override.name;
|
||||||
|
|
||||||
if (interval == null || name == null) {
|
if (interval == null || name == null) {
|
||||||
|
|
@ -762,13 +791,37 @@ DateTime _latest(DateTime first, DateTime second) {
|
||||||
return first.isAfter(second) ? first : second;
|
return first.isAfter(second) ? first : second;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compare only the calendar date parts of two timestamps.
|
TimeInterval _resolvedInterval({
|
||||||
bool _sameDate(DateTime first, DateTime second) {
|
required CivilDate date,
|
||||||
return first.year == second.year &&
|
required ClockTime startTime,
|
||||||
first.month == second.month &&
|
required ClockTime endTime,
|
||||||
first.day == second.day;
|
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 _requiredLockedString(
|
||||||
String value,
|
String value,
|
||||||
String name,
|
String name,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,11 @@ enum DomainValidationCode {
|
||||||
invalidActualInterval,
|
invalidActualInterval,
|
||||||
invalidTimeInterval,
|
invalidTimeInterval,
|
||||||
invalidSchedulingWindow,
|
invalidSchedulingWindow,
|
||||||
|
invalidCivilDate,
|
||||||
invalidClockTime,
|
invalidClockTime,
|
||||||
|
blankTimeZoneId,
|
||||||
|
nonexistentLocalTime,
|
||||||
|
ambiguousLocalTime,
|
||||||
emptyRecurrence,
|
emptyRecurrence,
|
||||||
invalidLockedBlock,
|
invalidLockedBlock,
|
||||||
invalidLockedOverride,
|
invalidLockedOverride,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
// code should use. It deliberately does not serialize domain models or import a
|
// code should use. It deliberately does not serialize domain models or import a
|
||||||
// MongoDB client.
|
// MongoDB client.
|
||||||
|
|
||||||
|
import 'time_contracts.dart';
|
||||||
|
|
||||||
/// DateTime storage convention for future document persistence.
|
/// DateTime storage convention for future document persistence.
|
||||||
abstract final class PersistenceDateTimeConvention {
|
abstract final class PersistenceDateTimeConvention {
|
||||||
/// Human-readable convention kept close to the helper for tests and docs.
|
/// 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.
|
/// Stable enum-name mapping plan for future document persistence.
|
||||||
extension PersistenceEnumName on Enum {
|
extension PersistenceEnumName on Enum {
|
||||||
/// Persist enum values by their Dart enum name.
|
/// Persist enum values by their Dart enum name.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
import 'scheduling_engine.dart';
|
import 'scheduling_engine.dart';
|
||||||
|
import 'time_contracts.dart';
|
||||||
|
|
||||||
/// Outcome of a quick-capture request.
|
/// Outcome of a quick-capture request.
|
||||||
///
|
///
|
||||||
|
|
@ -117,12 +118,22 @@ class QuickCaptureResult {
|
||||||
/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand
|
/// [SchedulingEngine]. It keeps quick-capture UI code from needing to understand
|
||||||
/// every scheduler precondition.
|
/// every scheduler precondition.
|
||||||
class QuickCaptureService {
|
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
|
/// Scheduling dependency. Defaults to the starter engine but can be swapped in
|
||||||
/// tests or future implementations.
|
/// tests or future implementations.
|
||||||
final SchedulingEngine engine;
|
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.
|
/// Capture a task and optionally place it into the next available slot.
|
||||||
///
|
///
|
||||||
/// Flow:
|
/// Flow:
|
||||||
|
|
@ -219,6 +230,47 @@ class QuickCaptureService {
|
||||||
schedulingResult: result,
|
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.
|
/// Find a task in a returned scheduling result.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
// 4. Private apply helpers: convert plans into updated tasks and notices.
|
// 4. Private apply helpers: convert plans into updated tasks and notices.
|
||||||
|
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
|
import 'time_contracts.dart';
|
||||||
|
|
||||||
/// Category for scheduler notices.
|
/// Category for scheduler notices.
|
||||||
///
|
///
|
||||||
|
|
@ -276,7 +277,12 @@ class SchedulingResult {
|
||||||
/// - `queue` is the ordered set of flexible tasks that may be placed or shifted.
|
/// - `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.
|
/// - `placement` is a map from task id to the interval chosen by the planner.
|
||||||
class SchedulingEngine {
|
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.
|
/// Insert a backlog task into the earliest available slot today.
|
||||||
///
|
///
|
||||||
|
|
@ -352,7 +358,7 @@ class SchedulingEngine {
|
||||||
input: input,
|
input: input,
|
||||||
insertedTask: task,
|
insertedTask: task,
|
||||||
placement: placement,
|
placement: placement,
|
||||||
updatedAt: updatedAt ?? DateTime.now(),
|
updatedAt: updatedAt ?? clock.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,7 +446,7 @@ class SchedulingEngine {
|
||||||
pushedTask: task,
|
pushedTask: task,
|
||||||
placement: placement,
|
placement: placement,
|
||||||
pushedTaskMessage: 'Flexible task pushed to next available slot.',
|
pushedTaskMessage: 'Flexible task pushed to next available slot.',
|
||||||
updatedAt: updatedAt ?? DateTime.now(),
|
updatedAt: updatedAt ?? clock.now(),
|
||||||
countPushedTaskAsManual: countAsManualPush,
|
countPushedTaskAsManual: countAsManualPush,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -515,7 +521,7 @@ class SchedulingEngine {
|
||||||
pushedTask: task,
|
pushedTask: task,
|
||||||
placement: placement,
|
placement: placement,
|
||||||
pushedTaskMessage: 'Flexible task moved to tomorrow.',
|
pushedTaskMessage: 'Flexible task moved to tomorrow.',
|
||||||
updatedAt: updatedAt ?? DateTime.now(),
|
updatedAt: updatedAt ?? clock.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -600,7 +606,7 @@ class SchedulingEngine {
|
||||||
input: input,
|
input: input,
|
||||||
rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(),
|
rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(),
|
||||||
placement: placement,
|
placement: placement,
|
||||||
updatedAt: updatedAt ?? DateTime.now(),
|
updatedAt: updatedAt ?? clock.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -657,7 +663,7 @@ class SchedulingEngine {
|
||||||
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
|
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
|
||||||
return task.copyWith(
|
return task.copyWith(
|
||||||
status: TaskStatus.backlog,
|
status: TaskStatus.backlog,
|
||||||
updatedAt: updatedAt ?? DateTime.now(),
|
updatedAt: updatedAt ?? clock.now(),
|
||||||
stats: task.stats.incrementMovedToBacklog(),
|
stats: task.stats.incrementMovedToBacklog(),
|
||||||
clearSchedule: true,
|
clearSchedule: true,
|
||||||
);
|
);
|
||||||
|
|
@ -669,7 +675,7 @@ class SchedulingEngine {
|
||||||
/// actual scheduled slot should change.
|
/// actual scheduled slot should change.
|
||||||
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
|
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
|
||||||
return task.copyWith(
|
return task.copyWith(
|
||||||
updatedAt: updatedAt ?? DateTime.now(),
|
updatedAt: updatedAt ?? clock.now(),
|
||||||
stats: task.stats.incrementManualPush(),
|
stats: task.stats.incrementManualPush(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -681,7 +687,7 @@ class SchedulingEngine {
|
||||||
/// or time block that cannot simply be rescheduled automatically.
|
/// or time block that cannot simply be rescheduled automatically.
|
||||||
Task markMissed(Task task, {DateTime? updatedAt}) {
|
Task markMissed(Task task, {DateTime? updatedAt}) {
|
||||||
final nextStats = task.stats.incrementMissed();
|
final nextStats = task.stats.incrementMissed();
|
||||||
final now = updatedAt ?? DateTime.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
|
||||||
if (task.type == TaskType.critical) {
|
if (task.type == TaskType.critical) {
|
||||||
return task.copyWith(
|
return task.copyWith(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
import 'scheduling_engine.dart';
|
import 'scheduling_engine.dart';
|
||||||
|
import 'time_contracts.dart';
|
||||||
|
|
||||||
/// Quick actions available from a flexible task card.
|
/// Quick actions available from a flexible task card.
|
||||||
///
|
///
|
||||||
|
|
@ -218,11 +219,15 @@ class SurpriseTaskLogResult {
|
||||||
class FlexibleTaskActionService {
|
class FlexibleTaskActionService {
|
||||||
const FlexibleTaskActionService({
|
const FlexibleTaskActionService({
|
||||||
this.schedulingEngine = const SchedulingEngine(),
|
this.schedulingEngine = const SchedulingEngine(),
|
||||||
|
this.clock = const SystemClock(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Scheduling dependency used for actions that need timeline changes.
|
/// Scheduling dependency used for actions that need timeline changes.
|
||||||
final SchedulingEngine schedulingEngine;
|
final SchedulingEngine schedulingEngine;
|
||||||
|
|
||||||
|
/// Clock boundary used when an action timestamp is not supplied.
|
||||||
|
final Clock clock;
|
||||||
|
|
||||||
/// Apply the first-stage quick action.
|
/// Apply the first-stage quick action.
|
||||||
///
|
///
|
||||||
/// Direct actions (`done`, `backlog`) return a changed task. `push` returns the
|
/// Direct actions (`done`, `backlog`) return a changed task. `push` returns the
|
||||||
|
|
@ -240,11 +245,12 @@ class FlexibleTaskActionService {
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case FlexibleTaskQuickAction.done:
|
case FlexibleTaskQuickAction.done:
|
||||||
|
final now = updatedAt ?? clock.now();
|
||||||
return FlexibleTaskActionResult(
|
return FlexibleTaskActionResult(
|
||||||
action: action,
|
action: action,
|
||||||
task: task.copyWith(
|
task: task.copyWith(
|
||||||
status: TaskStatus.completed,
|
status: TaskStatus.completed,
|
||||||
updatedAt: updatedAt ?? DateTime.now(),
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case FlexibleTaskQuickAction.push:
|
case FlexibleTaskQuickAction.push:
|
||||||
|
|
@ -258,9 +264,10 @@ class FlexibleTaskActionService {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
case FlexibleTaskQuickAction.backlog:
|
case FlexibleTaskQuickAction.backlog:
|
||||||
|
final now = updatedAt ?? clock.now();
|
||||||
return FlexibleTaskActionResult(
|
return FlexibleTaskActionResult(
|
||||||
action: action,
|
action: action,
|
||||||
task: schedulingEngine.moveToBacklog(task, updatedAt: updatedAt),
|
task: schedulingEngine.moveToBacklog(task, updatedAt: now),
|
||||||
);
|
);
|
||||||
case FlexibleTaskQuickAction.breakUp:
|
case FlexibleTaskQuickAction.breakUp:
|
||||||
return FlexibleTaskActionResult(
|
return FlexibleTaskActionResult(
|
||||||
|
|
@ -281,23 +288,24 @@ class FlexibleTaskActionService {
|
||||||
required String taskId,
|
required String taskId,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
final now = updatedAt ?? clock.now();
|
||||||
final result = switch (destination) {
|
final result = switch (destination) {
|
||||||
PushDestination.nextAvailableSlot =>
|
PushDestination.nextAvailableSlot =>
|
||||||
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
|
||||||
input: input,
|
input: input,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
updatedAt: updatedAt,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
PushDestination.tomorrowTopOfQueue =>
|
PushDestination.tomorrowTopOfQueue =>
|
||||||
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
|
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
|
||||||
input: input,
|
input: input,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
updatedAt: updatedAt,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
PushDestination.backlog => _moveTaskToBacklog(
|
PushDestination.backlog => _moveTaskToBacklog(
|
||||||
input: input,
|
input: input,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
updatedAt: updatedAt,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -386,11 +394,15 @@ class FlexibleTaskActionService {
|
||||||
class RequiredTaskActionService {
|
class RequiredTaskActionService {
|
||||||
const RequiredTaskActionService({
|
const RequiredTaskActionService({
|
||||||
this.schedulingEngine = const SchedulingEngine(),
|
this.schedulingEngine = const SchedulingEngine(),
|
||||||
|
this.clock = const SystemClock(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Scheduling dependency used for required missed behavior.
|
/// Scheduling dependency used for required missed behavior.
|
||||||
final SchedulingEngine schedulingEngine;
|
final SchedulingEngine schedulingEngine;
|
||||||
|
|
||||||
|
/// Clock boundary used when an action timestamp is not supplied.
|
||||||
|
final Clock clock;
|
||||||
|
|
||||||
/// Apply one required-card state transition.
|
/// Apply one required-card state transition.
|
||||||
RequiredTaskActionResult apply({
|
RequiredTaskActionResult apply({
|
||||||
required Task task,
|
required Task task,
|
||||||
|
|
@ -405,7 +417,7 @@ class RequiredTaskActionService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final now = updatedAt ?? DateTime.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
|
||||||
return switch (action) {
|
return switch (action) {
|
||||||
RequiredTaskAction.done => RequiredTaskActionResult(
|
RequiredTaskAction.done => RequiredTaskActionResult(
|
||||||
|
|
@ -467,18 +479,26 @@ class RequiredTaskActionService {
|
||||||
class SurpriseTaskLogService {
|
class SurpriseTaskLogService {
|
||||||
const SurpriseTaskLogService({
|
const SurpriseTaskLogService({
|
||||||
this.schedulingEngine = const SchedulingEngine(),
|
this.schedulingEngine = const SchedulingEngine(),
|
||||||
|
this.clock = const SystemClock(),
|
||||||
|
this.idGenerator,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Scheduling dependency used to move overlapping flexible tasks.
|
/// Scheduling dependency used to move overlapping flexible tasks.
|
||||||
final SchedulingEngine schedulingEngine;
|
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.
|
/// Log one surprise task against a scheduling snapshot.
|
||||||
SurpriseTaskLogResult log({
|
SurpriseTaskLogResult log({
|
||||||
required SurpriseTaskLogRequest request,
|
required SurpriseTaskLogRequest request,
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? DateTime.now();
|
final now = updatedAt ?? clock.now();
|
||||||
final surpriseTask = _createSurpriseTask(request, updatedAt: now);
|
final surpriseTask = _createSurpriseTask(request, updatedAt: now);
|
||||||
final surpriseInterval = _scheduledIntervalForTask(surpriseTask);
|
final surpriseInterval = _scheduledIntervalForTask(surpriseTask);
|
||||||
var currentTasks = <Task>[surpriseTask, ...input.tasks];
|
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(
|
Task _createSurpriseTask(
|
||||||
SurpriseTaskLogRequest request, {
|
SurpriseTaskLogRequest request, {
|
||||||
required DateTime updatedAt,
|
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',
|
name: 'Work',
|
||||||
startTime: ClockTime(hour: 10, minute: 0),
|
startTime: ClockTime(hour: 10, minute: 0),
|
||||||
endTime: ClockTime(hour: 9, minute: 0),
|
endTime: ClockTime(hour: 9, minute: 0),
|
||||||
date: now,
|
date: CivilDate.fromDateTime(now),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
|
|
@ -228,7 +228,7 @@ void main() {
|
||||||
expectValidationCode(
|
expectValidationCode(
|
||||||
() => LockedBlockOverride.add(
|
() => LockedBlockOverride.add(
|
||||||
id: 'override-1',
|
id: 'override-1',
|
||||||
date: now,
|
date: CivilDate.fromDateTime(now),
|
||||||
name: 'Extra work',
|
name: 'Extra work',
|
||||||
startTime: ClockTime(hour: 12, minute: 0),
|
startTime: ClockTime(hour: 12, minute: 0),
|
||||||
endTime: ClockTime(hour: 11, minute: 0),
|
endTime: ClockTime(hour: 11, minute: 0),
|
||||||
|
|
@ -239,7 +239,7 @@ void main() {
|
||||||
expectValidationCode(
|
expectValidationCode(
|
||||||
() => LockedBlockOverride(
|
() => LockedBlockOverride(
|
||||||
id: 'override-2',
|
id: 'override-2',
|
||||||
date: now,
|
date: CivilDate.fromDateTime(now),
|
||||||
type: LockedBlockOverrideType.remove,
|
type: LockedBlockOverrideType.remove,
|
||||||
name: 'Invalid remove',
|
name: 'Invalid remove',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|
|
||||||
|
|
@ -557,6 +557,8 @@ void main() {
|
||||||
|
|
||||||
group('Block 06.3 locked-time regressions', () {
|
group('Block 06.3 locked-time regressions', () {
|
||||||
final now = DateTime(2026, 6, 19, 12);
|
final now = DateTime(2026, 6, 19, 12);
|
||||||
|
const utcTimeZoneId = 'UTC';
|
||||||
|
const utcResolver = FixedOffsetTimeZoneResolver.utc();
|
||||||
|
|
||||||
test('locked occurrence exposes overlay data without becoming a task', () {
|
test('locked occurrence exposes overlay data without becoming a task', () {
|
||||||
final block = LockedBlock(
|
final block = LockedBlock(
|
||||||
|
|
@ -574,7 +576,9 @@ void main() {
|
||||||
final expansion = expandLockedBlocksForDay(
|
final expansion = expandLockedBlocksForDay(
|
||||||
blocks: [block],
|
blocks: [block],
|
||||||
overrides: const [],
|
overrides: const [],
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
);
|
);
|
||||||
final occurrence = expansion.occurrences.single;
|
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', () {
|
test('nullable task fields are preserved unless explicitly cleared', () {
|
||||||
final original = scheduledTask(
|
final original = scheduledTask(
|
||||||
id: 'nullable-task',
|
id: 'nullable-task',
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ void main() {
|
||||||
final override = LockedBlockOverride.remove(
|
final override = LockedBlockOverride.remove(
|
||||||
id: 'holiday',
|
id: 'holiday',
|
||||||
lockedBlockId: block.id,
|
lockedBlockId: block.id,
|
||||||
date: DateTime(2026, 6, 22),
|
date: CivilDate(2026, 6, 22),
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
);
|
);
|
||||||
final repository = InMemoryLockedBlockRepository();
|
final repository = InMemoryLockedBlockRepository();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:test/test.dart';
|
||||||
void main() {
|
void main() {
|
||||||
group('Domain model', () {
|
group('Domain model', () {
|
||||||
final now = DateTime(2026, 6, 19, 12);
|
final now = DateTime(2026, 6, 19, 12);
|
||||||
|
const utcTimeZoneId = 'UTC';
|
||||||
|
const utcResolver = FixedOffsetTimeZoneResolver.utc();
|
||||||
|
|
||||||
test('core enum values are importable', () {
|
test('core enum values are importable', () {
|
||||||
expect(TaskType.locked, TaskType.locked);
|
expect(TaskType.locked, TaskType.locked);
|
||||||
|
|
@ -411,7 +413,7 @@ void main() {
|
||||||
name: 'Appointment',
|
name: 'Appointment',
|
||||||
startTime: ClockTime(hour: 13, minute: 0),
|
startTime: ClockTime(hour: 13, minute: 0),
|
||||||
endTime: ClockTime(hour: 14, minute: 0),
|
endTime: ClockTime(hour: 14, minute: 0),
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
projectId: 'health',
|
projectId: 'health',
|
||||||
|
|
@ -439,15 +441,15 @@ void main() {
|
||||||
expect(oneOff.projectId, 'health');
|
expect(oneOff.projectId, 'health');
|
||||||
expect(workHours.isRecurring, isTrue);
|
expect(workHours.isRecurring, isTrue);
|
||||||
expect(workHours.hiddenByDefault, isTrue);
|
expect(workHours.hiddenByDefault, isTrue);
|
||||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue);
|
expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 19)), isTrue);
|
||||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 20)), isFalse);
|
expect(workHours.recurrence!.occursOn(CivilDate(2026, 6, 20)), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('locked block override removes only one date', () {
|
test('locked block override removes only one date', () {
|
||||||
final override = LockedBlockOverride.remove(
|
final override = LockedBlockOverride.remove(
|
||||||
id: 'friday-off',
|
id: 'friday-off',
|
||||||
lockedBlockId: 'work-hours',
|
lockedBlockId: 'work-hours',
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -455,14 +457,14 @@ void main() {
|
||||||
expect(
|
expect(
|
||||||
override.appliesTo(
|
override.appliesTo(
|
||||||
blockId: 'work-hours',
|
blockId: 'work-hours',
|
||||||
occurrenceDate: DateTime(2026, 6, 19),
|
occurrenceDate: CivilDate(2026, 6, 19),
|
||||||
),
|
),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
override.appliesTo(
|
override.appliesTo(
|
||||||
blockId: 'work-hours',
|
blockId: 'work-hours',
|
||||||
occurrenceDate: DateTime(2026, 6, 20),
|
occurrenceDate: CivilDate(2026, 6, 20),
|
||||||
),
|
),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
|
|
@ -491,38 +493,46 @@ void main() {
|
||||||
final halfDay = LockedBlockOverride.replace(
|
final halfDay = LockedBlockOverride.replace(
|
||||||
id: 'half-day',
|
id: 'half-day',
|
||||||
lockedBlockId: 'work-hours',
|
lockedBlockId: 'work-hours',
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
startTime: ClockTime(hour: 9, minute: 0),
|
startTime: ClockTime(hour: 9, minute: 0),
|
||||||
endTime: ClockTime(hour: 12, minute: 0),
|
endTime: ClockTime(hour: 12, minute: 0),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
);
|
);
|
||||||
final interval = halfDay.intervalForDate(DateTime(2026, 6, 19));
|
final interval = halfDay.intervalForDate(
|
||||||
|
targetDate: CivilDate(2026, 6, 19),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
|
);
|
||||||
|
|
||||||
expect(interval, isNotNull);
|
expect(interval, isNotNull);
|
||||||
expect(interval!.start, DateTime(2026, 6, 19, 9));
|
expect(interval!.start, DateTime.utc(2026, 6, 19, 9));
|
||||||
expect(interval.end, DateTime(2026, 6, 19, 12));
|
expect(interval.end, DateTime.utc(2026, 6, 19, 12));
|
||||||
expect(workHours.recurrence, same(recurrence));
|
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', () {
|
test('locked block override can add surprise locked time', () {
|
||||||
final surprise = LockedBlockOverride.add(
|
final surprise = LockedBlockOverride.add(
|
||||||
id: 'surprise-lock',
|
id: 'surprise-lock',
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
name: 'Urgent errand',
|
name: 'Urgent errand',
|
||||||
startTime: ClockTime(hour: 15, minute: 0),
|
startTime: ClockTime(hour: 15, minute: 0),
|
||||||
endTime: ClockTime(hour: 16, minute: 0),
|
endTime: ClockTime(hour: 16, minute: 0),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
projectId: 'home',
|
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.type, LockedBlockOverrideType.add);
|
||||||
expect(surprise.lockedBlockId, isNull);
|
expect(surprise.lockedBlockId, isNull);
|
||||||
expect(surprise.projectId, 'home');
|
expect(surprise.projectId, 'home');
|
||||||
expect(interval, isNotNull);
|
expect(interval, isNotNull);
|
||||||
expect(interval!.start, DateTime(2026, 6, 19, 15));
|
expect(interval!.start, DateTime.utc(2026, 6, 19, 15));
|
||||||
expect(interval.end, DateTime(2026, 6, 19, 16));
|
expect(interval.end, DateTime.utc(2026, 6, 19, 16));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('locked block expansion returns weekday occurrences', () {
|
test('locked block expansion returns weekday occurrences', () {
|
||||||
|
|
@ -547,21 +557,25 @@ void main() {
|
||||||
final fridayExpansion = expandLockedBlocksForDay(
|
final fridayExpansion = expandLockedBlocksForDay(
|
||||||
blocks: [workHours],
|
blocks: [workHours],
|
||||||
overrides: const [],
|
overrides: const [],
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
);
|
);
|
||||||
final saturdayExpansion = expandLockedBlocksForDay(
|
final saturdayExpansion = expandLockedBlocksForDay(
|
||||||
blocks: [workHours],
|
blocks: [workHours],
|
||||||
overrides: const [],
|
overrides: const [],
|
||||||
date: DateTime(2026, 6, 20),
|
date: CivilDate(2026, 6, 20),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
);
|
);
|
||||||
final occurrence = fridayExpansion.occurrences.single;
|
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.lockedBlockId, 'work-hours');
|
||||||
expect(occurrence.overrideId, isNull);
|
expect(occurrence.overrideId, isNull);
|
||||||
expect(occurrence.name, 'Work');
|
expect(occurrence.name, 'Work');
|
||||||
expect(occurrence.interval.start, DateTime(2026, 6, 19, 9));
|
expect(occurrence.interval.start, DateTime.utc(2026, 6, 19, 9));
|
||||||
expect(occurrence.interval.end, DateTime(2026, 6, 19, 17));
|
expect(occurrence.interval.end, DateTime.utc(2026, 6, 19, 17));
|
||||||
expect(occurrence.hiddenByDefault, isTrue);
|
expect(occurrence.hiddenByDefault, isTrue);
|
||||||
expect(occurrence.schedulingInterval.label, 'Work');
|
expect(occurrence.schedulingInterval.label, 'Work');
|
||||||
expect(saturdayExpansion.occurrences, isEmpty);
|
expect(saturdayExpansion.occurrences, isEmpty);
|
||||||
|
|
@ -589,13 +603,13 @@ void main() {
|
||||||
final fridayOff = LockedBlockOverride.remove(
|
final fridayOff = LockedBlockOverride.remove(
|
||||||
id: 'friday-off',
|
id: 'friday-off',
|
||||||
lockedBlockId: 'work-hours',
|
lockedBlockId: 'work-hours',
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
);
|
);
|
||||||
final halfDay = LockedBlockOverride.replace(
|
final halfDay = LockedBlockOverride.replace(
|
||||||
id: 'half-day',
|
id: 'half-day',
|
||||||
lockedBlockId: 'work-hours',
|
lockedBlockId: 'work-hours',
|
||||||
date: DateTime(2026, 6, 22),
|
date: CivilDate(2026, 6, 22),
|
||||||
name: 'Half-day work',
|
name: 'Half-day work',
|
||||||
startTime: ClockTime(hour: 9, minute: 0),
|
startTime: ClockTime(hour: 9, minute: 0),
|
||||||
endTime: ClockTime(hour: 12, minute: 0),
|
endTime: ClockTime(hour: 12, minute: 0),
|
||||||
|
|
@ -606,17 +620,23 @@ void main() {
|
||||||
final removed = expandLockedBlocksForDay(
|
final removed = expandLockedBlocksForDay(
|
||||||
blocks: [workHours],
|
blocks: [workHours],
|
||||||
overrides: [fridayOff, halfDay],
|
overrides: [fridayOff, halfDay],
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
);
|
);
|
||||||
final modified = expandLockedBlocksForDay(
|
final modified = expandLockedBlocksForDay(
|
||||||
blocks: [workHours],
|
blocks: [workHours],
|
||||||
overrides: [fridayOff, halfDay],
|
overrides: [fridayOff, halfDay],
|
||||||
date: DateTime(2026, 6, 22),
|
date: CivilDate(2026, 6, 22),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
);
|
);
|
||||||
final normal = expandLockedBlocksForDay(
|
final normal = expandLockedBlocksForDay(
|
||||||
blocks: [workHours],
|
blocks: [workHours],
|
||||||
overrides: [fridayOff, halfDay],
|
overrides: [fridayOff, halfDay],
|
||||||
date: DateTime(2026, 6, 23),
|
date: CivilDate(2026, 6, 23),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
);
|
);
|
||||||
final halfDayOccurrence = modified.occurrences.single;
|
final halfDayOccurrence = modified.occurrences.single;
|
||||||
|
|
||||||
|
|
@ -624,17 +644,20 @@ void main() {
|
||||||
expect(halfDayOccurrence.lockedBlockId, 'work-hours');
|
expect(halfDayOccurrence.lockedBlockId, 'work-hours');
|
||||||
expect(halfDayOccurrence.overrideId, 'half-day');
|
expect(halfDayOccurrence.overrideId, 'half-day');
|
||||||
expect(halfDayOccurrence.name, 'Half-day work');
|
expect(halfDayOccurrence.name, 'Half-day work');
|
||||||
expect(halfDayOccurrence.interval.start, DateTime(2026, 6, 22, 9));
|
expect(halfDayOccurrence.interval.start, DateTime.utc(2026, 6, 22, 9));
|
||||||
expect(halfDayOccurrence.interval.end, DateTime(2026, 6, 22, 12));
|
expect(halfDayOccurrence.interval.end, DateTime.utc(2026, 6, 22, 12));
|
||||||
expect(halfDayOccurrence.hiddenByDefault, isFalse);
|
expect(halfDayOccurrence.hiddenByDefault, isFalse);
|
||||||
expect(normal.occurrences.single.interval.end, DateTime(2026, 6, 23, 17));
|
expect(
|
||||||
expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 22)), isTrue);
|
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', () {
|
test('locked block expansion includes surprise locked time overrides', () {
|
||||||
final surprise = LockedBlockOverride.add(
|
final surprise = LockedBlockOverride.add(
|
||||||
id: 'surprise-lock',
|
id: 'surprise-lock',
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
name: 'Urgent errand',
|
name: 'Urgent errand',
|
||||||
startTime: ClockTime(hour: 15, minute: 0),
|
startTime: ClockTime(hour: 15, minute: 0),
|
||||||
endTime: ClockTime(hour: 16, minute: 0),
|
endTime: ClockTime(hour: 16, minute: 0),
|
||||||
|
|
@ -645,7 +668,9 @@ void main() {
|
||||||
final expansion = expandLockedBlocksForDay(
|
final expansion = expandLockedBlocksForDay(
|
||||||
blocks: const [],
|
blocks: const [],
|
||||||
overrides: [surprise],
|
overrides: [surprise],
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
);
|
);
|
||||||
final occurrence = expansion.occurrences.single;
|
final occurrence = expansion.occurrences.single;
|
||||||
|
|
||||||
|
|
@ -653,8 +678,8 @@ void main() {
|
||||||
expect(occurrence.overrideId, 'surprise-lock');
|
expect(occurrence.overrideId, 'surprise-lock');
|
||||||
expect(occurrence.name, 'Urgent errand');
|
expect(occurrence.name, 'Urgent errand');
|
||||||
expect(occurrence.projectId, 'home');
|
expect(occurrence.projectId, 'home');
|
||||||
expect(occurrence.interval.start, DateTime(2026, 6, 19, 15));
|
expect(occurrence.interval.start, DateTime.utc(2026, 6, 19, 15));
|
||||||
expect(occurrence.interval.end, DateTime(2026, 6, 19, 16));
|
expect(occurrence.interval.end, DateTime.utc(2026, 6, 19, 16));
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
|
|
@ -1020,6 +1045,8 @@ void main() {
|
||||||
|
|
||||||
group('SchedulingEngine starter behavior', () {
|
group('SchedulingEngine starter behavior', () {
|
||||||
final now = DateTime(2026, 6, 19, 12);
|
final now = DateTime(2026, 6, 19, 12);
|
||||||
|
const utcTimeZoneId = 'UTC';
|
||||||
|
const utcResolver = FixedOffsetTimeZoneResolver.utc();
|
||||||
const engine = SchedulingEngine();
|
const engine = SchedulingEngine();
|
||||||
|
|
||||||
Task flexibleTask() {
|
Task flexibleTask() {
|
||||||
|
|
@ -1253,7 +1280,7 @@ void main() {
|
||||||
final replacement = LockedBlockOverride.replace(
|
final replacement = LockedBlockOverride.replace(
|
||||||
id: 'longer-work',
|
id: 'longer-work',
|
||||||
lockedBlockId: 'work-hours',
|
lockedBlockId: 'work-hours',
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
startTime: ClockTime(hour: 13, minute: 0),
|
startTime: ClockTime(hour: 13, minute: 0),
|
||||||
endTime: ClockTime(hour: 13, minute: 30),
|
endTime: ClockTime(hour: 13, minute: 30),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|
@ -1261,15 +1288,17 @@ void main() {
|
||||||
final lockedIntervals = lockedSchedulingIntervalsForDay(
|
final lockedIntervals = lockedSchedulingIntervalsForDay(
|
||||||
blocks: [workHours],
|
blocks: [workHours],
|
||||||
overrides: [replacement],
|
overrides: [replacement],
|
||||||
date: DateTime(2026, 6, 19),
|
date: CivilDate(2026, 6, 19),
|
||||||
|
timeZoneId: utcTimeZoneId,
|
||||||
|
timeZoneResolver: utcResolver,
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
|
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
|
||||||
input: SchedulingInput(
|
input: SchedulingInput(
|
||||||
tasks: [backlogTask],
|
tasks: [backlogTask],
|
||||||
window: SchedulingWindow(
|
window: SchedulingWindow(
|
||||||
start: DateTime(2026, 6, 19, 13),
|
start: DateTime.utc(2026, 6, 19, 13),
|
||||||
end: DateTime(2026, 6, 19, 14),
|
end: DateTime.utc(2026, 6, 19, 14),
|
||||||
),
|
),
|
||||||
lockedIntervals: lockedIntervals,
|
lockedIntervals: lockedIntervals,
|
||||||
),
|
),
|
||||||
|
|
@ -1278,10 +1307,10 @@ void main() {
|
||||||
);
|
);
|
||||||
final inserted = result.tasks.single;
|
final inserted = result.tasks.single;
|
||||||
|
|
||||||
expect(lockedIntervals.single.start, DateTime(2026, 6, 19, 13));
|
expect(lockedIntervals.single.start, DateTime.utc(2026, 6, 19, 13));
|
||||||
expect(lockedIntervals.single.end, DateTime(2026, 6, 19, 13, 30));
|
expect(lockedIntervals.single.end, DateTime.utc(2026, 6, 19, 13, 30));
|
||||||
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 30));
|
expect(inserted.scheduledStart, DateTime.utc(2026, 6, 19, 13, 30));
|
||||||
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 45));
|
expect(inserted.scheduledEnd, DateTime.utc(2026, 6, 19, 13, 45));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('insert backlog task does not move inflexible or critical blocks', () {
|
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