From 613f9f4e2b7c0f4d5df640687afb334f54572174 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 25 Jun 2026 10:54:42 -0700 Subject: [PATCH] feat(app): add management use cases --- ...CK_14_Application_Use_Cases_Read_Models.md | 41 + lib/scheduler_core.dart | 1 + lib/src/application_layer.dart | 119 +++ lib/src/application_management.dart | 771 ++++++++++++++++++ lib/src/locked_time.dart | 16 + lib/src/models.dart | 11 + lib/src/today_state.dart | 37 +- test/application_management_test.dart | 348 ++++++++ 8 files changed, 1340 insertions(+), 4 deletions(-) create mode 100644 lib/src/application_management.dart create mode 100644 test/application_management_test.dart diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md b/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md index 9edcafb..0c073c1 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md @@ -239,6 +239,8 @@ startup/rollover orchestration. Recommended Codex level: high +Status: Complete + Tasks: - Add a Backlog query using repository-side candidate loading plus the existing @@ -276,6 +278,45 @@ Acceptance criteria: - Date-scoped override behavior is tested. - No management command deletes task history implicitly. +Completed implementation: + +- Added `src/application_management.dart` with + `V1ApplicationManagementUseCases`, stable management command codes, backlog + and project query contracts, typed project/locked/settings command inputs, and + notice acknowledgement results. +- Added a backlog query that loads candidates with + `TaskRepository.findByStatus(TaskStatus.backlog)` before applying the existing + backlog filter, sort, and green/blue/purple staleness marker policies. +- Used owner settings as the source for configurable backlog staleness + thresholds and added typed validation for invalid threshold updates. +- Added project create/update/archive commands plus a project defaults query + that returns configured defaults and learned `ProjectSuggestionSet` values + separately. +- Added `ProjectProfile.archivedAt` so archived projects disappear from normal + project queries without deleting or orphaning task history. +- Added locked-block create/update/archive commands and date-scoped add, remove, + and replace override commands. +- Added `LockedBlock.archivedAt` and updated locked-time expansion so archived + base blocks no longer create occurrences while unrelated add overrides remain + intact. +- Added settings updates for time zone, day boundary/default planning window, + compact-mode preference, and backlog thresholds, with typed warnings for + time-zone and planning-window reinterpretation. +- Added owner-scoped `NoticeAcknowledgementRecord` persistence, a unit-of-work + repository for acknowledgements, stable notice IDs, and Today-state filtering + for acknowledged one-time notices. +- Added management contract tests covering backlog thresholds, project + suggestion separation, project archiving without task deletion, date-scoped + locked overrides, locked-block archiving without override deletion, settings + warnings, and notice acknowledgement. + +Verification: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 274 tests +- `git diff --check`: passed + ## Chunk 14.5 — Rollover and app-open recovery orchestration Recommended Codex level: high diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 1b4de42..1ff3c88 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -19,6 +19,7 @@ export 'src/models.dart'; export 'src/application_commands.dart'; export 'src/application_layer.dart'; +export 'src/application_management.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 926a68d..3f2ab8f 100644 --- a/lib/src/application_layer.dart +++ b/lib/src/application_layer.dart @@ -177,6 +177,25 @@ class ApplicationOperationRecord { final DateTime committedAt; } +/// Owner-scoped acknowledgement for a one-time scheduling notice. +class NoticeAcknowledgementRecord { + NoticeAcknowledgementRecord({ + required String noticeId, + required String ownerId, + required this.acknowledgedAt, + }) : noticeId = _requiredTrimmed(noticeId, 'noticeId'), + ownerId = _requiredTrimmed(ownerId, 'ownerId'); + + /// Stable notice id generated by the read/use-case boundary. + final String noticeId; + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Instant the notice was consumed. + final DateTime acknowledgedAt; +} + /// Owner-level settings needed by application use cases. class OwnerSettings { OwnerSettings({ @@ -269,6 +288,18 @@ abstract interface class OwnerSettingsRepository { Future save(OwnerSettings settings); } +/// Repository contract for consumed one-time notices. +abstract interface class NoticeAcknowledgementRepository { + Future findById({ + required String ownerId, + required String noticeId, + }); + + Future> findByOwnerId(String ownerId); + + Future save(NoticeAcknowledgementRecord record); +} + /// Repository contract for exactly-once operation records. abstract interface class ApplicationOperationRepository { Future findById(String operationId); @@ -292,6 +323,8 @@ abstract interface class ApplicationUnitOfWorkRepositories { OwnerSettingsRepository get ownerSettings; + NoticeAcknowledgementRepository get noticeAcknowledgements; + ApplicationOperationRepository get operations; } @@ -355,6 +388,8 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { Iterable initialProjectStatistics = const [], Iterable initialOwnerSettings = const [], + Iterable initialNoticeAcknowledgements = + const [], Iterable initialOperations = const [], }) : _state = _InMemoryApplicationState( @@ -386,6 +421,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { for (final settings in initialOwnerSettings) settings.ownerId: settings, }, + noticeAcknowledgementsByScopedId: { + for (final acknowledgement in initialNoticeAcknowledgements) + _noticeAcknowledgementKey( + ownerId: acknowledgement.ownerId, + noticeId: acknowledgement.noticeId, + ): acknowledgement, + }, operationsById: { for (final operation in initialOperations) operation.operationId: operation, @@ -399,6 +441,23 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { List get currentTasks => List.unmodifiable(_state.tasksById.values); + /// Current committed projects, useful for contract tests. + List get currentProjects { + return List.unmodifiable(_state.projectsById.values); + } + + /// Current committed locked blocks, useful for contract tests. + List get currentLockedBlocks { + return List.unmodifiable(_state.lockedBlocksById.values); + } + + /// Current committed locked overrides, useful for contract tests. + List get currentLockedOverrides { + return List.unmodifiable( + _state.lockedOverridesById.values, + ); + } + /// Current committed activities, useful for contract tests. List get currentTaskActivities { return List.unmodifiable(_state.taskActivitiesById.values); @@ -417,6 +476,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { _state.ownerSettingsByOwnerId.values); } + /// Current committed notice acknowledgements, useful for contract tests. + List get currentNoticeAcknowledgements { + return List.unmodifiable( + _state.noticeAcknowledgementsByScopedId.values, + ); + } + /// Current committed operation records, useful for contract tests. List get currentOperations { return List.unmodifiable( @@ -549,6 +615,7 @@ class _InMemoryApplicationState { required this.taskActivitiesById, required this.projectStatisticsByProjectId, required this.ownerSettingsByOwnerId, + required this.noticeAcknowledgementsByScopedId, required this.operationsById, }); @@ -560,6 +627,8 @@ class _InMemoryApplicationState { final Map taskActivitiesById; final Map projectStatisticsByProjectId; final Map ownerSettingsByOwnerId; + final Map + noticeAcknowledgementsByScopedId; final Map operationsById; _InMemoryApplicationState copy() { @@ -577,6 +646,10 @@ class _InMemoryApplicationState { ownerSettingsByOwnerId: Map.of( ownerSettingsByOwnerId, ), + noticeAcknowledgementsByScopedId: + Map.of( + noticeAcknowledgementsByScopedId, + ), operationsById: Map.of( operationsById, ), @@ -605,6 +678,9 @@ class _InMemoryApplicationRepositories ownerSettings = _UnitOfWorkOwnerSettingsRepository( state.ownerSettingsByOwnerId, ), + noticeAcknowledgements = _UnitOfWorkNoticeAcknowledgementRepository( + state.noticeAcknowledgementsByScopedId, + ), operations = _UnitOfWorkOperationRepository(state.operationsById); @override @@ -628,6 +704,9 @@ class _InMemoryApplicationRepositories @override final OwnerSettingsRepository ownerSettings; + @override + final NoticeAcknowledgementRepository noticeAcknowledgements; + @override final ApplicationOperationRepository operations; } @@ -845,6 +924,39 @@ class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { } } +class _UnitOfWorkNoticeAcknowledgementRepository + implements NoticeAcknowledgementRepository { + _UnitOfWorkNoticeAcknowledgementRepository(this._recordsByScopedId); + + final Map _recordsByScopedId; + + @override + Future findById({ + required String ownerId, + required String noticeId, + }) async { + return _recordsByScopedId[ + _noticeAcknowledgementKey(ownerId: ownerId, noticeId: noticeId)]; + } + + @override + Future> findByOwnerId( + String ownerId, + ) async { + return List.unmodifiable( + _recordsByScopedId.values.where((record) => record.ownerId == ownerId), + ); + } + + @override + Future save(NoticeAcknowledgementRecord record) async { + _recordsByScopedId[_noticeAcknowledgementKey( + ownerId: record.ownerId, + noticeId: record.noticeId, + )] = record; + } +} + class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { _UnitOfWorkOperationRepository(this._operationsById); @@ -861,6 +973,13 @@ class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { } } +String _noticeAcknowledgementKey({ + required String ownerId, + required String noticeId, +}) { + return '$ownerId::$noticeId'; +} + String _requiredTrimmed(String value, String name) { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/lib/src/application_management.dart b/lib/src/application_management.dart new file mode 100644 index 0000000..7030721 --- /dev/null +++ b/lib/src/application_management.dart @@ -0,0 +1,771 @@ +// V1 management queries and commands. +// +// These use cases cover non-scheduling surfaces such as backlog views, project +// defaults, locked-time definitions, owner settings, and one-time notice +// acknowledgement. They stay UI-independent and use the same atomic +// application unit-of-work boundary as task commands. + +import 'application_layer.dart'; +import 'backlog.dart'; +import 'locked_time.dart'; +import 'models.dart'; +import 'project_statistics.dart'; +import 'time_contracts.dart'; + +/// Stable management command identifiers. +enum ApplicationManagementCommandCode { + createProject, + updateProject, + archiveProject, + createLockedBlock, + updateLockedBlock, + archiveLockedBlock, + addLockedBlockOverride, + removeLockedBlockOccurrence, + replaceLockedBlockOccurrence, + updateOwnerSettings, + acknowledgeNotice, +} + +/// Typed warning codes for settings changes that reinterpret local dates/times. +enum OwnerSettingsWarningCode { + timeZoneChanged, + planningWindowChanged, +} + +/// Backlog query request. +class GetBacklogRequest { + const GetBacklogRequest({ + required this.context, + this.filter, + this.sortKey = BacklogSortKey.priority, + }); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; + + /// Optional user-facing backlog filter. + final BacklogFilter? filter; + + /// User-facing sort order. + final BacklogSortKey sortKey; +} + +/// One backlog row with its configured staleness marker. +class BacklogItemReadModel { + const BacklogItemReadModel({ + required this.task, + required this.stalenessMarker, + }); + + /// Source backlog task. + final Task task; + + /// Green/blue/purple marker using owner settings. + final BacklogStalenessMarker stalenessMarker; +} + +/// Query result for the backlog surface. +class BacklogQueryResult { + BacklogQueryResult({ + required this.ownerId, + required this.settings, + required List items, + }) : items = List.unmodifiable(items); + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Effective owner settings used by the query. + final OwnerSettings settings; + + /// Filtered and sorted backlog rows. + final List items; +} + +/// Query request for project defaults and learned suggestions. +class GetProjectDefaultsRequest { + const GetProjectDefaultsRequest({ + required this.context, + this.includeArchived = false, + }); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; + + /// Whether archived projects should be included. + final bool includeArchived; +} + +/// Project defaults query result. +class ProjectDefaultsQueryResult { + ProjectDefaultsQueryResult({ + required this.ownerId, + required List projects, + }) : projects = List.unmodifiable(projects); + + /// Authorization-neutral owner scope. + final String ownerId; + + /// Configured defaults plus separately surfaced learned suggestions. + final List projects; +} + +/// Draft used when creating a project. +class ProjectProfileDraft { + const ProjectProfileDraft({ + this.id, + required this.name, + required this.colorKey, + this.defaultPriority = PriorityLevel.medium, + this.defaultReward = RewardLevel.notSet, + this.defaultDifficulty = DifficultyLevel.notSet, + this.defaultReminderProfile = ReminderProfile.gentle, + this.defaultDurationMinutes, + }); + + final String? id; + final String name; + final String colorKey; + final PriorityLevel defaultPriority; + final RewardLevel defaultReward; + final DifficultyLevel defaultDifficulty; + final ReminderProfile defaultReminderProfile; + final int? defaultDurationMinutes; +} + +/// Patch used when updating configured project defaults. +class ProjectProfileUpdate { + const ProjectProfileUpdate({ + this.name, + this.colorKey, + this.defaultPriority, + this.defaultReward, + this.defaultDifficulty, + this.defaultReminderProfile, + this.defaultDurationMinutes, + this.clearDefaultDuration = false, + }); + + final String? name; + final String? colorKey; + final PriorityLevel? defaultPriority; + final RewardLevel? defaultReward; + final DifficultyLevel? defaultDifficulty; + final ReminderProfile? defaultReminderProfile; + final int? defaultDurationMinutes; + final bool clearDefaultDuration; +} + +/// Draft used when creating a locked block. +class LockedBlockDraft { + const LockedBlockDraft({ + this.id, + required this.name, + required this.startTime, + required this.endTime, + this.date, + this.recurrence, + this.hiddenByDefault = true, + this.projectId, + }); + + final String? id; + final String name; + final ClockTime startTime; + final ClockTime endTime; + final CivilDate? date; + final LockedBlockRecurrence? recurrence; + final bool hiddenByDefault; + final String? projectId; +} + +/// Patch used when updating a locked block definition. +class LockedBlockUpdate { + const LockedBlockUpdate({ + this.name, + this.startTime, + this.endTime, + this.date, + this.recurrence, + this.hiddenByDefault, + this.projectId, + }); + + final String? name; + final ClockTime? startTime; + final ClockTime? endTime; + final CivilDate? date; + final LockedBlockRecurrence? recurrence; + final bool? hiddenByDefault; + final String? projectId; +} + +/// Result for owner settings updates. +class OwnerSettingsUpdateResult { + OwnerSettingsUpdateResult({ + required this.settings, + Iterable warnings = + const [], + }) : warnings = List.unmodifiable(warnings); + + /// Persisted owner settings. + final OwnerSettings settings; + + /// Typed warnings for changes that may need migration/UI confirmation. + final List warnings; +} + +/// Patch used when updating owner settings. +class OwnerSettingsUpdate { + const OwnerSettingsUpdate({ + this.timeZoneId, + this.dayStart, + this.dayEnd, + this.compactModeEnabled, + this.backlogStalenessSettings, + }); + + final String? timeZoneId; + final WallTime? dayStart; + final WallTime? dayEnd; + final bool? compactModeEnabled; + final BacklogStalenessSettings? backlogStalenessSettings; +} + +/// Result for notice acknowledgement commands. +class NoticeAcknowledgementResult { + const NoticeAcknowledgementResult({ + required this.record, + required this.alreadyAcknowledged, + }); + + /// Persisted acknowledgement record. + final NoticeAcknowledgementRecord record; + + /// Whether the notice had already been acknowledged before this command. + final bool alreadyAcknowledged; +} + +/// V1 management facade used by future Flutter state management. +class V1ApplicationManagementUseCases { + const V1ApplicationManagementUseCases({ + required this.applicationStore, + this.projectSuggestionService = const ProjectSuggestionService(), + }); + + /// Atomic application boundary. + final ApplicationUnitOfWork applicationStore; + + /// Learned project suggestion policy. + final ProjectSuggestionService projectSuggestionService; + + /// Load backlog candidates through the repository status filter, then apply + /// existing filter/sort/staleness policies in memory. + Future> getBacklog( + GetBacklogRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final ownerId = request.context.ownerTimeZone.ownerId; + final settings = await _ownerSettingsOrDefault( + repositories, + request.context.ownerTimeZone, + ); + final candidates = await repositories.tasks.findByStatus( + TaskStatus.backlog, + ); + final filtered = request.filter == null + ? candidates + : BacklogView( + tasks: candidates, + now: request.context.now, + stalenessSettings: settings.backlogStalenessSettings, + ).filter(request.filter!); + final view = BacklogView( + tasks: filtered, + now: request.context.now, + stalenessSettings: settings.backlogStalenessSettings, + ); + final sorted = view.sorted(request.sortKey); + + return ApplicationResult.success( + BacklogQueryResult( + ownerId: ownerId, + settings: settings, + items: [ + for (final task in sorted) + BacklogItemReadModel( + task: task, + stalenessMarker: view.stalenessMarkerFor(task), + ), + ], + ), + ); + }, + ); + } + + /// Load configured project defaults with learned suggestions kept separate. + Future> getProjectDefaults( + GetProjectDefaultsRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final projects = await repositories.projects.findAll(); + final resolutions = []; + final sortedProjects = [...projects] + ..sort((a, b) => a.name.compareTo(b.name)); + + for (final project in sortedProjects) { + if (project.isArchived && !request.includeArchived) { + continue; + } + final statistics = + await repositories.projectStatistics.findByProjectId(project.id); + resolutions.add( + projectSuggestionService.resolveDefaults( + project: project, + statistics: + statistics ?? ProjectStatistics(projectId: project.id), + ), + ); + } + + return ApplicationResult.success( + ProjectDefaultsQueryResult( + ownerId: request.context.ownerTimeZone.ownerId, + projects: resolutions, + ), + ); + }, + ); + } + + Future> createProject({ + required ApplicationOperationContext context, + required ProjectProfileDraft draft, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.createProject.name, + action: (repositories) async { + final id = draft.id ?? context.idGenerator.nextId(); + final existing = await repositories.projects.findById(id); + if (existing != null) { + return _failure( + ApplicationFailureCode.conflict, + entityId: id, + detailCode: 'projectAlreadyExists', + ); + } + final project = ProjectProfile( + id: id, + name: draft.name, + colorKey: draft.colorKey, + defaultPriority: draft.defaultPriority, + defaultReward: draft.defaultReward, + defaultDifficulty: draft.defaultDifficulty, + defaultReminderProfile: draft.defaultReminderProfile, + defaultDurationMinutes: draft.defaultDurationMinutes, + ); + await repositories.projects.save(project); + return ApplicationResult.success(project); + }, + ); + } + + Future> updateProject({ + required ApplicationOperationContext context, + required String projectId, + required ProjectProfileUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateProject.name, + action: (repositories) async { + final project = await _requireProject(repositories, projectId); + final updated = project.copyWith( + name: update.name, + colorKey: update.colorKey, + defaultPriority: update.defaultPriority, + defaultReward: update.defaultReward, + defaultDifficulty: update.defaultDifficulty, + defaultReminderProfile: update.defaultReminderProfile, + defaultDurationMinutes: update.defaultDurationMinutes, + clearDefaultDuration: update.clearDefaultDuration, + ); + await repositories.projects.save(updated); + return ApplicationResult.success(updated); + }, + ); + } + + Future> archiveProject({ + required ApplicationOperationContext context, + required String projectId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.archiveProject.name, + action: (repositories) async { + final project = await _requireProject(repositories, projectId); + final archived = project.copyWith(archivedAt: context.now); + await repositories.projects.save(archived); + return ApplicationResult.success(archived); + }, + ); + } + + Future> createLockedBlock({ + required ApplicationOperationContext context, + required LockedBlockDraft draft, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.createLockedBlock.name, + action: (repositories) async { + final id = draft.id ?? context.idGenerator.nextId(); + final existing = await repositories.lockedBlocks.findBlockById(id); + if (existing != null) { + return _failure( + ApplicationFailureCode.conflict, + entityId: id, + detailCode: 'lockedBlockAlreadyExists', + ); + } + final block = LockedBlock( + id: id, + name: draft.name, + startTime: draft.startTime, + endTime: draft.endTime, + date: draft.date, + recurrence: draft.recurrence, + hiddenByDefault: draft.hiddenByDefault, + projectId: draft.projectId, + createdAt: context.now, + updatedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(block); + return ApplicationResult.success(block); + }, + ); + } + + Future> updateLockedBlock({ + required ApplicationOperationContext context, + required String lockedBlockId, + required LockedBlockUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateLockedBlock.name, + action: (repositories) async { + final block = await _requireLockedBlock(repositories, lockedBlockId); + final updated = block.copyWith( + name: update.name, + startTime: update.startTime, + endTime: update.endTime, + date: update.date, + recurrence: update.recurrence, + hiddenByDefault: update.hiddenByDefault, + projectId: update.projectId, + updatedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(updated); + return ApplicationResult.success(updated); + }, + ); + } + + Future> archiveLockedBlock({ + required ApplicationOperationContext context, + required String lockedBlockId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.archiveLockedBlock.name, + action: (repositories) async { + final block = await _requireLockedBlock(repositories, lockedBlockId); + final archived = block.copyWith( + updatedAt: context.now, + archivedAt: context.now, + ); + await repositories.lockedBlocks.saveBlock(archived); + return ApplicationResult.success(archived); + }, + ); + } + + Future> addOneDayLockedOverride({ + required ApplicationOperationContext context, + required CivilDate date, + required String name, + required ClockTime startTime, + required ClockTime endTime, + bool hiddenByDefault = true, + String? projectId, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.addLockedBlockOverride.name, + action: (repositories) async { + final override = LockedBlockOverride.add( + id: context.idGenerator.nextId(), + date: date, + name: name, + startTime: startTime, + endTime: endTime, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> removeOneDayLockedOccurrence({ + required ApplicationOperationContext context, + required String lockedBlockId, + required CivilDate date, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.removeLockedBlockOccurrence.name, + action: (repositories) async { + await _requireLockedBlock(repositories, lockedBlockId); + final override = LockedBlockOverride.remove( + id: context.idGenerator.nextId(), + lockedBlockId: lockedBlockId, + date: date, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> replaceOneDayLockedOccurrence({ + required ApplicationOperationContext context, + required String lockedBlockId, + required CivilDate date, + required ClockTime startTime, + required ClockTime endTime, + String? name, + bool hiddenByDefault = true, + String? projectId, + }) { + return applicationStore.run( + context: context, + operationName: + ApplicationManagementCommandCode.replaceLockedBlockOccurrence.name, + action: (repositories) async { + await _requireLockedBlock(repositories, lockedBlockId); + final override = LockedBlockOverride.replace( + id: context.idGenerator.nextId(), + lockedBlockId: lockedBlockId, + date: date, + startTime: startTime, + endTime: endTime, + name: name, + hiddenByDefault: hiddenByDefault, + projectId: projectId, + createdAt: context.now, + ); + await repositories.lockedBlocks.saveOverride(override); + return ApplicationResult.success(override); + }, + ); + } + + Future> updateOwnerSettings({ + required ApplicationOperationContext context, + required OwnerSettingsUpdate update, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.updateOwnerSettings.name, + action: (repositories) async { + final current = await _ownerSettingsOrDefault( + repositories, + context.ownerTimeZone, + ); + final stalenessSettings = update.backlogStalenessSettings; + if (stalenessSettings != null && + (stalenessSettings.greenMaxAge.isNegative || + stalenessSettings.blueMaxAge.isNegative || + stalenessSettings.blueMaxAge < stalenessSettings.greenMaxAge)) { + return _failure( + ApplicationFailureCode.validation, + detailCode: 'invalidBacklogStalenessThresholds', + ); + } + final updated = current.copyWith( + timeZoneId: update.timeZoneId, + dayStart: update.dayStart, + dayEnd: update.dayEnd, + compactModeEnabled: update.compactModeEnabled, + backlogStalenessSettings: update.backlogStalenessSettings, + ); + final warnings = [ + if (update.timeZoneId != null && + update.timeZoneId != current.timeZoneId) + OwnerSettingsWarningCode.timeZoneChanged, + if ((update.dayStart != null && + update.dayStart != current.dayStart) || + (update.dayEnd != null && update.dayEnd != current.dayEnd)) + OwnerSettingsWarningCode.planningWindowChanged, + ]; + await repositories.ownerSettings.save(updated); + return ApplicationResult.success( + OwnerSettingsUpdateResult(settings: updated, warnings: warnings), + ); + }, + ); + } + + Future> acknowledgeNotice({ + required ApplicationOperationContext context, + required String noticeId, + }) { + return applicationStore.run( + context: context, + operationName: ApplicationManagementCommandCode.acknowledgeNotice.name, + action: (repositories) async { + final ownerId = context.ownerTimeZone.ownerId; + final parsed = _parseNoticeId(noticeId); + if (parsed == null) { + return _failure( + ApplicationFailureCode.validation, + entityId: noticeId, + detailCode: 'invalidNoticeId', + ); + } + final snapshot = + await repositories.schedulingSnapshots.findById(parsed.snapshotId); + if (snapshot == null || parsed.noticeIndex >= snapshot.notices.length) { + return _failure( + ApplicationFailureCode.notFound, + entityId: noticeId, + detailCode: 'noticeNotFound', + ); + } + final existing = await repositories.noticeAcknowledgements.findById( + ownerId: ownerId, + noticeId: noticeId, + ); + if (existing != null) { + return ApplicationResult.success( + NoticeAcknowledgementResult( + record: existing, + alreadyAcknowledged: true, + ), + ); + } + + final record = NoticeAcknowledgementRecord( + noticeId: noticeId, + ownerId: ownerId, + acknowledgedAt: context.now, + ); + await repositories.noticeAcknowledgements.save(record); + return ApplicationResult.success( + NoticeAcknowledgementResult( + record: record, + alreadyAcknowledged: false, + ), + ); + }, + ); + } + + Future _requireProject( + ApplicationUnitOfWorkRepositories repositories, + String projectId, + ) async { + final project = await repositories.projects.findById(projectId); + if (project == null) { + throw ApplicationFailureException( + ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: projectId, + detailCode: 'projectNotFound', + ), + ); + } + return project; + } + + Future _requireLockedBlock( + ApplicationUnitOfWorkRepositories repositories, + String lockedBlockId, + ) async { + final block = await repositories.lockedBlocks.findBlockById(lockedBlockId); + if (block == null) { + throw ApplicationFailureException( + ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: lockedBlockId, + detailCode: 'lockedBlockNotFound', + ), + ); + } + return block; + } +} + +Future _ownerSettingsOrDefault( + ApplicationUnitOfWorkRepositories repositories, + OwnerTimeZoneContext ownerTimeZone, +) async { + return await repositories.ownerSettings + .findByOwnerId(ownerTimeZone.ownerId) ?? + OwnerSettings( + ownerId: ownerTimeZone.ownerId, + timeZoneId: ownerTimeZone.timeZoneId, + ); +} + +ApplicationResult _failure( + ApplicationFailureCode code, { + String? entityId, + String? detailCode, +}) { + return ApplicationResult.failure( + ApplicationFailure( + code: code, + entityId: entityId, + detailCode: detailCode, + ), + ); +} + +_ParsedNoticeId? _parseNoticeId(String noticeId) { + final separator = noticeId.lastIndexOf(':'); + if (separator <= 0 || separator == noticeId.length - 1) { + return null; + } + final noticeIndex = int.tryParse(noticeId.substring(separator + 1)); + if (noticeIndex == null || noticeIndex < 0) { + return null; + } + return _ParsedNoticeId( + snapshotId: noticeId.substring(0, separator), + noticeIndex: noticeIndex, + ); +} + +class _ParsedNoticeId { + const _ParsedNoticeId({ + required this.snapshotId, + required this.noticeIndex, + }); + + final String snapshotId; + final int noticeIndex; +} diff --git a/lib/src/locked_time.dart b/lib/src/locked_time.dart index 1ba6be6..6b6bf16 100644 --- a/lib/src/locked_time.dart +++ b/lib/src/locked_time.dart @@ -83,6 +83,7 @@ class LockedBlock { this.recurrence, this.hiddenByDefault = true, this.projectId, + this.archivedAt, }) : id = _requiredLockedString(id, 'id', DomainValidationCode.blankStableId), name = _requiredLockedString( @@ -136,9 +137,18 @@ class LockedBlock { /// Last update timestamp for persistence/auditing. final DateTime updatedAt; + /// When present, this block no longer creates future occurrences. + /// + /// One-day overrides are intentionally stored separately and are not deleted + /// when a block is archived. + final DateTime? archivedAt; + /// Convenience check for whether this block expands through recurrence. bool get isRecurring => recurrence != null; + /// Whether this block has been archived. + bool get isArchived => archivedAt != null; + /// Return a copy with selected locked-block details changed. LockedBlock copyWith({ String? id, @@ -151,6 +161,8 @@ class LockedBlock { String? projectId, DateTime? createdAt, DateTime? updatedAt, + DateTime? archivedAt, + bool clearArchivedAt = false, }) { return LockedBlock( id: id ?? this.id, @@ -163,6 +175,7 @@ class LockedBlock { projectId: projectId ?? this.projectId, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), ); } } @@ -471,6 +484,9 @@ LockedScheduleExpansion expandLockedBlocksForDay({ }); for (final block in blocks) { + if (block.isArchived) { + continue; + } if (!_blockOccursOn(block, date)) { continue; } diff --git a/lib/src/models.dart b/lib/src/models.dart index 923cb62..0296333 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -526,6 +526,7 @@ class ProjectProfile { this.defaultDifficulty = DifficultyLevel.notSet, this.defaultReminderProfile = ReminderProfile.gentle, int? defaultDurationMinutes, + this.archivedAt, }) : id = _requiredTrimmed( id, 'id', @@ -575,6 +576,13 @@ class ProjectProfile { /// Optional duration estimate used for newly created tasks. final int? defaultDurationMinutes; + /// When present, this project is hidden from normal pickers but tasks keep + /// their project ids for history and filtering. + final DateTime? archivedAt; + + /// Whether the project has been archived. + bool get isArchived => archivedAt != null; + /// Create a task using project defaults while allowing explicit overrides. /// /// This keeps capture and project-default behavior in one place. Callers can @@ -634,6 +642,8 @@ class ProjectProfile { ReminderProfile? defaultReminderProfile, int? defaultDurationMinutes, bool clearDefaultDuration = false, + DateTime? archivedAt, + bool clearArchivedAt = false, }) { return ProjectProfile( id: id ?? this.id, @@ -647,6 +657,7 @@ class ProjectProfile { defaultDurationMinutes: clearDefaultDuration ? null : (defaultDurationMinutes ?? this.defaultDurationMinutes), + archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), ); } } diff --git a/lib/src/today_state.dart b/lib/src/today_state.dart index 46d2e5b..e4df6e1 100644 --- a/lib/src/today_state.dart +++ b/lib/src/today_state.dart @@ -180,6 +180,7 @@ class TodayCompactState { /// Pending notice data surfaced through Today state. class TodayPendingNotice { TodayPendingNotice({ + required this.noticeId, required this.snapshotId, required this.capturedAt, required this.message, @@ -191,6 +192,9 @@ class TodayPendingNotice { this.conflictCode, }) : parameters = Map.unmodifiable(parameters); + /// Stable id for acknowledgement/consume commands. + final String noticeId; + /// Source snapshot id. final String snapshotId; @@ -329,6 +333,10 @@ class GetTodayStateQuery { final overrides = await repositories.lockedBlocks.findAllOverrides(); final snapshots = await repositories.schedulingSnapshots.findInWindow(window); + final acknowledgedNoticeIds = + (await repositories.noticeAcknowledgements.findByOwnerId(ownerId)) + .map((record) => record.noticeId) + .toSet(); final lockedExpansion = expandLockedBlocksForDay( blocks: blocks, @@ -387,7 +395,10 @@ class GetTodayStateQuery { final selectedCurrent = _findById(selectedItems, current?.id); final selectedNextRequired = _findById(selectedItems, nextRequired?.id); final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id); - final pendingNotices = _pendingNoticesFromSnapshots(snapshots); + final pendingNotices = _pendingNoticesFromSnapshots( + snapshots, + acknowledgedNoticeIds: acknowledgedNoticeIds, + ); return ApplicationResult.success( TodayState( @@ -532,8 +543,9 @@ class GetTodayStateQuery { } static List _pendingNoticesFromSnapshots( - List snapshots, - ) { + List snapshots, { + Set acknowledgedNoticeIds = const {}, + }) { final sortedSnapshots = [...snapshots]..sort((a, b) { final capturedComparison = a.capturedAt.compareTo(b.capturedAt); if (capturedComparison != 0) { @@ -545,9 +557,15 @@ class GetTodayStateQuery { final notices = []; for (final snapshot in sortedSnapshots) { - for (final notice in snapshot.notices) { + for (var index = 0; index < snapshot.notices.length; index += 1) { + final noticeId = _noticeId(snapshot.id, index); + if (acknowledgedNoticeIds.contains(noticeId)) { + continue; + } + final notice = snapshot.notices[index]; notices.add( TodayPendingNotice( + noticeId: noticeId, snapshotId: snapshot.id, capturedAt: snapshot.capturedAt, message: notice.message, @@ -566,6 +584,17 @@ class GetTodayStateQuery { } } +String schedulingNoticeId({ + required String snapshotId, + required int noticeIndex, +}) { + return _noticeId(snapshotId, noticeIndex); +} + +String _noticeId(String snapshotId, int noticeIndex) { + return '$snapshotId:$noticeIndex'; +} + int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) { final startComparison = _compareNullableInstants(left.start, right.start); if (startComparison != 0) { diff --git a/test/application_management_test.dart b/test/application_management_test.dart new file mode 100644 index 0000000..aa3d05c --- /dev/null +++ b/test/application_management_test.dart @@ -0,0 +1,348 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('V1ApplicationManagementUseCases', () { + final now = DateTime.utc(2026, 6, 25, 12); + + test('queries backlog with owner thresholds, filters, and sorting', + () async { + final fresh = backlogTask( + id: 'fresh', + createdAt: now.subtract(const Duration(days: 1)), + ); + final aging = backlogTask( + id: 'aging', + createdAt: now.subtract(const Duration(days: 5)), + pushed: true, + ); + final stale = backlogTask( + id: 'stale', + createdAt: now.subtract(const Duration(days: 10)), + pushed: true, + ); + final planned = backlogTask( + id: 'planned', + createdAt: now.subtract(const Duration(days: 40)), + ).copyWith(status: TaskStatus.planned); + final store = InMemoryApplicationUnitOfWork( + initialTasks: [fresh, aging, stale, planned], + initialOwnerSettings: [ + OwnerSettings( + ownerId: 'owner-1', + timeZoneId: 'UTC', + backlogStalenessSettings: const BacklogStalenessSettings( + greenMaxAge: Duration(days: 2), + blueMaxAge: Duration(days: 6), + ), + ), + ], + ); + final useCases = V1ApplicationManagementUseCases( + applicationStore: store, + ); + + final result = await useCases.getBacklog( + GetBacklogRequest( + context: appContext(operationId: 'read-backlog', now: now), + filter: BacklogFilter.pushed, + sortKey: BacklogSortKey.age, + ), + ); + + final view = result.requireValue; + expect(view.items.map((item) => item.task.id), ['stale', 'aging']); + expect(view.items.map((item) => item.stalenessMarker), [ + BacklogStalenessMarker.purple, + BacklogStalenessMarker.blue, + ]); + expect( + view.items.any((item) => item.task.id == planned.id), + isFalse, + ); + }); + + test('keeps configured project defaults separate from learned suggestions', + () async { + final project = ProjectProfile( + id: 'home', + name: 'Home', + colorKey: 'home-blue', + defaultReward: RewardLevel.low, + defaultDifficulty: DifficultyLevel.easy, + defaultReminderProfile: ReminderProfile.gentle, + defaultDurationMinutes: 20, + ); + final statistics = ProjectStatistics( + projectId: 'home', + completedTaskCount: 3, + durationMinuteCounts: const {45: 3}, + rewardCounts: const {RewardLevel.high: 3}, + difficultyCounts: const {DifficultyLevel.hard: 3}, + reminderProfileCounts: const {ReminderProfile.persistent: 3}, + ); + final store = InMemoryApplicationUnitOfWork( + initialProjects: [project], + initialProjectStatistics: [statistics], + ); + + final result = await V1ApplicationManagementUseCases( + applicationStore: store, + ).getProjectDefaults( + GetProjectDefaultsRequest( + context: appContext(operationId: 'read-projects', now: now), + ), + ); + + final resolution = result.requireValue.projects.single; + expect(resolution.configuredDurationMinutes, 20); + expect(resolution.configuredReward, RewardLevel.low); + expect(resolution.configuredDifficulty, DifficultyLevel.easy); + expect(resolution.configuredReminderProfile, ReminderProfile.gentle); + expect(resolution.suggestions.durationMinutes!.value, 45); + expect(resolution.suggestions.reward!.value, RewardLevel.high); + expect(resolution.suggestions.difficulty!.value, DifficultyLevel.hard); + expect( + resolution.suggestions.reminderProfile!.value, + ReminderProfile.persistent, + ); + }); + + test('archives projects without deleting task history', () async { + final task = backlogTask(id: 'task-1', projectId: 'home', createdAt: now); + final store = InMemoryApplicationUnitOfWork( + initialProjects: [ + ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'), + ], + initialTasks: [task], + ); + final useCases = V1ApplicationManagementUseCases( + applicationStore: store, + ); + + final archive = await useCases.archiveProject( + context: appContext(operationId: 'archive-project', now: now), + projectId: 'home', + ); + final visibleProjects = await useCases.getProjectDefaults( + GetProjectDefaultsRequest( + context: appContext(operationId: 'read-active-projects', now: now), + ), + ); + final allProjects = await useCases.getProjectDefaults( + GetProjectDefaultsRequest( + context: appContext(operationId: 'read-all-projects', now: now), + includeArchived: true, + ), + ); + + expect(archive.requireValue.isArchived, isTrue); + expect(visibleProjects.requireValue.projects, isEmpty); + expect(allProjects.requireValue.projects.single.project.id, 'home'); + expect(store.currentTasks.single.id, task.id); + expect(store.currentTasks.single.projectId, 'home'); + }); + + test('creates date-scoped locked overrides and archives blocks safely', + () async { + final day = CivilDate(2026, 6, 25); + final block = LockedBlock( + id: 'work', + name: 'Work', + startTime: WallTime(hour: 9, minute: 0), + endTime: WallTime(hour: 17, minute: 0), + recurrence: LockedBlockRecurrence.weekly( + weekdays: {LockedWeekday.thursday}, + ), + createdAt: now, + updatedAt: now, + ); + final store = InMemoryApplicationUnitOfWork( + initialLockedBlocks: [block], + ); + final useCases = V1ApplicationManagementUseCases( + applicationStore: store, + ); + + final replacement = await useCases.replaceOneDayLockedOccurrence( + context: appContext(operationId: 'replace-work', now: now), + lockedBlockId: 'work', + date: day, + startTime: WallTime(hour: 10, minute: 0), + endTime: WallTime(hour: 12, minute: 0), + name: 'Short work', + ); + await useCases.addOneDayLockedOverride( + context: appContext(operationId: 'add-appointment', now: now), + date: day, + name: 'Appointment', + startTime: WallTime(hour: 14, minute: 0), + endTime: WallTime(hour: 15, minute: 0), + ); + + var expansion = expandLockedBlocksForDay( + blocks: store.currentLockedBlocks, + overrides: store.currentLockedOverrides, + date: day, + timeZoneId: 'UTC', + timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + ); + expect(replacement.requireValue.type, LockedBlockOverrideType.replace); + expect(expansion.occurrences.map((occurrence) => occurrence.name), [ + 'Short work', + 'Appointment', + ]); + + await useCases.archiveLockedBlock( + context: appContext(operationId: 'archive-work', now: now), + lockedBlockId: 'work', + ); + expansion = expandLockedBlocksForDay( + blocks: store.currentLockedBlocks, + overrides: store.currentLockedOverrides, + date: day, + timeZoneId: 'UTC', + timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + ); + + expect(store.currentLockedBlocks.single.isArchived, isTrue); + expect(store.currentLockedOverrides, hasLength(2)); + expect(expansion.occurrences.map((occurrence) => occurrence.name), [ + 'Appointment', + ]); + }); + + test('updates owner settings with typed reinterpretation warnings', + () async { + final store = InMemoryApplicationUnitOfWork( + initialOwnerSettings: [ + OwnerSettings( + ownerId: 'owner-1', + timeZoneId: 'UTC', + dayStart: WallTime(hour: 8, minute: 0), + dayEnd: WallTime(hour: 20, minute: 0), + ), + ], + ); + + final result = await V1ApplicationManagementUseCases( + applicationStore: store, + ).updateOwnerSettings( + context: appContext(operationId: 'settings', now: now), + update: OwnerSettingsUpdate( + timeZoneId: 'America/Los_Angeles', + dayStart: WallTime(hour: 7, minute: 0), + compactModeEnabled: true, + backlogStalenessSettings: const BacklogStalenessSettings( + greenMaxAge: Duration(days: 3), + blueMaxAge: Duration(days: 8), + ), + ), + ); + + final update = result.requireValue; + expect(update.settings.timeZoneId, 'America/Los_Angeles'); + expect(update.settings.dayStart, WallTime(hour: 7, minute: 0)); + expect(update.settings.compactModeEnabled, isTrue); + expect(update.settings.backlogStalenessSettings.blueMaxAge.inDays, 8); + expect(update.warnings, [ + OwnerSettingsWarningCode.timeZoneChanged, + OwnerSettingsWarningCode.planningWindowChanged, + ]); + }); + + test('acknowledges one-time notices and hides them from Today state', + () async { + final day = CivilDate(2026, 6, 25); + final snapshot = SchedulingStateSnapshot( + id: 'rollover', + capturedAt: now, + window: SchedulingWindow( + start: DateTime.utc(2026, 6, 25), + end: DateTime.utc(2026, 6, 25, 23, 59), + ), + tasks: const [], + notices: [ + SchedulingNotice( + 'Moved 2 unfinished tasks to today.', + type: SchedulingNoticeType.moved, + movementCode: + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + parameters: const {'count': 2}, + ), + ], + ); + final store = InMemoryApplicationUnitOfWork( + initialSchedulingSnapshots: [snapshot], + ); + final todayQuery = GetTodayStateQuery( + applicationStore: store, + timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + ); + final useCases = V1ApplicationManagementUseCases( + applicationStore: store, + ); + + final before = (await todayQuery.execute( + GetTodayStateRequest( + context: appContext(operationId: 'today-before', now: now), + date: day, + ), + )) + .requireValue; + final noticeId = before.pendingNotices.single.noticeId; + + final acknowledged = await useCases.acknowledgeNotice( + context: appContext(operationId: 'ack-notice', now: now), + noticeId: noticeId, + ); + final after = (await todayQuery.execute( + GetTodayStateRequest( + context: appContext(operationId: 'today-after', now: now), + date: day, + ), + )) + .requireValue; + + expect( + noticeId, schedulingNoticeId(snapshotId: 'rollover', noticeIndex: 0)); + expect(acknowledged.requireValue.alreadyAcknowledged, isFalse); + expect(store.currentNoticeAcknowledgements.single.noticeId, noticeId); + expect(after.pendingNotices, isEmpty); + }); + }); +} + +ApplicationOperationContext appContext({ + required String operationId, + required DateTime now, +}) { + return ApplicationOperationContext.start( + operationId: operationId, + ownerTimeZone: OwnerTimeZoneContext(ownerId: 'owner-1', timeZoneId: 'UTC'), + clock: FixedClock(now), + idGenerator: SequentialIdGenerator(prefix: operationId), + ); +} + +Task backlogTask({ + required String id, + required DateTime createdAt, + String projectId = 'inbox', + bool pushed = false, +}) { + return Task( + id: id, + title: 'Task $id', + projectId: projectId, + type: TaskType.flexible, + status: TaskStatus.backlog, + priority: PriorityLevel.medium, + durationMinutes: 30, + createdAt: createdAt, + updatedAt: createdAt, + stats: pushed + ? const TaskStatistics(manuallyPushedCount: 1) + : const TaskStatistics(), + ); +}