feat(locked-time): track completed locked-hour work

This commit is contained in:
Ashley Venn 2026-06-19 16:30:21 -07:00
parent 0cb81d2036
commit 4a307cc522
3 changed files with 243 additions and 1 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 05 — Recurring Locked Blocks
Status: Planned
Status: Completed
Purpose: Locked blocks are critical MVP. They reserve time, stay hidden by default, and are not tasks.
@ -108,6 +108,8 @@ Execution notes:
Recommended Codex level: medium
Status: Completed
Tasks:
Add helper logic to detect when a completed/surprise task overlaps locked time.
@ -124,6 +126,14 @@ Acceptance criteria:
- Tests cover partial overlap.
- Tests cover no overlap.
Execution notes:
- Added `completedDuringLockedHoursMinutes` to calculate completed/surprise task overlap with locked intervals.
- Added `trackCompletedDuringLockedHours` to return an updated task with locked-hour completion statistics when overlap exists.
- Overlap calculation merges intersecting locked intervals before counting minutes to avoid double counting.
- Added tests for tasks entirely inside locked time, partial overlap, no overlap, surprise task overlap, and overlapping locked intervals.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed.
Commit suggestion:
```text

View file

@ -351,6 +351,79 @@ List<TimeInterval> lockedSchedulingIntervalsForDay({
).schedulingIntervals;
}
/// Returns [task] with locked-hour completion statistics applied when relevant.
Task trackCompletedDuringLockedHours({
required Task task,
required List<TimeInterval> 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.
int completedDuringLockedHoursMinutes({
required Task task,
required List<TimeInterval> lockedIntervals,
}) {
if (!_shouldTrackLockedHourCompletion(task)) {
return 0;
}
final taskInterval = _scheduledIntervalForTask(task);
if (taskInterval == null) {
return 0;
}
final intersections = <TimeInterval>[];
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;
}
bool _blockOccursOn(LockedBlock block, DateTime date) {
final recurrence = block.recurrence;
if (recurrence != null) {
@ -424,6 +497,29 @@ LockedBlockOccurrence? _occurrenceFromAddedOverride(
);
}
bool _shouldTrackLockedHourCompletion(Task task) {
return task.status == TaskStatus.completed || task.type == TaskType.surprise;
}
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);
}
DateTime _earliest(DateTime first, DateTime second) {
return first.isBefore(second) ? first : second;
}
DateTime _latest(DateTime first, DateTime second) {
return first.isAfter(second) ? first : second;
}
bool _sameDate(DateTime first, DateTime second) {
return first.year == second.year &&
first.month == second.month &&

View file

@ -656,6 +656,142 @@ void main() {
expect(occurrence.interval.start, DateTime(2026, 6, 19, 15));
expect(occurrence.interval.end, DateTime(2026, 6, 19, 16));
});
test(
'locked hour completion tracking covers task entirely inside locked time',
() {
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 13, 15),
scheduledEnd: DateTime(2026, 6, 19, 13, 45),
);
final updated = trackCompletedDuringLockedHours(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
label: 'work',
),
],
);
expect(updated.stats.completedDuringLockedHoursCount, 1);
expect(updated.stats.completedDuringLockedHoursMinutes, 30);
expect(task.stats.completedDuringLockedHoursCount, 0);
});
test('locked hour completion tracking covers partial overlap', () {
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 13, 45),
scheduledEnd: DateTime(2026, 6, 19, 14, 15),
);
final minutes = completedDuringLockedHoursMinutes(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
label: 'work',
),
],
);
expect(minutes, 15);
});
test('locked hour completion tracking ignores no-overlap tasks', () {
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 14, 30),
);
final updated = trackCompletedDuringLockedHours(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
label: 'work',
),
],
);
expect(updated, same(task));
expect(updated.stats.completedDuringLockedHoursCount, 0);
expect(updated.stats.completedDuringLockedHoursMinutes, 0);
});
test('locked hour completion tracking covers surprise task overlap', () {
final task = Task.quickCapture(
id: 'surprise-1',
title: 'Unexpected call',
createdAt: now,
).copyWith(
type: TaskType.surprise,
scheduledStart: DateTime(2026, 6, 19, 13, 45),
scheduledEnd: DateTime(2026, 6, 19, 14, 15),
);
final updated = trackCompletedDuringLockedHours(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
label: 'work',
),
],
);
expect(updated.stats.completedDuringLockedHoursCount, 1);
expect(updated.stats.completedDuringLockedHoursMinutes, 15);
});
test('locked hour completion tracking does not double count overlaps', () {
final task = Task.quickCapture(
id: 'task-1',
title: 'Handle paperwork',
createdAt: now,
).copyWith(
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 14),
);
final minutes = completedDuringLockedHoursMinutes(
task: task,
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 13, 45),
label: 'work',
),
TimeInterval(
start: DateTime(2026, 6, 19, 13, 30),
end: DateTime(2026, 6, 19, 14),
label: 'appointment',
),
],
);
expect(minutes, 60);
});
});
group('SchedulingEngine starter behavior', () {