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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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.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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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