feat(app): add management use cases

This commit is contained in:
Ashley Venn 2026-06-25 10:54:42 -07:00
parent 60cad3814c
commit 613f9f4e2b
8 changed files with 1340 additions and 4 deletions

View file

@ -239,6 +239,8 @@ startup/rollover orchestration.
Recommended Codex level: high Recommended Codex level: high
Status: Complete
Tasks: Tasks:
- Add a Backlog query using repository-side candidate loading plus the existing - Add a Backlog query using repository-side candidate loading plus the existing
@ -276,6 +278,45 @@ Acceptance criteria:
- Date-scoped override behavior is tested. - Date-scoped override behavior is tested.
- No management command deletes task history implicitly. - 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 ## Chunk 14.5 — Rollover and app-open recovery orchestration
Recommended Codex level: high Recommended Codex level: high

View file

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

View file

@ -177,6 +177,25 @@ class ApplicationOperationRecord {
final DateTime committedAt; 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. /// Owner-level settings needed by application use cases.
class OwnerSettings { class OwnerSettings {
OwnerSettings({ OwnerSettings({
@ -269,6 +288,18 @@ abstract interface class OwnerSettingsRepository {
Future<void> save(OwnerSettings settings); Future<void> save(OwnerSettings settings);
} }
/// Repository contract for consumed one-time notices.
abstract interface class NoticeAcknowledgementRepository {
Future<NoticeAcknowledgementRecord?> findById({
required String ownerId,
required String noticeId,
});
Future<List<NoticeAcknowledgementRecord>> findByOwnerId(String ownerId);
Future<void> save(NoticeAcknowledgementRecord record);
}
/// Repository contract for exactly-once operation records. /// Repository contract for exactly-once operation records.
abstract interface class ApplicationOperationRepository { abstract interface class ApplicationOperationRepository {
Future<ApplicationOperationRecord?> findById(String operationId); Future<ApplicationOperationRecord?> findById(String operationId);
@ -292,6 +323,8 @@ abstract interface class ApplicationUnitOfWorkRepositories {
OwnerSettingsRepository get ownerSettings; OwnerSettingsRepository get ownerSettings;
NoticeAcknowledgementRepository get noticeAcknowledgements;
ApplicationOperationRepository get operations; ApplicationOperationRepository get operations;
} }
@ -355,6 +388,8 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
Iterable<ProjectStatistics> initialProjectStatistics = Iterable<ProjectStatistics> initialProjectStatistics =
const <ProjectStatistics>[], const <ProjectStatistics>[],
Iterable<OwnerSettings> initialOwnerSettings = const <OwnerSettings>[], Iterable<OwnerSettings> initialOwnerSettings = const <OwnerSettings>[],
Iterable<NoticeAcknowledgementRecord> initialNoticeAcknowledgements =
const <NoticeAcknowledgementRecord>[],
Iterable<ApplicationOperationRecord> initialOperations = Iterable<ApplicationOperationRecord> initialOperations =
const <ApplicationOperationRecord>[], const <ApplicationOperationRecord>[],
}) : _state = _InMemoryApplicationState( }) : _state = _InMemoryApplicationState(
@ -386,6 +421,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
for (final settings in initialOwnerSettings) for (final settings in initialOwnerSettings)
settings.ownerId: settings, settings.ownerId: settings,
}, },
noticeAcknowledgementsByScopedId: {
for (final acknowledgement in initialNoticeAcknowledgements)
_noticeAcknowledgementKey(
ownerId: acknowledgement.ownerId,
noticeId: acknowledgement.noticeId,
): acknowledgement,
},
operationsById: { operationsById: {
for (final operation in initialOperations) for (final operation in initialOperations)
operation.operationId: operation, operation.operationId: operation,
@ -399,6 +441,23 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
List<Task> get currentTasks => List<Task> get currentTasks =>
List<Task>.unmodifiable(_state.tasksById.values); List<Task>.unmodifiable(_state.tasksById.values);
/// Current committed projects, useful for contract tests.
List<ProjectProfile> get currentProjects {
return List<ProjectProfile>.unmodifiable(_state.projectsById.values);
}
/// Current committed locked blocks, useful for contract tests.
List<LockedBlock> get currentLockedBlocks {
return List<LockedBlock>.unmodifiable(_state.lockedBlocksById.values);
}
/// Current committed locked overrides, useful for contract tests.
List<LockedBlockOverride> get currentLockedOverrides {
return List<LockedBlockOverride>.unmodifiable(
_state.lockedOverridesById.values,
);
}
/// Current committed activities, useful for contract tests. /// Current committed activities, useful for contract tests.
List<TaskActivity> get currentTaskActivities { List<TaskActivity> get currentTaskActivities {
return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values); return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values);
@ -417,6 +476,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
_state.ownerSettingsByOwnerId.values); _state.ownerSettingsByOwnerId.values);
} }
/// Current committed notice acknowledgements, useful for contract tests.
List<NoticeAcknowledgementRecord> get currentNoticeAcknowledgements {
return List<NoticeAcknowledgementRecord>.unmodifiable(
_state.noticeAcknowledgementsByScopedId.values,
);
}
/// Current committed operation records, useful for contract tests. /// Current committed operation records, useful for contract tests.
List<ApplicationOperationRecord> get currentOperations { List<ApplicationOperationRecord> get currentOperations {
return List<ApplicationOperationRecord>.unmodifiable( return List<ApplicationOperationRecord>.unmodifiable(
@ -549,6 +615,7 @@ class _InMemoryApplicationState {
required this.taskActivitiesById, required this.taskActivitiesById,
required this.projectStatisticsByProjectId, required this.projectStatisticsByProjectId,
required this.ownerSettingsByOwnerId, required this.ownerSettingsByOwnerId,
required this.noticeAcknowledgementsByScopedId,
required this.operationsById, required this.operationsById,
}); });
@ -560,6 +627,8 @@ class _InMemoryApplicationState {
final Map<String, TaskActivity> taskActivitiesById; final Map<String, TaskActivity> taskActivitiesById;
final Map<String, ProjectStatistics> projectStatisticsByProjectId; final Map<String, ProjectStatistics> projectStatisticsByProjectId;
final Map<String, OwnerSettings> ownerSettingsByOwnerId; final Map<String, OwnerSettings> ownerSettingsByOwnerId;
final Map<String, NoticeAcknowledgementRecord>
noticeAcknowledgementsByScopedId;
final Map<String, ApplicationOperationRecord> operationsById; final Map<String, ApplicationOperationRecord> operationsById;
_InMemoryApplicationState copy() { _InMemoryApplicationState copy() {
@ -577,6 +646,10 @@ class _InMemoryApplicationState {
ownerSettingsByOwnerId: Map<String, OwnerSettings>.of( ownerSettingsByOwnerId: Map<String, OwnerSettings>.of(
ownerSettingsByOwnerId, ownerSettingsByOwnerId,
), ),
noticeAcknowledgementsByScopedId:
Map<String, NoticeAcknowledgementRecord>.of(
noticeAcknowledgementsByScopedId,
),
operationsById: Map<String, ApplicationOperationRecord>.of( operationsById: Map<String, ApplicationOperationRecord>.of(
operationsById, operationsById,
), ),
@ -605,6 +678,9 @@ class _InMemoryApplicationRepositories
ownerSettings = _UnitOfWorkOwnerSettingsRepository( ownerSettings = _UnitOfWorkOwnerSettingsRepository(
state.ownerSettingsByOwnerId, state.ownerSettingsByOwnerId,
), ),
noticeAcknowledgements = _UnitOfWorkNoticeAcknowledgementRepository(
state.noticeAcknowledgementsByScopedId,
),
operations = _UnitOfWorkOperationRepository(state.operationsById); operations = _UnitOfWorkOperationRepository(state.operationsById);
@override @override
@ -628,6 +704,9 @@ class _InMemoryApplicationRepositories
@override @override
final OwnerSettingsRepository ownerSettings; final OwnerSettingsRepository ownerSettings;
@override
final NoticeAcknowledgementRepository noticeAcknowledgements;
@override @override
final ApplicationOperationRepository operations; final ApplicationOperationRepository operations;
} }
@ -845,6 +924,39 @@ class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository {
} }
} }
class _UnitOfWorkNoticeAcknowledgementRepository
implements NoticeAcknowledgementRepository {
_UnitOfWorkNoticeAcknowledgementRepository(this._recordsByScopedId);
final Map<String, NoticeAcknowledgementRecord> _recordsByScopedId;
@override
Future<NoticeAcknowledgementRecord?> findById({
required String ownerId,
required String noticeId,
}) async {
return _recordsByScopedId[
_noticeAcknowledgementKey(ownerId: ownerId, noticeId: noticeId)];
}
@override
Future<List<NoticeAcknowledgementRecord>> findByOwnerId(
String ownerId,
) async {
return List<NoticeAcknowledgementRecord>.unmodifiable(
_recordsByScopedId.values.where((record) => record.ownerId == ownerId),
);
}
@override
Future<void> save(NoticeAcknowledgementRecord record) async {
_recordsByScopedId[_noticeAcknowledgementKey(
ownerId: record.ownerId,
noticeId: record.noticeId,
)] = record;
}
}
class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { class _UnitOfWorkOperationRepository implements ApplicationOperationRepository {
_UnitOfWorkOperationRepository(this._operationsById); _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) { String _requiredTrimmed(String value, String name) {
final trimmed = value.trim(); final trimmed = value.trim();
if (trimmed.isEmpty) { if (trimmed.isEmpty) {

View file

@ -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<BacklogItemReadModel> items,
}) : items = List<BacklogItemReadModel>.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<BacklogItemReadModel> 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<ProjectDefaultResolution> projects,
}) : projects = List<ProjectDefaultResolution>.unmodifiable(projects);
/// Authorization-neutral owner scope.
final String ownerId;
/// Configured defaults plus separately surfaced learned suggestions.
final List<ProjectDefaultResolution> 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<OwnerSettingsWarningCode> warnings =
const <OwnerSettingsWarningCode>[],
}) : warnings = List<OwnerSettingsWarningCode>.unmodifiable(warnings);
/// Persisted owner settings.
final OwnerSettings settings;
/// Typed warnings for changes that may need migration/UI confirmation.
final List<OwnerSettingsWarningCode> 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<ApplicationResult<BacklogQueryResult>> 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<ApplicationResult<ProjectDefaultsQueryResult>> getProjectDefaults(
GetProjectDefaultsRequest request,
) {
return applicationStore.read(
action: (repositories) async {
final projects = await repositories.projects.findAll();
final resolutions = <ProjectDefaultResolution>[];
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<ApplicationResult<ProjectProfile>> 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<ApplicationResult<ProjectProfile>> 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<ApplicationResult<ProjectProfile>> 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<ApplicationResult<LockedBlock>> 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<ApplicationResult<LockedBlock>> 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<ApplicationResult<LockedBlock>> 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<ApplicationResult<LockedBlockOverride>> 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<ApplicationResult<LockedBlockOverride>> 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<ApplicationResult<LockedBlockOverride>> 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<ApplicationResult<OwnerSettingsUpdateResult>> 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 = <OwnerSettingsWarningCode>[
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<ApplicationResult<NoticeAcknowledgementResult>> 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<ProjectProfile> _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<LockedBlock> _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<OwnerSettings> _ownerSettingsOrDefault(
ApplicationUnitOfWorkRepositories repositories,
OwnerTimeZoneContext ownerTimeZone,
) async {
return await repositories.ownerSettings
.findByOwnerId(ownerTimeZone.ownerId) ??
OwnerSettings(
ownerId: ownerTimeZone.ownerId,
timeZoneId: ownerTimeZone.timeZoneId,
);
}
ApplicationResult<T> _failure<T>(
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;
}

View file

@ -83,6 +83,7 @@ class LockedBlock {
this.recurrence, this.recurrence,
this.hiddenByDefault = true, this.hiddenByDefault = true,
this.projectId, this.projectId,
this.archivedAt,
}) : id = }) : id =
_requiredLockedString(id, 'id', DomainValidationCode.blankStableId), _requiredLockedString(id, 'id', DomainValidationCode.blankStableId),
name = _requiredLockedString( name = _requiredLockedString(
@ -136,9 +137,18 @@ class LockedBlock {
/// Last update timestamp for persistence/auditing. /// Last update timestamp for persistence/auditing.
final DateTime updatedAt; 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. /// Convenience check for whether this block expands through recurrence.
bool get isRecurring => recurrence != null; 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. /// Return a copy with selected locked-block details changed.
LockedBlock copyWith({ LockedBlock copyWith({
String? id, String? id,
@ -151,6 +161,8 @@ class LockedBlock {
String? projectId, String? projectId,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? archivedAt,
bool clearArchivedAt = false,
}) { }) {
return LockedBlock( return LockedBlock(
id: id ?? this.id, id: id ?? this.id,
@ -163,6 +175,7 @@ class LockedBlock {
projectId: projectId ?? this.projectId, projectId: projectId ?? this.projectId,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt),
); );
} }
} }
@ -471,6 +484,9 @@ LockedScheduleExpansion expandLockedBlocksForDay({
}); });
for (final block in blocks) { for (final block in blocks) {
if (block.isArchived) {
continue;
}
if (!_blockOccursOn(block, date)) { if (!_blockOccursOn(block, date)) {
continue; continue;
} }

View file

@ -526,6 +526,7 @@ class ProjectProfile {
this.defaultDifficulty = DifficultyLevel.notSet, this.defaultDifficulty = DifficultyLevel.notSet,
this.defaultReminderProfile = ReminderProfile.gentle, this.defaultReminderProfile = ReminderProfile.gentle,
int? defaultDurationMinutes, int? defaultDurationMinutes,
this.archivedAt,
}) : id = _requiredTrimmed( }) : id = _requiredTrimmed(
id, id,
'id', 'id',
@ -575,6 +576,13 @@ class ProjectProfile {
/// Optional duration estimate used for newly created tasks. /// Optional duration estimate used for newly created tasks.
final int? defaultDurationMinutes; 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. /// Create a task using project defaults while allowing explicit overrides.
/// ///
/// This keeps capture and project-default behavior in one place. Callers can /// This keeps capture and project-default behavior in one place. Callers can
@ -634,6 +642,8 @@ class ProjectProfile {
ReminderProfile? defaultReminderProfile, ReminderProfile? defaultReminderProfile,
int? defaultDurationMinutes, int? defaultDurationMinutes,
bool clearDefaultDuration = false, bool clearDefaultDuration = false,
DateTime? archivedAt,
bool clearArchivedAt = false,
}) { }) {
return ProjectProfile( return ProjectProfile(
id: id ?? this.id, id: id ?? this.id,
@ -647,6 +657,7 @@ class ProjectProfile {
defaultDurationMinutes: clearDefaultDuration defaultDurationMinutes: clearDefaultDuration
? null ? null
: (defaultDurationMinutes ?? this.defaultDurationMinutes), : (defaultDurationMinutes ?? this.defaultDurationMinutes),
archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt),
); );
} }
} }

