focus-flow/lib/src/locked_time.dart

943 lines
28 KiB
Dart

// Locked-time modeling and expansion.
//
// Locked time is anything the flexible scheduler should treat as unavailable:
// work, appointments, streams, sleep boundaries, relationship blocks, or one-off
// interruptions. This file converts human-friendly locked block definitions into
// concrete `TimeInterval`s that the scheduler can avoid.
import 'models.dart';
import 'time_contracts.dart';
/// Weekday value using DateTime's Monday-first convention.
///
/// Dart represents weekdays as integers where Monday is `1` and Sunday is `7`.
/// This enum wraps those integers so the rest of the code can talk in readable
/// names while still comparing directly to [DateTime.weekday].
enum LockedWeekday {
monday(DateTime.monday),
tuesday(DateTime.tuesday),
wednesday(DateTime.wednesday),
thursday(DateTime.thursday),
friday(DateTime.friday),
saturday(DateTime.saturday),
sunday(DateTime.sunday);
const LockedWeekday(this.dateTimeValue);
/// Matching [DateTime.weekday] integer value.
final int dateTimeValue;
}
/// Backwards-compatible name for explicit wall-clock time.
typedef ClockTime = WallTime;
/// Recurrence rule for locked time.
///
/// The current starter implementation only supports weekly recurrence because
/// that covers the product's fixed work/stream/relationship blocks. This object
/// keeps recurrence separate from [LockedBlock] so monthly or custom recurrence
/// rules can be added later without changing the rest of the locked-time model.
class LockedBlockRecurrence {
LockedBlockRecurrence.weekly({
required Set<LockedWeekday> weekdays,
}) : weekdays = Set<LockedWeekday>.unmodifiable(weekdays) {
if (weekdays.isEmpty) {
throw DomainValidationException(
code: DomainValidationCode.emptyRecurrence,
invalidValue: weekdays,
name: 'weekdays',
message: 'Recurring locked blocks need at least one weekday.',
);
}
}
/// Weekdays when this recurrence should produce an occurrence.
final Set<LockedWeekday> weekdays;
/// Whether this recurrence applies to [date].
bool occursOn(CivilDate date) {
return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday);
}
}
/// Scheduling constraint that reserves time without becoming a task card.
///
/// Locked blocks represent time the flexible scheduler should avoid: work hours,
/// appointments, sleep boundaries, streams, relationship blocks, or any other
/// commitment that should not be automatically rearranged. They are modeled
/// separately from normal tasks because the UI may show them as subtle overlays
/// rather than actionable task cards.
///
/// A block is either:
/// - one-off, with [date] set and [recurrence] null; or
/// - recurring, with [recurrence] set and [date] usually null.
class LockedBlock {
LockedBlock({
required String id,
required String name,
required this.startTime,
required this.endTime,
required this.createdAt,
required this.updatedAt,
this.date,
this.recurrence,
this.hiddenByDefault = true,
this.projectId,
}) : id =
_requiredLockedString(id, 'id', DomainValidationCode.blankStableId),
name = _requiredLockedString(
name, 'name', DomainValidationCode.blankName) {
if (recurrence == null && date == null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedBlock,
invalidValue: null,
name: 'date',
message: 'One-off locked blocks need a date.',
);
}
if (!_clockTimeIsBefore(startTime, endTime)) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedBlock,
invalidValue: {'startTime': startTime, 'endTime': endTime},
name: 'startTime/endTime',
message: 'Locked block end time must be after start time.',
);
}
}
/// Stable id for persistence and one-day overrides.
final String id;
/// User-facing label, such as `Work`, `Stream`, or `Relationship block`.
final String name;
/// Start time-of-day. Combined with a date during expansion.
final ClockTime startTime;
/// End time-of-day. Combined with a date during expansion.
final ClockTime endTime;
/// Calendar date for one-off locked blocks. Recurring blocks leave this null.
final CivilDate? date;
/// Optional weekly recurrence. Null means this is a one-off block.
final LockedBlockRecurrence? recurrence;
/// Whether UI should keep this block visually quiet by default. Scheduling still
/// treats hidden blocks as blocked time.
final bool hiddenByDefault;
/// Optional project/category association for UI colors or reports.
final String? projectId;
/// Creation timestamp for persistence/auditing.
final DateTime createdAt;
/// Last update timestamp for persistence/auditing.
final DateTime updatedAt;
/// Convenience check for whether this block expands through recurrence.
bool get isRecurring => recurrence != null;
/// Return a copy with selected locked-block details changed.
LockedBlock copyWith({
String? id,
String? name,
ClockTime? startTime,
ClockTime? endTime,
CivilDate? date,
LockedBlockRecurrence? recurrence,
bool? hiddenByDefault,
String? projectId,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return LockedBlock(
id: id ?? this.id,
name: name ?? this.name,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
date: date ?? this.date,
recurrence: recurrence ?? this.recurrence,
hiddenByDefault: hiddenByDefault ?? this.hiddenByDefault,
projectId: projectId ?? this.projectId,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
/// Concrete locked-time occurrence for one calendar day.
///
/// [LockedBlock] is a rule; [LockedBlockOccurrence] is the actual result on a
/// specific date. The scheduler only needs occurrences/time intervals, while UI
/// can use ids and visibility flags to explain where the blocked time came from.
class LockedBlockOccurrence {
const LockedBlockOccurrence({
required this.name,
required this.interval,
required this.hiddenByDefault,
this.lockedBlockId,
this.overrideId,
this.projectId,
});
/// Display/debug label for this occurrence.
final String name;
/// Concrete start/end on one calendar date.
final TimeInterval interval;
/// Visibility hint for UI; does not affect scheduling.
final bool hiddenByDefault;
/// Source recurring/one-off block id, when this came from a base block.
final String? lockedBlockId;
/// Source override id, when this was replaced or added for one date.
final String? overrideId;
/// Optional project/category association for UI colors or reports.
final String? projectId;
/// Scheduler-facing interval. Visibility only affects future UI overlays.
///
/// A fresh [TimeInterval] is returned with [name] as the label so scheduling
/// notices/debugging can identify the blocked source without depending on the
/// richer occurrence object.
TimeInterval get schedulingInterval {
return TimeInterval(
start: interval.start,
end: interval.end,
label: name,
);
}
}
/// Type of one-day override applied to locked time.
///
/// Overrides let the app handle holidays, one-off appointments, or changed work
/// hours without editing the base recurring rule.
enum LockedBlockOverrideType {
/// Suppress one occurrence of a recurring locked block.
remove,
/// Replace one occurrence with different details.
replace,
/// Add a one-off locked occurrence that has no base recurring block.
add,
}
/// One-day change to locked time that leaves the base recurrence unchanged.
///
/// Overrides are applied during expansion for a single date only. This is safer
/// than modifying the recurring block because the normal schedule remains intact
/// for every other day.
///
/// Use the named factories to create valid override shapes:
/// - [remove] references a base block and suppresses that occurrence.
/// - [replace] references a base block and swaps its details for one day.
/// - [add] creates an extra locked occurrence that has no base block.
class LockedBlockOverride {
LockedBlockOverride({
required String id,
required this.date,
required this.type,
required this.createdAt,
required this.updatedAt,
String? lockedBlockId,
String? name,
this.startTime,
this.endTime,
this.hiddenByDefault = true,
this.projectId,
}) : id =
_requiredLockedString(id, 'id', DomainValidationCode.blankStableId),
lockedBlockId = _nullableLockedString(
lockedBlockId,
'lockedBlockId',
DomainValidationCode.blankStableId,
),
name = _nullableLockedString(
name,
'name',
DomainValidationCode.blankName,
) {
_validateOverrideShape(
type: type,
lockedBlockId: this.lockedBlockId,
name: this.name,
startTime: startTime,
endTime: endTime,
);
}
/// Removes a recurring occurrence for one date.
factory LockedBlockOverride.remove({
required String id,
required String lockedBlockId,
required CivilDate date,
required DateTime createdAt,
DateTime? updatedAt,
}) {
return LockedBlockOverride(
id: id,
lockedBlockId: lockedBlockId,
date: date,
type: LockedBlockOverrideType.remove,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
}
/// Replaces a recurring occurrence with different times for one date.
factory LockedBlockOverride.replace({
required String id,
required String lockedBlockId,
required CivilDate date,
required ClockTime startTime,
required ClockTime endTime,
required DateTime createdAt,
String? name,
bool hiddenByDefault = true,
String? projectId,
DateTime? updatedAt,
}) {
return LockedBlockOverride(
id: id,
lockedBlockId: lockedBlockId,
date: date,
type: LockedBlockOverrideType.replace,
name: name,
startTime: startTime,
endTime: endTime,
hiddenByDefault: hiddenByDefault,
projectId: projectId,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
}
/// Adds surprise locked time for one date.
factory LockedBlockOverride.add({
required String id,
required CivilDate date,
required String name,
required ClockTime startTime,
required ClockTime endTime,
required DateTime createdAt,
bool hiddenByDefault = true,
String? projectId,
DateTime? updatedAt,
}) {
return LockedBlockOverride(
id: id,
date: date,
type: LockedBlockOverrideType.add,
name: name,
startTime: startTime,
endTime: endTime,
hiddenByDefault: hiddenByDefault,
projectId: projectId,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
}
/// Stable id for persistence and debugging.
final String id;
/// Base block id affected by remove/replace overrides. Null for add overrides.
final String? lockedBlockId;
/// Date the override applies to.
final CivilDate date;
/// Kind of override operation.
final LockedBlockOverrideType type;
/// Optional replacement/addition name.
final String? name;
/// Optional replacement/addition start time.
final ClockTime? startTime;
/// Optional replacement/addition end time.
final ClockTime? endTime;
/// Visibility hint for UI; scheduling still blocks this time.
final bool hiddenByDefault;
/// Optional project/category association for UI colors or reports.
final String? projectId;
/// Creation timestamp for persistence/auditing.
final DateTime createdAt;
/// Last update timestamp for persistence/auditing.
final DateTime updatedAt;
/// Whether this override targets [blockId] on [occurrenceDate].
bool appliesTo({
required String blockId,
required CivilDate occurrenceDate,
}) {
return lockedBlockId == blockId && _sameDate(date, occurrenceDate);
}
/// Build a concrete interval for [targetDate] when this override has enough
/// time data and applies to that date.
///
/// Remove overrides intentionally return null because they do not create a new
/// interval. Replacement/add overrides need both [startTime] and [endTime].
TimeInterval? intervalForDate({
required CivilDate targetDate,
required String timeZoneId,
required TimeZoneResolver timeZoneResolver,
TimeZoneResolutionOptions resolutionOptions =
const TimeZoneResolutionOptions(),
}) {
final start = startTime;
final end = endTime;
if (start == null || end == null || !_sameDate(date, targetDate)) {
return null;
}
return _resolvedInterval(
date: targetDate,
startTime: start,
endTime: end,
label: name,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
}
}
/// Expands locked blocks and one-day overrides into concrete intervals.
///
/// This object is the boundary between human-friendly locked block definitions
/// and scheduler-friendly intervals. It retains the occurrence details for UI
/// while exposing [schedulingIntervals] for placement algorithms.
class LockedScheduleExpansion {
const LockedScheduleExpansion({
required this.date,
required this.occurrences,
});
/// Calendar date represented by this expansion, normalized to year/month/day.
final CivilDate date;
/// Concrete locked occurrences active on [date].
final List<LockedBlockOccurrence> occurrences;
/// Just the intervals the scheduler needs to avoid.
List<TimeInterval> get schedulingIntervals {
return List<TimeInterval>.unmodifiable(
occurrences.map((occurrence) => occurrence.schedulingInterval),
);
}
}
/// Returns concrete locked-time occurrences for [date].
///
/// Expansion order:
/// 1. Sort overrides deterministically by creation time, then id.
/// 2. For each base block that occurs on the date, collect its overrides.
/// 3. Skip the occurrence if any remove override applies.
/// 4. Use the latest replacement override if one exists.
/// 5. Add one-off `add` overrides for the date.
/// 6. Sort occurrences by start time for predictable UI/scheduler behavior.
LockedScheduleExpansion expandLockedBlocksForDay({
required List<LockedBlock> blocks,
required List<LockedBlockOverride> overrides,
required CivilDate date,
required String timeZoneId,
required TimeZoneResolver timeZoneResolver,
TimeZoneResolutionOptions resolutionOptions =
const TimeZoneResolutionOptions(),
}) {
final occurrences = <LockedBlockOccurrence>[];
final sortedOverrides = [...overrides]..sort((a, b) {
final createdComparison = a.createdAt.compareTo(b.createdAt);
if (createdComparison != 0) {
return createdComparison;
}
return a.id.compareTo(b.id);
});
for (final block in blocks) {
if (!_blockOccursOn(block, date)) {
continue;
}
final blockOverrides = sortedOverrides
.where(
(override) =>
override.appliesTo(blockId: block.id, occurrenceDate: date),
)
.toList(growable: false);
if (blockOverrides.any(
(override) => override.type == LockedBlockOverrideType.remove,
)) {
continue;
}
final replacement = _lastReplacementOverride(blockOverrides);
occurrences.add(
replacement == null
? _occurrenceFromBlock(
block,
date,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
)
: _occurrenceFromReplacement(
block: block,
override: replacement,
date: date,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
),
);
}
for (final override in sortedOverrides) {
if (override.type != LockedBlockOverrideType.add ||
!_sameDate(override.date, date)) {
continue;
}
final occurrence = _occurrenceFromAddedOverride(
override,
date,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
if (occurrence != null) {
occurrences.add(occurrence);
}
}
occurrences.sort((a, b) {
final startComparison = a.interval.start.compareTo(b.interval.start);
if (startComparison != 0) {
return startComparison;
}
return a.name.compareTo(b.name);
});
return LockedScheduleExpansion(
date: date,
occurrences: List<LockedBlockOccurrence>.unmodifiable(occurrences),
);
}
/// Return the last replacement override from an already sorted override list.
///
/// Later-created replacements win, which lets a user correct a one-day override
/// without deleting older history first.
LockedBlockOverride? _lastReplacementOverride(
List<LockedBlockOverride> overrides,
) {
for (final override in overrides.reversed) {
if (override.type == LockedBlockOverrideType.replace) {
return override;
}
}
return null;
}
/// Convenience wrapper when callers only need scheduler intervals.
List<TimeInterval> lockedSchedulingIntervalsForDay({
required List<LockedBlock> blocks,
required List<LockedBlockOverride> overrides,
required CivilDate date,
required String timeZoneId,
required TimeZoneResolver timeZoneResolver,
TimeZoneResolutionOptions resolutionOptions =
const TimeZoneResolutionOptions(),
}) {
return expandLockedBlocksForDay(
blocks: blocks,
overrides: overrides,
date: date,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
).schedulingIntervals;
}
/// Returns [task] with locked-hour completion statistics applied when relevant.
///
/// This does not decide whether completion is allowed. It only records that a
/// completion overlapped locked time, which future reports can surface as a
/// boundary-leak signal.
Task trackCompletedDuringLockedHours({
required Task task,
required List<TimeInterval> lockedIntervals,
}) {
final overlapMinutes = completedDuringLockedHoursMinutes(
task: task,
lockedIntervals: lockedIntervals,
);
if (overlapMinutes == 0) {
return task;
}
return task.copyWith(
stats: task.stats.incrementCompletedDuringLockedHours(overlapMinutes),
);
}
/// Calculates how many scheduled task minutes overlap locked intervals.
///
/// Multiple locked intervals may overlap each other. To avoid double-counting,
/// intersections are merged before minutes are summed.
int completedDuringLockedHoursMinutes({
required Task task,
required List<TimeInterval> lockedIntervals,
}) {
if (!_shouldTrackLockedHourCompletion(task)) {
return 0;
}
final taskInterval = _scheduledIntervalForTask(task);
if (taskInterval == null) {
return 0;
}
final intersections = <TimeInterval>[];
for (final lockedInterval in lockedIntervals) {
if (!taskInterval.overlaps(lockedInterval)) {
continue;
}
intersections.add(
TimeInterval(
start: _latest(taskInterval.start, lockedInterval.start),
end: _earliest(taskInterval.end, lockedInterval.end),
),
);
}
if (intersections.isEmpty) {
return 0;
}
intersections.sort((a, b) => a.start.compareTo(b.start));
var total = Duration.zero;
var current = intersections.first;
for (final interval in intersections.skip(1)) {
if (interval.start.isAfter(current.end)) {
total += current.duration;
current = interval;
continue;
}
current = TimeInterval(
start: current.start,
end: _latest(current.end, interval.end),
);
}
total += current.duration;
return total.inMinutes;
}
/// Whether a base block should produce an occurrence on [date].
bool _blockOccursOn(LockedBlock block, CivilDate date) {
final recurrence = block.recurrence;
if (recurrence != null) {
return recurrence.occursOn(date);
}
return _sameDate(block.date!, date);
}
/// Convert a base block rule into a concrete occurrence for [date].
LockedBlockOccurrence _occurrenceFromBlock(
LockedBlock block,
CivilDate date, {
required String timeZoneId,
required TimeZoneResolver timeZoneResolver,
TimeZoneResolutionOptions resolutionOptions =
const TimeZoneResolutionOptions(),
}) {
return LockedBlockOccurrence(
lockedBlockId: block.id,
name: block.name,
interval: _resolvedInterval(
date: date,
startTime: block.startTime,
endTime: block.endTime,
label: block.name,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
),
hiddenByDefault: block.hiddenByDefault,
projectId: block.projectId,
);
}
/// Convert a replacement override into a concrete occurrence.
///
/// Missing override fields fall back to the original block, so a one-day change
/// can replace only the time, only the name, or only the project association.
LockedBlockOccurrence _occurrenceFromReplacement({
required LockedBlock block,
required LockedBlockOverride override,
required CivilDate date,
required String timeZoneId,
required TimeZoneResolver timeZoneResolver,
TimeZoneResolutionOptions resolutionOptions =
const TimeZoneResolutionOptions(),
}) {
final startTime = override.startTime ?? block.startTime;
final endTime = override.endTime ?? block.endTime;
final name = override.name ?? block.name;
return LockedBlockOccurrence(
lockedBlockId: block.id,
overrideId: override.id,
name: name,
interval: _resolvedInterval(
date: date,
startTime: startTime,
endTime: endTime,
label: name,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
),
hiddenByDefault: override.hiddenByDefault,
projectId: override.projectId ?? block.projectId,
);
}
/// Convert an add override into a concrete occurrence when it is complete.
LockedBlockOccurrence? _occurrenceFromAddedOverride(
LockedBlockOverride override,
CivilDate date, {
required String timeZoneId,
required TimeZoneResolver timeZoneResolver,
TimeZoneResolutionOptions resolutionOptions =
const TimeZoneResolutionOptions(),
}) {
final interval = override.intervalForDate(
targetDate: date,
timeZoneId: timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
final name = override.name;
if (interval == null || name == null) {
return null;
}
return LockedBlockOccurrence(
overrideId: override.id,
name: name,
interval: TimeInterval(
start: interval.start,
end: interval.end,
label: name,
),
hiddenByDefault: override.hiddenByDefault,
projectId: override.projectId,
);
}
/// Whether this task state/type should be checked for locked-hour completion.
bool _shouldTrackLockedHourCompletion(Task task) {
return task.status == TaskStatus.completed || task.type == TaskType.surprise;
}
/// Build a valid scheduled interval for a task, or null if the task is unplaced.
TimeInterval? _scheduledIntervalForTask(Task task) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null || !start.isBefore(end)) {
return null;
}
return TimeInterval(start: start, end: end, label: task.id);
}
/// Return whichever timestamp is earlier.
DateTime _earliest(DateTime first, DateTime second) {
return first.isBefore(second) ? first : second;
}
/// Return whichever timestamp is later.
DateTime _latest(DateTime first, DateTime second) {
return first.isAfter(second) ? first : second;
}
TimeInterval _resolvedInterval({
required CivilDate date,
required ClockTime startTime,
required ClockTime endTime,
required String? label,
required String timeZoneId,
required TimeZoneResolver timeZoneResolver,
required TimeZoneResolutionOptions resolutionOptions,
}) {
final start = timeZoneResolver.resolve(
date: date,
wallTime: startTime,
timeZoneId: timeZoneId,
options: resolutionOptions,
);
final end = timeZoneResolver.resolve(
date: date,
wallTime: endTime,
timeZoneId: timeZoneId,
options: resolutionOptions,
);
return TimeInterval(
start: start.instant,
end: end.instant,
label: label,
);
}
bool _sameDate(CivilDate first, CivilDate second) => first == second;
String _requiredLockedString(
String value,
String name,
DomainValidationCode code,
) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw DomainValidationException(
code: code,
invalidValue: value,
name: name,
message: '$name cannot be blank.',
);
}
return trimmed;
}
String? _nullableLockedString(
String? value,
String name,
DomainValidationCode code,
) {
if (value == null) {
return null;
}
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw DomainValidationException(
code: code,
invalidValue: value,
name: name,
message: '$name cannot be blank.',
);
}
return trimmed;
}
bool _clockTimeIsBefore(ClockTime start, ClockTime end) {
if (start.hour != end.hour) {
return start.hour < end.hour;
}
return start.minute < end.minute;
}
void _validateOverrideShape({
required LockedBlockOverrideType type,
required String? lockedBlockId,
required String? name,
required ClockTime? startTime,
required ClockTime? endTime,
}) {
switch (type) {
case LockedBlockOverrideType.remove:
if (lockedBlockId == null ||
name != null ||
startTime != null ||
endTime != null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: type,
name: 'LockedBlockOverride.remove',
message: 'Remove overrides only reference a locked block and date.',
);
}
case LockedBlockOverrideType.replace:
if (lockedBlockId == null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: type,
name: 'lockedBlockId',
message: 'Replace overrides must reference a locked block.',
);
}
_validateOverrideInterval(startTime: startTime, endTime: endTime);
case LockedBlockOverrideType.add:
if (lockedBlockId != null || name == null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: type,
name: 'LockedBlockOverride.add',
message:
'Add overrides need a name and cannot reference a base block.',
);
}
if (startTime == null || endTime == null) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: type,
name: 'startTime/endTime',
message: 'Add overrides need both start and end time.',
);
}
_validateOverrideInterval(startTime: startTime, endTime: endTime);
}
}
void _validateOverrideInterval({
required ClockTime? startTime,
required ClockTime? endTime,
}) {
if ((startTime == null) != (endTime == null)) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: {'startTime': startTime, 'endTime': endTime},
name: 'startTime/endTime',
message: 'Override start and end time must both be present or absent.',
);
}
if (startTime != null &&
endTime != null &&
!_clockTimeIsBefore(startTime, endTime)) {
throw DomainValidationException(
code: DomainValidationCode.invalidLockedOverride,
invalidValue: {'startTime': startTime, 'endTime': endTime},
name: 'startTime/endTime',
message: 'Override end time must be after start time.',
);
}
}