forked from eva/focus-flow
feat: enforce past task push semantics
This commit is contained in:
parent
4d373ce250
commit
93f4b12283
12 changed files with 447 additions and 19 deletions
|
|
@ -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<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.
|
||||
|
|
@ -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].
|
||||
Future<TodayTimelineItem> _readTodayItem(
|
||||
WidgetTester tester,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ class Task {
|
|||
this.actualStart,
|
||||
this.actualEnd,
|
||||
this.completedAt,
|
||||
this.backlogEnteredAt,
|
||||
this.backlogEnteredAtProvenance,
|
||||
String? parentTaskId,
|
||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
this.reminderOverride,
|
||||
|
|
@ -127,6 +129,7 @@ class Task {
|
|||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
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<BacklogTag>? 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = <TimeInterval>[
|
||||
...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 = <String, TimeInterval>{};
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class FlexibleTaskActionService {
|
|||
required SchedulingInput input,
|
||||
required String taskId,
|
||||
DateTime? updatedAt,
|
||||
DateTime? earliestStart,
|
||||
String? operationId,
|
||||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
}) {
|
||||
|
|
@ -153,6 +154,7 @@ class FlexibleTaskActionService {
|
|||
input: input,
|
||||
taskId: taskId,
|
||||
updatedAt: now,
|
||||
earliestStart: earliestStart,
|
||||
),
|
||||
PushDestination.tomorrowTopOfQueue =>
|
||||
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue