feat: enforce past task push semantics

This commit is contained in:
Ashley Venn 2026-07-02 12:49:22 -07:00
parent 4d373ce250
commit 93f4b12283
12 changed files with 447 additions and 19 deletions

View file

@ -209,6 +209,180 @@ void main() {
expect((await _readBacklog(tester, composition)).items, isEmpty); 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<SchedulerCommandSuccess>());
final pushTomorrowItem = await _readTodayItem(
tester,
composition,
_date,
pushTomorrowTitle,
);
await tester.runAsync(
() => commandController.pushTaskToTomorrow(
taskId: pushTomorrowItem.id,
expectedUpdatedAt: pushTomorrowItem.item.updatedAt,
),
);
expect(commandController.state, isA<SchedulerCommandSuccess>());
final pushBacklogItem = await _readTodayItem(
tester,
composition,
_date,
pushBacklogTitle,
);
await tester.runAsync(
() => commandController.pushTaskToBacklog(
taskId: pushBacklogItem.id,
expectedUpdatedAt: pushBacklogItem.item.updatedAt,
),
);
expect(commandController.state, isA<SchedulerCommandSuccess>());
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<SchedulerCommandSuccess>());
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. /// 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<void> _captureAndSchedule(
WidgetTester tester,
PersistentSchedulerComposition composition,
SchedulerCommandController commandController,
String title,
) async {
await tester.runAsync(() => commandController.quickCaptureToBacklog(title));
expect(commandController.state, isA<SchedulerCommandSuccess>());
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<SchedulerCommandSuccess>());
}
/// Reads one Today timeline item by [title]. /// Reads one Today timeline item by [title].
Future<TodayTimelineItem> _readTodayItem( Future<TodayTimelineItem> _readTodayItem(
WidgetTester tester, WidgetTester tester,

View file

@ -903,6 +903,9 @@ class V1ApplicationCommandUseCases {
input: state.input, input: state.input,
taskId: taskId, taskId: taskId,
updatedAt: context.now, updatedAt: context.now,
earliestStart: destination == PushDestination.nextAvailableSlot
? context.now
: null,
operationId: context.operationId, operationId: context.operationId,
existingActivities: existingActivities, existingActivities: existingActivities,
); );

View file

@ -44,6 +44,8 @@ class Task {
this.actualStart, this.actualStart,
this.actualEnd, this.actualEnd,
this.completedAt, this.completedAt,
this.backlogEnteredAt,
this.backlogEnteredAtProvenance,
String? parentTaskId, String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{}, Set<BacklogTag> backlogTags = const <BacklogTag>{},
this.reminderOverride, this.reminderOverride,
@ -127,6 +129,7 @@ class Task {
Set<BacklogTag> backlogTags = const <BacklogTag>{}, Set<BacklogTag> backlogTags = const <BacklogTag>{},
DateTime? updatedAt, DateTime? updatedAt,
}) { }) {
final entersBacklog = status == TaskStatus.backlog;
return Task( return Task(
id: id, id: id,
title: title.trim(), title: title.trim(),
@ -138,10 +141,19 @@ class Task {
difficulty: difficulty, difficulty: difficulty,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt ?? createdAt, updatedAt: updatedAt ?? createdAt,
backlogEnteredAt: entersBacklog ? createdAt : null,
backlogEnteredAtProvenance:
entersBacklog ? backlogEnteredAtProvenanceQuickCapture : null,
backlogTags: backlogTags, 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. /// Stable identifier used by persistence, UI selection, and scheduler changes.
final String id; final String id;
@ -186,6 +198,12 @@ class Task {
/// Explicit completion timestamp, distinct from the generic update timestamp. /// Explicit completion timestamp, distinct from the generic update timestamp.
final DateTime? completedAt; 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. /// Parent task id when this task is a child/subtask. Null means top-level task.
final String? parentTaskId; final String? parentTaskId;
@ -219,6 +237,9 @@ class Task {
/// Backlog status means the task is stored for later and has no active slot. /// Backlog status means the task is stored for later and has no active slot.
bool get isBacklog => status == TaskStatus.backlog; 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. /// Return a copy with selected fields changed.
/// ///
/// The core uses this instead of mutation. That matters because scheduling /// The core uses this instead of mutation. That matters because scheduling
@ -247,6 +268,8 @@ class Task {
DateTime? actualStart, DateTime? actualStart,
DateTime? actualEnd, DateTime? actualEnd,
DateTime? completedAt, DateTime? completedAt,
DateTime? backlogEnteredAt,
String? backlogEnteredAtProvenance,
String? parentTaskId, String? parentTaskId,
Set<BacklogTag>? backlogTags, Set<BacklogTag>? backlogTags,
ReminderProfile? reminderOverride, ReminderProfile? reminderOverride,
@ -258,6 +281,7 @@ class Task {
bool clearSchedule = false, bool clearSchedule = false,
bool clearActualInterval = false, bool clearActualInterval = false,
bool clearCompletion = false, bool clearCompletion = false,
bool clearBacklogEntry = false,
bool clearParentTask = false, bool clearParentTask = false,
bool clearReminderOverride = false, bool clearReminderOverride = false,
}) { }) {
@ -279,6 +303,12 @@ class Task {
clearActualInterval ? null : (actualStart ?? this.actualStart), clearActualInterval ? null : (actualStart ?? this.actualStart),
actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd),
completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), completedAt: clearCompletion ? null : (completedAt ?? this.completedAt),
backlogEnteredAt: clearBacklogEntry
? null
: (backlogEnteredAt ?? this.backlogEnteredAt),
backlogEnteredAtProvenance: clearBacklogEntry
? null
: (backlogEnteredAtProvenance ?? this.backlogEnteredAtProvenance),
parentTaskId: parentTaskId:
clearParentTask ? null : (parentTaskId ?? this.parentTaskId), clearParentTask ? null : (parentTaskId ?? this.parentTaskId),
backlogTags: backlogTags ?? this.backlogTags, backlogTags: backlogTags ?? this.backlogTags,

View file

@ -55,8 +55,10 @@ abstract final class TaskDocumentMapping {
TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument(
task.stats, task.stats,
), ),
TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), TaskDocumentFields.backlogEnteredAt:
TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, _dateTimeToStored(backlogEnteredAt ?? task.backlogEnteredAt),
TaskDocumentFields.backlogEnteredAtProvenance:
backlogEnteredAtProvenance ?? task.backlogEnteredAtProvenance,
}; };
} }
@ -106,6 +108,14 @@ abstract final class TaskDocumentMapping {
document, document,
TaskDocumentFields.completedAt, TaskDocumentFields.completedAt,
), ),
backlogEnteredAt: _requiredNullableDateTime(
document,
TaskDocumentFields.backlogEnteredAt,
),
backlogEnteredAtProvenance: _requiredNullableString(
document,
TaskDocumentFields.backlogEnteredAtProvenance,
),
parentTaskId: _requiredNullableString( parentTaskId: _requiredNullableString(
document, document,
TaskDocumentFields.parentTaskId, TaskDocumentFields.parentTaskId,

View file

@ -25,14 +25,14 @@ class BacklogStalenessSettings {
/// Return the visual age marker for [task] relative to [now]. /// Return the visual age marker for [task] relative to [now].
/// ///
/// This uses [Task.createdAt], not [Task.updatedAt], because the marker is /// This uses [Task.backlogFreshnessAnchorAt], not [Task.updatedAt], because
/// meant to show how long the idea has existed in the system. A task edited /// ordinary edits should not make an old backlog item look fresh. Commands
/// yesterday but created two months ago should still feel old in the backlog. /// that deliberately re-enter Backlog reset the anchor.
BacklogStalenessMarker markerFor({ BacklogStalenessMarker markerFor({
required Task task, required Task task,
required DateTime now, required DateTime now,
}) { }) {
final age = now.difference(task.createdAt); final age = now.difference(task.backlogFreshnessAnchorAt);
if (age <= greenMaxAge) { if (age <= greenMaxAge) {
return BacklogStalenessMarker.green; return BacklogStalenessMarker.green;

View file

@ -26,11 +26,11 @@ class BacklogView {
/// Clock value supplied by the caller so age/staleness behavior is testable. /// Clock value supplied by the caller so age/staleness behavior is testable.
final DateTime now; 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 /// Tasks persisted before the anchor existed fall back to [Task.createdAt].
/// persistence adds that field, both stale filtering and visual staleness use /// This keeps older data readable while letting Push to Backlog make a task
/// task creation age so they do not disagree after a task edit. /// fresh without rewriting original creation history.
final Duration staleAfter; final Duration staleAfter;
/// Color-bucket threshold configuration for backlog aging indicators. /// Color-bucket threshold configuration for backlog aging indicators.
@ -101,7 +101,8 @@ class BacklogView {
BacklogFilter.criticalMissed => BacklogFilter.criticalMissed =>
task.type == TaskType.critical && task.stats.missedCount > 0, task.type == TaskType.critical && task.stats.missedCount > 0,
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), 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, BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
}; };
} }
@ -116,7 +117,8 @@ class BacklogView {
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)), _priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
BacklogSortKey.rewardVsEffort => BacklogSortKey.rewardVsEffort =>
_rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)), _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.project => a.projectId.compareTo(b.projectId),
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
}; };

