diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_05_Recurring_Locked_Blocks.md b/Codex Documentation/Archived plans/V1_BLOCK_05_Recurring_Locked_Blocks.md similarity index 86% rename from Codex Documentation/Current Software Plan/V1_BLOCK_05_Recurring_Locked_Blocks.md rename to Codex Documentation/Archived plans/V1_BLOCK_05_Recurring_Locked_Blocks.md index f7f4c0b..f63be88 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_05_Recurring_Locked_Blocks.md +++ b/Codex Documentation/Archived plans/V1_BLOCK_05_Recurring_Locked_Blocks.md @@ -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 diff --git a/lib/src/locked_time.dart b/lib/src/locked_time.dart index d03d806..4ab41d1 100644 --- a/lib/src/locked_time.dart +++ b/lib/src/locked_time.dart @@ -351,6 +351,79 @@ List lockedSchedulingIntervalsForDay({ ).schedulingIntervals; } +/// Returns [task] with locked-hour completion statistics applied when relevant. +Task trackCompletedDuringLockedHours({ + required Task task, + required List 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 lockedIntervals, +}) { + if (!_shouldTrackLockedHourCompletion(task)) { + return 0; + } + + final taskInterval = _scheduledIntervalForTask(task); + if (taskInterval == null) { + return 0; + } + + final intersections = []; + 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 && diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index 9fbe497..734fdb3 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -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', () {