// 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'; /// 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; } /// Time of day without a calendar date. /// /// Locked blocks are often defined as "every weekday from 8:00 to 17:00" rather /// than as one specific timestamp. [ClockTime] stores just the hour/minute part /// and can later be projected onto a concrete date with [onDate]. class ClockTime { const ClockTime({ required this.hour, required this.minute, }) : assert(hour >= 0 && hour < 24, 'hour must be 0-23'), assert(minute >= 0 && minute < 60, 'minute must be 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. /// /// 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 { const LockedBlockRecurrence.weekly({ required this.weekdays, }); /// Weekdays when this recurrence should produce an occurrence. final Set weekdays; /// Whether this recurrence applies to [date]. bool occursOn(DateTime 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 { const LockedBlock({ required this.id, required this.name, required this.startTime, required this.endTime, required this.createdAt, required this.updatedAt, this.date, this.recurrence, this.hiddenByDefault = true, this.projectId, }) : assert( recurrence != null || date != null, 'date is required for one-off locked blocks', ); /// 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 DateTime? 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; } /// 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 { const LockedBlockOverride({ required this.id, required this.date, required this.type, required this.createdAt, required this.updatedAt, this.lockedBlockId, this.name, this.startTime, this.endTime, this.hiddenByDefault = true, this.projectId, }); /// Removes a recurring occurrence for one date. factory LockedBlockOverride.remove({ required String id, required String lockedBlockId, required DateTime 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 DateTime 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 DateTime 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. Time components are ignored by date checks. final DateTime 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 DateTime 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(DateTime targetDate) { final start = startTime; final end = endTime; if (start == null || end == null || !_sameDate(date, targetDate)) { return null; } return TimeInterval( start: start.onDate(targetDate), end: end.onDate(targetDate), label: name, ); } } /// 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 DateTime 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 DateTime date, }) { 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) : _occurrenceFromReplacement( block: block, override: replacement, date: date, ), ); } for (final override in sortedOverrides) { if (override.type != LockedBlockOverrideType.add || !_sameDate(override.date, date)) { continue; } final occurrence = _occurrenceFromAddedOverride(override, date); 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: DateTime(date.year, date.month, date.day), 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 DateTime date, }) { return expandLockedBlocksForDay( blocks: blocks, overrides: overrides, date: date, ).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, DateTime 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, DateTime date, ) { return LockedBlockOccurrence( lockedBlockId: block.id, name: block.name, interval: TimeInterval( start: block.startTime.onDate(date), end: block.endTime.onDate(date), label: block.name, ), 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 DateTime date, }) { 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: TimeInterval( start: startTime.onDate(date), end: endTime.onDate(date), label: name, ), hiddenByDefault: override.hiddenByDefault, projectId: override.projectId ?? block.projectId, ); } /// Convert an add override into a concrete occurrence when it is complete. LockedBlockOccurrence? _occurrenceFromAddedOverride( LockedBlockOverride override, DateTime date, ) { final interval = override.intervalForDate(date); 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; } /// Compare only the calendar date parts of two timestamps. bool _sameDate(DateTime first, DateTime second) { return first.year == second.year && first.month == second.month && first.day == second.day; }