forked from eva/focus-flow
feat(app): add management use cases
This commit is contained in:
parent
60cad3814c
commit
613f9f4e2b
8 changed files with 1340 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
abstract interface class ApplicationOperationRepository {
|
||||
Future<ApplicationOperationRecord?> 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<ProjectStatistics> initialProjectStatistics =
|
||||
const <ProjectStatistics>[],
|
||||
Iterable<OwnerSettings> initialOwnerSettings = const <OwnerSettings>[],
|
||||
Iterable<NoticeAcknowledgementRecord> initialNoticeAcknowledgements =
|
||||
const <NoticeAcknowledgementRecord>[],
|
||||
Iterable<ApplicationOperationRecord> initialOperations =
|
||||
const <ApplicationOperationRecord>[],
|
||||
}) : _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<Task> get currentTasks =>
|
||||
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.
|
||||
List<TaskActivity> get currentTaskActivities {
|
||||
return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values);
|
||||
|
|
@ -417,6 +476,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
|
|||
_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.
|
||||
List<ApplicationOperationRecord> get currentOperations {
|
||||
return List<ApplicationOperationRecord>.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<String, TaskActivity> taskActivitiesById;
|
||||
final Map<String, ProjectStatistics> projectStatisticsByProjectId;
|
||||
final Map<String, OwnerSettings> ownerSettingsByOwnerId;
|
||||
final Map<String, NoticeAcknowledgementRecord>
|
||||
noticeAcknowledgementsByScopedId;
|
||||
final Map<String, ApplicationOperationRecord> operationsById;
|
||||
|
||||
_InMemoryApplicationState copy() {
|
||||
|
|
@ -577,6 +646,10 @@ class _InMemoryApplicationState {
|
|||
ownerSettingsByOwnerId: Map<String, OwnerSettings>.of(
|
||||
ownerSettingsByOwnerId,
|
||||
),
|
||||
noticeAcknowledgementsByScopedId:
|
||||
Map<String, NoticeAcknowledgementRecord>.of(
|
||||
noticeAcknowledgementsByScopedId,
|
||||
),
|
||||
operationsById: Map<String, ApplicationOperationRecord>.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<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 {
|
||||
_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) {
|
||||
|
|
|
|||
771
lib/src/application_management.dart
Normal file
771
lib/src/application_management.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object?>.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<TodayPendingNotice> _pendingNoticesFromSnapshots(
|
||||
List<SchedulingStateSnapshot> snapshots,
|
||||
) {
|
||||
List<SchedulingStateSnapshot> snapshots, {
|
||||
Set<String> acknowledgedNoticeIds = const <String>{},
|
||||
}) {
|
||||
final sortedSnapshots = [...snapshots]..sort((a, b) {
|
||||
final capturedComparison = a.capturedAt.compareTo(b.capturedAt);
|
||||
if (capturedComparison != 0) {
|
||||
|
|
@ -545,9 +557,15 @@ class GetTodayStateQuery {
|
|||
final notices = <TodayPendingNotice>[];
|
||||
|
||||
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) {
|
||||
|
|
|
|||
348
test/application_management_test.dart
Normal file
348
test/application_management_test.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue