feat(app): add app-open rollover recovery

This commit is contained in:
Ashley Venn 2026-06-25 11:00:22 -07:00
parent 613f9f4e2b
commit 61882e5cd3
5 changed files with 711 additions and 1 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 14 — Application Use Cases and UI-Ready Read Models
Status: In progress
Status: Complete
Purpose: Add the orchestration layer that a future Flutter UI can call without
assembling scheduler inputs, coordinating multiple repositories, or manually
@ -321,6 +321,8 @@ Verification:
Recommended Codex level: high
Status: Complete
Tasks:
- Add an explicit app-open/start-day use case that determines whether a source
@ -355,6 +357,39 @@ Acceptance criteria:
- Notice lifecycle is persisted and acknowledgeable.
- The full application facade contract suite passes in memory.
Completed implementation:
- Added `src/application_recovery.dart` with `V1AppOpenRecoveryUseCases`,
explicit source-day/target-day request objects, deterministic recovery
outcomes, and structured recovery results.
- Implemented app-open recovery as an explicit command that checks one
owner/source-local-date key and never runs as a Today-read side effect.
- Keyed rollover idempotency by deterministic owner/source-date scheduling
snapshot IDs, so retries and multiple app opens do not roll the same source
day twice even with different operation IDs.
- Loaded source-day and target-day scheduled tasks through bounded repository
window queries, preventing future-day tasks from being pulled into rollover.
- Used the existing pure scheduler rollover behavior to preserve source-day
flexible order at the top of the target day while shifting target-day flexible
tasks as needed.
- Persisted changed tasks, rollover movement activities, the deterministic
scheduling snapshot, the calm aggregate notice, and the operation record in
one unit-of-work transaction.
- Saved no-op source days with deterministic snapshots and no notice so repeated
app opens remain idempotent without creating duplicate pending notices.
- Reused the Chunk 14.4 notice acknowledgement path so Today state surfaces the
pending rollover notice until it is acknowledged.
- Added end-to-end in-memory recovery tests covering capture, next-day app open,
target queue insertion, notice visibility, acknowledgement, second-open
idempotency, no-op recording, and future-day exclusion.
Verification:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 277 tests
- `git diff --check`: passed
Commit suggestion:
```text

View file

@ -20,6 +20,7 @@ export 'src/models.dart';
export 'src/application_commands.dart';
export 'src/application_layer.dart';
export 'src/application_management.dart';
export 'src/application_recovery.dart';
export 'src/backlog.dart';
export 'src/child_tasks.dart';
export 'src/document_mapping.dart';

View file

@ -458,6 +458,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
);
}
/// Current committed scheduling snapshots, useful for contract tests.
List<SchedulingStateSnapshot> get currentSchedulingSnapshots {
return List<SchedulingStateSnapshot>.unmodifiable(
_state.schedulingSnapshotsById.values,
);
}
/// Current committed activities, useful for contract tests.
List<TaskActivity> get currentTaskActivities {
return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values);

View file

@ -0,0 +1,401 @@
// App-open/start-day recovery orchestration.
//
// This layer makes rollover explicit. Reading Today never runs recovery; a UI or
// app lifecycle boundary calls this use case when it wants to check one source
// day and persist any resulting V1 flexible-task rollover.
import 'application_layer.dart';
import 'locked_time.dart';
import 'models.dart';
import 'repositories.dart';
import 'scheduling_engine.dart';
import 'task_lifecycle.dart';
import 'time_contracts.dart';
/// Stable app-open recovery outcomes.
enum AppOpenRecoveryOutcome {
rolledOver,
noUnfinishedFlexibleTasks,
alreadyProcessed,
}
/// Request for explicit app-open/start-day rollover recovery.
class RunAppOpenRecoveryRequest {
const RunAppOpenRecoveryRequest({
required this.context,
required this.sourceDate,
this.targetDate,
});
/// Atomic operation context.
final ApplicationOperationContext context;
/// Owner-local day to inspect for unfinished flexible work.
final CivilDate sourceDate;
/// Owner-local target day. Defaults to the next local day.
final CivilDate? targetDate;
/// Resolved target day.
CivilDate get resolvedTargetDate => targetDate ?? sourceDate.addDays(1);
}
/// Result returned by app-open recovery.
class AppOpenRecoveryResult {
AppOpenRecoveryResult({
required this.outcome,
required this.sourceDate,
required this.targetDate,
required this.snapshot,
required List<Task> changedTasks,
required List<TaskActivity> activities,
required List<SchedulingNotice> notices,
required List<SchedulingChange> changes,
}) : changedTasks = List<Task>.unmodifiable(changedTasks),
activities = List<TaskActivity>.unmodifiable(activities),
notices = List<SchedulingNotice>.unmodifiable(notices),
changes = List<SchedulingChange>.unmodifiable(changes);
/// High-level deterministic outcome.
final AppOpenRecoveryOutcome outcome;
/// Owner-local source day that was checked.
final CivilDate sourceDate;
/// Owner-local target day used for placement and notice surfacing.
final CivilDate targetDate;
/// Persisted deterministic source-date snapshot.
final SchedulingStateSnapshot snapshot;
/// Tasks changed by this operation.
final List<Task> changedTasks;
/// Movement activities persisted with changed tasks.
final List<TaskActivity> activities;
/// Notices returned to the caller.
final List<SchedulingNotice> notices;
/// Machine-readable movement records.
final List<SchedulingChange> changes;
}
/// Explicit app-open recovery facade.
class V1AppOpenRecoveryUseCases {
const V1AppOpenRecoveryUseCases({
required this.applicationStore,
required this.timeZoneResolver,
this.resolutionOptions = const TimeZoneResolutionOptions(),
this.schedulingEngine = const SchedulingEngine(),
});
/// Atomic application boundary.
final ApplicationUnitOfWork applicationStore;
/// Explicit owner-local date resolver.
final TimeZoneResolver timeZoneResolver;
/// DST/nonexistent/repeated local time policy.
final TimeZoneResolutionOptions resolutionOptions;
/// Pure scheduling engine.
final SchedulingEngine schedulingEngine;
/// Run source-day rollover at most once for one owner/source-date key.
Future<ApplicationResult<AppOpenRecoveryResult>> runAppOpenRecovery(
RunAppOpenRecoveryRequest request,
) {
return applicationStore.run(
context: request.context,
operationName: 'appOpenRecovery',
action: (repositories) async {
final context = request.context;
final ownerId = context.ownerTimeZone.ownerId;
final sourceDate = request.sourceDate;
final targetDate = request.resolvedTargetDate;
final snapshotId = _rolloverSnapshotId(
ownerId: ownerId,
sourceDate: sourceDate,
);
final existingSnapshot =
await repositories.schedulingSnapshots.findById(snapshotId);
if (existingSnapshot != null) {
return ApplicationResult.success(
AppOpenRecoveryResult(
outcome: AppOpenRecoveryOutcome.alreadyProcessed,
sourceDate: sourceDate,
targetDate: targetDate,
snapshot: existingSnapshot,
changedTasks: const <Task>[],
activities: const <TaskActivity>[],
notices: existingSnapshot.notices,
changes: existingSnapshot.changes,
),
);
}
final settings = await _ownerSettingsOrDefault(
repositories,
context.ownerTimeZone,
);
final sourceWindow = _windowForDate(sourceDate, settings);
final targetWindow = _windowForDate(targetDate, settings);
final tasks = await _loadSourceAndTargetTasks(
repositories,
sourceWindow: sourceWindow,
targetWindow: targetWindow,
);
final blocks = await repositories.lockedBlocks.findAllBlocks();
final overrides = await repositories.lockedBlocks.findAllOverrides();
final lockedExpansion = expandLockedBlocksForDay(
blocks: blocks,
overrides: overrides,
date: targetDate,
timeZoneId: settings.timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
final input = SchedulingInput(
tasks: tasks,
window: targetWindow,
lockedIntervals: lockedExpansion.schedulingIntervals,
);
final schedulingResult =
schedulingEngine.rollOverUnfinishedFlexibleTasks(
input: input,
sourceWindow: sourceWindow,
updatedAt: context.now,
);
if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow ||
schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.noSlot,
entityId: snapshotId,
detailCode: schedulingResult.outcomeCode.name,
),
);
}
final changedTasks = _changedTasks(tasks, schedulingResult.tasks);
final activities = _activitiesForRolloverChanges(
result: schedulingResult,
beforeTasks: tasks,
afterTasks: schedulingResult.tasks,
operationId: context.operationId,
occurredAt: context.now,
);
final rolloverNotice = _rolloverNotice(
sourceWindow: sourceWindow,
result: schedulingResult,
);
final snapshot = SchedulingStateSnapshot(
id: snapshotId,
capturedAt: context.now,
window: targetWindow,
tasks: schedulingResult.tasks,
lockedIntervals: input.lockedIntervals,
notices: [
if (rolloverNotice != null) rolloverNotice,
],
changes: schedulingResult.changes,
operationName: 'appOpenRecovery',
);
await repositories.tasks.saveAll(changedTasks);
await repositories.taskActivities.saveAll(activities);
await repositories.schedulingSnapshots.save(snapshot);
return ApplicationResult.success(
AppOpenRecoveryResult(
outcome: rolloverNotice == null
? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks
: AppOpenRecoveryOutcome.rolledOver,
sourceDate: sourceDate,
targetDate: targetDate,
snapshot: snapshot,
changedTasks: changedTasks,
activities: activities,
notices: snapshot.notices,
changes: schedulingResult.changes,
),
);
},
);
}
SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) {
return SchedulingWindow(
start: _resolve(
date: date,
wallTime: settings.dayStart,
timeZoneId: settings.timeZoneId,
),
end: _resolve(
date: date,
wallTime: settings.dayEnd,
timeZoneId: settings.timeZoneId,
),
);
}
DateTime _resolve({
required CivilDate date,
required WallTime wallTime,
required String timeZoneId,
}) {
return timeZoneResolver
.resolve(
date: date,
wallTime: wallTime,
timeZoneId: timeZoneId,
options: resolutionOptions,
)
.instant;
}
}
Future<OwnerSettings> _ownerSettingsOrDefault(
ApplicationUnitOfWorkRepositories repositories,
OwnerTimeZoneContext ownerTimeZone,
) async {
return await repositories.ownerSettings
.findByOwnerId(ownerTimeZone.ownerId) ??
OwnerSettings(
ownerId: ownerTimeZone.ownerId,
timeZoneId: ownerTimeZone.timeZoneId,
);
}
Future<List<Task>> _loadSourceAndTargetTasks(
ApplicationUnitOfWorkRepositories repositories, {
required SchedulingWindow sourceWindow,
required SchedulingWindow targetWindow,
}) async {
final byId = <String, Task>{};
for (final task in await repositories.tasks.findScheduledInWindow(
sourceWindow,
)) {
byId[task.id] = task;
}
for (final task in await repositories.tasks.findScheduledInWindow(
targetWindow,
)) {
byId[task.id] = task;
}
return List<Task>.unmodifiable(byId.values);
}
SchedulingNotice? _rolloverNotice({
required SchedulingWindow sourceWindow,
required SchedulingResult result,
}) {
if (result.outcomeCode != SchedulingOutcomeCode.success) {
return null;
}
final rolledTaskIds = result.changes
.where(
(change) =>
change.previousStart != null &&
change.previousEnd != null &&
sourceWindow.contains(
TimeInterval(
start: change.previousStart!, end: change.previousEnd!),
),
)
.map((change) => change.taskId)
.toSet();
if (rolledTaskIds.isEmpty) {
return null;
}
return SchedulingNotice(
'${rolledTaskIds.length} unfinished flexible tasks were moved to today.',
type: SchedulingNoticeType.moved,
movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
parameters: {
'count': rolledTaskIds.length,
'taskIds': List<String>.unmodifiable(rolledTaskIds),
},
);
}
List<TaskActivity> _activitiesForRolloverChanges({
required SchedulingResult result,
required List<Task> beforeTasks,
required List<Task> afterTasks,
required String operationId,
required DateTime occurredAt,
}) {
if (result.outcomeCode != SchedulingOutcomeCode.success) {
return const <TaskActivity>[];
}
final beforeById = {
for (final task in beforeTasks) task.id: task,
};
final afterById = {
for (final task in afterTasks) task.id: task,
};
final activities = <TaskActivity>[];
for (final change in result.changes) {
final before = beforeById[change.taskId];
final after = afterById[change.taskId];
if (before == null || after == null) {
continue;
}
activities.add(
TaskActivity(
id: '$operationId:${change.taskId}:automaticPush',
operationId: operationId,
code: TaskActivityCode.automaticallyPushed,
taskId: change.taskId,
projectId: before.projectId,
occurredAt: occurredAt,
metadata: {
'previousStatus': before.status.name,
'nextStatus': after.status.name,
'previousStart': change.previousStart,
'previousEnd': change.previousEnd,
'nextStart': change.nextStart,
'nextEnd': change.nextEnd,
},
),
);
}
return List<TaskActivity>.unmodifiable(activities);
}
List<Task> _changedTasks(List<Task> beforeTasks, List<Task> afterTasks) {
final beforeById = {
for (final task in beforeTasks) task.id: task,
};
final changed = <Task>[];
for (final task in afterTasks) {
final before = beforeById[task.id];
if (before == null || _taskChanged(before, task)) {
changed.add(task);
}
}
return List<Task>.unmodifiable(changed);
}
bool _taskChanged(Task before, Task after) {
return before.status != after.status ||
before.scheduledStart != after.scheduledStart ||
before.scheduledEnd != after.scheduledEnd ||
before.updatedAt != after.updatedAt ||
before.stats != after.stats;
}
String _rolloverSnapshotId({
required String ownerId,
required CivilDate sourceDate,
}) {
return 'rollover:$ownerId:${sourceDate.toIsoString()}';
}

View file

@ -0,0 +1,266 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('V1AppOpenRecoveryUseCases', () {
final sourceDay = CivilDate(2026, 6, 25);
final targetDay = CivilDate(2026, 6, 26);
final createdAt = DateTime.utc(2026, 6, 20, 8);
test('rolls source-day flexible work once and exposes notice lifecycle',
() async {
final targetExisting = task(
id: 'target-existing',
title: 'Target existing',
type: TaskType.flexible,
status: TaskStatus.planned,
createdAt: createdAt,
start: DateTime.utc(2026, 6, 26, 9),
end: DateTime.utc(2026, 6, 26, 9, 30),
);
final store = storeWithSettings(tasks: [targetExisting]);
final commandUseCases = commands(store);
final recoveryUseCases = recovery(store);
final managementUseCases =
V1ApplicationManagementUseCases(applicationStore: store);
final todayQuery = today(store);
final capture = await commandUseCases.quickCaptureToNextAvailableSlot(
context: appContext('capture-source', DateTime.utc(2026, 6, 25, 8)),
localDate: sourceDay,
title: 'Captured source task',
durationMinutes: 30,
);
expect(capture.isSuccess, isTrue);
final capturedTaskId = capture.requireValue.changedTasks.single.id;
final firstOpen = await recoveryUseCases.runAppOpenRecovery(
RunAppOpenRecoveryRequest(
context: appContext('open-next-day', DateTime.utc(2026, 6, 26, 8)),
sourceDate: sourceDay,
targetDate: targetDay,
),
);
expect(firstOpen.requireValue.outcome, AppOpenRecoveryOutcome.rolledOver);
expect(firstOpen.requireValue.changedTasks.map((task) => task.id), [
capturedTaskId,
'target-existing',
]);
expect(taskById(store.currentTasks, capturedTaskId).status,
TaskStatus.planned);
expect(taskById(store.currentTasks, capturedTaskId).scheduledStart,
DateTime.utc(2026, 6, 26, 9));
expect(taskById(store.currentTasks, 'target-existing').scheduledStart,
DateTime.utc(2026, 6, 26, 9, 30));
expect(
store.currentSchedulingSnapshots.single.notices.single.movementCode,
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver);
expect(
store.currentSchedulingSnapshots.single.notices.single
.parameters['count'],
1);
final todayBeforeAck = (await todayQuery.execute(
GetTodayStateRequest(
context: appContext('today-before-ack', DateTime.utc(2026, 6, 26, 8)),
date: targetDay,
),
))
.requireValue;
expect(todayBeforeAck.pendingNotices.single.parameters['count'], 1);
await managementUseCases.acknowledgeNotice(
context: appContext('ack-rollover', DateTime.utc(2026, 6, 26, 8, 5)),
noticeId: todayBeforeAck.pendingNotices.single.noticeId,
);
final todayAfterAck = (await todayQuery.execute(
GetTodayStateRequest(
context:
appContext('today-after-ack', DateTime.utc(2026, 6, 26, 8, 6)),
date: targetDay,
),
))
.requireValue;
final secondOpen = await recoveryUseCases.runAppOpenRecovery(
RunAppOpenRecoveryRequest(
context:
appContext('open-next-day-again', DateTime.utc(2026, 6, 26, 9)),
sourceDate: sourceDay,
targetDate: targetDay,
),
);
expect(todayAfterAck.pendingNotices, isEmpty);
expect(
secondOpen.requireValue.outcome,
AppOpenRecoveryOutcome.alreadyProcessed,
);
expect(secondOpen.requireValue.changedTasks, isEmpty);
expect(store.currentSchedulingSnapshots, hasLength(1));
expect(
store.currentOperations
.map((operation) => operation.operationId)
.toSet(),
containsAll({
'capture-source',
'open-next-day',
'ack-rollover',
'open-next-day-again',
}),
);
});
test('records deterministic no-op without creating a notice', () async {
final completed = task(
id: 'done',
title: 'Done',
type: TaskType.flexible,
status: TaskStatus.completed,
createdAt: createdAt,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
);
final store = storeWithSettings(tasks: [completed]);
final result = await recovery(store).runAppOpenRecovery(
RunAppOpenRecoveryRequest(
context: appContext('open-noop', DateTime.utc(2026, 6, 26, 8)),
sourceDate: sourceDay,
targetDate: targetDay,
),
);
final duplicate = await recovery(store).runAppOpenRecovery(
RunAppOpenRecoveryRequest(
context: appContext('open-noop-again', DateTime.utc(2026, 6, 26, 9)),
sourceDate: sourceDay,
targetDate: targetDay,
),
);
expect(
result.requireValue.outcome,
AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks,
);
expect(result.requireValue.notices, isEmpty);
expect(store.currentSchedulingSnapshots.single.notices, isEmpty);
expect(duplicate.requireValue.outcome,
AppOpenRecoveryOutcome.alreadyProcessed);
expect(store.currentSchedulingSnapshots, hasLength(1));
});
test('does not pull future-day tasks into source-day rollover', () async {
final sourceTask = task(
id: 'source',
title: 'Source',
type: TaskType.flexible,
status: TaskStatus.planned,
createdAt: createdAt,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
);
final futureTask = task(
id: 'future',
title: 'Future',
type: TaskType.flexible,
status: TaskStatus.planned,
createdAt: createdAt,
start: DateTime.utc(2026, 6, 27, 9),
end: DateTime.utc(2026, 6, 27, 9, 30),
);
final store = storeWithSettings(tasks: [sourceTask, futureTask]);
final result = await recovery(store).runAppOpenRecovery(
RunAppOpenRecoveryRequest(
context: appContext('open-future-safe', DateTime.utc(2026, 6, 26, 8)),
sourceDate: sourceDay,
targetDate: targetDay,
),
);
expect(result.isSuccess, isTrue);
expect(taskById(store.currentTasks, sourceTask.id).scheduledStart,
DateTime.utc(2026, 6, 26, 9));
expect(taskById(store.currentTasks, futureTask.id).scheduledStart,
DateTime.utc(2026, 6, 27, 9));
expect(
result.requireValue.changedTasks.map((task) => task.id),
isNot(contains('future')),
);
});
});
}
V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) {
return V1ApplicationCommandUseCases(
applicationStore: store,
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
);
}
V1AppOpenRecoveryUseCases recovery(InMemoryApplicationUnitOfWork store) {
return V1AppOpenRecoveryUseCases(
applicationStore: store,
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
);
}
GetTodayStateQuery today(InMemoryApplicationUnitOfWork store) {
return GetTodayStateQuery(
applicationStore: store,
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
);
}
InMemoryApplicationUnitOfWork storeWithSettings({
List<Task> tasks = const <Task>[],
}) {
return InMemoryApplicationUnitOfWork(
initialTasks: tasks,
initialOwnerSettings: [
OwnerSettings(
ownerId: 'owner-1',
timeZoneId: 'UTC',
dayStart: WallTime(hour: 9, minute: 0),
dayEnd: WallTime(hour: 17, minute: 0),
),
],
);
}
ApplicationOperationContext appContext(String operationId, DateTime now) {
return ApplicationOperationContext.start(
operationId: operationId,
ownerTimeZone: OwnerTimeZoneContext(ownerId: 'owner-1', timeZoneId: 'UTC'),
clock: FixedClock(now),
idGenerator: SequentialIdGenerator(prefix: '$operationId-id'),
);
}
Task task({
required String id,
required String title,
required TaskType type,
required TaskStatus status,
required DateTime createdAt,
DateTime? start,
DateTime? end,
}) {
return Task(
id: id,
title: title,
projectId: 'home',
type: type,
status: status,
durationMinutes:
start != null && end != null ? end.difference(start).inMinutes : 30,
scheduledStart: start,
scheduledEnd: end,
createdAt: createdAt,
updatedAt: createdAt,
);
}
Task taskById(List<Task> tasks, String taskId) {
return tasks.singleWhere((task) => task.id == taskId);
}