forked from eva/focus-flow
feat(locked-time): expand recurring blocks for scheduling
This commit is contained in:
parent
c803b53a71
commit
0cb81d2036
3 changed files with 420 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
return first.year == second.year &&
|
||||
first.month == second.month &&
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue