feat(locked-time): expand recurring blocks for scheduling

This commit is contained in:
Ashley Venn 2026-06-19 16:22:54 -07:00
parent c803b53a71
commit 0cb81d2036
3 changed files with 420 additions and 1 deletions

View file

@ -77,6 +77,8 @@ BREAKPOINT: Stop here. Confirm `high` mode before handling recurrence/override e
Recommended Codex level: extra high Recommended Codex level: extra high
Status: Completed
Tasks: Tasks:
Implement expansion from recurring locked blocks + overrides into concrete intervals for a day. 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: one-day override removes/modifies the occurrence.
- Test: scheduling engine sees the final locked intervals. - 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 ## Chunk 5.4 — Track completed during locked hours
Recommended Codex level: medium Recommended Codex level: medium

View file

@ -53,15 +53,22 @@ class LockedBlock {
required this.endTime, required this.endTime,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
this.date,
this.recurrence, this.recurrence,
this.hiddenByDefault = true, this.hiddenByDefault = true,
this.projectId, this.projectId,
}); }) : assert(
recurrence != null || date != null,
'date is required for one-off locked blocks',
);
final String id; final String id;
final String name; final String name;
final ClockTime startTime; final ClockTime startTime;
final ClockTime endTime; final ClockTime endTime;
/// Calendar date for one-off locked blocks. Recurring blocks leave this null.
final DateTime? date;
final LockedBlockRecurrence? recurrence; final LockedBlockRecurrence? recurrence;
final bool hiddenByDefault; final bool hiddenByDefault;
final String? projectId; final String? projectId;
@ -71,6 +78,34 @@ class LockedBlock {
bool get isRecurring => recurrence != null; 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. /// Type of one-day override applied to locked time.
enum LockedBlockOverrideType { enum LockedBlockOverrideType {
remove, 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<LockedBlockOccurrence> occurrences;
List<TimeInterval> get schedulingIntervals {
return List<TimeInterval>.unmodifiable(
occurrences.map((occurrence) => occurrence.schedulingInterval),
);
}
}
/// Returns concrete locked-time occurrences for [date].
LockedScheduleExpansion expandLockedBlocksForDay({
required List<LockedBlock> blocks,
required List<LockedBlockOverride> overrides,
required DateTime date,
}) {
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)
: _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<LockedBlockOccurrence>.unmodifiable(occurrences),
);
}
LockedBlockOverride? _lastReplacementOverride(
List<LockedBlockOverride> overrides,
) {
for (final override in overrides.reversed) {
if (override.type == LockedBlockOverrideType.replace) {
return override;
}
}
return null;
}
List<TimeInterval> lockedSchedulingIntervalsForDay({
required List<LockedBlock> blocks,
required List<LockedBlockOverride> 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) { bool _sameDate(DateTime first, DateTime second) {
return first.year == second.year && return first.year == second.year &&
first.month == second.month && first.month == second.month &&

View file

@ -411,6 +411,7 @@ void main() {
name: 'Appointment', name: 'Appointment',
startTime: const ClockTime(hour: 13, minute: 0), startTime: const ClockTime(hour: 13, minute: 0),
endTime: const ClockTime(hour: 14, minute: 0), endTime: const ClockTime(hour: 14, minute: 0),
date: DateTime(2026, 6, 19),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
projectId: 'health', projectId: 'health',
@ -523,6 +524,138 @@ void main() {
expect(interval!.start, DateTime(2026, 6, 19, 15)); expect(interval!.start, DateTime(2026, 6, 19, 15));
expect(interval.end, DateTime(2026, 6, 19, 16)); 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', () { group('SchedulingEngine starter behavior', () {
@ -738,6 +871,58 @@ void main() {
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 35)); 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', () { test('insert backlog task does not move inflexible or critical blocks', () {
final backlogTask = Task.quickCapture( final backlogTask = Task.quickCapture(
id: 'backlog-1', id: 'backlog-1',