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 ed96b49..fb549bf 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 @@ -8,6 +8,8 @@ Purpose: Locked blocks are critical MVP. They reserve time, stay hidden by defau Recommended Codex level: high +Status: Completed + Tasks: Create a model for locked blocks with: @@ -31,10 +33,21 @@ Acceptance criteria: - Model supports one-off and recurring locked blocks. - Work-hours style recurrence can be represented. +Execution notes: + +- Added `ClockTime`, `LockedWeekday`, `LockedBlockRecurrence`, and `LockedBlock`. +- Locked blocks include id, name, start/end time, optional recurrence, hidden-by-default flag, optional project id, and created/updated timestamps. +- Locked blocks remain scheduling constraints and are not modeled as ordinary tasks. +- One-off locked blocks and weekday work-hours recurrence are represented. +- Added tests for one-off locked blocks and weekday work-hours recurrence. +- `dart analyze` and `dart test` passed. + ## Chunk 5.2 — One-day override model Recommended Codex level: high +Status: Completed + Tasks: Support one-day overrides for recurring locked blocks: @@ -50,6 +63,14 @@ Acceptance criteria: - Test: half-day modifies only one date. - Test: recurring rule remains unchanged. +Execution notes: + +- Added `LockedBlockOverrideType` and `LockedBlockOverride`. +- Overrides support removing one occurrence, replacing one occurrence's times, and adding surprise locked time for one date. +- Override objects do not mutate the base recurrence rule. +- Added tests for Friday-off removal, half-day replacement, surprise locked time, and unchanged recurrence. +- `dart analyze` and `dart test` passed. + BREAKPOINT: Stop here. Confirm `high` mode before handling recurrence/override expansion. ## Chunk 5.3 — Expand locked blocks into schedule constraints diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 5b8dce0..70fa664 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -4,6 +4,7 @@ export 'src/models.dart'; export 'src/backlog.dart'; +export 'src/locked_time.dart'; export 'src/quick_capture.dart'; export 'src/scheduling_engine.dart'; export 'src/task_statistics.dart'; diff --git a/lib/src/locked_time.dart b/lib/src/locked_time.dart new file mode 100644 index 0000000..bdc9d6e --- /dev/null +++ b/lib/src/locked_time.dart @@ -0,0 +1,208 @@ +import 'models.dart'; + +/// Weekday value using DateTime's Monday-first convention. +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); + + final int dateTimeValue; +} + +/// Time of day without a calendar date. +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'); + + final int hour; + final int minute; + + DateTime onDate(DateTime date) { + return DateTime(date.year, date.month, date.day, hour, minute); + } +} + +/// Recurrence rule for locked time. +class LockedBlockRecurrence { + const LockedBlockRecurrence.weekly({ + required this.weekdays, + }); + + final Set weekdays; + + bool occursOn(DateTime date) { + return weekdays.any((weekday) => weekday.dateTimeValue == date.weekday); + } +} + +/// Scheduling constraint that reserves time without becoming a task card. +class LockedBlock { + const LockedBlock({ + required this.id, + required this.name, + required this.startTime, + required this.endTime, + required this.createdAt, + required this.updatedAt, + this.recurrence, + this.hiddenByDefault = true, + this.projectId, + }); + + final String id; + final String name; + final ClockTime startTime; + final ClockTime endTime; + final LockedBlockRecurrence? recurrence; + final bool hiddenByDefault; + final String? projectId; + final DateTime createdAt; + final DateTime updatedAt; + + bool get isRecurring => recurrence != null; +} + +/// Type of one-day override applied to locked time. +enum LockedBlockOverrideType { + remove, + replace, + add, +} + +/// One-day change to locked time that leaves the base recurrence unchanged. +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, + ); + } + + final String id; + final String? lockedBlockId; + final DateTime date; + final LockedBlockOverrideType type; + final String? name; + final ClockTime? startTime; + final ClockTime? endTime; + final bool hiddenByDefault; + final String? projectId; + final DateTime createdAt; + final DateTime updatedAt; + + bool appliesTo({ + required String blockId, + required DateTime occurrenceDate, + }) { + return lockedBlockId == blockId && _sameDate(date, occurrenceDate); + } + + 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, + ); + } +} + +bool _sameDate(DateTime first, DateTime second) { + return first.year == second.year && + first.month == second.month && + first.day == second.day; +} diff --git a/lib/src/models.dart b/lib/src/models.dart index b3ed26c..fad47ef 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -324,21 +324,3 @@ class TimeInterval { return start.isBefore(other.end) && end.isAfter(other.start); } } - -/// Starter representation of locked time. -/// -/// Later recurring-block chunks should expand this without exposing hidden -/// locked time in UI-facing defaults. -class LockedBlock { - const LockedBlock({ - required this.id, - required this.name, - required this.interval, - this.hiddenByDefault = true, - }); - - final String id; - final String name; - final TimeInterval interval; - final bool hiddenByDefault; -} diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index ea6b703..52942fa 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -404,6 +404,125 @@ void main() { expect(updated.completedDuringLockedHoursCount, 1); expect(updated.completedDuringLockedHoursMinutes, 15); }); + + test('locked block model supports one-off and recurring constraints', () { + final oneOff = LockedBlock( + id: 'appointment', + name: 'Appointment', + startTime: const ClockTime(hour: 13, minute: 0), + endTime: const ClockTime(hour: 14, minute: 0), + createdAt: now, + updatedAt: now, + projectId: 'health', + ); + 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, + ); + + expect(oneOff.isRecurring, isFalse); + expect(oneOff.hiddenByDefault, isTrue); + expect(oneOff.projectId, 'health'); + expect(workHours.isRecurring, isTrue); + expect(workHours.hiddenByDefault, isTrue); + expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue); + expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 20)), isFalse); + }); + + test('locked block override removes only one date', () { + final override = LockedBlockOverride.remove( + id: 'friday-off', + lockedBlockId: 'work-hours', + date: DateTime(2026, 6, 19), + createdAt: now, + ); + + expect(override.type, LockedBlockOverrideType.remove); + expect( + override.appliesTo( + blockId: 'work-hours', + occurrenceDate: DateTime(2026, 6, 19), + ), + isTrue, + ); + expect( + override.appliesTo( + blockId: 'work-hours', + occurrenceDate: DateTime(2026, 6, 20), + ), + isFalse, + ); + }); + + test('locked block override modifies one date without recurrence changes', + () { + final recurrence = const LockedBlockRecurrence.weekly( + weekdays: { + LockedWeekday.monday, + LockedWeekday.tuesday, + LockedWeekday.wednesday, + LockedWeekday.thursday, + LockedWeekday.friday, + }, + ); + final workHours = LockedBlock( + id: 'work-hours', + name: 'Work', + startTime: const ClockTime(hour: 9, minute: 0), + endTime: const ClockTime(hour: 17, minute: 0), + recurrence: recurrence, + createdAt: now, + updatedAt: now, + ); + final halfDay = LockedBlockOverride.replace( + id: 'half-day', + lockedBlockId: 'work-hours', + date: DateTime(2026, 6, 19), + startTime: const ClockTime(hour: 9, minute: 0), + endTime: const ClockTime(hour: 12, minute: 0), + createdAt: now, + ); + final interval = halfDay.intervalForDate(DateTime(2026, 6, 19)); + + expect(interval, isNotNull); + expect(interval!.start, DateTime(2026, 6, 19, 9)); + expect(interval.end, DateTime(2026, 6, 19, 12)); + expect(workHours.recurrence, same(recurrence)); + expect(workHours.recurrence!.occursOn(DateTime(2026, 6, 19)), isTrue); + }); + + test('locked block override can add surprise locked time', () { + 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 interval = surprise.intervalForDate(DateTime(2026, 6, 19)); + + expect(surprise.type, LockedBlockOverrideType.add); + expect(surprise.lockedBlockId, isNull); + expect(surprise.projectId, 'home'); + expect(interval, isNotNull); + expect(interval!.start, DateTime(2026, 6, 19, 15)); + expect(interval.end, DateTime(2026, 6, 19, 16)); + }); }); group('SchedulingEngine starter behavior', () {