feat(locked-time): track completed locked-hour work
This commit is contained in:
parent
0cb81d2036
commit
4a307cc522
3 changed files with 243 additions and 1 deletions
|
|
@ -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
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue