forked from eva/focus-flow
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
|
# 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.
|
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
|
Recommended Codex level: medium
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Add helper logic to detect when a completed/surprise task overlaps locked time.
|
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 partial overlap.
|
||||||
- Tests cover no 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:
|
Commit suggestion:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -351,6 +351,79 @@ List<TimeInterval> lockedSchedulingIntervalsForDay({
|
||||||
).schedulingIntervals;
|
).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) {
|
bool _blockOccursOn(LockedBlock block, DateTime date) {
|
||||||
final recurrence = block.recurrence;
|
final recurrence = block.recurrence;
|
||||||
if (recurrence != null) {
|
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) {
|
bool _sameDate(DateTime first, DateTime second) {
|
||||||
return first.year == second.year &&
|
return first.year == second.year &&
|
||||||
first.month == second.month &&
|
first.month == second.month &&
|
||||||
|
|
|
||||||
|
|
@ -656,6 +656,142 @@ void main() {
|
||||||
expect(occurrence.interval.start, DateTime(2026, 6, 19, 15));
|
expect(occurrence.interval.start, DateTime(2026, 6, 19, 15));
|
||||||
expect(occurrence.interval.end, DateTime(2026, 6, 19, 16));
|
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', () {
|
group('SchedulingEngine starter behavior', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue