From 93f4b12283a7d504113fa8282c54e7d4a0c38be4 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 2 Jul 2026 12:49:22 -0700 Subject: [PATCH] feat: enforce past task push semantics --- .../test/persistent_composition_test.dart | 198 ++++++++++++++++++ .../v1_application_command_use_cases.dart | 3 + .../lib/src/domain/models/entities/task.dart | 30 +++ .../tasks/task_document_mapping.dart | 14 +- .../staleness/backlog_staleness_settings.dart | 8 +- .../backlog/views/backlog_view.dart | 14 +- .../engine/scheduling_engine.dart | 18 +- .../flexible_task_action_service.dart | 2 + .../services/task_transition_service.dart | 9 + .../test/application_commands_test.dart | 50 ++++- .../test/document_mapping_test.dart | 31 +++ .../test/scheduling_engine_test.dart | 89 +++++++- 12 files changed, 447 insertions(+), 19 deletions(-) diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index 9d51b19..7f85e92 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -209,6 +209,180 @@ void main() { expect((await _readBacklog(tester, composition)).items, isEmpty); }, ); + + testWidgets('past task push destinations persist across reopen', ( + tester, + ) async { + final path = await _runAsync(tester, _tempDatabasePath); + const pushNextTitle = 'Push next persistence'; + const pushTomorrowTitle = 'Push tomorrow persistence'; + const pushBacklogTitle = 'Push backlog persistence'; + var commandNow = _instant(8); + + var composition = await _openComposition( + tester, + path: path, + selectedDate: _date, + clock: FixedClock(commandNow), + ); + var commandController = _commandController( + composition, + selectedDate: _date, + operationIdPrefix: 'past-push', + now: () => commandNow, + ); + + await _captureAndSchedule( + tester, + composition, + commandController, + pushNextTitle, + ); + await _captureAndSchedule( + tester, + composition, + commandController, + pushTomorrowTitle, + ); + await _captureAndSchedule( + tester, + composition, + commandController, + pushBacklogTitle, + ); + + commandNow = _instant(12); + final pastCards = + TodayScreenData.fromTodayState( + await _readToday(tester, composition, _date), + now: commandNow, + ).cards.where( + (card) => { + pushNextTitle, + pushTomorrowTitle, + pushBacklogTitle, + }.contains(card.title), + ); + expect(pastCards, hasLength(3)); + expect( + pastCards.map((card) => card.showPastTaskPushButton), + everyElement(isTrue), + ); + + final pushNextItem = await _readTodayItem( + tester, + composition, + _date, + pushNextTitle, + ); + await tester.runAsync( + () => commandController.pushTaskToNextAvailableSlot( + taskId: pushNextItem.id, + expectedUpdatedAt: pushNextItem.item.updatedAt, + ), + ); + expect(commandController.state, isA()); + + final pushTomorrowItem = await _readTodayItem( + tester, + composition, + _date, + pushTomorrowTitle, + ); + await tester.runAsync( + () => commandController.pushTaskToTomorrow( + taskId: pushTomorrowItem.id, + expectedUpdatedAt: pushTomorrowItem.item.updatedAt, + ), + ); + expect(commandController.state, isA()); + + final pushBacklogItem = await _readTodayItem( + tester, + composition, + _date, + pushBacklogTitle, + ); + await tester.runAsync( + () => commandController.pushTaskToBacklog( + taskId: pushBacklogItem.id, + expectedUpdatedAt: pushBacklogItem.item.updatedAt, + ), + ); + expect(commandController.state, isA()); + + final pushedNext = await _readTodayItem( + tester, + composition, + _date, + pushNextTitle, + ); + expect(pushedNext.start, _instant(12)); + expect(pushedNext.end, _instant(12, 30)); + + commandNow = _instant(12, 45); + await tester.runAsync( + () => commandController.completeTask( + taskId: pushedNext.id, + taskType: pushedNext.taskType, + expectedUpdatedAt: pushedNext.item.updatedAt, + ), + ); + expect(commandController.state, isA()); + commandController.dispose(); + await _closeComposition(tester, composition); + + composition = await _openComposition( + tester, + path: path, + selectedDate: _date, + clock: FixedClock(_instant(13)), + ); + addTearDown(() => tester.runAsync(composition.dispose)); + + final reopenedNext = await _readTodayItem( + tester, + composition, + _date, + pushNextTitle, + ); + expect(reopenedNext.taskStatus, TaskStatus.completed); + expect(reopenedNext.start, _instant(12)); + expect(reopenedNext.item.completedAt, _instant(12, 45)); + final reopenedNextCard = TodayScreenData.fromTodayState( + await _readToday(tester, composition, _date), + now: _instant(13), + ).cards.singleWhere((card) => card.title == pushNextTitle); + expect(reopenedNextCard.showPastTaskPushButton, isFalse); + + final reopenedTomorrow = await _readTodayItem( + tester, + composition, + _date.addDays(1), + pushTomorrowTitle, + ); + expect(reopenedTomorrow.taskStatus, TaskStatus.planned); + expect(reopenedTomorrow.start, DateTime.utc(2026, 1, 3)); + expect(reopenedTomorrow.end, DateTime.utc(2026, 1, 3, 0, 30)); + + final reopenedBacklog = await _readBacklog(tester, composition); + final backlogTask = reopenedBacklog.items.single.task; + expect(backlogTask.title, pushBacklogTitle); + expect(backlogTask.scheduledStart, isNull); + expect(backlogTask.backlogEnteredAt, _instant(12)); + expect( + backlogTask.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + expect( + (await _readToday( + tester, + composition, + _date, + )).timelineItems.map((item) => item.item.displayTitle), + isNot(contains(pushBacklogTitle)), + ); + }); } /// Fixed date used by persistent Flutter tests. @@ -288,6 +462,30 @@ SchedulerCommandController _commandController( ); } +/// Captures [title] into Backlog and schedules it on the selected test date. +Future _captureAndSchedule( + WidgetTester tester, + PersistentSchedulerComposition composition, + SchedulerCommandController commandController, + String title, +) async { + await tester.runAsync(() => commandController.quickCaptureToBacklog(title)); + expect(commandController.state, isA()); + + final captured = (await _readBacklog( + tester, + composition, + )).items.singleWhere((item) => item.task.title == title).task; + await tester.runAsync( + () => commandController.scheduleBacklogItem( + taskId: captured.id, + durationMinutes: 30, + expectedUpdatedAt: captured.updatedAt, + ), + ); + expect(commandController.state, isA()); +} + /// Reads one Today timeline item by [title]. Future _readTodayItem( WidgetTester tester, diff --git a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart index 245753c..b569a62 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart @@ -903,6 +903,9 @@ class V1ApplicationCommandUseCases { input: state.input, taskId: taskId, updatedAt: context.now, + earliestStart: destination == PushDestination.nextAvailableSlot + ? context.now + : null, operationId: context.operationId, existingActivities: existingActivities, ); diff --git a/packages/scheduler_core/lib/src/domain/models/entities/task.dart b/packages/scheduler_core/lib/src/domain/models/entities/task.dart index 46b9b8d..1801175 100644 --- a/packages/scheduler_core/lib/src/domain/models/entities/task.dart +++ b/packages/scheduler_core/lib/src/domain/models/entities/task.dart @@ -44,6 +44,8 @@ class Task { this.actualStart, this.actualEnd, this.completedAt, + this.backlogEnteredAt, + this.backlogEnteredAtProvenance, String? parentTaskId, Set backlogTags = const {}, this.reminderOverride, @@ -127,6 +129,7 @@ class Task { Set backlogTags = const {}, DateTime? updatedAt, }) { + final entersBacklog = status == TaskStatus.backlog; return Task( id: id, title: title.trim(), @@ -138,10 +141,19 @@ class Task { difficulty: difficulty, createdAt: createdAt, updatedAt: updatedAt ?? createdAt, + backlogEnteredAt: entersBacklog ? createdAt : null, + backlogEnteredAtProvenance: + entersBacklog ? backlogEnteredAtProvenanceQuickCapture : null, backlogTags: backlogTags, ); } + /// Provenance value for tasks first created directly in Backlog. + static const backlogEnteredAtProvenanceQuickCapture = 'quickCapture'; + + /// Provenance value for scheduled tasks moved or pushed back to Backlog. + static const backlogEnteredAtProvenanceMovedToBacklog = 'movedToBacklog'; + /// Stable identifier used by persistence, UI selection, and scheduler changes. final String id; @@ -186,6 +198,12 @@ class Task { /// Explicit completion timestamp, distinct from the generic update timestamp. final DateTime? completedAt; + /// Freshness anchor for Backlog aging. Null falls back to [createdAt]. + final DateTime? backlogEnteredAt; + + /// Optional explanation for how [backlogEnteredAt] was set. + final String? backlogEnteredAtProvenance; + /// Parent task id when this task is a child/subtask. Null means top-level task. final String? parentTaskId; @@ -219,6 +237,9 @@ class Task { /// Backlog status means the task is stored for later and has no active slot. bool get isBacklog => status == TaskStatus.backlog; + /// Timestamp used for Backlog freshness and age filters. + DateTime get backlogFreshnessAnchorAt => backlogEnteredAt ?? createdAt; + /// Return a copy with selected fields changed. /// /// The core uses this instead of mutation. That matters because scheduling @@ -247,6 +268,8 @@ class Task { DateTime? actualStart, DateTime? actualEnd, DateTime? completedAt, + DateTime? backlogEnteredAt, + String? backlogEnteredAtProvenance, String? parentTaskId, Set? backlogTags, ReminderProfile? reminderOverride, @@ -258,6 +281,7 @@ class Task { bool clearSchedule = false, bool clearActualInterval = false, bool clearCompletion = false, + bool clearBacklogEntry = false, bool clearParentTask = false, bool clearReminderOverride = false, }) { @@ -279,6 +303,12 @@ class Task { clearActualInterval ? null : (actualStart ?? this.actualStart), actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), + backlogEnteredAt: clearBacklogEntry + ? null + : (backlogEnteredAt ?? this.backlogEnteredAt), + backlogEnteredAtProvenance: clearBacklogEntry + ? null + : (backlogEnteredAtProvenance ?? this.backlogEnteredAtProvenance), parentTaskId: clearParentTask ? null : (parentTaskId ?? this.parentTaskId), backlogTags: backlogTags ?? this.backlogTags, diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart index 3f43df4..59c0f55 100644 --- a/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/tasks/task_document_mapping.dart @@ -55,8 +55,10 @@ abstract final class TaskDocumentMapping { TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( task.stats, ), - TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), - TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, + TaskDocumentFields.backlogEnteredAt: + _dateTimeToStored(backlogEnteredAt ?? task.backlogEnteredAt), + TaskDocumentFields.backlogEnteredAtProvenance: + backlogEnteredAtProvenance ?? task.backlogEnteredAtProvenance, }; } @@ -106,6 +108,14 @@ abstract final class TaskDocumentMapping { document, TaskDocumentFields.completedAt, ), + backlogEnteredAt: _requiredNullableDateTime( + document, + TaskDocumentFields.backlogEnteredAt, + ), + backlogEnteredAtProvenance: _requiredNullableString( + document, + TaskDocumentFields.backlogEnteredAtProvenance, + ), parentTaskId: _requiredNullableString( document, TaskDocumentFields.parentTaskId, diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart index f100a3a..5f8973c 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/staleness/backlog_staleness_settings.dart @@ -25,14 +25,14 @@ class BacklogStalenessSettings { /// Return the visual age marker for [task] relative to [now]. /// - /// This uses [Task.createdAt], not [Task.updatedAt], because the marker is - /// meant to show how long the idea has existed in the system. A task edited - /// yesterday but created two months ago should still feel old in the backlog. + /// This uses [Task.backlogFreshnessAnchorAt], not [Task.updatedAt], because + /// ordinary edits should not make an old backlog item look fresh. Commands + /// that deliberately re-enter Backlog reset the anchor. BacklogStalenessMarker markerFor({ required Task task, required DateTime now, }) { - final age = now.difference(task.createdAt); + final age = now.difference(task.backlogFreshnessAnchorAt); if (age <= greenMaxAge) { return BacklogStalenessMarker.green; diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart index e19cecf..defde6f 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart @@ -26,11 +26,11 @@ class BacklogView { /// Clock value supplied by the caller so age/staleness behavior is testable. final DateTime now; - /// Age since [Task.createdAt] that qualifies for the `stale` filter. + /// Age since the task's Backlog freshness anchor that qualifies for the `stale` filter. /// - /// V1 does not yet store a separate "entered backlog at" timestamp. Until - /// persistence adds that field, both stale filtering and visual staleness use - /// task creation age so they do not disagree after a task edit. + /// Tasks persisted before the anchor existed fall back to [Task.createdAt]. + /// This keeps older data readable while letting Push to Backlog make a task + /// fresh without rewriting original creation history. final Duration staleAfter; /// Color-bucket threshold configuration for backlog aging indicators. @@ -101,7 +101,8 @@ class BacklogView { BacklogFilter.criticalMissed => task.type == TaskType.critical && task.stats.missedCount > 0, BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), - BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter, + BacklogFilter.stale => + now.difference(task.backlogFreshnessAnchorAt) >= staleAfter, BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet, }; } @@ -116,7 +117,8 @@ class BacklogView { _priorityRank(b.priority).compareTo(_priorityRank(a.priority)), BacklogSortKey.rewardVsEffort => _rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)), - BacklogSortKey.age => a.createdAt.compareTo(b.createdAt), + BacklogSortKey.age => + a.backlogFreshnessAnchorAt.compareTo(b.backlogFreshnessAnchorAt), BacklogSortKey.project => a.projectId.compareTo(b.projectId), BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), }; diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart index abb5d5b..332daec 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart @@ -137,6 +137,7 @@ class SchedulingEngine { required SchedulingInput input, required String taskId, DateTime? updatedAt, + DateTime? earliestStart, bool countAsManualPush = true, }) { // Resolve the selected task by id so UI code only needs to pass a stable @@ -173,7 +174,8 @@ class SchedulingEngine { } final currentInterval = _scheduledIntervalFor(task); - if (currentInterval == null || !input.window.contains(currentInterval)) { + if (currentInterval == null || + (earliestStart == null && !input.window.contains(currentInterval))) { return _unchangedResult( input, SchedulingNotice( @@ -207,6 +209,7 @@ class SchedulingEngine { input: input, task: task, currentInterval: currentInterval, + earliestStart: earliestStart, ); if (placement == null) { @@ -738,7 +741,12 @@ _BacklogInsertionPlan? _planFlexiblePush({ required SchedulingInput input, required Task task, required TimeInterval currentInterval, + DateTime? earliestStart, }) { + final pushPoint = _laterOf( + currentInterval.end, + earliestStart == null ? input.window.start : earliestStart, + ); final fixedBlocks = [ ...input.blockedIntervals, ]; @@ -746,7 +754,7 @@ _BacklogInsertionPlan? _planFlexiblePush({ _PlacementItem( task: task, duration: currentInterval.duration, - earliestStart: currentInterval.end, + earliestStart: pushPoint, ), ]; @@ -766,7 +774,7 @@ _BacklogInsertionPlan? _planFlexiblePush({ continue; } - final startsBeforePushPoint = interval.start.isBefore(currentInterval.end); + final startsBeforePushPoint = interval.start.isBefore(pushPoint); final startsAfterWindow = interval.start.isAfter(input.window.end) || interval.start.isAtSameMomentAs(input.window.end); @@ -786,7 +794,7 @@ _BacklogInsertionPlan? _planFlexiblePush({ fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); - var cursor = currentInterval.end; + var cursor = pushPoint; final placements = {}; for (final item in queue) { @@ -944,6 +952,7 @@ SchedulingResult _applyPlacement({ scheduledStart: interval.start, scheduledEnd: interval.end, updatedAt: updatedAt, + clearBacklogEntry: isInsertedTask, ); final updatedTask = _applySchedulingActivity( originalTask: task, @@ -1039,6 +1048,7 @@ SchedulingResult _applyPushPlacement({ scheduledStart: interval.start, scheduledEnd: interval.end, updatedAt: updatedAt, + clearBacklogEntry: true, ); final updatedTask = _applySchedulingActivity( originalTask: task, diff --git a/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart b/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart index 12870b9..921815e 100644 --- a/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_actions/services/flexible_task_action_service.dart @@ -123,6 +123,7 @@ class FlexibleTaskActionService { required SchedulingInput input, required String taskId, DateTime? updatedAt, + DateTime? earliestStart, String? operationId, Iterable existingActivities = const [], }) { @@ -153,6 +154,7 @@ class FlexibleTaskActionService { input: input, taskId: taskId, updatedAt: now, + earliestStart: earliestStart, ), PushDestination.tomorrowTopOfQueue => schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( diff --git a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart index 3425815..599ed3c 100644 --- a/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart +++ b/packages/scheduler_core/lib/src/scheduling/task_lifecycle/services/task_transition_service.dart @@ -181,6 +181,7 @@ class TaskTransitionService { completedAt: occurredAt, actualStart: actualStart, actualEnd: actualEnd, + clearBacklogEntry: true, ); final activities = [ _activity( @@ -245,6 +246,9 @@ class TaskTransitionService { status: TaskStatus.backlog, updatedAt: occurredAt, clearSchedule: true, + backlogEnteredAt: occurredAt, + backlogEnteredAtProvenance: + Task.backlogEnteredAtProvenanceMovedToBacklog, ) : task.copyWith( status: TaskStatus.missed, @@ -313,6 +317,7 @@ class TaskTransitionService { status: TaskStatus.cancelled, updatedAt: occurredAt, clearSchedule: true, + clearBacklogEntry: true, ); final activities = [ _activity( @@ -359,6 +364,7 @@ class TaskTransitionService { status: TaskStatus.noLongerRelevant, updatedAt: occurredAt, clearSchedule: true, + clearBacklogEntry: true, ); final activities = [ @@ -446,6 +452,8 @@ class TaskTransitionService { status: TaskStatus.backlog, updatedAt: occurredAt, clearSchedule: true, + backlogEnteredAt: occurredAt, + backlogEnteredAtProvenance: Task.backlogEnteredAtProvenanceMovedToBacklog, ); final activities = [ _activity( @@ -489,6 +497,7 @@ class TaskTransitionService { final restored = task.copyWith( status: TaskStatus.planned, updatedAt: occurredAt, + clearBacklogEntry: true, ); final activities = [ _activity( diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index b95e931..fea2d50 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -252,7 +252,13 @@ void main() { DateTime.utc(2026, 6, 26, 9), ); expect(moveBacklog.isSuccess, isTrue); - expect(taskById(store.currentTasks, first.id).status, TaskStatus.backlog); + final movedBacklog = taskById(store.currentTasks, first.id); + expect(movedBacklog.status, TaskStatus.backlog); + expect(movedBacklog.backlogEnteredAt, DateTime.utc(2026, 6, 25, 8, 10)); + expect( + movedBacklog.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); expect( store.currentTaskActivities .map((activity) => activity.operationId) @@ -261,6 +267,48 @@ void main() { ); }); + test('push next moves an overdue task after the command time', () async { + final overdue = task( + id: 'overdue', + title: 'Overdue', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 24, 9), + end: DateTime.utc(2026, 6, 24, 9, 30), + ); + final afternoon = task( + id: 'afternoon', + title: 'Afternoon', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 13), + end: DateTime.utc(2026, 6, 25, 13, 30), + ); + final store = storeWithSettings(tasks: [overdue, afternoon]); + + final result = await commands(store).pushFlexibleToNextAvailableSlot( + context: appContext('push-overdue', DateTime.utc(2026, 6, 25, 12)), + localDate: day, + taskId: overdue.id, + ); + + expect(result.isSuccess, isTrue); + final pushed = taskById(store.currentTasks, overdue.id); + expect(pushed.scheduledStart, DateTime.utc(2026, 6, 25, 12)); + expect(pushed.scheduledEnd, DateTime.utc(2026, 6, 25, 12, 30)); + expect(pushed.stats.manuallyPushedCount, 1); + expect(taskById(store.currentTasks, afternoon.id).scheduledStart, + DateTime.utc(2026, 6, 25, 13)); + expect( + store.currentTaskActivities + .where((activity) => activity.operationId == 'push-overdue') + .map((activity) => activity.code), + [TaskActivityCode.manuallyPushed], + ); + }); + test('completion commands persist activities and project statistics', () async { final flexible = task( diff --git a/packages/scheduler_core/test/document_mapping_test.dart b/packages/scheduler_core/test/document_mapping_test.dart index 5de95f5..7f74373 100644 --- a/packages/scheduler_core/test/document_mapping_test.dart +++ b/packages/scheduler_core/test/document_mapping_test.dart @@ -47,6 +47,8 @@ void main() { expect(restored.scheduledStart, task.scheduledStart); expect(restored.actualStart, task.actualStart); expect(restored.completedAt, task.completedAt); + expect(restored.backlogEnteredAt, createdAt); + expect(restored.backlogEnteredAtProvenance, 'recorded'); expect(restored.backlogTags, task.backlogTags); expect(restored.stats.manuallyPushedCount, 1); @@ -73,6 +75,35 @@ void main() { expect(nullableRestored.scheduledStart, isNull); }); + test('task documents serialize backlog entry fields from the task', () { + final task = Task.quickCapture( + id: 'backlog-entry', + title: 'Backlog entry', + createdAt: createdAt, + ).copyWith( + backlogEnteredAt: updatedAt, + backlogEnteredAtProvenance: + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + + final document = task.toDocument(ownerId: 'owner-1'); + final restored = TaskDocumentMapping.fromDocument(document); + + expect( + document[TaskDocumentFields.backlogEnteredAt], + '2026-06-23T08:05:00.000Z', + ); + expect( + document[TaskDocumentFields.backlogEnteredAtProvenance], + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + expect(restored.backlogEnteredAt, updatedAt); + expect( + restored.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + }); + test('task documents preserve explicit schedule clearing', () { final task = scheduledTask(id: 'scheduled', createdAt: createdAt); diff --git a/packages/scheduler_core/test/scheduling_engine_test.dart b/packages/scheduler_core/test/scheduling_engine_test.dart index 5097dc5..6d9e5d0 100644 --- a/packages/scheduler_core/test/scheduling_engine_test.dart +++ b/packages/scheduler_core/test/scheduling_engine_test.dart @@ -42,6 +42,12 @@ void main() { expect(task.scheduledEnd, isNull); expect(task.createdAt, now); expect(task.updatedAt, now); + expect(task.backlogEnteredAt, now); + expect( + task.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceQuickCapture, + ); + expect(task.backlogFreshnessAnchorAt, now); }); test('quick capture requires a title', () { @@ -132,7 +138,7 @@ void main() { ]); }); - test('backlog sorting uses backlog keys instead of original schedule', () { + test('backlog sorting uses backlog entry timestamps', () { final older = Task.quickCapture( id: 'older', title: 'Older backlog item', @@ -151,7 +157,7 @@ void main() { ); final movedOlder = const SchedulingEngine().moveToBacklog( older, - updatedAt: now, + updatedAt: now.subtract(const Duration(minutes: 5)), ); final movedNewer = const SchedulingEngine().moveToBacklog( newer, @@ -161,6 +167,11 @@ void main() { expect(movedOlder.scheduledStart, isNull); expect(movedNewer.scheduledStart, isNull); + expect( + movedOlder.backlogEnteredAt, + now.subtract(const Duration(minutes: 5)), + ); + expect(movedNewer.backlogEnteredAt, now); expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [ 'older', 'newer', @@ -249,6 +260,44 @@ void main() { expect(view.stalenessMarkerFor(stale), BacklogStalenessMarker.purple); }); + test('backlog freshness uses backlog entry time instead of creation time', + () { + final oldButRecentlyMoved = Task( + id: 'recent-backlog', + title: 'Recently moved', + projectId: 'home', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: now.subtract(const Duration(days: 60)), + updatedAt: now, + backlogEnteredAt: now.subtract(const Duration(days: 1)), + backlogEnteredAtProvenance: + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); + final oldAndStillOld = Task.quickCapture( + id: 'old-backlog', + title: 'Old backlog', + createdAt: now.subtract(const Duration(days: 60)), + ); + final view = BacklogView( + tasks: [oldButRecentlyMoved, oldAndStillOld], + now: now, + ); + + expect( + view.filter(BacklogFilter.stale).map((task) => task.id), + ['old-backlog'], + ); + expect( + view.stalenessMarkerFor(oldButRecentlyMoved), + BacklogStalenessMarker.green, + ); + expect( + view.sorted(BacklogSortKey.age).map((task) => task.id), + ['old-backlog', 'recent-backlog'], + ); + }); + test('backlog staleness markers support configurable thresholds', () { final task = Task.quickCapture( id: 'custom', @@ -1423,6 +1472,32 @@ void main() { expect(result.notices.single.type, SchedulingNoticeType.moved); }); + test('push flexible task can start after explicit current time', () { + final task = flexibleTask().copyWith( + scheduledStart: DateTime(2026, 6, 18, 9), + scheduledEnd: DateTime(2026, 6, 18, 9, 30), + durationMinutes: 30, + ); + final result = engine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: [task], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 17), + ), + ), + taskId: 'task-1', + updatedAt: DateTime(2026, 6, 19, 12), + earliestStart: DateTime(2026, 6, 19, 12), + ); + final pushed = result.tasks.single; + + expect(pushed.scheduledStart, DateTime(2026, 6, 19, 12)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 12, 30)); + expect(pushed.stats.manuallyPushedCount, 1); + expect(result.notices.single.type, SchedulingNoticeType.moved); + }); + test('push flexible task bumps downstream flexible tasks in order', () { final first = flexibleTask().copyWith( id: 'flexible-1', @@ -1762,6 +1837,11 @@ void main() { expect(moved.status, TaskStatus.backlog); expect(moved.scheduledStart, isNull); expect(moved.scheduledEnd, isNull); + expect(moved.backlogEnteredAt, now); + expect( + moved.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); expect(moved.stats.movedToBacklogCount, 1); }); @@ -1771,6 +1851,11 @@ void main() { final missed = engine.markMissed(task, updatedAt: now); expect(missed.status, TaskStatus.backlog); + expect(missed.backlogEnteredAt, now); + expect( + missed.backlogEnteredAtProvenance, + Task.backlogEnteredAtProvenanceMovedToBacklog, + ); expect(missed.stats.missedCount, 1); expect(missed.stats.movedToBacklogCount, 1); });