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);
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue