From 0cb81d2036bee465e28301944c01a232a9089382 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Fri, 19 Jun 2026 16:22:54 -0700 Subject: [PATCH] feat(locked-time): expand recurring blocks for scheduling --- .../V1_BLOCK_05_Recurring_Locked_Blocks.md | 11 + lib/src/locked_time.dart | 225 +++++++++++++++++- test/scheduling_engine_test.dart | 185 ++++++++++++++ 3 files changed, 420 insertions(+), 1 deletion(-) diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_05_Recurring_Locked_Blocks.md b/Codex Documentation/Current Software Plan/V1_BLOCK_05_Recurring_Locked_Blocks.md index fb549bf..f7f4c0b 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_05_Recurring_Locked_Blocks.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_05_Recurring_Locked_Blocks.md @@ -77,6 +77,8 @@ BREAKPOINT: Stop here. Confirm `high` mode before handling recurrence/override e Recommended Codex level: extra high +Status: Completed + Tasks: Implement expansion from recurring locked blocks + overrides into concrete intervals for a day. @@ -93,6 +95,15 @@ Acceptance criteria: - Test: one-day override removes/modifies the occurrence. - Test: scheduling engine sees the final locked intervals. +Execution notes: + +- Added an explicit `date` field for one-off locked blocks so non-recurring constraints have a testable calendar anchor. +- Added `LockedBlockOccurrence`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, and `lockedSchedulingIntervalsForDay`. +- Expansion resolves weekly recurrence, one-day remove/replace overrides, and added surprise locked time for a target day. +- Hidden-by-default remains occurrence metadata only; scheduler-facing intervals are always returned for scheduling. +- Added tests for weekday recurrence expansion, one-day remove/replace behavior, surprise locked time expansion, and scheduling engine use of final expanded locked intervals. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. + ## Chunk 5.4 — Track completed during locked hours Recommended Codex level: medium diff --git a/lib/src/locked_time.dart b/lib/src/locked_time.dart index bdc9d6e..d03d806 100644 --- a/lib/src/locked_time.dart +++ b/lib/src/locked_time.dart @@ -53,15 +53,22 @@ class LockedBlock { 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', + ); final String id; final String name; final ClockTime startTime; final ClockTime endTime; + + /// Calendar date for one-off locked blocks. Recurring blocks leave this null. + final DateTime? date; final LockedBlockRecurrence? recurrence; final bool hiddenByDefault; final String? projectId; @@ -71,6 +78,34 @@ class LockedBlock { bool get isRecurring => recurrence != null; } +/// Concrete locked-time occurrence for one calendar day. +class LockedBlockOccurrence { + const LockedBlockOccurrence({ + required this.name, + required this.interval, + required this.hiddenByDefault, + this.lockedBlockId, + this.overrideId, + this.projectId, + }); + + final String name; + final TimeInterval interval; + final bool hiddenByDefault; + final String? lockedBlockId; + final String? overrideId; + final String? projectId; + + /// Scheduler-facing interval. Visibility only affects future UI overlays. + TimeInterval get schedulingInterval { + return TimeInterval( + start: interval.start, + end: interval.end, + label: name, + ); + } +} + /// Type of one-day override applied to locked time. enum LockedBlockOverrideType { remove, @@ -201,6 +236,194 @@ class LockedBlockOverride { } } +/// Expands locked blocks and one-day overrides into concrete intervals. +class LockedScheduleExpansion { + const LockedScheduleExpansion({ + required this.date, + required this.occurrences, + }); + + final DateTime date; + final List occurrences; + + List get schedulingIntervals { + return List.unmodifiable( + occurrences.map((occurrence) => occurrence.schedulingInterval), + ); + } +} + +/// Returns concrete locked-time occurrences for [date]. +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), + ); +} + +LockedBlockOverride? _lastReplacementOverride( + List overrides, +) { + for (final override in overrides.reversed) { + if (override.type == LockedBlockOverrideType.replace) { + return override; + } + } + + return null; +} + +List lockedSchedulingIntervalsForDay({ + required List blocks, + required List overrides, + required DateTime date, +}) { + return expandLockedBlocksForDay( + blocks: blocks, + overrides: overrides, + date: date, + ).schedulingIntervals; +} + +bool _blockOccursOn(LockedBlock block, DateTime date) { + final recurrence = block.recurrence; + if (recurrence != null) { + return recurrence.occursOn(date); + } + + return _sameDate(block.date!, 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, + ); +} + +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, + ); +} + +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, + ); +} + bool _sameDate(DateTime first, DateTime second) { return first.year == second.year && first.month == second.month && diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index 52942fa..9fbe497 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -411,6 +411,7 @@ void main() { name: 'Appointment', startTime: const ClockTime(hour: 13, minute: 0), endTime: const ClockTime(hour: 14, minute: 0), + date: DateTime(2026, 6, 19), createdAt: now, updatedAt: now, projectId: 'health', @@ -523,6 +524,138 @@ void main() { expect(interval!.start, DateTime(2026, 6, 19, 15)); expect(interval.end, DateTime(2026, 6, 19, 16)); }); + + test('locked block expansion returns weekday occurrences', () { + final workHours = LockedBlock( + id: 'work-hours', + name: 'Work', + startTime: const ClockTime(hour: 9, minute: 0), + endTime: const ClockTime(hour: 17, minute: 0), + recurrence: const LockedBlockRecurrence.weekly( + weekdays: { + LockedWeekday.monday, + LockedWeekday.tuesday, + LockedWeekday.wednesday, + LockedWeekday.thursday, + LockedWeekday.friday, + }, + ), + createdAt: now, + updatedAt: now, + ); + + final fridayExpansion = expandLockedBlocksForDay( + blocks: [workHours], + overrides: const [], + date: DateTime(2026, 6, 19), + ); + final saturdayExpansion = expandLockedBlocksForDay( + blocks: [workHours], + overrides: const [], + date: DateTime(2026, 6, 20), + ); + final occurrence = fridayExpansion.occurrences.single; + + expect(fridayExpansion.date, DateTime(2026, 6, 19)); + expect(occurrence.lockedBlockId, 'work-hours'); + expect(occurrence.overrideId, isNull); + expect(occurrence.name, 'Work'); + expect(occurrence.interval.start, DateTime(2026, 6, 19, 9)); + expect(occurrence.interval.end, DateTime(2026, 6, 19, 17)); + expect(occurrence.hiddenByDefault, isTrue); + expect(occurrence.schedulingInterval.label, 'Work'); + expect(saturdayExpansion.occurrences, isEmpty); + }); + + test('locked block expansion applies remove and replace overrides', () { + final workHours = LockedBlock( + id: 'work-hours', + name: 'Work', + startTime: const ClockTime(hour: 9, minute: 0), + endTime: const ClockTime(hour: 17, minute: 0), + recurrence: const LockedBlockRecurrence.weekly( + weekdays: { + LockedWeekday.monday, + LockedWeekday.tuesday, + LockedWeekday.wednesday, + LockedWeekday.thursday, + LockedWeekday.friday, + }, + ), + hiddenByDefault: true, + createdAt: now, + updatedAt: now, + ); + final fridayOff = LockedBlockOverride.remove( + id: 'friday-off', + lockedBlockId: 'work-hours', + date: DateTime(2026, 6, 19), + createdAt: now, + ); + final halfDay = LockedBlockOverride.replace( + id: 'half-day', + lockedBlockId: 'work-hours', + date: DateTime(2026, 6, 22), + name: 'Half-day work', + startTime: const ClockTime(hour: 9, minute: 0), + endTime: const ClockTime(hour: 12, minute: 0), + hiddenByDefault: false, + createdAt: now.add(const Duration(minutes: 1)), + ); + + final removed = expandLockedBlocksForDay( + blocks: [workHours], + overrides: [fridayOff, halfDay], + date: DateTime(2026, 6, 19), + ); + final modified = expandLockedBlocksForDay( + blocks: [workHours], + overrides: [fridayOff, halfDay], + date: DateTime(2026, 6, 22), + ); + final normal = expandLockedBlocksForDay( + blocks: [workHours], + overrides: [fridayOff, halfDay], + date: DateTime(2026, 6, 23), + ); + final halfDayOccurrence = modified.occurrences.single; + + expect(removed.occurrences, isEmpty); + expect(halfDayOccurrence.lockedBlockId, 'work-hours'); + expect(halfDayOccurrence.overrideId, 'half-day'); + expect(halfDayOccurrence.name, 'Half-day work'); + expect(halfDayOccurrence.interval.start, DateTime(2026, 6, 22, 9)); + expect(halfDayOccurrence.interval.end, DateTime(2026, 6, 22, 12)); + expect(halfDayOccurrence.hiddenByDefault, isFalse); + expect(normal.occurrences.single.interval.end, DateTime(2026, 6, 23, 17)); + expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 22)), isTrue); + }); + + test('locked block expansion includes surprise locked time overrides', () { + final surprise = LockedBlockOverride.add( + id: 'surprise-lock', + date: DateTime(2026, 6, 19), + name: 'Urgent errand', + startTime: const ClockTime(hour: 15, minute: 0), + endTime: const ClockTime(hour: 16, minute: 0), + createdAt: now, + projectId: 'home', + ); + + final expansion = expandLockedBlocksForDay( + blocks: const [], + overrides: [surprise], + date: DateTime(2026, 6, 19), + ); + final occurrence = expansion.occurrences.single; + + expect(occurrence.lockedBlockId, isNull); + expect(occurrence.overrideId, 'surprise-lock'); + expect(occurrence.name, 'Urgent errand'); + expect(occurrence.projectId, 'home'); + expect(occurrence.interval.start, DateTime(2026, 6, 19, 15)); + expect(occurrence.interval.end, DateTime(2026, 6, 19, 16)); + }); }); group('SchedulingEngine starter behavior', () { @@ -738,6 +871,58 @@ void main() { expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 35)); }); + test('insert backlog task respects expanded locked blocks', () { + final backlogTask = Task.quickCapture( + id: 'backlog-1', + title: 'Start laundry', + createdAt: now, + ).copyWith(durationMinutes: 15); + final workHours = LockedBlock( + id: 'work-hours', + name: 'Work', + startTime: const ClockTime(hour: 13, minute: 0), + endTime: const ClockTime(hour: 13, minute: 20), + recurrence: const LockedBlockRecurrence.weekly( + weekdays: {LockedWeekday.friday}, + ), + hiddenByDefault: true, + createdAt: now, + updatedAt: now, + ); + final replacement = LockedBlockOverride.replace( + id: 'longer-work', + lockedBlockId: 'work-hours', + date: DateTime(2026, 6, 19), + startTime: const ClockTime(hour: 13, minute: 0), + endTime: const ClockTime(hour: 13, minute: 30), + createdAt: now, + ); + final lockedIntervals = lockedSchedulingIntervalsForDay( + blocks: [workHours], + overrides: [replacement], + date: DateTime(2026, 6, 19), + ); + + final result = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [backlogTask], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + lockedIntervals: lockedIntervals, + ), + taskId: 'backlog-1', + updatedAt: now, + ); + final inserted = result.tasks.single; + + expect(lockedIntervals.single.start, DateTime(2026, 6, 19, 13)); + expect(lockedIntervals.single.end, DateTime(2026, 6, 19, 13, 30)); + expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 30)); + expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); + }); + test('insert backlog task does not move inflexible or critical blocks', () { final backlogTask = Task.quickCapture( id: 'backlog-1',