// 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 weekdays, }) : weekdays = Set.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 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 occurrences; /// Just the intervals the scheduler needs to avoid. List get schedulingIntervals { return List.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 blocks, required List overrides, required CivilDate date, required String timeZoneId, required TimeZoneResolver timeZoneResolver, TimeZoneResolutionOptions resolutionOptions = const TimeZoneResolutionOptions(), }) { final occurrences = []; 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.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 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 lockedSchedulingIntervalsForDay({ required List blocks, required List 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 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 lockedIntervals, }) { if (!_shouldTrackLockedHourCompletion(task)) { return 0; } final taskInterval = _scheduledIntervalForTask(task); if (taskInterval == null) { return 0; } final intersections = []; 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.', ); } }