View file

@ -137,6 +137,7 @@ class SchedulingEngine {
required SchedulingInput input, required SchedulingInput input,
required String taskId, required String taskId,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? earliestStart,
bool countAsManualPush = true, bool countAsManualPush = true,
}) { }) {
// Resolve the selected task by id so UI code only needs to pass a stable // 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); final currentInterval = _scheduledIntervalFor(task);
if (currentInterval == null || !input.window.contains(currentInterval)) { if (currentInterval == null ||
(earliestStart == null && !input.window.contains(currentInterval))) {
return _unchangedResult( return _unchangedResult(
input, input,
SchedulingNotice( SchedulingNotice(
@ -207,6 +209,7 @@ class SchedulingEngine {
input: input, input: input,
task: task, task: task,
currentInterval: currentInterval, currentInterval: currentInterval,
earliestStart: earliestStart,
); );
if (placement == null) { if (placement == null) {
@ -738,7 +741,12 @@ _BacklogInsertionPlan? _planFlexiblePush({
required SchedulingInput input, required SchedulingInput input,
required Task task, required Task task,
required TimeInterval currentInterval, required TimeInterval currentInterval,
DateTime? earliestStart,
}) { }) {
final pushPoint = _laterOf(
currentInterval.end,
earliestStart == null ? input.window.start : earliestStart,
);
final fixedBlocks = <TimeInterval>[ final fixedBlocks = <TimeInterval>[
...input.blockedIntervals, ...input.blockedIntervals,
]; ];
@ -746,7 +754,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
_PlacementItem( _PlacementItem(
task: task, task: task,
duration: currentInterval.duration, duration: currentInterval.duration,
earliestStart: currentInterval.end, earliestStart: pushPoint,
), ),
]; ];
@ -766,7 +774,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
continue; continue;
} }
final startsBeforePushPoint = interval.start.isBefore(currentInterval.end); final startsBeforePushPoint = interval.start.isBefore(pushPoint);
final startsAfterWindow = interval.start.isAfter(input.window.end) || final startsAfterWindow = interval.start.isAfter(input.window.end) ||
interval.start.isAtSameMomentAs(input.window.end); interval.start.isAtSameMomentAs(input.window.end);
@ -786,7 +794,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
var cursor = currentInterval.end; var cursor = pushPoint;
final placements = <String, TimeInterval>{}; final placements = <String, TimeInterval>{};
for (final item in queue) { for (final item in queue) {
@ -944,6 +952,7 @@ SchedulingResult _applyPlacement({
scheduledStart: interval.start, scheduledStart: interval.start,
scheduledEnd: interval.end, scheduledEnd: interval.end,
updatedAt: updatedAt, updatedAt: updatedAt,
clearBacklogEntry: isInsertedTask,
); );
final updatedTask = _applySchedulingActivity( final updatedTask = _applySchedulingActivity(
originalTask: task, originalTask: task,
@ -1039,6 +1048,7 @@ SchedulingResult _applyPushPlacement({
scheduledStart: interval.start, scheduledStart: interval.start,
scheduledEnd: interval.end, scheduledEnd: interval.end,
updatedAt: updatedAt, updatedAt: updatedAt,
clearBacklogEntry: true,
); );
final updatedTask = _applySchedulingActivity( final updatedTask = _applySchedulingActivity(
originalTask: task, originalTask: task,

View file

@ -123,6 +123,7 @@ class FlexibleTaskActionService {
required SchedulingInput input, required SchedulingInput input,
required String taskId, required String taskId,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? earliestStart,
String? operationId, String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[], Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) { }) {
@ -153,6 +154,7 @@ class FlexibleTaskActionService {
input: input, input: input,
taskId: taskId, taskId: taskId,
updatedAt: now, updatedAt: now,
earliestStart: earliestStart,
), ),
PushDestination.tomorrowTopOfQueue => PushDestination.tomorrowTopOfQueue =>
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(

View file

@ -181,6 +181,7 @@ class TaskTransitionService {
completedAt: occurredAt, completedAt: occurredAt,
actualStart: actualStart, actualStart: actualStart,
actualEnd: actualEnd, actualEnd: actualEnd,
clearBacklogEntry: true,
); );
final activities = [ final activities = [
_activity( _activity(
@ -245,6 +246,9 @@ class TaskTransitionService {
status: TaskStatus.backlog, status: TaskStatus.backlog,
updatedAt: occurredAt, updatedAt: occurredAt,
clearSchedule: true, clearSchedule: true,
backlogEnteredAt: occurredAt,
backlogEnteredAtProvenance:
Task.backlogEnteredAtProvenanceMovedToBacklog,
) )
: task.copyWith( : task.copyWith(
status: TaskStatus.missed, status: TaskStatus.missed,
@ -313,6 +317,7 @@ class TaskTransitionService {
status: TaskStatus.cancelled, status: TaskStatus.cancelled,
updatedAt: occurredAt, updatedAt: occurredAt,
clearSchedule: true, clearSchedule: true,
clearBacklogEntry: true,
); );
final activities = [ final activities = [
_activity( _activity(
@ -359,6 +364,7 @@ class TaskTransitionService {
status: TaskStatus.noLongerRelevant, status: TaskStatus.noLongerRelevant,
updatedAt: occurredAt, updatedAt: occurredAt,
clearSchedule: true, clearSchedule: true,
clearBacklogEntry: true,
); );
final activities = [ final activities = [
@ -446,6 +452,8 @@ class TaskTransitionService {
status: TaskStatus.backlog, status: TaskStatus.backlog,
updatedAt: occurredAt, updatedAt: occurredAt,
clearSchedule: true, clearSchedule: true,
backlogEnteredAt: occurredAt,
backlogEnteredAtProvenance: Task.backlogEnteredAtProvenanceMovedToBacklog,
); );
final activities = [ final activities = [
_activity( _activity(
@ -489,6 +497,7 @@ class TaskTransitionService {
final restored = task.copyWith( final restored = task.copyWith(
status: TaskStatus.planned, status: TaskStatus.planned,
updatedAt: occurredAt, updatedAt: occurredAt,
clearBacklogEntry: true,
); );
final activities = [ final activities = [
_activity( _activity(

View file

@ -252,7 +252,13 @@ void main() {
DateTime.utc(2026, 6, 26, 9), DateTime.utc(2026, 6, 26, 9),
); );
expect(moveBacklog.isSuccess, isTrue); 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( expect(
store.currentTaskActivities store.currentTaskActivities
.map((activity) => activity.operationId) .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', test('completion commands persist activities and project statistics',
() async { () async {
final flexible = task( final flexible = task(

View file

@ -47,6 +47,8 @@ void main() {
expect(restored.scheduledStart, task.scheduledStart); expect(restored.scheduledStart, task.scheduledStart);
expect(restored.actualStart, task.actualStart); expect(restored.actualStart, task.actualStart);
expect(restored.completedAt, task.completedAt); expect(restored.completedAt, task.completedAt);
expect(restored.backlogEnteredAt, createdAt);
expect(restored.backlogEnteredAtProvenance, 'recorded');
expect(restored.backlogTags, task.backlogTags); expect(restored.backlogTags, task.backlogTags);
expect(restored.stats.manuallyPushedCount, 1); expect(restored.stats.manuallyPushedCount, 1);
@ -73,6 +75,35 @@ void main() {
expect(nullableRestored.scheduledStart, isNull); 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', () { test('task documents preserve explicit schedule clearing', () {
final task = scheduledTask(id: 'scheduled', createdAt: createdAt); final task = scheduledTask(id: 'scheduled', createdAt: createdAt);

View file

@ -42,6 +42,12 @@ void main() {
expect(task.scheduledEnd, isNull); expect(task.scheduledEnd, isNull);
expect(task.createdAt, now); expect(task.createdAt, now);
expect(task.updatedAt, now); expect(task.updatedAt, now);
expect(task.backlogEnteredAt, now);
expect(
task.backlogEnteredAtProvenance,
Task.backlogEnteredAtProvenanceQuickCapture,
);
expect(task.backlogFreshnessAnchorAt, now);
}); });
test('quick capture requires a title', () { 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( final older = Task.quickCapture(
id: 'older', id: 'older',
title: 'Older backlog item', title: 'Older backlog item',
@ -151,7 +157,7 @@ void main() {
); );
final movedOlder = const SchedulingEngine().moveToBacklog( final movedOlder = const SchedulingEngine().moveToBacklog(
older, older,
updatedAt: now, updatedAt: now.subtract(const Duration(minutes: 5)),
); );
final movedNewer = const SchedulingEngine().moveToBacklog( final movedNewer = const SchedulingEngine().moveToBacklog(
newer, newer,
@ -161,6 +167,11 @@ void main() {
expect(movedOlder.scheduledStart, isNull); expect(movedOlder.scheduledStart, isNull);
expect(movedNewer.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), [ expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [
'older', 'older',
'newer', 'newer',
@ -249,6 +260,44 @@ void main() {
expect(view.stalenessMarkerFor(stale), BacklogStalenessMarker.purple); 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', () { test('backlog staleness markers support configurable thresholds', () {
final task = Task.quickCapture( final task = Task.quickCapture(
id: 'custom', id: 'custom',
@ -1423,6 +1472,32 @@ void main() {
expect(result.notices.single.type, SchedulingNoticeType.moved); 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', () { test('push flexible task bumps downstream flexible tasks in order', () {
final first = flexibleTask().copyWith( final first = flexibleTask().copyWith(
id: 'flexible-1', id: 'flexible-1',
@ -1762,6 +1837,11 @@ void main() {
expect(moved.status, TaskStatus.backlog); expect(moved.status, TaskStatus.backlog);
expect(moved.scheduledStart, isNull); expect(moved.scheduledStart, isNull);
expect(moved.scheduledEnd, isNull); expect(moved.scheduledEnd, isNull);
expect(moved.backlogEnteredAt, now);
expect(
moved.backlogEnteredAtProvenance,
Task.backlogEnteredAtProvenanceMovedToBacklog,
);
expect(moved.stats.movedToBacklogCount, 1); expect(moved.stats.movedToBacklogCount, 1);
}); });
@ -1771,6 +1851,11 @@ void main() {
final missed = engine.markMissed(task, updatedAt: now); final missed = engine.markMissed(task, updatedAt: now);
expect(missed.status, TaskStatus.backlog); expect(missed.status, TaskStatus.backlog);
expect(missed.backlogEnteredAt, now);
expect(
missed.backlogEnteredAtProvenance,
Task.backlogEnteredAtProvenanceMovedToBacklog,
);
expect(missed.stats.missedCount, 1); expect(missed.stats.missedCount, 1);
expect(missed.stats.movedToBacklogCount, 1); expect(missed.stats.movedToBacklogCount, 1);
}); });