diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md b/Codex Documentation/Archived plans/V1_BLOCK_14_Application_Use_Cases_Read_Models.md similarity index 89% rename from Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md rename to Codex Documentation/Archived plans/V1_BLOCK_14_Application_Use_Cases_Read_Models.md index 0c073c1..2ecacc5 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md +++ b/Codex Documentation/Archived plans/V1_BLOCK_14_Application_Use_Cases_Read_Models.md @@ -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 diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 1ff3c88..e11c015 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -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'; diff --git a/lib/src/application_layer.dart b/lib/src/application_layer.dart index 3f2ab8f..8e71641 100644 --- a/lib/src/application_layer.dart +++ b/lib/src/application_layer.dart @@ -458,6 +458,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { ); } + /// Current committed scheduling snapshots, useful for contract tests. + List get currentSchedulingSnapshots { + return List.unmodifiable( + _state.schedulingSnapshotsById.values, + ); + } + /// Current committed activities, useful for contract tests. List get currentTaskActivities { return List.unmodifiable(_state.taskActivitiesById.values); diff --git a/lib/src/application_recovery.dart b/lib/src/application_recovery.dart new file mode 100644 index 0000000..a064b42 --- /dev/null +++ b/lib/src/application_recovery.dart @@ -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 changedTasks, + required List activities, + required List notices, + required List changes, + }) : changedTasks = List.unmodifiable(changedTasks), + activities = List.unmodifiable(activities), + notices = List.unmodifiable(notices), + changes = List.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 changedTasks; + + /// Movement activities persisted with changed tasks. + final List activities; + + /// Notices returned to the caller. + final List notices; + + /// Machine-readable movement records. + final List 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> 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 [], + activities: const [], + 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 _ownerSettingsOrDefault( + ApplicationUnitOfWorkRepositories repositories, + OwnerTimeZoneContext ownerTimeZone, +) async { + return await repositories.ownerSettings + .findByOwnerId(ownerTimeZone.ownerId) ?? + OwnerSettings( + ownerId: ownerTimeZone.ownerId, + timeZoneId: ownerTimeZone.timeZoneId, + ); +} + +Future> _loadSourceAndTargetTasks( + ApplicationUnitOfWorkRepositories repositories, { + required SchedulingWindow sourceWindow, + required SchedulingWindow targetWindow, +}) async { + final byId = {}; + 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.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.unmodifiable(rolledTaskIds), + }, + ); +} + +List _activitiesForRolloverChanges({ + required SchedulingResult result, + required List beforeTasks, + required List afterTasks, + required String operationId, + required DateTime occurredAt, +}) { + if (result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + final beforeById = { + for (final task in beforeTasks) task.id: task, + }; + final afterById = { + for (final task in afterTasks) task.id: task, + }; + final activities = []; + + 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.unmodifiable(activities); +} + +List _changedTasks(List beforeTasks, List afterTasks) { + final beforeById = { + for (final task in beforeTasks) task.id: task, + }; + final changed = []; + + for (final task in afterTasks) { + final before = beforeById[task.id]; + if (before == null || _taskChanged(before, task)) { + changed.add(task); + } + } + + return List.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()}'; +} diff --git a/test/application_recovery_test.dart b/test/application_recovery_test.dart new file mode 100644 index 0000000..81e9cd2 --- /dev/null +++ b/test/application_recovery_test.dart @@ -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 tasks = const [], +}) { + 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 tasks, String taskId) { + return tasks.singleWhere((task) => task.id == taskId); +}