943 lines
28 KiB
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.',
|
|
);
|
|
}
|
|
}
|