View file

@ -180,6 +180,7 @@ class TodayCompactState {
/// Pending notice data surfaced through Today state. /// Pending notice data surfaced through Today state.
class TodayPendingNotice { class TodayPendingNotice {
TodayPendingNotice({ TodayPendingNotice({
required this.noticeId,
required this.snapshotId, required this.snapshotId,
required this.capturedAt, required this.capturedAt,
required this.message, required this.message,
@ -191,6 +192,9 @@ class TodayPendingNotice {
this.conflictCode, this.conflictCode,
}) : parameters = Map<String, Object?>.unmodifiable(parameters); }) : parameters = Map<String, Object?>.unmodifiable(parameters);
/// Stable id for acknowledgement/consume commands.
final String noticeId;
/// Source snapshot id. /// Source snapshot id.
final String snapshotId; final String snapshotId;
@ -329,6 +333,10 @@ class GetTodayStateQuery {
final overrides = await repositories.lockedBlocks.findAllOverrides(); final overrides = await repositories.lockedBlocks.findAllOverrides();
final snapshots = final snapshots =
await repositories.schedulingSnapshots.findInWindow(window); await repositories.schedulingSnapshots.findInWindow(window);
final acknowledgedNoticeIds =
(await repositories.noticeAcknowledgements.findByOwnerId(ownerId))
.map((record) => record.noticeId)
.toSet();
final lockedExpansion = expandLockedBlocksForDay( final lockedExpansion = expandLockedBlocksForDay(
blocks: blocks, blocks: blocks,
@ -387,7 +395,10 @@ class GetTodayStateQuery {
final selectedCurrent = _findById(selectedItems, current?.id); final selectedCurrent = _findById(selectedItems, current?.id);
final selectedNextRequired = _findById(selectedItems, nextRequired?.id); final selectedNextRequired = _findById(selectedItems, nextRequired?.id);
final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id); final selectedNextFlexible = _findById(selectedItems, nextFlexible?.id);
final pendingNotices = _pendingNoticesFromSnapshots(snapshots); final pendingNotices = _pendingNoticesFromSnapshots(
snapshots,
acknowledgedNoticeIds: acknowledgedNoticeIds,
);
return ApplicationResult.success( return ApplicationResult.success(
TodayState( TodayState(
@ -532,8 +543,9 @@ class GetTodayStateQuery {
} }
static List<TodayPendingNotice> _pendingNoticesFromSnapshots( static List<TodayPendingNotice> _pendingNoticesFromSnapshots(
List<SchedulingStateSnapshot> snapshots, List<SchedulingStateSnapshot> snapshots, {
) { Set<String> acknowledgedNoticeIds = const <String>{},
}) {
final sortedSnapshots = [...snapshots]..sort((a, b) { final sortedSnapshots = [...snapshots]..sort((a, b) {
final capturedComparison = a.capturedAt.compareTo(b.capturedAt); final capturedComparison = a.capturedAt.compareTo(b.capturedAt);
if (capturedComparison != 0) { if (capturedComparison != 0) {
@ -545,9 +557,15 @@ class GetTodayStateQuery {
final notices = <TodayPendingNotice>[]; final notices = <TodayPendingNotice>[];
for (final snapshot in sortedSnapshots) { 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( notices.add(
TodayPendingNotice( TodayPendingNotice(
noticeId: noticeId,
snapshotId: snapshot.id, snapshotId: snapshot.id,
capturedAt: snapshot.capturedAt, capturedAt: snapshot.capturedAt,
message: notice.message, 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) { int _compareTodayItems(TodayTimelineItem left, TodayTimelineItem right) {
final startComparison = _compareNullableInstants(left.start, right.start); final startComparison = _compareNullableInstants(left.start, right.start);
if (startComparison != 0) { if (startComparison != 0) {

View file

@ -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(),
);
}