refactor: split package source files

This commit is contained in:
Ashley Venn 2026-07-01 12:46:45 -07:00
parent cbefe201d3
commit 891bbec1fd
396 changed files with 24606 additions and 23881 deletions

View file

@ -7,6 +7,12 @@ import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
part 'encrypted_backup/backup_decryption_exception.dart';
part 'encrypted_backup/backup_encryption_codec.dart';
part 'encrypted_backup/backup_exception.dart';
part 'encrypted_backup/backup_file_names.dart';
part 'encrypted_backup/backup_paths.dart';
part 'encrypted_backup/encrypted_backup_operations.dart';
/// Default scheduler data directory name under the user's home directory.
const defaultSchedulerDirectoryName = 'ADHD_Scheduler';
@ -25,241 +31,3 @@ const _nonceLength = 12;
const _keyLength = 32;
final _utf8 = utf8.encoder;
/// Thrown when an encrypted backup cannot be decrypted with the passphrase.
final class BackupDecryptionException implements Exception {
/// Creates a decryption failure with optional diagnostic [message].
const BackupDecryptionException([
this.message = 'Backup could not be decrypted.',
]);
/// Diagnostic text for logs and tests.
final String message;
@override
String toString() => 'BackupDecryptionException: $message';
}
/// Thrown when backup input paths or file contents are invalid.
final class BackupException implements Exception {
/// Creates a backup exception with diagnostic [message].
const BackupException(this.message);
/// Diagnostic text for logs and tests.
final String message;
@override
String toString() => 'BackupException: $message';
}
/// Create an encrypted backup of the scheduler SQLite file.
///
/// Defaults:
/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite`
/// - Backup dir: `~/ADHD_Scheduler/backups`
/// - File name: `yyyymmdd_hhmm.db.enc`
Future<File> createEncryptedBackup({
required String passphrase,
File? sqliteFile,
Directory? backupDirectory,
DateTime? now,
}) async {
final source = sqliteFile ?? defaultSchedulerSqliteFile();
if (!await source.exists()) {
throw BackupException('SQLite file does not exist: ${source.path}');
}
final destinationDirectory =
backupDirectory ?? defaultSchedulerBackupDirectory();
await destinationDirectory.create(recursive: true);
final timestamp = _backupTimestamp(now ?? DateTime.now());
final destination = await _nextAvailableBackupFile(
destinationDirectory,
'$timestamp$_fileExtension',
);
final plaintext = await source.readAsBytes();
final encoded = await _encryptBytes(
passphrase: passphrase,
plaintext: plaintext,
);
return destination.writeAsBytes(encoded, flush: true);
}
/// Restore an encrypted backup over the scheduler SQLite file.
///
/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and
/// tools can pass [targetFile] to restore into a temp or alternate database.
Future<void> restoreEncryptedBackup(
File backup,
String passphrase, {
File? targetFile,
}) async {
if (!await backup.exists()) {
throw BackupException('Backup file does not exist: ${backup.path}');
}
final plaintext = await _decryptBytes(
passphrase: passphrase,
encoded: await backup.readAsBytes(),
);
final target = targetFile ?? defaultSchedulerSqliteFile();
await target.parent.create(recursive: true);
final temporary = File('${target.path}.restore.tmp');
await temporary.writeAsBytes(plaintext, flush: true);
if (await target.exists()) {
await target.delete();
}
await temporary.rename(target.path);
}
/// Default scheduler SQLite database path.
File defaultSchedulerSqliteFile() {
return File(_joinPath(
_homeDirectory().path,
defaultSchedulerDirectoryName,
defaultSchedulerDatabaseFileName,
));
}
/// Default encrypted backup output directory.
Directory defaultSchedulerBackupDirectory() {
return Directory(_joinPath(
_homeDirectory().path,
defaultSchedulerDirectoryName,
defaultSchedulerBackupDirectoryName,
));
}
Future<List<int>> _encryptBytes({
required String passphrase,
required List<int> plaintext,
}) async {
final salt = _randomBytes(_saltLength);
final nonce = _randomBytes(_nonceLength);
final key = await _deriveKey(passphrase: passphrase, salt: salt);
final algorithm = AesGcm.with256bits();
final secretBox = await algorithm.encrypt(
plaintext,
secretKey: key,
nonce: nonce,
);
final envelope = <String, Object?>{
'magic': _formatMagic,
'kdf': 'pbkdf2-hmac-sha256',
'iterations': _kdfIterations,
'cipher': 'aes-256-gcm',
'salt': base64Encode(salt),
'nonce': base64Encode(secretBox.nonce),
'ciphertext': base64Encode(secretBox.cipherText),
'mac': base64Encode(secretBox.mac.bytes),
};
return _utf8.convert(jsonEncode(envelope));
}
Future<List<int>> _decryptBytes({
required String passphrase,
required List<int> encoded,
}) async {
try {
final envelope = jsonDecode(utf8.decode(encoded)) as Map<String, Object?>;
if (envelope['magic'] != _formatMagic ||
envelope['kdf'] != 'pbkdf2-hmac-sha256' ||
envelope['iterations'] != _kdfIterations ||
envelope['cipher'] != 'aes-256-gcm') {
throw const BackupDecryptionException('Unsupported backup format.');
}
final salt = base64Decode(envelope['salt'] as String);
final nonce = base64Decode(envelope['nonce'] as String);
final cipherText = base64Decode(envelope['ciphertext'] as String);
final mac = Mac(base64Decode(envelope['mac'] as String));
final key = await _deriveKey(passphrase: passphrase, salt: salt);
final algorithm = AesGcm.with256bits();
return await algorithm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: mac),
secretKey: key,
);
} on BackupDecryptionException {
rethrow;
} on Object {
throw const BackupDecryptionException();
}
}
Future<SecretKey> _deriveKey({
required String passphrase,
required List<int> salt,
}) {
final algorithm = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: _kdfIterations,
bits: _keyLength * 8,
);
return algorithm.deriveKey(
secretKey: SecretKey(utf8.encode(passphrase)),
nonce: salt,
);
}
List<int> _randomBytes(int length) {
final random = Random.secure();
return Uint8List.fromList(
List<int>.generate(length, (_) => random.nextInt(256)),
);
}
Future<File> _nextAvailableBackupFile(Directory directory, String name) async {
final preferred = File(_joinPath(directory.path, name));
if (!await preferred.exists()) {
return preferred;
}
final dot = name.lastIndexOf('.');
final stem = dot == -1 ? name : name.substring(0, dot);
final extension = dot == -1 ? '' : name.substring(dot);
var counter = 2;
while (true) {
final candidate =
File(_joinPath(directory.path, '$stem-$counter$extension'));
if (!await candidate.exists()) {
return candidate;
}
counter += 1;
}
}
String _backupTimestamp(DateTime value) {
final local = value.toLocal();
return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_'
'${_two(local.hour)}${_two(local.minute)}';
}
String _four(int value) => value.toString().padLeft(4, '0');
String _two(int value) => value.toString().padLeft(2, '0');
Directory _homeDirectory() {
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
Platform.environment['HOMEDRIVE'];
if (home == null || home.trim().isEmpty) {
throw const BackupException('Home directory could not be resolved.');
}
return Directory(home);
}
String _joinPath(String first, String second, [String? third]) {
final separator = Platform.pathSeparator;
final left = first.endsWith(separator)
? first.substring(0, first.length - separator.length)
: first;
final middle = second.startsWith(separator) ? second.substring(1) : second;
final joined = '$left$separator$middle';
if (third == null) {
return joined;
}
final right = third.startsWith(separator) ? third.substring(1) : third;
return '$joined$separator$right';
}

View file

@ -0,0 +1,15 @@
part of '../encrypted_backup.dart';
/// Thrown when an encrypted backup cannot be decrypted with the passphrase.
final class BackupDecryptionException implements Exception {
/// Creates a decryption failure with optional diagnostic [message].
const BackupDecryptionException([
this.message = 'Backup could not be decrypted.',
]);
/// Diagnostic text for logs and tests.
final String message;
@override
String toString() => 'BackupDecryptionException: $message';
}

View file

@ -0,0 +1,79 @@
part of '../encrypted_backup.dart';
Future<List<int>> _encryptBytes({
required String passphrase,
required List<int> plaintext,
}) async {
final salt = _randomBytes(_saltLength);
final nonce = _randomBytes(_nonceLength);
final key = await _deriveKey(passphrase: passphrase, salt: salt);
final algorithm = AesGcm.with256bits();
final secretBox = await algorithm.encrypt(
plaintext,
secretKey: key,
nonce: nonce,
);
final envelope = <String, Object?>{
'magic': _formatMagic,
'kdf': 'pbkdf2-hmac-sha256',
'iterations': _kdfIterations,
'cipher': 'aes-256-gcm',
'salt': base64Encode(salt),
'nonce': base64Encode(secretBox.nonce),
'ciphertext': base64Encode(secretBox.cipherText),
'mac': base64Encode(secretBox.mac.bytes),
};
return _utf8.convert(jsonEncode(envelope));
}
Future<List<int>> _decryptBytes({
required String passphrase,
required List<int> encoded,
}) async {
try {
final envelope = jsonDecode(utf8.decode(encoded)) as Map<String, Object?>;
if (envelope['magic'] != _formatMagic ||
envelope['kdf'] != 'pbkdf2-hmac-sha256' ||
envelope['iterations'] != _kdfIterations ||
envelope['cipher'] != 'aes-256-gcm') {
throw const BackupDecryptionException('Unsupported backup format.');
}
final salt = base64Decode(envelope['salt'] as String);
final nonce = base64Decode(envelope['nonce'] as String);
final cipherText = base64Decode(envelope['ciphertext'] as String);
final mac = Mac(base64Decode(envelope['mac'] as String));
final key = await _deriveKey(passphrase: passphrase, salt: salt);
final algorithm = AesGcm.with256bits();
return await algorithm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: mac),
secretKey: key,
);
} on BackupDecryptionException {
rethrow;
} on Object {
throw const BackupDecryptionException();
}
}
Future<SecretKey> _deriveKey({
required String passphrase,
required List<int> salt,
}) {
final algorithm = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: _kdfIterations,
bits: _keyLength * 8,
);
return algorithm.deriveKey(
secretKey: SecretKey(utf8.encode(passphrase)),
nonce: salt,
);
}
List<int> _randomBytes(int length) {
final random = Random.secure();
return Uint8List.fromList(
List<int>.generate(length, (_) => random.nextInt(256)),
);
}

View file

@ -0,0 +1,13 @@
part of '../encrypted_backup.dart';
/// Thrown when backup input paths or file contents are invalid.
final class BackupException implements Exception {
/// Creates a backup exception with diagnostic [message].
const BackupException(this.message);
/// Diagnostic text for logs and tests.
final String message;
@override
String toString() => 'BackupException: $message';
}

View file

@ -0,0 +1,30 @@
part of '../encrypted_backup.dart';
Future<File> _nextAvailableBackupFile(Directory directory, String name) async {
final preferred = File(_joinPath(directory.path, name));
if (!await preferred.exists()) {
return preferred;
}
final dot = name.lastIndexOf('.');
final stem = dot == -1 ? name : name.substring(0, dot);
final extension = dot == -1 ? '' : name.substring(dot);
var counter = 2;
while (true) {
final candidate =
File(_joinPath(directory.path, '$stem-$counter$extension'));
if (!await candidate.exists()) {
return candidate;
}
counter += 1;
}
}
String _backupTimestamp(DateTime value) {
final local = value.toLocal();
return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_'
'${_two(local.hour)}${_two(local.minute)}';
}
String _four(int value) => value.toString().padLeft(4, '0');
String _two(int value) => value.toString().padLeft(2, '0');

View file

@ -0,0 +1,43 @@
part of '../encrypted_backup.dart';
/// Default scheduler SQLite database path.
File defaultSchedulerSqliteFile() {
return File(_joinPath(
_homeDirectory().path,
defaultSchedulerDirectoryName,
defaultSchedulerDatabaseFileName,
));
}
/// Default encrypted backup output directory.
Directory defaultSchedulerBackupDirectory() {
return Directory(_joinPath(
_homeDirectory().path,
defaultSchedulerDirectoryName,
defaultSchedulerBackupDirectoryName,
));
}
Directory _homeDirectory() {
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
Platform.environment['HOMEDRIVE'];
if (home == null || home.trim().isEmpty) {
throw const BackupException('Home directory could not be resolved.');
}
return Directory(home);
}
String _joinPath(String first, String second, [String? third]) {
final separator = Platform.pathSeparator;
final left = first.endsWith(separator)
? first.substring(0, first.length - separator.length)
: first;
final middle = second.startsWith(separator) ? second.substring(1) : second;
final joined = '$left$separator$middle';
if (third == null) {
return joined;
}
final right = third.startsWith(separator) ? third.substring(1) : third;
return '$joined$separator$right';
}

View file

@ -0,0 +1,64 @@
part of '../encrypted_backup.dart';
/// Create an encrypted backup of the scheduler SQLite file.
///
/// Defaults:
/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite`
/// - Backup dir: `~/ADHD_Scheduler/backups`
/// - File name: `yyyymmdd_hhmm.db.enc`
Future<File> createEncryptedBackup({
required String passphrase,
File? sqliteFile,
Directory? backupDirectory,
DateTime? now,
}) async {
final source = sqliteFile ?? defaultSchedulerSqliteFile();
if (!await source.exists()) {
throw BackupException('SQLite file does not exist: ${source.path}');
}
final destinationDirectory =
backupDirectory ?? defaultSchedulerBackupDirectory();
await destinationDirectory.create(recursive: true);
final timestamp = _backupTimestamp(now ?? DateTime.now());
final destination = await _nextAvailableBackupFile(
destinationDirectory,
'$timestamp$_fileExtension',
);
final plaintext = await source.readAsBytes();
final encoded = await _encryptBytes(
passphrase: passphrase,
plaintext: plaintext,
);
return destination.writeAsBytes(encoded, flush: true);
}
/// Restore an encrypted backup over the scheduler SQLite file.
///
/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and
/// tools can pass [targetFile] to restore into a temp or alternate database.
Future<void> restoreEncryptedBackup(
File backup,
String passphrase, {
File? targetFile,
}) async {
if (!await backup.exists()) {
throw BackupException('Backup file does not exist: ${backup.path}');
}
final plaintext = await _decryptBytes(
passphrase: passphrase,
encoded: await backup.readAsBytes(),
);
final target = targetFile ?? defaultSchedulerSqliteFile();
await target.parent.create(recursive: true);
final temporary = File('${target.path}.restore.tmp');
await temporary.writeAsBytes(plaintext, flush: true);
if (await target.exists()) {
await target.delete();
}
await temporary.rename(target.path);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
part of '../application_commands.dart';
/// Draft row for creating one child task from an application command.
class ApplicationChildTaskDraft {
const ApplicationChildTaskDraft({
required this.title,
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
this.durationMinutes,
this.projectId,
});
/// User-entered child title.
final String title;
/// Optional child priority.
final PriorityLevel? priority;
/// Optional reward estimate.
final RewardLevel reward;
/// Optional difficulty estimate.
final DifficultyLevel difficulty;
/// Optional duration estimate.
final int? durationMinutes;
/// Optional project override. Null inherits from the parent.
final String? projectId;
}

View file

@ -0,0 +1,22 @@
part of '../application_commands.dart';
/// Stable V1 application command identifiers.
enum ApplicationCommandCode {
quickCaptureToBacklog,
quickCaptureToNextAvailableSlot,
scheduleBacklogItemToNextAvailableSlot,
pushFlexibleToNextAvailableSlot,
pushFlexibleToTomorrowTopOfQueue,
moveFlexibleToBacklog,
completeFlexibleTask,
uncompleteTask,
applyRequiredTaskAction,
logSurpriseTask,
createProtectedFreeSlot,
updateProtectedFreeSlot,
removeProtectedFreeSlot,
breakUpTask,
completeChildTask,
completeParentTask,
completeParentFromChild,
}

View file

@ -0,0 +1,30 @@
part of '../application_commands.dart';
/// Hint for read models that should be refreshed after a command.
class ApplicationCommandReadHint {
ApplicationCommandReadHint({
CivilDate? localDate,
Iterable<CivilDate> affectedDates = const <CivilDate>[],
Iterable<String> affectedTaskIds = const <String>[],
this.refreshToday = true,
}) : localDate = localDate,
affectedDates = List<CivilDate>.unmodifiable(
{
if (localDate != null) localDate,
...affectedDates,
},
),
affectedTaskIds = List<String>.unmodifiable(affectedTaskIds);
/// Primary local date for a Today refresh, when known.
final CivilDate? localDate;
/// All local dates touched by the command.
final List<CivilDate> affectedDates;
/// Task ids changed or created by the command.
final List<String> affectedTaskIds;
/// Whether Today-style read models should be refreshed.
final bool refreshToday;
}

View file

@ -0,0 +1,69 @@
part of '../application_commands.dart';
/// Structured result returned by V1 application commands.
class ApplicationCommandResult {
ApplicationCommandResult({
required this.commandCode,
required String operationId,
required List<Task> changedTasks,
required this.readHint,
List<TaskActivity> activities = const <TaskActivity>[],
List<ProjectStatistics> projectStatistics = const <ProjectStatistics>[],
List<SchedulingNotice> notices = const <SchedulingNotice>[],
List<SchedulingChange> changes = const <SchedulingChange>[],
List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
List<ProtectedFreeSlotConflict> protectedFreeSlotConflicts =
const <ProtectedFreeSlotConflict>[],
List<String> childTaskIds = const <String>[],
this.schedulingResult,
}) : operationId = _requiredTrimmed(operationId, 'operationId'),
changedTasks = List<Task>.unmodifiable(changedTasks),
activities = List<TaskActivity>.unmodifiable(activities),
projectStatistics = List<ProjectStatistics>.unmodifiable(
projectStatistics,
),
notices = List<SchedulingNotice>.unmodifiable(notices),
changes = List<SchedulingChange>.unmodifiable(changes),
overlaps = List<SchedulingOverlap>.unmodifiable(overlaps),
protectedFreeSlotConflicts =
List<ProtectedFreeSlotConflict>.unmodifiable(
protectedFreeSlotConflicts,
),
childTaskIds = List<String>.unmodifiable(childTaskIds);
/// Command that produced this result.
final ApplicationCommandCode commandCode;
/// Exactly-once operation id.
final String operationId;
/// Tasks inserted or changed by the command.
final List<Task> changedTasks;
/// New internal activities persisted with the command.
final List<TaskActivity> activities;
/// Project aggregates updated by completion activities.
final List<ProjectStatistics> projectStatistics;
/// Scheduling notices returned by domain services.
final List<SchedulingNotice> notices;
/// Scheduling movement records returned by domain services.
final List<SchedulingChange> changes;
/// Scheduling overlaps returned by domain services.
final List<SchedulingOverlap> overlaps;
/// Protected rest conflicts caused by an explicit required placement.
final List<ProtectedFreeSlotConflict> protectedFreeSlotConflicts;
/// Child task ids created or affected by child-task commands.
final List<String> childTaskIds;
/// Full scheduling result when the command invoked the scheduler.
final SchedulingResult? schedulingResult;
/// Read-model refresh hint for UI/state management.
final ApplicationCommandReadHint readHint;
}

View file

@ -0,0 +1,252 @@
part of '../application_commands.dart';
class _PlanningState {
_PlanningState({
required List<Task> tasks,
required this.window,
required List<TimeInterval> lockedIntervals,
}) : tasks = List<Task>.unmodifiable(tasks),
lockedIntervals = List<TimeInterval>.unmodifiable(lockedIntervals);
final List<Task> tasks;
final SchedulingWindow window;
final List<TimeInterval> lockedIntervals;
SchedulingInput get input {
return SchedulingInput(
tasks: tasks,
window: window,
lockedIntervals: lockedIntervals,
);
}
_PlanningState withTask(Task task) {
if (tasks.any((existing) => existing.id == task.id)) {
return this;
}
return _PlanningState(
tasks: [task, ...tasks],
window: window,
lockedIntervals: lockedIntervals,
);
}
_PlanningState replaceTask(Task replacement) {
return _PlanningState(
tasks: _replaceTask(tasks, replacement),
window: window,
lockedIntervals: lockedIntervals,
);
}
}
ApplicationResult<ApplicationCommandResult> _failure(
ApplicationFailureCode code, {
String? entityId,
String? detailCode,
}) {
return ApplicationResult.failure(
ApplicationFailure(
code: code,
entityId: entityId,
detailCode: detailCode,
),
);
}
ApplicationResult<ApplicationCommandResult> _failureForQuickCapture(
QuickCaptureResult result,
) {
final schedulingResult = result.schedulingResult;
if (schedulingResult != null) {
final failure = _failureForSchedulingResult(
schedulingResult,
entityId: result.task.id,
);
if (failure != null) {
return ApplicationResult.failure(failure);
}
}
return _failure(
ApplicationFailureCode.validation,
entityId: result.task.id,
detailCode: result.status.name,
);
}
ApplicationFailure? _failureForSchedulingResult(
SchedulingResult result, {
required String entityId,
}) {
return switch (result.outcomeCode) {
SchedulingOutcomeCode.success || SchedulingOutcomeCode.noOp => null,
SchedulingOutcomeCode.conflict => null,
SchedulingOutcomeCode.notFound => ApplicationFailure(
code: ApplicationFailureCode.notFound,
entityId: entityId,
detailCode: result.outcomeCode.name,
),
SchedulingOutcomeCode.noSlot ||
SchedulingOutcomeCode.overflow =>
ApplicationFailure(
code: ApplicationFailureCode.noSlot,
entityId: entityId,
detailCode: result.outcomeCode.name,
),
SchedulingOutcomeCode.invalidState => ApplicationFailure(
code: ApplicationFailureCode.conflict,
entityId: entityId,
detailCode: result.outcomeCode.name,
),
};
}
ApplicationFailure? _staleFailure(Task task, DateTime? expectedUpdatedAt) {
if (expectedUpdatedAt == null ||
_sameNullableInstant(task.updatedAt, expectedUpdatedAt)) {
return null;
}
return ApplicationFailure(
code: ApplicationFailureCode.staleRevision,
entityId: task.id,
);
}
List<TaskActivity> _activitiesForSchedulingChanges({
required SchedulingResult? result,
required List<Task> beforeTasks,
required String operationId,
required DateTime occurredAt,
required String selectedTaskId,
required TaskActivityCode selectedActivityCode,
required TaskActivityCode movedActivityCode,
}) {
if (result == null || result.outcomeCode != SchedulingOutcomeCode.success) {
return const <TaskActivity>[];
}
final activities = <TaskActivity>[];
for (final change in result.changes) {
final original = _taskById(beforeTasks, change.taskId);
if (original == null) {
continue;
}
final code = change.taskId == selectedTaskId
? selectedActivityCode
: movedActivityCode;
activities.add(
TaskActivity(
id: '$operationId:${change.taskId}:${code.name}',
operationId: operationId,
code: code,
taskId: change.taskId,
projectId: original.projectId,
occurredAt: occurredAt,
metadata: {
'previousStatus': original.status.name,
'nextStatus': _nextStatusForChange(original, code).name,
'previousStart': change.previousStart,
'previousEnd': change.previousEnd,
'nextStart': change.nextStart,
'nextEnd': change.nextEnd,
},
),
);
}
return List<TaskActivity>.unmodifiable(activities);
}
TaskStatus _nextStatusForChange(Task task, TaskActivityCode code) {
if (code == TaskActivityCode.restoredFromBacklog) {
return TaskStatus.planned;
}
if (code == TaskActivityCode.movedToBacklog) {
return TaskStatus.backlog;
}
return task.status;
}
List<Task> _changedTasks(List<Task> beforeTasks, List<Task> afterTasks) {
final beforeById = {
for (final task in beforeTasks) task.id: task,
};
final changed = <Task>[];
for (final task in afterTasks) {
final before = beforeById[task.id];
if (before == null || _taskChanged(before, task)) {
changed.add(task);
}
}
return List<Task>.unmodifiable(changed);
}
List<String> _changedTaskIds(List<Task> beforeTasks, List<Task> afterTasks) {
return _taskIdsFromTasks(_changedTasks(beforeTasks, afterTasks));
}
bool _taskChanged(Task before, Task after) {
return before.title != after.title ||
before.projectId != after.projectId ||
before.type != after.type ||
before.status != after.status ||
before.priority != after.priority ||
before.reward != after.reward ||
before.difficulty != after.difficulty ||
before.durationMinutes != after.durationMinutes ||
!_sameNullableInstant(before.scheduledStart, after.scheduledStart) ||
!_sameNullableInstant(before.scheduledEnd, after.scheduledEnd) ||
!_sameNullableInstant(before.actualStart, after.actualStart) ||
!_sameNullableInstant(before.actualEnd, after.actualEnd) ||
!_sameNullableInstant(before.completedAt, after.completedAt) ||
before.parentTaskId != after.parentTaskId ||
!_sameNullableInstant(before.updatedAt, after.updatedAt) ||
before.stats != after.stats;
}
bool _sameNullableInstant(DateTime? first, DateTime? second) {
if (first == null || second == null) {
return first == null && second == null;
}
return first.isAtSameMomentAs(second);
}
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}
List<Task> _replaceTask(List<Task> tasks, Task replacement) {
return tasks
.map((task) => task.id == replacement.id ? replacement : task)
.toList(growable: false);
}
List<String> _taskIdsFromChanges(List<SchedulingChange> changes) {
return List<String>.unmodifiable(
changes.map((change) => change.taskId).toSet(),
);
}
List<String> _taskIdsFromTasks(List<Task> tasks) {
return List<String>.unmodifiable(tasks.map((task) => task.id).toSet());
}
String _requiredTrimmed(String value, String name) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw ArgumentError.value(value, name, 'Value is required.');
}
return trimmed;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
part of '../application_layer.dart';
/// Typed failure returned by application commands and queries.
class ApplicationFailure {
const ApplicationFailure({
required this.code,
this.entityId,
this.detailCode,
});
/// Stable machine-readable category.
final ApplicationFailureCode code;
/// Optional stable entity id related to the failure.
final String? entityId;
/// Optional narrower stable reason, such as a domain validation code name.
final String? detailCode;
}

View file

@ -0,0 +1,13 @@
part of '../application_layer.dart';
/// Stable application-layer result categories.
enum ApplicationFailureCode {
validation,
notFound,
conflict,
staleRevision,
noSlot,
duplicateOperation,
persistenceFailure,
unexpected,
}

View file

@ -0,0 +1,9 @@
part of '../application_layer.dart';
/// Exception for expected failures raised inside a unit-of-work callback.
class ApplicationFailureException implements Exception {
const ApplicationFailureException(this.failure);
/// Failure to return from the application boundary.
final ApplicationFailure failure;
}

View file

@ -0,0 +1,44 @@
part of '../application_layer.dart';
/// Per-operation application context.
class ApplicationOperationContext {
ApplicationOperationContext({
required String operationId,
required this.now,
required this.ownerTimeZone,
required this.clock,
required this.idGenerator,
}) : operationId = _requiredTrimmed(operationId, 'operationId');
/// Convenience constructor that captures [Clock.now] once.
factory ApplicationOperationContext.start({
required String operationId,
required OwnerTimeZoneContext ownerTimeZone,
required Clock clock,
required IdGenerator idGenerator,
}) {
return ApplicationOperationContext(
operationId: operationId,
now: clock.now(),
ownerTimeZone: ownerTimeZone,
clock: clock,
idGenerator: idGenerator,
);
}
/// Exactly-once key for this user command.
final String operationId;
/// Instant used by the operation. Use this instead of repeatedly reading the
/// clock during one command.
final DateTime now;
/// Owner scope and time-zone data for date-sensitive rules.
final OwnerTimeZoneContext ownerTimeZone;
/// Injected clock available to lower-level services that need one.
final Clock clock;
/// Injected ID generator for records created by the operation.
final IdGenerator idGenerator;
}

View file

@ -0,0 +1,25 @@
part of '../application_layer.dart';
/// Persisted exactly-once operation record.
class ApplicationOperationRecord {
ApplicationOperationRecord({
required String operationId,
required String ownerId,
required String operationName,
required this.committedAt,
}) : operationId = _requiredTrimmed(operationId, 'operationId'),
ownerId = _requiredTrimmed(ownerId, 'ownerId'),
operationName = _requiredTrimmed(operationName, 'operationName');
/// Exactly-once operation id.
final String operationId;
/// Authorization-neutral owner scope.
final String ownerId;
/// Stable use-case/command label.
final String operationName;
/// Commit instant.
final DateTime committedAt;
}

View file

@ -0,0 +1,17 @@
part of '../application_layer.dart';
/// Repository contract for exactly-once operation records.
abstract interface class ApplicationOperationRepository {
Future<ApplicationOperationRecord?> findById(String operationId);
Future<ApplicationOperationRecord?> findByIdempotencyKey({
required String ownerId,
required String operationId,
});
Future<void> save(ApplicationOperationRecord operation);
Future<RepositoryMutationResult> insertIfAbsent(
ApplicationOperationRecord operation,
);
}

View file

@ -0,0 +1,6 @@
part of '../application_layer.dart';
/// Exception representing persistence/driver failures at repository boundaries.
class ApplicationPersistenceException implements Exception {
const ApplicationPersistenceException();
}

View file

@ -0,0 +1,40 @@
part of '../application_layer.dart';
/// Result wrapper for expected application success/failure paths.
class ApplicationResult<T> {
const ApplicationResult._({
this.value,
this.failure,
});
/// Successful result carrying [value].
factory ApplicationResult.success(T value) {
return ApplicationResult._(value: value);
}
/// Expected failure result.
factory ApplicationResult.failure(ApplicationFailure failure) {
return ApplicationResult._(failure: failure);
}
/// Successful value, if any.
final T? value;
/// Typed failure, if the operation did not succeed.
final ApplicationFailure? failure;
/// Whether the operation succeeded.
bool get isSuccess => failure == null;
/// Whether the operation failed with a typed application failure.
bool get isFailure => failure != null;
/// Return the value or throw if this result is a failure.
T get requireValue {
if (failure != null) {
throw StateError(
'Application result is a failure: ${failure!.code.name}');
}
return value as T;
}
}

View file

@ -0,0 +1,30 @@
part of '../application_layer.dart';
/// Repository loader for common scheduler input assembly.
class ApplicationSchedulingLoader {
const ApplicationSchedulingLoader(this.repositories);
/// Repositories for the current application operation.
final ApplicationUnitOfWorkRepositories repositories;
/// Load scheduler input for [window] from the repository boundary.
///
/// UI code should not persist partial [SchedulingResult] objects. Commands
/// should load authoritative state here, invoke domain services, then commit
/// resulting entities through a unit of work.
Future<SchedulingInput> loadInputForWindow({
required SchedulingWindow window,
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
}) async {
final scheduledTasks = await repositories.tasks.findScheduledInWindow(
window,
);
return SchedulingInput(
tasks: scheduledTasks,
window: window,
lockedIntervals: lockedIntervals,
requiredVisibleIntervals: requiredVisibleIntervals,
);
}
}

View file

@ -0,0 +1,18 @@
part of '../application_layer.dart';
/// Atomic application transaction boundary.
abstract interface class ApplicationUnitOfWork {
Future<ApplicationResult<T>> run<T>({
required ApplicationOperationContext context,
required String operationName,
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
});
Future<ApplicationResult<T>> read<T>({
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
});
}

View file

@ -0,0 +1,22 @@
part of '../application_layer.dart';
/// Repository set available inside one application unit of work.
abstract interface class ApplicationUnitOfWorkRepositories {
TaskRepository get tasks;
ProjectRepository get projects;
LockedBlockRepository get lockedBlocks;
SchedulingSnapshotRepository get schedulingSnapshots;
TaskActivityRepository get taskActivities;
ProjectStatisticsRepository get projectStatistics;
OwnerSettingsRepository get ownerSettings;
NoticeAcknowledgementRepository get noticeAcknowledgements;
ApplicationOperationRepository get operations;
}

View file

@ -0,0 +1,72 @@
part of '../application_layer.dart';
class _InMemoryApplicationRepositories
implements ApplicationUnitOfWorkRepositories {
_InMemoryApplicationRepositories(_InMemoryApplicationState state)
: tasks = _UnitOfWorkTaskRepository(
tasksById: state.tasksById,
ownersById: state.taskOwnersById,
revisionsById: state.taskRevisionsById,
),
projects = _UnitOfWorkProjectRepository(
projectsById: state.projectsById,
ownersById: state.projectOwnersById,
revisionsById: state.projectRevisionsById,
),
lockedBlocks = _UnitOfWorkLockedBlockRepository(
blocksById: state.lockedBlocksById,
blockOwnersById: state.lockedBlockOwnersById,
blockRevisionsById: state.lockedBlockRevisionsById,
overridesById: state.lockedOverridesById,
overrideOwnersById: state.lockedOverrideOwnersById,
overrideRevisionsById: state.lockedOverrideRevisionsById,
),
schedulingSnapshots = _UnitOfWorkSchedulingSnapshotRepository(
state.schedulingSnapshotsById,
),
taskActivities = _UnitOfWorkTaskActivityRepository(
activitiesById: state.taskActivitiesById,
ownersById: state.taskActivityOwnersById,
),
projectStatistics = _UnitOfWorkProjectStatisticsRepository(
statisticsByProjectId: state.projectStatisticsByProjectId,
ownersByProjectId: state.projectStatisticsOwnersByProjectId,
revisionsByProjectId: state.projectStatisticsRevisionsByProjectId,
),
ownerSettings = _UnitOfWorkOwnerSettingsRepository(
settingsByOwnerId: state.ownerSettingsByOwnerId,
revisionsByOwnerId: state.ownerSettingsRevisionsByOwnerId,
),
noticeAcknowledgements = _UnitOfWorkNoticeAcknowledgementRepository(
recordsByScopedId: state.noticeAcknowledgementsByScopedId,
revisionsByScopedId: state.noticeAcknowledgementRevisionsByScopedId,
),
operations = _UnitOfWorkOperationRepository(state.operationsById);
@override
final TaskRepository tasks;
@override
final ProjectRepository projects;
@override
final LockedBlockRepository lockedBlocks;
@override
final SchedulingSnapshotRepository schedulingSnapshots;
@override
final TaskActivityRepository taskActivities;
@override
final ProjectStatisticsRepository projectStatistics;
@override
final OwnerSettingsRepository ownerSettings;
@override
final NoticeAcknowledgementRepository noticeAcknowledgements;
@override
final ApplicationOperationRepository operations;
}

View file

@ -0,0 +1,106 @@
part of '../application_layer.dart';
class _InMemoryApplicationState {
_InMemoryApplicationState({
required this.tasksById,
required this.taskOwnersById,
required this.taskRevisionsById,
required this.projectsById,
required this.projectOwnersById,
required this.projectRevisionsById,
required this.lockedBlocksById,
required this.lockedBlockOwnersById,
required this.lockedBlockRevisionsById,
required this.lockedOverridesById,
required this.lockedOverrideOwnersById,
required this.lockedOverrideRevisionsById,
required this.schedulingSnapshotsById,
required this.taskActivitiesById,
required this.taskActivityOwnersById,
required this.projectStatisticsByProjectId,
required this.projectStatisticsOwnersByProjectId,
required this.projectStatisticsRevisionsByProjectId,
required this.ownerSettingsByOwnerId,
required this.ownerSettingsRevisionsByOwnerId,
required this.noticeAcknowledgementsByScopedId,
required this.noticeAcknowledgementRevisionsByScopedId,
required this.operationsById,
});
final Map<String, Task> tasksById;
final Map<String, String> taskOwnersById;
final Map<String, int> taskRevisionsById;
final Map<String, ProjectProfile> projectsById;
final Map<String, String> projectOwnersById;
final Map<String, int> projectRevisionsById;
final Map<String, LockedBlock> lockedBlocksById;
final Map<String, String> lockedBlockOwnersById;
final Map<String, int> lockedBlockRevisionsById;
final Map<String, LockedBlockOverride> lockedOverridesById;
final Map<String, String> lockedOverrideOwnersById;
final Map<String, int> lockedOverrideRevisionsById;
final Map<String, SchedulingStateSnapshot> schedulingSnapshotsById;
final Map<String, TaskActivity> taskActivitiesById;
final Map<String, String> taskActivityOwnersById;
final Map<String, ProjectStatistics> projectStatisticsByProjectId;
final Map<String, String> projectStatisticsOwnersByProjectId;
final Map<String, int> projectStatisticsRevisionsByProjectId;
final Map<String, OwnerSettings> ownerSettingsByOwnerId;
final Map<String, int> ownerSettingsRevisionsByOwnerId;
final Map<String, NoticeAcknowledgementRecord>
noticeAcknowledgementsByScopedId;
final Map<String, int> noticeAcknowledgementRevisionsByScopedId;
final Map<String, ApplicationOperationRecord> operationsById;
_InMemoryApplicationState copy() {
return _InMemoryApplicationState(
tasksById: Map<String, Task>.of(tasksById),
taskOwnersById: Map<String, String>.of(taskOwnersById),
taskRevisionsById: Map<String, int>.of(taskRevisionsById),
projectsById: Map<String, ProjectProfile>.of(projectsById),
projectOwnersById: Map<String, String>.of(projectOwnersById),
projectRevisionsById: Map<String, int>.of(projectRevisionsById),
lockedBlocksById: Map<String, LockedBlock>.of(lockedBlocksById),
lockedBlockOwnersById: Map<String, String>.of(lockedBlockOwnersById),
lockedBlockRevisionsById: Map<String, int>.of(
lockedBlockRevisionsById,
),
lockedOverridesById:
Map<String, LockedBlockOverride>.of(lockedOverridesById),
lockedOverrideOwnersById: Map<String, String>.of(
lockedOverrideOwnersById,
),
lockedOverrideRevisionsById: Map<String, int>.of(
lockedOverrideRevisionsById,
),
schedulingSnapshotsById:
Map<String, SchedulingStateSnapshot>.of(schedulingSnapshotsById),
taskActivitiesById: Map<String, TaskActivity>.of(taskActivitiesById),
taskActivityOwnersById: Map<String, String>.of(taskActivityOwnersById),
projectStatisticsByProjectId:
Map<String, ProjectStatistics>.of(projectStatisticsByProjectId),
projectStatisticsOwnersByProjectId: Map<String, String>.of(
projectStatisticsOwnersByProjectId,
),
projectStatisticsRevisionsByProjectId: Map<String, int>.of(
projectStatisticsRevisionsByProjectId,
),
ownerSettingsByOwnerId: Map<String, OwnerSettings>.of(
ownerSettingsByOwnerId,
),
ownerSettingsRevisionsByOwnerId: Map<String, int>.of(
ownerSettingsRevisionsByOwnerId,
),
noticeAcknowledgementsByScopedId:
Map<String, NoticeAcknowledgementRecord>.of(
noticeAcknowledgementsByScopedId,
),
noticeAcknowledgementRevisionsByScopedId: Map<String, int>.of(
noticeAcknowledgementRevisionsByScopedId,
),
operationsById: Map<String, ApplicationOperationRecord>.of(
operationsById,
),
);
}
}

View file

@ -0,0 +1,286 @@
part of '../application_layer.dart';
/// In-memory unit-of-work implementation for deterministic application tests.
class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
InMemoryApplicationUnitOfWork({
Iterable<Task> initialTasks = const <Task>[],
Iterable<ProjectProfile> initialProjects = const <ProjectProfile>[],
Iterable<LockedBlock> initialLockedBlocks = const <LockedBlock>[],
Iterable<LockedBlockOverride> initialLockedOverrides =
const <LockedBlockOverride>[],
Iterable<SchedulingStateSnapshot> initialSchedulingSnapshots =
const <SchedulingStateSnapshot>[],
Iterable<TaskActivity> initialTaskActivities = const <TaskActivity>[],
Iterable<ProjectStatistics> initialProjectStatistics =
const <ProjectStatistics>[],
Iterable<OwnerSettings> initialOwnerSettings = const <OwnerSettings>[],
Iterable<NoticeAcknowledgementRecord> initialNoticeAcknowledgements =
const <NoticeAcknowledgementRecord>[],
Iterable<ApplicationOperationRecord> initialOperations =
const <ApplicationOperationRecord>[],
}) : _state = _InMemoryApplicationState(
tasksById: {
for (final task in initialTasks) task.id: task,
},
taskOwnersById: {
for (final task in initialTasks) task.id: 'owner-1',
},
taskRevisionsById: {
for (final task in initialTasks) task.id: 1,
},
projectsById: {
for (final project in initialProjects) project.id: project,
},
projectOwnersById: {
for (final project in initialProjects) project.id: 'owner-1',
},
projectRevisionsById: {
for (final project in initialProjects) project.id: 1,
},
lockedBlocksById: {
for (final block in initialLockedBlocks) block.id: block,
},
lockedBlockOwnersById: {
for (final block in initialLockedBlocks) block.id: 'owner-1',
},
lockedBlockRevisionsById: {
for (final block in initialLockedBlocks) block.id: 1,
},
lockedOverridesById: {
for (final override in initialLockedOverrides)
override.id: override,
},
lockedOverrideOwnersById: {
for (final override in initialLockedOverrides)
override.id: 'owner-1',
},
lockedOverrideRevisionsById: {
for (final override in initialLockedOverrides) override.id: 1,
},
schedulingSnapshotsById: {
for (final snapshot in initialSchedulingSnapshots)
snapshot.id: snapshot,
},
taskActivitiesById: {
for (final activity in initialTaskActivities) activity.id: activity,
},
taskActivityOwnersById: {
for (final activity in initialTaskActivities)
activity.id: 'owner-1',
},
projectStatisticsByProjectId: {
for (final statistics in initialProjectStatistics)
statistics.projectId: statistics,
},
projectStatisticsOwnersByProjectId: {
for (final statistics in initialProjectStatistics)
statistics.projectId: 'owner-1',
},
projectStatisticsRevisionsByProjectId: {
for (final statistics in initialProjectStatistics)
statistics.projectId: 1,
},
ownerSettingsByOwnerId: {
for (final settings in initialOwnerSettings)
settings.ownerId: settings,
},
ownerSettingsRevisionsByOwnerId: {
for (final settings in initialOwnerSettings) settings.ownerId: 1,
},
noticeAcknowledgementsByScopedId: {
for (final acknowledgement in initialNoticeAcknowledgements)
_noticeAcknowledgementKey(
ownerId: acknowledgement.ownerId,
noticeId: acknowledgement.noticeId,
): acknowledgement,
},
noticeAcknowledgementRevisionsByScopedId: {
for (final acknowledgement in initialNoticeAcknowledgements)
_noticeAcknowledgementKey(
ownerId: acknowledgement.ownerId,
noticeId: acknowledgement.noticeId,
): 1,
},
operationsById: {
for (final operation in initialOperations)
operation.operationId: operation,
},
);
_InMemoryApplicationState _state;
bool _failNextCommit = false;
/// Current committed tasks, useful for contract tests.
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 scheduling snapshots, useful for contract tests.
List<SchedulingStateSnapshot> get currentSchedulingSnapshots {
return List<SchedulingStateSnapshot>.unmodifiable(
_state.schedulingSnapshotsById.values,
);
}
/// Current committed activities, useful for contract tests.
List<TaskActivity> get currentTaskActivities {
return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values);
}
/// Current committed project statistics, useful for contract tests.
List<ProjectStatistics> get currentProjectStatistics {
return List<ProjectStatistics>.unmodifiable(
_state.projectStatisticsByProjectId.values,
);
}
/// Current committed owner settings, useful for contract tests.
List<OwnerSettings> get currentOwnerSettings {
return List<OwnerSettings>.unmodifiable(
_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(
_state.operationsById.values,
);
}
/// Cause the next successful transaction to fail at commit time.
void failNextCommitForTesting() {
_failNextCommit = true;
}
@override
Future<ApplicationResult<T>> run<T>({
required ApplicationOperationContext context,
required String operationName,
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
}) async {
if (_state.operationsById.containsKey(context.operationId)) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.duplicateOperation,
entityId: context.operationId,
),
);
}
final staged = _state.copy();
final repositories = _InMemoryApplicationRepositories(staged);
try {
final result = await action(repositories);
if (result.isFailure) {
return result;
}
staged.operationsById[context.operationId] = ApplicationOperationRecord(
operationId: context.operationId,
ownerId: context.ownerTimeZone.ownerId,
operationName: operationName,
committedAt: context.now,
);
if (_failNextCommit) {
_failNextCommit = false;
throw const ApplicationPersistenceException();
}
_state = staged;
return result;
} on ApplicationFailureException catch (error) {
return ApplicationResult.failure(error.failure);
} on DomainValidationException catch (error) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.validation,
detailCode: error.code.name,
),
);
} on ArgumentError catch (error) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.validation,
detailCode: error.name,
),
);
} on ApplicationPersistenceException {
return ApplicationResult.failure(
const ApplicationFailure(
code: ApplicationFailureCode.persistenceFailure,
),
);
} catch (_) {
return ApplicationResult.failure(
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
);
}
}
@override
Future<ApplicationResult<T>> read<T>({
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
}) async {
final staged = _state.copy();
final repositories = _InMemoryApplicationRepositories(staged);
try {
return await action(repositories);
} on ApplicationFailureException catch (error) {
return ApplicationResult.failure(error.failure);
} on DomainValidationException catch (error) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.validation,
detailCode: error.code.name,
),
);
} on ArgumentError catch (error) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.validation,
detailCode: error.name,
),
);
} on ApplicationPersistenceException {
return ApplicationResult.failure(
const ApplicationFailure(
code: ApplicationFailureCode.persistenceFailure,
),
);
} catch (_) {
return ApplicationResult.failure(
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
);
}
}
}

View file

@ -0,0 +1,20 @@
part of '../application_layer.dart';
/// 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;
}

View file

@ -0,0 +1,22 @@
part of '../application_layer.dart';
/// 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<RepositoryPage<NoticeAcknowledgementRecord>> findByOwner({
required String ownerId,
RepositoryPageRequest page = const RepositoryPageRequest(),
});
Future<void> save(NoticeAcknowledgementRecord record);
Future<RepositoryMutationResult> insert(
NoticeAcknowledgementRecord record,
);
}

View file

@ -0,0 +1,62 @@
part of '../application_layer.dart';
/// Owner-level settings needed by application use cases.
class OwnerSettings {
OwnerSettings({
required String ownerId,
required String timeZoneId,
WallTime? dayStart,
WallTime? dayEnd,
this.compactModeEnabled = false,
this.backlogStalenessSettings = const BacklogStalenessSettings(),
}) : ownerId = _requiredTrimmed(ownerId, 'ownerId'),
timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'),
dayStart = dayStart ?? WallTime(hour: 0, minute: 0),
dayEnd = dayEnd ?? WallTime(hour: 23, minute: 59) {
if (this.dayStart.compareTo(this.dayEnd) >= 0) {
throw ArgumentError.value(
{'dayStart': this.dayStart, 'dayEnd': this.dayEnd},
'dayEnd',
'Day end must be after day start.',
);
}
}
/// Authorization-neutral owner scope.
final String ownerId;
/// Owner time-zone id used by date-sensitive application use cases.
final String timeZoneId;
/// Default local day start.
final WallTime dayStart;
/// Default local day end.
final WallTime dayEnd;
/// Persisted compact-mode preference.
final bool compactModeEnabled;
/// Configurable backlog age thresholds.
final BacklogStalenessSettings backlogStalenessSettings;
/// Return a copy with selected settings changed.
OwnerSettings copyWith({
String? ownerId,
String? timeZoneId,
WallTime? dayStart,
WallTime? dayEnd,
bool? compactModeEnabled,
BacklogStalenessSettings? backlogStalenessSettings,
}) {
return OwnerSettings(
ownerId: ownerId ?? this.ownerId,
timeZoneId: timeZoneId ?? this.timeZoneId,
dayStart: dayStart ?? this.dayStart,
dayEnd: dayEnd ?? this.dayEnd,
compactModeEnabled: compactModeEnabled ?? this.compactModeEnabled,
backlogStalenessSettings:
backlogStalenessSettings ?? this.backlogStalenessSettings,
);
}
}

View file

@ -0,0 +1,15 @@
part of '../application_layer.dart';
/// Repository contract for owner settings.
abstract interface class OwnerSettingsRepository {
Future<OwnerSettings?> findByOwnerId(String ownerId);
Future<RepositoryRecord<OwnerSettings>?> findRecordByOwnerId(String ownerId);
Future<void> save(OwnerSettings settings);
Future<RepositoryMutationResult> saveIfRevision({
required OwnerSettings settings,
required int expectedRevision,
});
}

View file

@ -0,0 +1,16 @@
part of '../application_layer.dart';
/// Owner-local time context supplied to application operations.
class OwnerTimeZoneContext {
OwnerTimeZoneContext({
required String ownerId,
required String timeZoneId,
}) : ownerId = _requiredTrimmed(ownerId, 'ownerId'),
timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId');
/// Authorization-neutral owner scope. V1 does not implement accounts.
final String ownerId;
/// IANA-style time-zone identifier chosen by the app boundary.
final String timeZoneId;
}

View file

@ -0,0 +1,20 @@
part of '../application_layer.dart';
/// Repository contract for project statistics aggregates.
abstract interface class ProjectStatisticsRepository {
Future<ProjectStatistics?> findByProjectId(String projectId);
Future<RepositoryRecord<ProjectStatistics>?> findRecordByProjectId(
String projectId,
);
Future<List<ProjectStatistics>> findAll();
Future<void> save(ProjectStatistics statistics);
Future<RepositoryMutationResult> saveIfRevision({
required ProjectStatistics statistics,
required String ownerId,
required int expectedRevision,
});
}

View file

@ -0,0 +1,28 @@
part of '../application_layer.dart';
/// Repository contract for internal task activities.
abstract interface class TaskActivityRepository {
Future<TaskActivity?> findById(String id);
Future<List<TaskActivity>> findByOperationId(String operationId);
Future<List<TaskActivity>> findByTaskId(String taskId);
Future<List<TaskActivity>> findByProjectId(String projectId);
Future<List<TaskActivity>> findAll();
Future<RepositoryPage<TaskActivity>> findByOwner({
required String ownerId,
RepositoryPageRequest page = const RepositoryPageRequest(),
});
Future<void> save(TaskActivity activity);
Future<void> saveAll(Iterable<TaskActivity> activities);
Future<RepositoryMutationResult> append({
required TaskActivity activity,
required String ownerId,
});
}

View file

@ -0,0 +1,254 @@
part of '../application_layer.dart';
class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository {
_UnitOfWorkLockedBlockRepository({
required Map<String, LockedBlock> blocksById,
required Map<String, String> blockOwnersById,
required Map<String, int> blockRevisionsById,
required Map<String, LockedBlockOverride> overridesById,
required Map<String, String> overrideOwnersById,
required Map<String, int> overrideRevisionsById,
}) : _blocksById = blocksById,
_blockOwnersById = blockOwnersById,
_blockRevisionsById = blockRevisionsById,
_overridesById = overridesById,
_overrideOwnersById = overrideOwnersById,
_overrideRevisionsById = overrideRevisionsById;
final Map<String, LockedBlock> _blocksById;
final Map<String, String> _blockOwnersById;
final Map<String, int> _blockRevisionsById;
final Map<String, LockedBlockOverride> _overridesById;
final Map<String, String> _overrideOwnersById;
final Map<String, int> _overrideRevisionsById;
@override
Future<LockedBlock?> findBlockById(String id) async => _blocksById[id];
@override
Future<RepositoryRecord<LockedBlock>?> findBlockRecordById(String id) async {
final block = _blocksById[id];
if (block == null) {
return null;
}
return RepositoryRecord(
value: block,
ownerId: _blockOwnerFor(id),
revision: _blockRevisionFor(id),
archivedAt: block.archivedAt,
);
}
@override
Future<List<LockedBlock>> findAllBlocks() async {
return List<LockedBlock>.unmodifiable(_blocksById.values);
}
@override
Future<RepositoryPage<LockedBlock>> findBlocksByOwner({
required String ownerId,
bool includeArchived = false,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final blocks = _blocksById.values
.where(
(block) =>
_blockOwnerFor(block.id) == ownerId &&
(includeArchived || !block.isArchived),
)
.toList(growable: false)
..sort(_compareLockedBlocks);
return _page(blocks, page);
}
@override
Future<LockedBlockOverride?> findOverrideById(String id) async {
return _overridesById[id];
}
@override
Future<RepositoryRecord<LockedBlockOverride>?> findOverrideRecordById(
String id,
) async {
final override = _overridesById[id];
if (override == null) {
return null;
}
return RepositoryRecord(
value: override,
ownerId: _overrideOwnerFor(id),
revision: _overrideRevisionFor(id),
);
}
@override
Future<List<LockedBlockOverride>> findAllOverrides() async {
return List<LockedBlockOverride>.unmodifiable(_overridesById.values);
}
@override
Future<RepositoryPage<LockedBlockOverride>> findOverridesForDate({
required String ownerId,
required CivilDate date,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final overrides = _overridesById.values
.where(
(override) =>
_overrideOwnerFor(override.id) == ownerId &&
override.date == date,
)
.toList(growable: false)
..sort(_compareLockedOverrides);
return _page(overrides, page);
}
@override
Future<void> saveBlock(LockedBlock block) async {
_blocksById[block.id] = block;
_blockOwnersById.putIfAbsent(block.id, () => 'owner-1');
_blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1;
}
@override
Future<void> saveOverride(LockedBlockOverride override) async {
_overridesById[override.id] = override;
_overrideOwnersById.putIfAbsent(override.id, () => 'owner-1');
_overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1;
}
@override
Future<RepositoryMutationResult> insertBlock({
required LockedBlock block,
required String ownerId,
}) async {
if (_blocksById.containsKey(block.id)) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.duplicateId,
entityId: block.id,
),
);
}
_blocksById[block.id] = block;
_blockOwnersById[block.id] = ownerId;
_blockRevisionsById[block.id] = 1;
return RepositoryMutationResult.success(revision: 1);
}
@override
Future<RepositoryMutationResult> saveBlockIfRevision({
required LockedBlock block,
required String ownerId,
required int expectedRevision,
}) async {
if (!_blocksById.containsKey(block.id) ||
_blockOwnerFor(block.id) != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound,
entityId: block.id,
),
);
}
final actualRevision = _blockRevisionFor(block.id);
if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.staleRevision,
entityId: block.id,
expectedRevision: expectedRevision,
actualRevision: actualRevision,
),
);
}
final nextRevision = actualRevision + 1;
_blocksById[block.id] = block;
_blockOwnersById[block.id] = ownerId;
_blockRevisionsById[block.id] = nextRevision;
return RepositoryMutationResult.success(revision: nextRevision);
}
@override
Future<RepositoryMutationResult> archiveBlock({
required String blockId,
required String ownerId,
required int expectedRevision,
required DateTime archivedAt,
}) async {
final block = _blocksById[blockId];
if (block == null) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound,
entityId: blockId,
),
);
}
return saveBlockIfRevision(
block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt),
ownerId: ownerId,
expectedRevision: expectedRevision,
);
}
@override
Future<RepositoryMutationResult> insertOverride({
required LockedBlockOverride override,
required String ownerId,
}) async {
if (_overridesById.containsKey(override.id)) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.duplicateId,
entityId: override.id,
),
);
}
_overridesById[override.id] = override;
_overrideOwnersById[override.id] = ownerId;
_overrideRevisionsById[override.id] = 1;
return RepositoryMutationResult.success(revision: 1);
}
@override
Future<RepositoryMutationResult> saveOverrideIfRevision({
required LockedBlockOverride override,
required String ownerId,
required int expectedRevision,
}) async {
if (!_overridesById.containsKey(override.id) ||
_overrideOwnerFor(override.id) != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound,
entityId: override.id,
),
);
}
final actualRevision = _overrideRevisionFor(override.id);
if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.staleRevision,
entityId: override.id,
expectedRevision: expectedRevision,
actualRevision: actualRevision,
),
);
}
final nextRevision = actualRevision + 1;
_overridesById[override.id] = override;
_overrideOwnersById[override.id] = ownerId;
_overrideRevisionsById[override.id] = nextRevision;
return RepositoryMutationResult.success(revision: nextRevision);
}
String _blockOwnerFor(String id) => _blockOwnersById[id] ?? 'owner-1';
int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1;
String _overrideOwnerFor(String id) => _overrideOwnersById[id] ?? 'owner-1';
int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1;
}

View file

@ -0,0 +1,74 @@
part of '../application_layer.dart';
class _UnitOfWorkNoticeAcknowledgementRepository
implements NoticeAcknowledgementRepository {
_UnitOfWorkNoticeAcknowledgementRepository({
required Map<String, NoticeAcknowledgementRecord> recordsByScopedId,
required Map<String, int> revisionsByScopedId,
}) : _recordsByScopedId = recordsByScopedId,
_revisionsByScopedId = revisionsByScopedId;
final Map<String, NoticeAcknowledgementRecord> _recordsByScopedId;
final Map<String, int> _revisionsByScopedId;
@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<RepositoryPage<NoticeAcknowledgementRecord>> findByOwner({
required String ownerId,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final records = _recordsByScopedId.values
.where((record) => record.ownerId == ownerId)
.toList(growable: false)
..sort(_compareNoticeAcknowledgements);
return _page(records, page);
}
@override
Future<void> save(NoticeAcknowledgementRecord record) async {
final key = _noticeAcknowledgementKey(
ownerId: record.ownerId,
noticeId: record.noticeId,
);
_recordsByScopedId[key] = record;
_revisionsByScopedId[key] = (_revisionsByScopedId[key] ?? 1) + 1;
}
@override
Future<RepositoryMutationResult> insert(
NoticeAcknowledgementRecord record,
) async {
final key = _noticeAcknowledgementKey(
ownerId: record.ownerId,
noticeId: record.noticeId,
);
if (_recordsByScopedId.containsKey(key)) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.duplicateId,
entityId: record.noticeId,
),
);
}
_recordsByScopedId[key] = record;
_revisionsByScopedId[key] = 1;
return RepositoryMutationResult.success(revision: 1);
}
}

View file

@ -0,0 +1,154 @@
part of '../application_layer.dart';
class _UnitOfWorkOperationRepository implements ApplicationOperationRepository {
_UnitOfWorkOperationRepository(this._operationsById);
final Map<String, ApplicationOperationRecord> _operationsById;
@override
Future<ApplicationOperationRecord?> findById(String operationId) async {
return _operationsById[operationId];
}
@override
Future<ApplicationOperationRecord?> findByIdempotencyKey({
required String ownerId,
required String operationId,
}) async {
final operation = _operationsById[operationId];
if (operation == null || operation.ownerId != ownerId) {
return null;
}
return operation;
}
@override
Future<void> save(ApplicationOperationRecord operation) async {
_operationsById[operation.operationId] = operation;
}
@override
Future<RepositoryMutationResult> insertIfAbsent(
ApplicationOperationRecord operation,
) async {
if (_operationsById.containsKey(operation.operationId)) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.duplicateOperation,
entityId: operation.operationId,
),
);
}
_operationsById[operation.operationId] = operation;
return RepositoryMutationResult.success(revision: 1);
}
}
String _noticeAcknowledgementKey({
required String ownerId,
required String noticeId,
}) {
return '$ownerId::$noticeId';
}
RepositoryPage<T> _page<T>(List<T> sortedItems, RepositoryPageRequest request) {
final offset = int.tryParse(request.cursor ?? '') ?? 0;
final safeOffset = offset.clamp(0, sortedItems.length);
final end = (safeOffset + request.limit).clamp(0, sortedItems.length);
final items = sortedItems.sublist(safeOffset, end);
final nextCursor = end < sortedItems.length ? end.toString() : null;
return RepositoryPage<T>(
items: List<T>.unmodifiable(items),
nextCursor: nextCursor,
);
}
bool _taskOverlapsWindow(Task task, SchedulingWindow window) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return false;
}
return TimeInterval(start: start, end: end).overlaps(window.interval);
}
int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id);
int _compareScheduledTasks(Task left, Task right) {
final leftStart = left.scheduledStart;
final rightStart = right.scheduledStart;
if (leftStart != null && rightStart != null) {
final startComparison = leftStart.compareTo(rightStart);
if (startComparison != 0) {
return startComparison;
}
}
return left.id.compareTo(right.id);
}
int _compareBacklogCandidates(Task left, Task right) {
final createdComparison = left.createdAt.compareTo(right.createdAt);
if (createdComparison != 0) {
return createdComparison;
}
return left.id.compareTo(right.id);
}
int _compareProjects(ProjectProfile left, ProjectProfile right) {
final nameComparison = left.name.compareTo(right.name);
if (nameComparison != 0) {
return nameComparison;
}
return left.id.compareTo(right.id);
}
int _compareLockedBlocks(LockedBlock left, LockedBlock right) {
final nameComparison = left.name.compareTo(right.name);
if (nameComparison != 0) {
return nameComparison;
}
return left.id.compareTo(right.id);
}
int _compareLockedOverrides(
LockedBlockOverride left,
LockedBlockOverride right,
) {
final dateComparison = left.date.compareTo(right.date);
if (dateComparison != 0) {
return dateComparison;
}
final createdComparison = left.createdAt.compareTo(right.createdAt);
if (createdComparison != 0) {
return createdComparison;
}
return left.id.compareTo(right.id);
}
int _compareActivities(TaskActivity left, TaskActivity right) {
final occurredComparison = left.occurredAt.compareTo(right.occurredAt);
if (occurredComparison != 0) {
return occurredComparison;
}
return left.id.compareTo(right.id);
}
int _compareNoticeAcknowledgements(
NoticeAcknowledgementRecord left,
NoticeAcknowledgementRecord right,
) {
final acknowledgedComparison =
left.acknowledgedAt.compareTo(right.acknowledgedAt);
if (acknowledgedComparison != 0) {
return acknowledgedComparison;
}
return left.noticeId.compareTo(right.noticeId);
}
String _requiredTrimmed(String value, String name) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw ArgumentError.value(value, name, 'Value is required.');
}
return trimmed;
}

View file

@ -0,0 +1,70 @@
part of '../application_layer.dart';
class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository {
_UnitOfWorkOwnerSettingsRepository({
required Map<String, OwnerSettings> settingsByOwnerId,
required Map<String, int> revisionsByOwnerId,
}) : _settingsByOwnerId = settingsByOwnerId,
_revisionsByOwnerId = revisionsByOwnerId;
final Map<String, OwnerSettings> _settingsByOwnerId;
final Map<String, int> _revisionsByOwnerId;
@override
Future<OwnerSettings?> findByOwnerId(String ownerId) async {
return _settingsByOwnerId[ownerId];
}
@override
Future<RepositoryRecord<OwnerSettings>?> findRecordByOwnerId(
String ownerId,
) async {
final settings = _settingsByOwnerId[ownerId];
if (settings == null) {
return null;
}
return RepositoryRecord(
value: settings,
ownerId: ownerId,
revision: _revisionsByOwnerId[ownerId] ?? 1,
);
}
@override
Future<void> save(OwnerSettings settings) async {
_settingsByOwnerId[settings.ownerId] = settings;
_revisionsByOwnerId[settings.ownerId] =
(_revisionsByOwnerId[settings.ownerId] ?? 1) + 1;
}
@override
Future<RepositoryMutationResult> saveIfRevision({
required OwnerSettings settings,
required int expectedRevision,
}) async {
final ownerId = settings.ownerId;
if (!_settingsByOwnerId.containsKey(ownerId)) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound,
entityId: ownerId,
),
);
}
final actualRevision = _revisionsByOwnerId[ownerId] ?? 1;
if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.staleRevision,
entityId: ownerId,
expectedRevision: expectedRevision,
actualRevision: actualRevision,
),
);
}
final nextRevision = actualRevision + 1;
_settingsByOwnerId[ownerId] = settings;
_revisionsByOwnerId[ownerId] = nextRevision;
return RepositoryMutationResult.success(revision: nextRevision);
}
}

View file

@ -0,0 +1,140 @@
part of '../application_layer.dart';
class _UnitOfWorkProjectRepository implements ProjectRepository {
_UnitOfWorkProjectRepository({
required Map<String, ProjectProfile> projectsById,
required Map<String, String> ownersById,
required Map<String, int> revisionsById,
}) : _projectsById = projectsById,
_ownersById = ownersById,
_revisionsById = revisionsById;
final Map<String, ProjectProfile> _projectsById;
final Map<String, String> _ownersById;
final Map<String, int> _revisionsById;
@override
Future<ProjectProfile?> findById(String id) async => _projectsById[id];
@override
Future<RepositoryRecord<ProjectProfile>?> findRecordById(String id) async {
final project = _projectsById[id];
if (project == null) {
return null;
}
return RepositoryRecord(
value: project,
ownerId: _ownerFor(id),
revision: _revisionFor(id),
archivedAt: project.archivedAt,
);
}
@override
Future<List<ProjectProfile>> findAll() async {
return List<ProjectProfile>.unmodifiable(_projectsById.values);
}
@override
Future<RepositoryPage<ProjectProfile>> findByOwner({
required String ownerId,
bool includeArchived = false,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final projects = _projectsById.values
.where(
(project) =>
_ownerFor(project.id) == ownerId &&
(includeArchived || !project.isArchived),
)
.toList(growable: false)
..sort(_compareProjects);
return _page(projects, page);
}
@override
Future<void> save(ProjectProfile project) async {
_projectsById[project.id] = project;
_ownersById.putIfAbsent(project.id, () => 'owner-1');
_revisionsById[project.id] = _revisionFor(project.id) + 1;
}
@override
Future<RepositoryMutationResult> insert({
required ProjectProfile project,
required String ownerId,
}) async {
if (_projectsById.containsKey(project.id)) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.duplicateId,
entityId: project.id,
),
);
}
_projectsById[project.id] = project;
_ownersById[project.id] = ownerId;
_revisionsById[project.id] = 1;
return RepositoryMutationResult.success(revision: 1);
}
@override
Future<RepositoryMutationResult> saveIfRevision({
required ProjectProfile project,
required String ownerId,
required int expectedRevision,
}) async {
if (!_projectsById.containsKey(project.id) ||
_ownerFor(project.id) != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound,
entityId: project.id,
),
);
}
final actualRevision = _revisionFor(project.id);
if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.staleRevision,
entityId: project.id,
expectedRevision: expectedRevision,
actualRevision: actualRevision,
),
);
}
final nextRevision = actualRevision + 1;
_projectsById[project.id] = project;
_ownersById[project.id] = ownerId;
_revisionsById[project.id] = nextRevision;
return RepositoryMutationResult.success(revision: nextRevision);
}
@override
Future<RepositoryMutationResult> archive({
required String projectId,
required String ownerId,
required int expectedRevision,
required DateTime archivedAt,
}) async {
final project = _projectsById[projectId];
if (project == null) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound,
entityId: projectId,
),
);
}
return saveIfRevision(
project: project.copyWith(archivedAt: archivedAt),
ownerId: ownerId,
expectedRevision: expectedRevision,
);
}
String _ownerFor(String id) => _ownersById[id] ?? 'owner-1';
int _revisionFor(String id) => _revisionsById[id] ?? 1;
}

View file

@ -0,0 +1,83 @@
part of '../application_layer.dart';
class _UnitOfWorkProjectStatisticsRepository
implements ProjectStatisticsRepository {
_UnitOfWorkProjectStatisticsRepository({
required Map<String, ProjectStatistics> statisticsByProjectId,
required Map<String, String> ownersByProjectId,
required Map<String, int> revisionsByProjectId,
}) : _statisticsByProjectId = statisticsByProjectId,
_ownersByProjectId = ownersByProjectId,
_revisionsByProjectId = revisionsByProjectId;
final Map<String, ProjectStatistics> _statisticsByProjectId;
final Map<String, String> _ownersByProjectId;
final Map<String, int> _revisionsByProjectId;
@override
Future<ProjectStatistics?> findByProjectId(String projectId) async {
return _statisticsByProjectId[projectId];
}
@override
Future<RepositoryRecord<ProjectStatistics>?> findRecordByProjectId(
String projectId,
) async {
final statistics = _statisticsByProjectId[projectId];
if (statistics == null) {
return null;
}
return RepositoryRecord(
value: statistics,
ownerId: _ownersByProjectId[projectId] ?? 'owner-1',
revision: _revisionsByProjectId[projectId] ?? 1,
);
}
@override
Future<List<ProjectStatistics>> findAll() async {
return List<ProjectStatistics>.unmodifiable(_statisticsByProjectId.values);
}
@override
Future<void> save(ProjectStatistics statistics) async {
_statisticsByProjectId[statistics.projectId] = statistics;
_ownersByProjectId.putIfAbsent(statistics.projectId, () => 'owner-1');
_revisionsByProjectId[statistics.projectId] =
(_revisionsByProjectId[statistics.projectId] ?? 1) + 1;
}
@override
Future<RepositoryMutationResult> saveIfRevision({
required ProjectStatistics statistics,
required String ownerId,
required int expectedRevision,
}) async {
final projectId = statistics.projectId;
if (!_statisticsByProjectId.containsKey(projectId) ||
(_ownersByProjectId[projectId] ?? 'owner-1') != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound,
entityId: projectId,
),
);
}
final actualRevision = _revisionsByProjectId[projectId] ?? 1;
if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.staleRevision,
entityId: projectId,
expectedRevision: expectedRevision,
actualRevision: actualRevision,
),
);
}
final nextRevision = actualRevision + 1;
_statisticsByProjectId[projectId] = statistics;
_ownersByProjectId[projectId] = ownerId;
_revisionsByProjectId[projectId] = nextRevision;
return RepositoryMutationResult.success(revision: nextRevision);
}
}

View file

@ -0,0 +1,29 @@
part of '../application_layer.dart';
class _UnitOfWorkSchedulingSnapshotRepository
implements SchedulingSnapshotRepository {
_UnitOfWorkSchedulingSnapshotRepository(this._snapshotsById);
final Map<String, SchedulingStateSnapshot> _snapshotsById;
@override
Future<SchedulingStateSnapshot?> findById(String id) async {
return _snapshotsById[id];
}
@override
Future<List<SchedulingStateSnapshot>> findInWindow(
SchedulingWindow window,
) async {
return List<SchedulingStateSnapshot>.unmodifiable(
_snapshotsById.values.where(
(snapshot) => snapshot.window.interval.overlaps(window.interval),
),
);
}
@override
Future<void> save(SchedulingStateSnapshot snapshot) async {
_snapshotsById[snapshot.id] = snapshot;
}
}

View file

@ -0,0 +1,91 @@
part of '../application_layer.dart';
class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository {
_UnitOfWorkTaskActivityRepository({
required Map<String, TaskActivity> activitiesById,
required Map<String, String> ownersById,
}) : _activitiesById = activitiesById,
_ownersById = ownersById;
final Map<String, TaskActivity> _activitiesById;
final Map<String, String> _ownersById;
@override
Future<TaskActivity?> findById(String id) async => _activitiesById[id];
@override
Future<List<TaskActivity>> findByOperationId(String operationId) async {
return List<TaskActivity>.unmodifiable(
_activitiesById.values.where(
(activity) => activity.operationId == operationId,
),
);
}
@override
Future<List<TaskActivity>> findByTaskId(String taskId) async {
return List<TaskActivity>.unmodifiable(
_activitiesById.values.where((activity) => activity.taskId == taskId),
);
}
@override
Future<List<TaskActivity>> findByProjectId(String projectId) async {
return List<TaskActivity>.unmodifiable(
_activitiesById.values.where(
(activity) => activity.projectId == projectId,
),
);
}
@override
Future<List<TaskActivity>> findAll() async {
return List<TaskActivity>.unmodifiable(_activitiesById.values);
}
@override
Future<RepositoryPage<TaskActivity>> findByOwner({
required String ownerId,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final activities = _activitiesById.values
.where((activity) => _ownerFor(activity.id) == ownerId)
.toList(growable: false)
..sort(_compareActivities);
return _page(activities, page);
}
@override
Future<void> save(TaskActivity activity) async {
_activitiesById[activity.id] = activity;
_ownersById.putIfAbsent(activity.id, () => 'owner-1');
}
@override
Future<void> saveAll(Iterable<TaskActivity> activities) async {
for (final activity in activities) {
_activitiesById[activity.id] = activity;
_ownersById.putIfAbsent(activity.id, () => 'owner-1');
}
}
@override
Future<RepositoryMutationResult> append({
required TaskActivity activity,
required String ownerId,
}) async {
if (_activitiesById.containsKey(activity.id)) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.duplicateId,
entityId: activity.id,
),
);
}
_activitiesById[activity.id] = activity;
_ownersById[activity.id] = ownerId;
return RepositoryMutationResult.success(revision: 1);
}
String _ownerFor(String id) => _ownersById[id] ?? 'owner-1';
}

View file

@ -0,0 +1,212 @@
part of '../application_layer.dart';
class _UnitOfWorkTaskRepository implements TaskRepository {
_UnitOfWorkTaskRepository({
required Map<String, Task> tasksById,
required Map<String, String> ownersById,
required Map<String, int> revisionsById,
}) : _tasksById = tasksById,
_ownersById = ownersById,
_revisionsById = revisionsById;
final Map<String, Task> _tasksById;
final Map<String, String> _ownersById;
final Map<String, int> _revisionsById;
@override
Future<Task?> findById(String id) async => _tasksById[id];
@override
Future<RepositoryRecord<Task>?> findRecordById(String id) async {
final task = _tasksById[id];
if (task == null) {
return null;
}
return RepositoryRecord(
value: task,
ownerId: _ownerFor(id),
revision: _revisionFor(id),
);
}
@override
Future<List<Task>> findAll() async {
return List<Task>.unmodifiable(_tasksById.values);
}
@override
Future<List<Task>> findByStatus(TaskStatus status) async {
return List<Task>.unmodifiable(
_tasksById.values.where((task) => task.status == status),
);
}
@override
Future<RepositoryPage<Task>> findByOwner({
required String ownerId,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final tasks = _tasksById.values
.where((task) => _ownerFor(task.id) == ownerId)
.toList(growable: false)
..sort(_compareTaskIds);
return _page(tasks, page);
}
@override
Future<RepositoryPage<Task>> findByProjectId({
required String ownerId,
required String projectId,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final tasks = _tasksById.values
.where(
(task) =>
_ownerFor(task.id) == ownerId && task.projectId == projectId,
)
.toList(growable: false)
..sort(_compareTaskIds);
return _page(tasks, page);
}
@override
Future<RepositoryPage<Task>> findByParentTaskId({
required String ownerId,
required String parentTaskId,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final tasks = _tasksById.values
.where(
(task) =>
_ownerFor(task.id) == ownerId &&
task.parentTaskId == parentTaskId,
)
.toList(growable: false)
..sort(_compareTaskIds);
return _page(tasks, page);
}
@override
Future<RepositoryPage<Task>> findBacklogCandidates(
BacklogCandidateQuery query,
) async {
final tasks = _tasksById.values
.where(
(task) =>
_ownerFor(task.id) == query.ownerId &&
task.status == TaskStatus.backlog &&
(query.projectId == null || task.projectId == query.projectId) &&
query.tags.every(task.backlogTags.contains),
)
.toList(growable: false)
..sort(_compareBacklogCandidates);
return _page(tasks, query.page);
}
@override
Future<List<Task>> findScheduledInWindow(SchedulingWindow window) async {
return List<Task>.unmodifiable(
_tasksById.values.where((task) {
return _taskOverlapsWindow(task, window);
}),
);
}
@override
Future<RepositoryPage<Task>> findScheduledInWindowForOwner({
required String ownerId,
required SchedulingWindow window,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) async {
final tasks = _tasksById.values
.where(
(task) =>
_ownerFor(task.id) == ownerId &&
_taskOverlapsWindow(task, window),
)
.toList(growable: false)
..sort(_compareScheduledTasks);
return _page(tasks, page);
}
@override
Future<RepositoryPage<Task>> findForLocalDay({
required String ownerId,
required CivilDate localDate,
required SchedulingWindow window,
RepositoryPageRequest page = const RepositoryPageRequest(),
}) {
return findScheduledInWindowForOwner(
ownerId: ownerId,
window: window,
page: page,
);
}
@override
Future<void> save(Task task) async {
_tasksById[task.id] = task;
_ownersById.putIfAbsent(task.id, () => 'owner-1');
_revisionsById[task.id] = _revisionFor(task.id) + 1;
}
@override
Future<void> saveAll(Iterable<Task> tasks) async {
for (final task in tasks) {
await save(task);
}
}
@override
Future<RepositoryMutationResult> insert({
required Task task,
required String ownerId,
}) async {
if (_tasksById.containsKey(task.id)) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.duplicateId,
entityId: task.id,
),
);
}
_tasksById[task.id] = task;
_ownersById[task.id] = ownerId;
_revisionsById[task.id] = 1;
return RepositoryMutationResult.success(revision: 1);
}
@override
Future<RepositoryMutationResult> saveIfRevision({
required Task task,
required String ownerId,
required int expectedRevision,
}) async {
if (!_tasksById.containsKey(task.id) || _ownerFor(task.id) != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound, entityId: task.id),
);
}
final actualRevision = _revisionFor(task.id);
if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.staleRevision,
entityId: task.id,
expectedRevision: expectedRevision,
actualRevision: actualRevision,
),
);
}
final nextRevision = actualRevision + 1;
_tasksById[task.id] = task;
_ownersById[task.id] = ownerId;
_revisionsById[task.id] = nextRevision;
return RepositoryMutationResult.success(revision: nextRevision);
}
String _ownerFor(String id) => _ownersById[id] ?? 'owner-1';
int _revisionFor(String id) => _revisionsById[id] ?? 1;
}

View file

@ -14,765 +14,19 @@ 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.findByOwner(
ownerId: request.context.ownerTimeZone.ownerId,
includeArchived: request.includeArchived,
))
.items;
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;
}
part 'application_management/application_management_command_code.dart';
part 'application_management/owner_settings_warning_code.dart';
part 'application_management/get_backlog_request.dart';
part 'application_management/backlog_item_read_model.dart';
part 'application_management/backlog_query_result.dart';
part 'application_management/get_project_defaults_request.dart';
part 'application_management/project_defaults_query_result.dart';
part 'application_management/project_profile_draft.dart';
part 'application_management/project_profile_update.dart';
part 'application_management/locked_block_draft.dart';
part 'application_management/locked_block_update.dart';
part 'application_management/owner_settings_update_result.dart';
part 'application_management/owner_settings_update.dart';
part 'application_management/notice_acknowledgement_result.dart';
part 'application_management/v1_application_management_use_cases.dart';
part 'application_management/parsed_notice_id.dart';

View file

@ -0,0 +1,16 @@
part of '../application_management.dart';
/// Stable management command identifiers.
enum ApplicationManagementCommandCode {
createProject,
updateProject,
archiveProject,
createLockedBlock,
updateLockedBlock,
archiveLockedBlock,
addLockedBlockOverride,
removeLockedBlockOccurrence,
replaceLockedBlockOccurrence,
updateOwnerSettings,
acknowledgeNotice,
}

View file

@ -0,0 +1,15 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,19 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,19 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,15 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,24 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,22 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,15 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,18 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,16 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,7 @@
part of '../application_management.dart';
/// Typed warning codes for settings changes that reinterpret local dates/times.
enum OwnerSettingsWarningCode {
timeZoneChanged,
planningWindowChanged,
}

View file

@ -0,0 +1,11 @@
part of '../application_management.dart';
class _ParsedNoticeId {
const _ParsedNoticeId({
required this.snapshotId,
required this.noticeIndex,
});
final String snapshotId;
final int noticeIndex;
}

View file

@ -0,0 +1,15 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,24 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,24 @@
part of '../application_management.dart';
/// 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;
}

View file

@ -0,0 +1,518 @@
part of '../application_management.dart';
/// 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.findByOwner(
ownerId: request.context.ownerTimeZone.ownerId,
includeArchived: request.includeArchived,
))
.items;
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,
);
}

View file

@ -14,398 +14,7 @@ import 'repositories.dart';
import 'scheduling_engine.dart';
import 'task_lifecycle.dart';
import 'time_contracts.dart';
/// Stable app-open recovery outcomes.
enum AppOpenRecoveryOutcome {
rolledOver,
noUnfinishedFlexibleTasks,
alreadyProcessed,
}
/// Request for explicit app-open/start-day rollover recovery.
class RunAppOpenRecoveryRequest {
const RunAppOpenRecoveryRequest({
required this.context,
required this.sourceDate,
this.targetDate,
});
/// Atomic operation context.
final ApplicationOperationContext context;
/// Owner-local day to inspect for unfinished flexible work.
final CivilDate sourceDate;
/// Owner-local target day. Defaults to the next local day.
final CivilDate? targetDate;
/// Resolved target day.
CivilDate get resolvedTargetDate => targetDate ?? sourceDate.addDays(1);
}
/// Result returned by app-open recovery.
class AppOpenRecoveryResult {
AppOpenRecoveryResult({
required this.outcome,
required this.sourceDate,
required this.targetDate,
required this.snapshot,
required List<Task> changedTasks,
required List<TaskActivity> activities,
required List<SchedulingNotice> notices,
required List<SchedulingChange> changes,
}) : changedTasks = List<Task>.unmodifiable(changedTasks),
activities = List<TaskActivity>.unmodifiable(activities),
notices = List<SchedulingNotice>.unmodifiable(notices),
changes = List<SchedulingChange>.unmodifiable(changes);
/// High-level deterministic outcome.
final AppOpenRecoveryOutcome outcome;
/// Owner-local source day that was checked.
final CivilDate sourceDate;
/// Owner-local target day used for placement and notice surfacing.
final CivilDate targetDate;
/// Persisted deterministic source-date snapshot.
final SchedulingStateSnapshot snapshot;
/// Tasks changed by this operation.
final List<Task> changedTasks;
/// Movement activities persisted with changed tasks.
final List<TaskActivity> activities;
/// Notices returned to the caller.
final List<SchedulingNotice> notices;
/// Machine-readable movement records.
final List<SchedulingChange> changes;
}
/// Explicit app-open recovery facade.
class V1AppOpenRecoveryUseCases {
const V1AppOpenRecoveryUseCases({
required this.applicationStore,
required this.timeZoneResolver,
this.resolutionOptions = const TimeZoneResolutionOptions(),
this.schedulingEngine = const SchedulingEngine(),
});
/// Atomic application boundary.
final ApplicationUnitOfWork applicationStore;
/// Explicit owner-local date resolver.
final TimeZoneResolver timeZoneResolver;
/// DST/nonexistent/repeated local time policy.
final TimeZoneResolutionOptions resolutionOptions;
/// Pure scheduling engine.
final SchedulingEngine schedulingEngine;
/// Run source-day rollover at most once for one owner/source-date key.
Future<ApplicationResult<AppOpenRecoveryResult>> runAppOpenRecovery(
RunAppOpenRecoveryRequest request,
) {
return applicationStore.run(
context: request.context,
operationName: 'appOpenRecovery',
action: (repositories) async {
final context = request.context;
final ownerId = context.ownerTimeZone.ownerId;
final sourceDate = request.sourceDate;
final targetDate = request.resolvedTargetDate;
final snapshotId = _rolloverSnapshotId(
ownerId: ownerId,
sourceDate: sourceDate,
);
final existingSnapshot =
await repositories.schedulingSnapshots.findById(snapshotId);
if (existingSnapshot != null) {
return ApplicationResult.success(
AppOpenRecoveryResult(
outcome: AppOpenRecoveryOutcome.alreadyProcessed,
sourceDate: sourceDate,
targetDate: targetDate,
snapshot: existingSnapshot,
changedTasks: const <Task>[],
activities: const <TaskActivity>[],
notices: existingSnapshot.notices,
changes: existingSnapshot.changes,
),
);
}
final settings = await _ownerSettingsOrDefault(
repositories,
context.ownerTimeZone,
);
final sourceWindow = _windowForDate(sourceDate, settings);
final targetWindow = _windowForDate(targetDate, settings);
final tasks = await _loadSourceAndTargetTasks(
repositories,
sourceWindow: sourceWindow,
targetWindow: targetWindow,
);
final blocks = (await repositories.lockedBlocks.findBlocksByOwner(
ownerId: context.ownerTimeZone.ownerId,
))
.items;
final overrides = (await repositories.lockedBlocks.findOverridesForDate(
ownerId: context.ownerTimeZone.ownerId,
date: targetDate,
))
.items;
final lockedExpansion = expandLockedBlocksForDay(
blocks: blocks,
overrides: overrides,
date: targetDate,
timeZoneId: settings.timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
final input = SchedulingInput(
tasks: tasks,
window: targetWindow,
lockedIntervals: lockedExpansion.schedulingIntervals,
);
final schedulingResult =
schedulingEngine.rollOverUnfinishedFlexibleTasks(
input: input,
sourceWindow: sourceWindow,
updatedAt: context.now,
);
if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow ||
schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.noSlot,
entityId: snapshotId,
detailCode: schedulingResult.outcomeCode.name,
),
);
}
final changedTasks = _changedTasks(tasks, schedulingResult.tasks);
final activities = _activitiesForRolloverChanges(
result: schedulingResult,
beforeTasks: tasks,
afterTasks: schedulingResult.tasks,
operationId: context.operationId,
occurredAt: context.now,
);
final rolloverNotice = _rolloverNotice(
sourceWindow: sourceWindow,
result: schedulingResult,
);
final snapshot = SchedulingStateSnapshot(
id: snapshotId,
capturedAt: context.now,
window: targetWindow,
tasks: schedulingResult.tasks,
lockedIntervals: input.lockedIntervals,
notices: [
if (rolloverNotice != null) rolloverNotice,
],
changes: schedulingResult.changes,
operationName: 'appOpenRecovery',
);
await repositories.tasks.saveAll(changedTasks);
await repositories.taskActivities.saveAll(activities);
await repositories.schedulingSnapshots.save(snapshot);
return ApplicationResult.success(
AppOpenRecoveryResult(
outcome: rolloverNotice == null
? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks
: AppOpenRecoveryOutcome.rolledOver,
sourceDate: sourceDate,
targetDate: targetDate,
snapshot: snapshot,
changedTasks: changedTasks,
activities: activities,
notices: snapshot.notices,
changes: schedulingResult.changes,
),
);
},
);
}
SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) {
return SchedulingWindow(
start: _resolve(
date: date,
wallTime: settings.dayStart,
timeZoneId: settings.timeZoneId,
),
end: _resolve(
date: date,
wallTime: settings.dayEnd,
timeZoneId: settings.timeZoneId,
),
);
}
DateTime _resolve({
required CivilDate date,
required WallTime wallTime,
required String timeZoneId,
}) {
return timeZoneResolver
.resolve(
date: date,
wallTime: wallTime,
timeZoneId: timeZoneId,
options: resolutionOptions,
)
.instant;
}
}
Future<OwnerSettings> _ownerSettingsOrDefault(
ApplicationUnitOfWorkRepositories repositories,
OwnerTimeZoneContext ownerTimeZone,
) async {
return await repositories.ownerSettings
.findByOwnerId(ownerTimeZone.ownerId) ??
OwnerSettings(
ownerId: ownerTimeZone.ownerId,
timeZoneId: ownerTimeZone.timeZoneId,
);
}
Future<List<Task>> _loadSourceAndTargetTasks(
ApplicationUnitOfWorkRepositories repositories, {
required SchedulingWindow sourceWindow,
required SchedulingWindow targetWindow,
}) async {
final byId = <String, Task>{};
for (final task in await repositories.tasks.findScheduledInWindow(
sourceWindow,
)) {
byId[task.id] = task;
}
for (final task in await repositories.tasks.findScheduledInWindow(
targetWindow,
)) {
byId[task.id] = task;
}
return List<Task>.unmodifiable(byId.values);
}
SchedulingNotice? _rolloverNotice({
required SchedulingWindow sourceWindow,
required SchedulingResult result,
}) {
if (result.outcomeCode != SchedulingOutcomeCode.success) {
return null;
}
final rolledTaskIds = result.changes
.where(
(change) =>
change.previousStart != null &&
change.previousEnd != null &&
sourceWindow.contains(
TimeInterval(
start: change.previousStart!, end: change.previousEnd!),
),
)
.map((change) => change.taskId)
.toSet();
if (rolledTaskIds.isEmpty) {
return null;
}
return SchedulingNotice(
'${rolledTaskIds.length} unfinished flexible tasks were moved to today.',
type: SchedulingNoticeType.moved,
movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
parameters: {
'count': rolledTaskIds.length,
'taskIds': List<String>.unmodifiable(rolledTaskIds),
},
);
}
List<TaskActivity> _activitiesForRolloverChanges({
required SchedulingResult result,
required List<Task> beforeTasks,
required List<Task> afterTasks,
required String operationId,
required DateTime occurredAt,
}) {
if (result.outcomeCode != SchedulingOutcomeCode.success) {
return const <TaskActivity>[];
}
final beforeById = {
for (final task in beforeTasks) task.id: task,
};
final afterById = {
for (final task in afterTasks) task.id: task,
};
final activities = <TaskActivity>[];
for (final change in result.changes) {
final before = beforeById[change.taskId];
final after = afterById[change.taskId];
if (before == null || after == null) {
continue;
}
activities.add(
TaskActivity(
id: '$operationId:${change.taskId}:automaticPush',
operationId: operationId,
code: TaskActivityCode.automaticallyPushed,
taskId: change.taskId,
projectId: before.projectId,
occurredAt: occurredAt,
metadata: {
'previousStatus': before.status.name,
'nextStatus': after.status.name,
'previousStart': change.previousStart,
'previousEnd': change.previousEnd,
'nextStart': change.nextStart,
'nextEnd': change.nextEnd,
},
),
);
}
return List<TaskActivity>.unmodifiable(activities);
}
List<Task> _changedTasks(List<Task> beforeTasks, List<Task> afterTasks) {
final beforeById = {
for (final task in beforeTasks) task.id: task,
};
final changed = <Task>[];
for (final task in afterTasks) {
final before = beforeById[task.id];
if (before == null || _taskChanged(before, task)) {
changed.add(task);
}
}
return List<Task>.unmodifiable(changed);
}
bool _taskChanged(Task before, Task after) {
return before.status != after.status ||
before.scheduledStart != after.scheduledStart ||
before.scheduledEnd != after.scheduledEnd ||
before.updatedAt != after.updatedAt ||
before.stats != after.stats;
}
String _rolloverSnapshotId({
required String ownerId,
required CivilDate sourceDate,
}) {
return 'rollover:$ownerId:${sourceDate.toIsoString()}';
}
part 'application_recovery/app_open_recovery_outcome.dart';
part 'application_recovery/run_app_open_recovery_request.dart';
part 'application_recovery/app_open_recovery_result.dart';
part 'application_recovery/v1_app_open_recovery_use_cases.dart';

View file

@ -0,0 +1,8 @@
part of '../application_recovery.dart';
/// Stable app-open recovery outcomes.
enum AppOpenRecoveryOutcome {
rolledOver,
noUnfinishedFlexibleTasks,
alreadyProcessed,
}

View file

@ -0,0 +1,42 @@
part of '../application_recovery.dart';
/// Result returned by app-open recovery.
class AppOpenRecoveryResult {
AppOpenRecoveryResult({
required this.outcome,
required this.sourceDate,
required this.targetDate,
required this.snapshot,
required List<Task> changedTasks,
required List<TaskActivity> activities,
required List<SchedulingNotice> notices,
required List<SchedulingChange> changes,
}) : changedTasks = List<Task>.unmodifiable(changedTasks),
activities = List<TaskActivity>.unmodifiable(activities),
notices = List<SchedulingNotice>.unmodifiable(notices),
changes = List<SchedulingChange>.unmodifiable(changes);
/// High-level deterministic outcome.
final AppOpenRecoveryOutcome outcome;
/// Owner-local source day that was checked.
final CivilDate sourceDate;
/// Owner-local target day used for placement and notice surfacing.
final CivilDate targetDate;
/// Persisted deterministic source-date snapshot.
final SchedulingStateSnapshot snapshot;
/// Tasks changed by this operation.
final List<Task> changedTasks;
/// Movement activities persisted with changed tasks.
final List<TaskActivity> activities;
/// Notices returned to the caller.
final List<SchedulingNotice> notices;
/// Machine-readable movement records.
final List<SchedulingChange> changes;
}

View file

@ -0,0 +1,22 @@
part of '../application_recovery.dart';
/// Request for explicit app-open/start-day rollover recovery.
class RunAppOpenRecoveryRequest {
const RunAppOpenRecoveryRequest({
required this.context,
required this.sourceDate,
this.targetDate,
});
/// Atomic operation context.
final ApplicationOperationContext context;
/// Owner-local day to inspect for unfinished flexible work.
final CivilDate sourceDate;
/// Owner-local target day. Defaults to the next local day.
final CivilDate? targetDate;
/// Resolved target day.
CivilDate get resolvedTargetDate => targetDate ?? sourceDate.addDays(1);
}

View file

@ -0,0 +1,327 @@
part of '../application_recovery.dart';
/// Explicit app-open recovery facade.
class V1AppOpenRecoveryUseCases {
const V1AppOpenRecoveryUseCases({
required this.applicationStore,
required this.timeZoneResolver,
this.resolutionOptions = const TimeZoneResolutionOptions(),
this.schedulingEngine = const SchedulingEngine(),
});
/// Atomic application boundary.
final ApplicationUnitOfWork applicationStore;
/// Explicit owner-local date resolver.
final TimeZoneResolver timeZoneResolver;
/// DST/nonexistent/repeated local time policy.
final TimeZoneResolutionOptions resolutionOptions;
/// Pure scheduling engine.
final SchedulingEngine schedulingEngine;
/// Run source-day rollover at most once for one owner/source-date key.
Future<ApplicationResult<AppOpenRecoveryResult>> runAppOpenRecovery(
RunAppOpenRecoveryRequest request,
) {
return applicationStore.run(
context: request.context,
operationName: 'appOpenRecovery',
action: (repositories) async {
final context = request.context;
final ownerId = context.ownerTimeZone.ownerId;
final sourceDate = request.sourceDate;
final targetDate = request.resolvedTargetDate;
final snapshotId = _rolloverSnapshotId(
ownerId: ownerId,
sourceDate: sourceDate,
);
final existingSnapshot =
await repositories.schedulingSnapshots.findById(snapshotId);
if (existingSnapshot != null) {
return ApplicationResult.success(
AppOpenRecoveryResult(
outcome: AppOpenRecoveryOutcome.alreadyProcessed,
sourceDate: sourceDate,
targetDate: targetDate,
snapshot: existingSnapshot,
changedTasks: const <Task>[],
activities: const <TaskActivity>[],
notices: existingSnapshot.notices,
changes: existingSnapshot.changes,
),
);
}
final settings = await _ownerSettingsOrDefault(
repositories,
context.ownerTimeZone,
);
final sourceWindow = _windowForDate(sourceDate, settings);
final targetWindow = _windowForDate(targetDate, settings);
final tasks = await _loadSourceAndTargetTasks(
repositories,
sourceWindow: sourceWindow,
targetWindow: targetWindow,
);
final blocks = (await repositories.lockedBlocks.findBlocksByOwner(
ownerId: context.ownerTimeZone.ownerId,
))
.items;
final overrides = (await repositories.lockedBlocks.findOverridesForDate(
ownerId: context.ownerTimeZone.ownerId,
date: targetDate,
))
.items;
final lockedExpansion = expandLockedBlocksForDay(
blocks: blocks,
overrides: overrides,
date: targetDate,
timeZoneId: settings.timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
final input = SchedulingInput(
tasks: tasks,
window: targetWindow,
lockedIntervals: lockedExpansion.schedulingIntervals,
);
final schedulingResult =
schedulingEngine.rollOverUnfinishedFlexibleTasks(
input: input,
sourceWindow: sourceWindow,
updatedAt: context.now,
);
if (schedulingResult.outcomeCode == SchedulingOutcomeCode.overflow ||
schedulingResult.outcomeCode == SchedulingOutcomeCode.noSlot) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.noSlot,
entityId: snapshotId,
detailCode: schedulingResult.outcomeCode.name,
),
);
}
final changedTasks = _changedTasks(tasks, schedulingResult.tasks);
final activities = _activitiesForRolloverChanges(
result: schedulingResult,
beforeTasks: tasks,
afterTasks: schedulingResult.tasks,
operationId: context.operationId,
occurredAt: context.now,
);
final rolloverNotice = _rolloverNotice(
sourceWindow: sourceWindow,
result: schedulingResult,
);
final snapshot = SchedulingStateSnapshot(
id: snapshotId,
capturedAt: context.now,
window: targetWindow,
tasks: schedulingResult.tasks,
lockedIntervals: input.lockedIntervals,
notices: [
if (rolloverNotice != null) rolloverNotice,
],
changes: schedulingResult.changes,
operationName: 'appOpenRecovery',
);
await repositories.tasks.saveAll(changedTasks);
await repositories.taskActivities.saveAll(activities);
await repositories.schedulingSnapshots.save(snapshot);
return ApplicationResult.success(
AppOpenRecoveryResult(
outcome: rolloverNotice == null
? AppOpenRecoveryOutcome.noUnfinishedFlexibleTasks
: AppOpenRecoveryOutcome.rolledOver,
sourceDate: sourceDate,
targetDate: targetDate,
snapshot: snapshot,
changedTasks: changedTasks,
activities: activities,
notices: snapshot.notices,
changes: schedulingResult.changes,
),
);
},
);
}
SchedulingWindow _windowForDate(CivilDate date, OwnerSettings settings) {
return SchedulingWindow(
start: _resolve(
date: date,
wallTime: settings.dayStart,
timeZoneId: settings.timeZoneId,
),
end: _resolve(
date: date,
wallTime: settings.dayEnd,
timeZoneId: settings.timeZoneId,
),
);
}
DateTime _resolve({
required CivilDate date,
required WallTime wallTime,
required String timeZoneId,
}) {
return timeZoneResolver
.resolve(
date: date,
wallTime: wallTime,
timeZoneId: timeZoneId,
options: resolutionOptions,
)
.instant;
}
}
Future<OwnerSettings> _ownerSettingsOrDefault(
ApplicationUnitOfWorkRepositories repositories,
OwnerTimeZoneContext ownerTimeZone,
) async {
return await repositories.ownerSettings
.findByOwnerId(ownerTimeZone.ownerId) ??
OwnerSettings(
ownerId: ownerTimeZone.ownerId,
timeZoneId: ownerTimeZone.timeZoneId,
);
}
Future<List<Task>> _loadSourceAndTargetTasks(
ApplicationUnitOfWorkRepositories repositories, {
required SchedulingWindow sourceWindow,
required SchedulingWindow targetWindow,
}) async {
final byId = <String, Task>{};
for (final task in await repositories.tasks.findScheduledInWindow(
sourceWindow,
)) {
byId[task.id] = task;
}
for (final task in await repositories.tasks.findScheduledInWindow(
targetWindow,
)) {
byId[task.id] = task;
}
return List<Task>.unmodifiable(byId.values);
}
SchedulingNotice? _rolloverNotice({
required SchedulingWindow sourceWindow,
required SchedulingResult result,
}) {
if (result.outcomeCode != SchedulingOutcomeCode.success) {
return null;
}
final rolledTaskIds = result.changes
.where(
(change) =>
change.previousStart != null &&
change.previousEnd != null &&
sourceWindow.contains(
TimeInterval(
start: change.previousStart!, end: change.previousEnd!),
),
)
.map((change) => change.taskId)
.toSet();
if (rolledTaskIds.isEmpty) {
return null;
}
return SchedulingNotice(
'${rolledTaskIds.length} unfinished flexible tasks were moved to today.',
type: SchedulingNoticeType.moved,
movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
parameters: {
'count': rolledTaskIds.length,
'taskIds': List<String>.unmodifiable(rolledTaskIds),
},
);
}
List<TaskActivity> _activitiesForRolloverChanges({
required SchedulingResult result,
required List<Task> beforeTasks,
required List<Task> afterTasks,
required String operationId,
required DateTime occurredAt,
}) {
if (result.outcomeCode != SchedulingOutcomeCode.success) {
return const <TaskActivity>[];
}
final beforeById = {
for (final task in beforeTasks) task.id: task,
};
final afterById = {
for (final task in afterTasks) task.id: task,
};
final activities = <TaskActivity>[];
for (final change in result.changes) {
final before = beforeById[change.taskId];
final after = afterById[change.taskId];
if (before == null || after == null) {
continue;
}
activities.add(
TaskActivity(
id: '$operationId:${change.taskId}:automaticPush',
operationId: operationId,
code: TaskActivityCode.automaticallyPushed,
taskId: change.taskId,
projectId: before.projectId,
occurredAt: occurredAt,
metadata: {
'previousStatus': before.status.name,
'nextStatus': after.status.name,
'previousStart': change.previousStart,
'previousEnd': change.previousEnd,
'nextStart': change.nextStart,
'nextEnd': change.nextEnd,
},
),
);
}
return List<TaskActivity>.unmodifiable(activities);
}
List<Task> _changedTasks(List<Task> beforeTasks, List<Task> afterTasks) {
final beforeById = {
for (final task in beforeTasks) task.id: task,
};
final changed = <Task>[];
for (final task in afterTasks) {
final before = beforeById[task.id];
if (before == null || _taskChanged(before, task)) {
changed.add(task);
}
}
return List<Task>.unmodifiable(changed);
}
bool _taskChanged(Task before, Task after) {
return before.status != after.status ||
before.scheduledStart != after.scheduledStart ||
before.scheduledEnd != after.scheduledEnd ||
before.updatedAt != after.updatedAt ||
before.stats != after.stats;
}
String _rolloverSnapshotId({
required String ownerId,
required CivilDate sourceDate,
}) {
return 'rollover:$ownerId:${sourceDate.toIsoString()}';
}

View file

@ -9,291 +9,9 @@ library;
// scheduler so the engine can focus on moving tasks through time.
import 'models.dart';
/// Derived backlog filters for a unified backlog list.
///
/// These filters do not store separate task collections. They are projections
/// over the same master task list. That is important because a task can move
/// between today's timeline and backlog by changing [Task.status], without
/// needing to copy it between separate stores.
enum BacklogFilter {
/// Uncategorized captured tasks in the default inbox project.
inbox,
/// Tasks that have been manually or automatically pushed at least once.
pushed,
/// Critical tasks that have missed at least once and need recovery attention.
criticalMissed,
/// Someday/maybe tasks that are intentionally kept out of normal pressure.
wishlist,
/// Tasks whose [Task.updatedAt] age exceeds the configured stale threshold.
stale,
/// Tasks still missing a reward estimate. Useful during cleanup/review.
noRewardSet,
}
/// Sort options for a unified backlog list.
///
/// Sort keys are intentionally product-facing rather than database-facing. For
/// example, `rewardVsEffort` maps to a simple derived score instead of a stored
/// field. Persistence can later index the underlying fields if needed.
enum BacklogSortKey {
/// Highest priority first.
priority,
/// Best simple reward-minus-difficulty score first.
rewardVsEffort,
/// Oldest created task first.
age,
/// Lexicographic project id grouping. Future UI can replace this with project
/// display order while keeping the same public key.
project,
/// Most frequently pushed tasks first.
timesPushed,
}
/// Visual age bucket for backlog display.
///
/// This supports the design rule that old backlog items should visually age
/// from green to blue to purple. The enum names describe semantic buckets; UI
/// code should translate them into actual theme colors.
enum BacklogStalenessMarker {
/// Fresh backlog item. Default: created within seven days.
green,
/// Aging backlog item. Default: created within thirty days.
blue,
/// Old/stale backlog item. Default: created more than thirty days ago.
purple,
}
/// Configurable thresholds for backlog age markers.
///
/// The defaults match the current design spec: less than a week is fresh, less
/// than a month is aging, and anything older is stale. Keeping the thresholds in
/// a value object makes future settings/preferences easy to inject in tests or
/// user configuration.
class BacklogStalenessSettings {
const BacklogStalenessSettings({
this.greenMaxAge = const Duration(days: 7),
this.blueMaxAge = const Duration(days: 30),
});
/// Maximum age that still counts as fresh/green.
final Duration greenMaxAge;
/// Maximum age that still counts as aging/blue. Anything older is purple.
final Duration blueMaxAge;
/// Return the visual age marker for [task] relative to [now].
///
/// This uses [Task.createdAt], not [Task.updatedAt], because the marker is
/// meant to show how long the idea has existed in the system. A task edited
/// yesterday but created two months ago should still feel old in the backlog.
BacklogStalenessMarker markerFor({
required Task task,
required DateTime now,
}) {
final age = now.difference(task.createdAt);
if (age <= greenMaxAge) {
return BacklogStalenessMarker.green;
}
if (age <= blueMaxAge) {
return BacklogStalenessMarker.blue;
}
return BacklogStalenessMarker.purple;
}
}
/// Read-only backlog projection over the unified task list.
///
/// [BacklogView] is a query/helper object. It does not mutate tasks or own data;
/// it receives the current task list and exposes common backlog slices for UI.
/// That keeps backlog display logic out of widgets and avoids duplicating the
/// same filtering rules in multiple screens.
class BacklogView {
BacklogView({
required List<Task> tasks,
required this.now,
this.staleAfter = const Duration(days: 31),
this.stalenessSettings = const BacklogStalenessSettings(),
}) : tasks = List<Task>.unmodifiable(tasks);
/// Master task list supplied by the caller. Only `status == backlog` items are
/// shown by this view.
final List<Task> tasks;
/// Clock value supplied by the caller so age/staleness behavior is testable.
final DateTime now;
/// Age since [Task.createdAt] that qualifies for the `stale` filter.
///
/// V1 does not yet store a separate "entered backlog at" timestamp. Until
/// persistence adds that field, both stale filtering and visual staleness use
/// task creation age so they do not disagree after a task edit.
final Duration staleAfter;
/// Color-bucket threshold configuration for backlog aging indicators.
final BacklogStalenessSettings stalenessSettings;
/// All tasks currently in backlog status.
///
/// The returned list is a snapshot. It is not intended to be modified and then
/// written back; state changes should go through scheduling/action services.
List<Task> get backlogTasks {
return tasks.where((task) => task.isBacklog).toList(growable: false);
}
/// Return backlog tasks matching a single user-facing filter.
///
/// Filtering always starts from [backlogTasks], so a completed or planned task
/// will never appear here even if it has matching statistics.
List<Task> filter(BacklogFilter filter) {
return backlogTasks.where((task) => _matchesFilter(task, filter)).toList(
growable: false,
);
}
/// Return all backlog tasks sorted by a user-facing ordering.
///
/// A new list is created before sorting so the original [tasks] list is never
/// reordered by a read operation. The final list is unmodifiable to make that
/// intent explicit to callers.
List<Task> sorted(BacklogSortKey sortKey) {
final sortedTasks = backlogTasks
.asMap()
.entries
.map(
(entry) => _IndexedTask(
task: entry.value,
originalIndex: entry.key,
),
)
.toList(growable: false);
sortedTasks.sort((a, b) {
final comparison = _compareTasks(a.task, b.task, sortKey);
if (comparison != 0) {
return comparison;
}
return a.originalIndex.compareTo(b.originalIndex);
});
return List<Task>.unmodifiable(
sortedTasks.map((indexedTask) => indexedTask.task),
);
}
/// Return the green/blue/purple marker for one task.
BacklogStalenessMarker stalenessMarkerFor(Task task) {
return stalenessSettings.markerFor(task: task, now: now);
}
/// Private predicate implementing every [BacklogFilter] option.
///
/// Keeping this as a switch expression makes new filters obvious: add the enum
/// value and the compiler forces this method to handle it.
bool _matchesFilter(Task task, BacklogFilter filter) {
return switch (filter) {
BacklogFilter.inbox => task.projectId == 'inbox',
BacklogFilter.pushed =>
task.stats.manuallyPushedCount > 0 || task.stats.autoPushedCount > 0,
BacklogFilter.criticalMissed =>
task.type == TaskType.critical && task.stats.missedCount > 0,
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist),
BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter,
BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
};
}
/// Comparison callback used by [sorted].
///
/// Sort directions are encoded here. Higher priority/reward/push counts should
/// appear earlier, while older age uses the earliest [Task.createdAt] first.
int _compareTasks(Task a, Task b, BacklogSortKey sortKey) {
return switch (sortKey) {
BacklogSortKey.priority =>
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
BacklogSortKey.rewardVsEffort =>
_rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)),
BacklogSortKey.age => a.createdAt.compareTo(b.createdAt),
BacklogSortKey.project => a.projectId.compareTo(b.projectId),
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
};
}
}
/// Convert nullable priority into a stable numeric rank for sorting.
///
/// Null priority is treated like medium so partially imported data behaves like
/// normal starter tasks instead of sinking to the bottom.
int _priorityRank(PriorityLevel? priority) {
return switch (priority) {
PriorityLevel.veryLow => 0,
PriorityLevel.low => 1,
PriorityLevel.medium || null => 2,
PriorityLevel.high => 3,
PriorityLevel.veryHigh => 4,
};
}
/// Convert reward enum values to numeric ranks for derived scoring.
int _rewardRank(RewardLevel reward) {
return switch (reward) {
RewardLevel.notSet => 0,
RewardLevel.veryLow => 1,
RewardLevel.low => 2,
RewardLevel.medium => 3,
RewardLevel.high => 4,
RewardLevel.veryHigh => 5,
};
}
/// Convert difficulty enum values to numeric ranks for derived scoring.
int _difficultyRank(DifficultyLevel difficulty) {
return switch (difficulty) {
DifficultyLevel.notSet => 0,
DifficultyLevel.veryEasy => 1,
DifficultyLevel.easy => 2,
DifficultyLevel.medium => 3,
DifficultyLevel.hard => 4,
DifficultyLevel.veryHard => 5,
};
}
/// Simple motivation score: reward minus difficulty.
///
/// Positive scores suggest high payoff for lower activation cost. Negative scores
/// suggest high effort for lower payoff. This is deliberately simple for V1 and
/// can be replaced by richer heuristics later without changing the public sort
/// key.
int _rewardVsEffortScore(Task task) {
return _rewardRank(task.reward) - _difficultyRank(task.difficulty);
}
/// Total manual and automatic pushes recorded on the task.
int _timesPushed(Task task) {
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
}
/// Backlog task paired with its source-list position for stable sorting.
class _IndexedTask {
const _IndexedTask({
required this.task,
required this.originalIndex,
});
final Task task;
final int originalIndex;
}
part 'backlog/backlog_filter.dart';
part 'backlog/backlog_sort_key.dart';
part 'backlog/backlog_staleness_marker.dart';
part 'backlog/backlog_staleness_settings.dart';
part 'backlog/backlog_view.dart';
part 'backlog/indexed_task.dart';

View file

@ -0,0 +1,27 @@
part of '../backlog.dart';
/// Derived backlog filters for a unified backlog list.
///
/// These filters do not store separate task collections. They are projections
/// over the same master task list. That is important because a task can move
/// between today's timeline and backlog by changing [Task.status], without
/// needing to copy it between separate stores.
enum BacklogFilter {
/// Uncategorized captured tasks in the default inbox project.
inbox,
/// Tasks that have been manually or automatically pushed at least once.
pushed,
/// Critical tasks that have missed at least once and need recovery attention.
criticalMissed,
/// Someday/maybe tasks that are intentionally kept out of normal pressure.
wishlist,
/// Tasks whose [Task.updatedAt] age exceeds the configured stale threshold.
stale,
/// Tasks still missing a reward estimate. Useful during cleanup/review.
noRewardSet,
}

View file

@ -0,0 +1,24 @@
part of '../backlog.dart';
/// Sort options for a unified backlog list.
///
/// Sort keys are intentionally product-facing rather than database-facing. For
/// example, `rewardVsEffort` maps to a simple derived score instead of a stored
/// field. Persistence can later index the underlying fields if needed.
enum BacklogSortKey {
/// Highest priority first.
priority,
/// Best simple reward-minus-difficulty score first.
rewardVsEffort,
/// Oldest created task first.
age,
/// Lexicographic project id grouping. Future UI can replace this with project
/// display order while keeping the same public key.
project,
/// Most frequently pushed tasks first.
timesPushed,
}

View file

@ -0,0 +1,17 @@
part of '../backlog.dart';
/// Visual age bucket for backlog display.
///
/// This supports the design rule that old backlog items should visually age
/// from green to blue to purple. The enum names describe semantic buckets; UI
/// code should translate them into actual theme colors.
enum BacklogStalenessMarker {
/// Fresh backlog item. Default: created within seven days.
green,
/// Aging backlog item. Default: created within thirty days.
blue,
/// Old/stale backlog item. Default: created more than thirty days ago.
purple,
}

View file

@ -0,0 +1,42 @@
part of '../backlog.dart';
/// Configurable thresholds for backlog age markers.
///
/// The defaults match the current design spec: less than a week is fresh, less
/// than a month is aging, and anything older is stale. Keeping the thresholds in
/// a value object makes future settings/preferences easy to inject in tests or
/// user configuration.
class BacklogStalenessSettings {
const BacklogStalenessSettings({
this.greenMaxAge = const Duration(days: 7),
this.blueMaxAge = const Duration(days: 30),
});
/// Maximum age that still counts as fresh/green.
final Duration greenMaxAge;
/// Maximum age that still counts as aging/blue. Anything older is purple.
final Duration blueMaxAge;
/// Return the visual age marker for [task] relative to [now].
///
/// This uses [Task.createdAt], not [Task.updatedAt], because the marker is
/// meant to show how long the idea has existed in the system. A task edited
/// yesterday but created two months ago should still feel old in the backlog.
BacklogStalenessMarker markerFor({
required Task task,
required DateTime now,
}) {
final age = now.difference(task.createdAt);
if (age <= greenMaxAge) {
return BacklogStalenessMarker.green;
}
if (age <= blueMaxAge) {
return BacklogStalenessMarker.blue;
}
return BacklogStalenessMarker.purple;
}
}

View file

@ -0,0 +1,172 @@
part of '../backlog.dart';
/// Read-only backlog projection over the unified task list.
///
/// [BacklogView] is a query/helper object. It does not mutate tasks or own data;
/// it receives the current task list and exposes common backlog slices for UI.
/// That keeps backlog display logic out of widgets and avoids duplicating the
/// same filtering rules in multiple screens.
class BacklogView {
BacklogView({
required List<Task> tasks,
required this.now,
this.staleAfter = const Duration(days: 31),
this.stalenessSettings = const BacklogStalenessSettings(),
}) : tasks = List<Task>.unmodifiable(tasks);
/// Master task list supplied by the caller. Only `status == backlog` items are
/// shown by this view.
final List<Task> tasks;
/// Clock value supplied by the caller so age/staleness behavior is testable.
final DateTime now;
/// Age since [Task.createdAt] that qualifies for the `stale` filter.
///
/// V1 does not yet store a separate "entered backlog at" timestamp. Until
/// persistence adds that field, both stale filtering and visual staleness use
/// task creation age so they do not disagree after a task edit.
final Duration staleAfter;
/// Color-bucket threshold configuration for backlog aging indicators.
final BacklogStalenessSettings stalenessSettings;
/// All tasks currently in backlog status.
///
/// The returned list is a snapshot. It is not intended to be modified and then
/// written back; state changes should go through scheduling/action services.
List<Task> get backlogTasks {
return tasks.where((task) => task.isBacklog).toList(growable: false);
}
/// Return backlog tasks matching a single user-facing filter.
///
/// Filtering always starts from [backlogTasks], so a completed or planned task
/// will never appear here even if it has matching statistics.
List<Task> filter(BacklogFilter filter) {
return backlogTasks.where((task) => _matchesFilter(task, filter)).toList(
growable: false,
);
}
/// Return all backlog tasks sorted by a user-facing ordering.
///
/// A new list is created before sorting so the original [tasks] list is never
/// reordered by a read operation. The final list is unmodifiable to make that
/// intent explicit to callers.
List<Task> sorted(BacklogSortKey sortKey) {
final sortedTasks = backlogTasks
.asMap()
.entries
.map(
(entry) => _IndexedTask(
task: entry.value,
originalIndex: entry.key,
),
)
.toList(growable: false);
sortedTasks.sort((a, b) {
final comparison = _compareTasks(a.task, b.task, sortKey);
if (comparison != 0) {
return comparison;
}
return a.originalIndex.compareTo(b.originalIndex);
});
return List<Task>.unmodifiable(
sortedTasks.map((indexedTask) => indexedTask.task),
);
}
/// Return the green/blue/purple marker for one task.
BacklogStalenessMarker stalenessMarkerFor(Task task) {
return stalenessSettings.markerFor(task: task, now: now);
}
/// Private predicate implementing every [BacklogFilter] option.
///
/// Keeping this as a switch expression makes new filters obvious: add the enum
/// value and the compiler forces this method to handle it.
bool _matchesFilter(Task task, BacklogFilter filter) {
return switch (filter) {
BacklogFilter.inbox => task.projectId == 'inbox',
BacklogFilter.pushed =>
task.stats.manuallyPushedCount > 0 || task.stats.autoPushedCount > 0,
BacklogFilter.criticalMissed =>
task.type == TaskType.critical && task.stats.missedCount > 0,
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist),
BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter,
BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
};
}
/// Comparison callback used by [sorted].
///
/// Sort directions are encoded here. Higher priority/reward/push counts should
/// appear earlier, while older age uses the earliest [Task.createdAt] first.
int _compareTasks(Task a, Task b, BacklogSortKey sortKey) {
return switch (sortKey) {
BacklogSortKey.priority =>
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
BacklogSortKey.rewardVsEffort =>
_rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)),
BacklogSortKey.age => a.createdAt.compareTo(b.createdAt),
BacklogSortKey.project => a.projectId.compareTo(b.projectId),
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
};
}
}
/// Convert nullable priority into a stable numeric rank for sorting.
///
/// Null priority is treated like medium so partially imported data behaves like
/// normal starter tasks instead of sinking to the bottom.
int _priorityRank(PriorityLevel? priority) {
return switch (priority) {
PriorityLevel.veryLow => 0,
PriorityLevel.low => 1,
PriorityLevel.medium || null => 2,
PriorityLevel.high => 3,
PriorityLevel.veryHigh => 4,
};
}
/// Convert reward enum values to numeric ranks for derived scoring.
int _rewardRank(RewardLevel reward) {
return switch (reward) {
RewardLevel.notSet => 0,
RewardLevel.veryLow => 1,
RewardLevel.low => 2,
RewardLevel.medium => 3,
RewardLevel.high => 4,
RewardLevel.veryHigh => 5,
};
}
/// Convert difficulty enum values to numeric ranks for derived scoring.
int _difficultyRank(DifficultyLevel difficulty) {
return switch (difficulty) {
DifficultyLevel.notSet => 0,
DifficultyLevel.veryEasy => 1,
DifficultyLevel.easy => 2,
DifficultyLevel.medium => 3,
DifficultyLevel.hard => 4,
DifficultyLevel.veryHard => 5,
};
}
/// Simple motivation score: reward minus difficulty.
///
/// Positive scores suggest high payoff for lower activation cost. Negative scores
/// suggest high effort for lower payoff. This is deliberately simple for V1 and
/// can be replaced by richer heuristics later without changing the public sort
/// key.
int _rewardVsEffortScore(Task task) {
return _rewardRank(task.reward) - _difficultyRank(task.difficulty);
}
/// Total manual and automatic pushes recorded on the task.
int _timesPushed(Task task) {
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
}

View file

@ -0,0 +1,12 @@
part of '../backlog.dart';
/// Backlog task paired with its source-list position for stable sorting.
class _IndexedTask {
const _IndexedTask({
required this.task,
required this.originalIndex,
});
final Task task;
final int originalIndex;
}

View file

@ -10,746 +10,12 @@ library;
import 'models.dart';
import 'task_lifecycle.dart';
import 'time_contracts.dart';
/// Row-style input for creating one child task.
///
/// Priority is nullable on purpose. A null priority means the user did not set
/// one, so callers should preserve row insertion order instead of treating the
/// child as medium priority.
class ChildTaskEntry {
const ChildTaskEntry({
required this.id,
required this.title,
required this.createdAt,
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
this.durationMinutes,
this.projectId,
this.updatedAt,
});
/// Caller-generated child id.
final String id;
/// User-entered child title.
final String title;
/// Creation timestamp supplied by the caller.
final DateTime createdAt;
/// Optional explicit child priority.
final PriorityLevel? priority;
/// Optional explicit child reward. Missing reward stays `notSet`.
final RewardLevel reward;
/// Optional explicit child difficulty.
final DifficultyLevel difficulty;
/// Optional child duration estimate.
final int? durationMinutes;
/// Optional project override. Null means inherit the parent project.
final String? projectId;
/// Optional update timestamp.
final DateTime? updatedAt;
/// Convert this entry into a child [Task] owned by [parent].
Task toTask({
required Task parent,
}) {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
return Task(
id: id,
title: trimmedTitle,
projectId: projectId ?? parent.projectId,
type: TaskType.flexible,
status: TaskStatus.backlog,
priority: priority,
reward: reward,
difficulty: difficulty,
durationMinutes: durationMinutes,
parentTaskId: parent.id,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
}
}
/// Command input for breaking one parent task into direct children.
class ChildTaskBreakUpRequest {
ChildTaskBreakUpRequest({
required String parentTaskId,
required List<ChildTaskEntry> entries,
required String operationId,
}) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'),
entries = List<ChildTaskEntry>.unmodifiable(entries),
operationId = _requiredTrimmed(operationId, 'operationId');
/// Parent task that will own every created child.
final String parentTaskId;
/// Ordered child rows supplied by the caller.
final List<ChildTaskEntry> entries;
/// Idempotency key for the application command that creates the children.
final String operationId;
}
/// Result from breaking a parent task into direct children.
class ChildTaskBreakUpResult {
ChildTaskBreakUpResult({
required List<Task> tasks,
required this.parentTask,
required List<Task> childTasks,
required this.operationId,
List<String> createdChildTaskIds = const <String>[],
List<TaskActivity> activities = const <TaskActivity>[],
}) : tasks = List<Task>.unmodifiable(tasks),
childTasks = List<Task>.unmodifiable(childTasks),
createdChildTaskIds = List<String>.unmodifiable(createdChildTaskIds),
activities = List<TaskActivity>.unmodifiable(activities);
/// Replacement task list including newly created children.
final List<Task> tasks;
/// Parent that owns the created children.
final Task parentTask;
/// Created children in request order.
final List<Task> childTasks;
/// Idempotency key for the command.
final String operationId;
/// Child ids created by this command.
final List<String> createdChildTaskIds;
/// Activity records produced by this command.
///
/// V1 child creation is represented by task inserts, so this is currently
/// empty while still giving application use cases one atomic mutation shape.
final List<TaskActivity> activities;
}
/// Creates direct child tasks from ordered child-entry rows.
class ChildTaskBreakUpService {
const ChildTaskBreakUpService();
/// Break `request.parentTaskId` into direct child tasks.
///
/// This service validates the full requested set before returning any
/// mutations. Persistence and transaction wiring are handled by later blocks.
ChildTaskBreakUpResult breakUp({
required List<Task> tasks,
required ChildTaskBreakUpRequest request,
}) {
final parent = _taskById(tasks, request.parentTaskId);
if (parent == null) {
throw ArgumentError.value(
request.parentTaskId,
'parentTaskId',
'Parent not found.',
);
}
if (request.entries.isEmpty) {
throw ArgumentError.value(
request.entries,
'entries',
'At least one child task is required.',
);
}
final existingIds = tasks.map((task) => task.id).toSet();
final requestedIds = <String>{};
for (final entry in request.entries) {
final childId = entry.id.trim();
if (childId.isEmpty) {
throw ArgumentError.value(entry.id, 'id', 'Child id is required.');
}
if (childId == parent.id) {
throw ArgumentError.value(
entry.id,
'id',
'Child id cannot match the parent id.',
);
}
if (!requestedIds.add(childId)) {
throw ArgumentError.value(
entry.id,
'id',
'Child ids must be unique within one break-up command.',
);
}
if (existingIds.contains(childId)) {
throw ArgumentError.value(
entry.id,
'id',
'Child id is already used by another task.',
);
}
}
final childTasks = request.entries
.map((entry) => entry.toTask(parent: parent))
.toList(growable: false);
return ChildTaskBreakUpResult(
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),
parentTask: parent,
childTasks: childTasks,
operationId: request.operationId,
createdChildTaskIds:
childTasks.map((child) => child.id).toList(growable: false),
);
}
}
/// Read-only parent/child projection over a task list.
///
/// This is intentionally not a scheduler. It answers ownership questions such
/// as "which tasks belong to this parent?" while leaving task placement and
/// completion rules to other domain services.
class ChildTaskView {
ChildTaskView({
required List<Task> tasks,
}) : tasks = List<Task>.unmodifiable(tasks);
/// Source task list supplied by the caller.
final List<Task> tasks;
/// Direct children owned by [parent], preserving source-list order.
List<Task> childrenOf(Task parent) {
return List<Task>.unmodifiable(
tasks.where((task) => task.parentTaskId == parent.id),
);
}
/// Direct children sorted by explicit priority while preserving row order ties.
///
/// Children without priority sort after explicit priorities. Within each
/// priority group, including the no-priority group, source-list order is kept.
List<Task> childrenOfSortedByPriority(Task parent) {
final indexedChildren = childrenOf(parent)
.asMap()
.entries
.map(
(entry) => _IndexedChild(
task: entry.value,
originalIndex: entry.key,
),
)
.toList(growable: false);
indexedChildren.sort((a, b) {
final priorityComparison = _priorityRank(b.task.priority)
.compareTo(_priorityRank(a.task.priority));
if (priorityComparison != 0) {
return priorityComparison;
}
return a.originalIndex.compareTo(b.originalIndex);
});
return List<Task>.unmodifiable(
indexedChildren.map((child) => child.task),
);
}
/// Parent task for [child], or null when the parent is not in [tasks].
Task? parentOf(Task child) {
final parentId = child.parentTaskId;
if (parentId == null) {
return null;
}
for (final task in tasks) {
if (task.id == parentId) {
return task;
}
}
return null;
}
/// Whether [child] is directly owned by [parent].
bool isChildOf({
required Task child,
required Task parent,
}) {
return child.parentTaskId == parent.id;
}
/// Aggregate direct child status counts for [parent].
ChildTaskSummary summaryFor(Task parent) {
return ChildTaskSummary.fromChildren(childrenOf(parent));
}
/// Whether this helper would auto-complete [parent].
bool parentShouldAutoComplete(Task parent) {
return summaryFor(parent).allChildrenCompleted;
}
}
/// Result from child/parent completion propagation.
class ChildTaskCompletionResult {
ChildTaskCompletionResult({
required List<Task> tasks,
required List<String> changedTaskIds,
this.completedChildId,
this.completedParentId,
List<String> forceCompletedChildIds = const <String>[],
List<TaskActivity> activities = const <TaskActivity>[],
List<TaskTransitionOutcomeCode> transitionOutcomeCodes =
const <TaskTransitionOutcomeCode>[],
this.autoCompletedParent = false,
this.canCompleteParentExplicitly = false,
}) : tasks = List<Task>.unmodifiable(tasks),
changedTaskIds = List<String>.unmodifiable(changedTaskIds),
forceCompletedChildIds =
List<String>.unmodifiable(forceCompletedChildIds),
activities = List<TaskActivity>.unmodifiable(activities),
transitionOutcomeCodes = List<TaskTransitionOutcomeCode>.unmodifiable(
transitionOutcomeCodes);
/// Replacement task list after the completion action.
final List<Task> tasks;
/// Task ids whose completion state changed.
final List<String> changedTaskIds;
/// Child id completed by the initial child-complete action, when applicable.
final String? completedChildId;
/// Parent id completed by the action, when applicable.
final String? completedParentId;
/// Direct child ids completed by explicit parent-complete behavior.
final List<String> forceCompletedChildIds;
/// Internal activities produced by parent/child completion propagation.
final List<TaskActivity> activities;
/// Transition outcomes returned while applying completion propagation.
final List<TaskTransitionOutcomeCode> transitionOutcomeCodes;
/// Whether the parent was completed because all direct children are complete.
final bool autoCompletedParent;
/// Whether callers may offer an explicit parent-complete action.
final bool canCompleteParentExplicitly;
}
/// Applies direct parent/child completion rules.
///
/// This service intentionally handles only one ownership level. It does not walk
/// dependency graphs, and it only completes sibling children when the caller
/// explicitly completes the parent.
class ChildTaskCompletionService {
const ChildTaskCompletionService({
this.clock = const SystemClock(),
this.transitionService = const TaskTransitionService(),
});
/// Clock boundary used when a completion timestamp is not supplied.
final Clock clock;
/// Canonical lifecycle transition dependency.
final TaskTransitionService transitionService;
/// Complete one child and auto-complete its parent only when all children are complete.
ChildTaskCompletionResult completeChild({
required List<Task> tasks,
required String childTaskId,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final now = updatedAt ?? clock.now();
final child = _taskById(tasks, childTaskId);
if (child == null) {
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
}
final parentId = child.parentTaskId;
if (parentId == null) {
throw ArgumentError.value(
childTaskId, 'childTaskId', 'Task is not a child.');
}
final parent = _taskById(tasks, parentId);
if (parent == null) {
throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
}
final opId =
operationId ?? _defaultOperationId('complete-child', child.id, now);
final childTransition = _completeWithTransition(
task: child,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': 'child',
'parentTaskId': parent.id,
},
);
final withChildCompleted = _replaceTask(tasks, childTransition.task);
final view = ChildTaskView(tasks: withChildCompleted);
final shouldCompleteParent = parent.status != TaskStatus.completed &&
view.parentShouldAutoComplete(parent);
final activities = <TaskActivity>[...childTransition.activities];
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
if (!shouldCompleteParent) {
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(withChildCompleted),
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
completedChildId: child.id,
canCompleteParentExplicitly: parent.status != TaskStatus.completed,
activities: activities,
transitionOutcomeCodes: outcomes,
);
}
final parentTransition = _completeWithTransition(
task: parent,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': 'autoParentAfterLastChild',
'completedChildTaskId': child.id,
},
);
activities.addAll(parentTransition.activities);
outcomes.add(parentTransition.outcomeCode);
final updatedParent = parentTransition.task;
final completedTasks = _replaceTask(withChildCompleted, updatedParent);
final changedTaskIds = <String>[
if (childTransition.applied) child.id,
if (parentTransition.applied) parent.id,
];
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(completedTasks),
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
completedChildId: child.id,
completedParentId: parent.id,
autoCompletedParent: true,
activities: activities,
transitionOutcomeCodes: outcomes,
);
}
/// Complete a parent from one of its direct children and force-complete siblings.
ChildTaskCompletionResult completeParentFromChild({
required List<Task> tasks,
required String childTaskId,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final child = _taskById(tasks, childTaskId);
if (child == null) {
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
}
final parentId = child.parentTaskId;
if (parentId == null) {
throw ArgumentError.value(
childTaskId,
'childTaskId',
'Task is not a child.',
);
}
return completeParent(
tasks: tasks,
parentTaskId: parentId,
updatedAt: updatedAt,
operationId: operationId,
initiatingChildTaskId: child.id,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
);
}
/// Explicitly complete a parent and any direct children not already completed.
ChildTaskCompletionResult completeParent({
required List<Task> tasks,
required String parentTaskId,
DateTime? updatedAt,
String? operationId,
String? initiatingChildTaskId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final now = updatedAt ?? clock.now();
final parent = _taskById(tasks, parentTaskId);
if (parent == null) {
throw ArgumentError.value(
parentTaskId, 'parentTaskId', 'Parent not found.');
}
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
if (initiatingChildTaskId != null &&
!directChildren.any((child) => child.id == initiatingChildTaskId)) {
throw ArgumentError.value(
initiatingChildTaskId,
'initiatingChildTaskId',
'Initiating task must be a direct child of the parent.',
);
}
final opId =
operationId ?? _defaultOperationId('complete-parent', parent.id, now);
final forceCompletedChildIds = <String>[];
final changedTaskIds = <String>[];
final activities = <TaskActivity>[];
final outcomes = <TaskTransitionOutcomeCode>[];
final updatedTasks = <Task>[];
for (final task in tasks) {
if (task.id == parent.id) {
final transition = _completeWithTransition(
task: task,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource':
initiatingChildTaskId == null ? 'parent' : 'parentFromChild',
if (initiatingChildTaskId != null)
'initiatingChildTaskId': initiatingChildTaskId,
},
);
final completedParent = transition.task;
updatedTasks.add(completedParent);
activities.addAll(transition.activities);
outcomes.add(transition.outcomeCode);
if (transition.applied) {
changedTaskIds.add(task.id);
}
continue;
}
final isDirectChild = directChildren.any((child) => child.id == task.id);
if (isDirectChild && task.status != TaskStatus.completed) {
final transition = _completeWithTransition(
task: task,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': initiatingChildTaskId == null
? 'parentForce'
: 'childForceParent',
'parentTaskId': parent.id,
if (initiatingChildTaskId != null)
'initiatingChildTaskId': initiatingChildTaskId,
},
);
updatedTasks.add(transition.task);
activities.addAll(transition.activities);
outcomes.add(transition.outcomeCode);
if (transition.applied) {
forceCompletedChildIds.add(task.id);
changedTaskIds.add(task.id);
}
continue;
}
updatedTasks.add(task);
}
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(updatedTasks),
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
completedParentId: parent.id,
forceCompletedChildIds: List<String>.unmodifiable(forceCompletedChildIds),
activities: List<TaskActivity>.unmodifiable(activities),
transitionOutcomeCodes: List<TaskTransitionOutcomeCode>.unmodifiable(
outcomes,
),
);
}
TaskTransitionResult _completeWithTransition({
required Task task,
required String operationId,
required DateTime occurredAt,
required Iterable<TaskActivity> existingActivities,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata,
}) {
return transitionService.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: operationId,
activityId: _activityId(
operationId,
task.id,
TaskActivityCode.completed,
),
occurredAt: occurredAt,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: metadata,
);
}
}
/// Status counts for a parent's direct children.
class ChildTaskSummary {
const ChildTaskSummary({
required this.totalCount,
required this.plannedCount,
required this.activeCount,
required this.completedCount,
required this.missedCount,
required this.cancelledCount,
required this.noLongerRelevantCount,
required this.backlogCount,
});
/// Build counts from [children].
factory ChildTaskSummary.fromChildren(List<Task> children) {
var plannedCount = 0;
var activeCount = 0;
var completedCount = 0;
var missedCount = 0;
var cancelledCount = 0;
var noLongerRelevantCount = 0;
var backlogCount = 0;
for (final child in children) {
switch (child.status) {
case TaskStatus.planned:
plannedCount += 1;
case TaskStatus.active:
activeCount += 1;
case TaskStatus.completed:
completedCount += 1;
case TaskStatus.missed:
missedCount += 1;
case TaskStatus.cancelled:
cancelledCount += 1;
case TaskStatus.noLongerRelevant:
noLongerRelevantCount += 1;
case TaskStatus.backlog:
backlogCount += 1;
}
}
return ChildTaskSummary(
totalCount: children.length,
plannedCount: plannedCount,
activeCount: activeCount,
completedCount: completedCount,
missedCount: missedCount,
cancelledCount: cancelledCount,
noLongerRelevantCount: noLongerRelevantCount,
backlogCount: backlogCount,
);
}
/// Total direct children counted.
final int totalCount;
/// Children in planned status.
final int plannedCount;
/// Children in active status.
final int activeCount;
/// Children in completed status.
final int completedCount;
/// Children in missed status.
final int missedCount;
/// Children in cancelled status.
final int cancelledCount;
/// Children marked no longer relevant.
final int noLongerRelevantCount;
/// Children currently in backlog.
final int backlogCount;
/// Whether at least one direct child exists.
bool get hasChildren => totalCount > 0;
/// Whether every direct child is completed.
bool get allChildrenCompleted => hasChildren && completedCount == totalCount;
}
class _IndexedChild {
const _IndexedChild({
required this.task,
required this.originalIndex,
});
final Task task;
final int originalIndex;
}
int _priorityRank(PriorityLevel? priority) {
return switch (priority) {
PriorityLevel.veryLow => 0,
PriorityLevel.low => 1,
PriorityLevel.medium => 2,
PriorityLevel.high => 3,
PriorityLevel.veryHigh => 4,
null => -1,
};
}
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}
List<Task> _replaceTask(List<Task> tasks, Task replacement) {
return tasks
.map((task) => task.id == replacement.id ? replacement : task)
.toList(growable: false);
}
String _defaultOperationId(String actionName, String taskId, DateTime at) {
return '$actionName:$taskId:${at.toIso8601String()}';
}
String _activityId(
String operationId,
String taskId,
TaskActivityCode activityCode,
) {
return '$operationId:$taskId:${activityCode.name}';
}
String _requiredTrimmed(String value, String name) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw ArgumentError.value(value, name, 'Value is required.');
}
return trimmed;
}
part 'child_tasks/child_task_entry.dart';
part 'child_tasks/child_task_break_up_request.dart';
part 'child_tasks/child_task_break_up_result.dart';
part 'child_tasks/child_task_break_up_service.dart';
part 'child_tasks/child_task_view.dart';
part 'child_tasks/child_task_completion_result.dart';
part 'child_tasks/child_task_completion_service.dart';
part 'child_tasks/child_task_summary.dart';
part 'child_tasks/indexed_child.dart';

View file

@ -0,0 +1,21 @@
part of '../child_tasks.dart';
/// Command input for breaking one parent task into direct children.
class ChildTaskBreakUpRequest {
ChildTaskBreakUpRequest({
required String parentTaskId,
required List<ChildTaskEntry> entries,
required String operationId,
}) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'),
entries = List<ChildTaskEntry>.unmodifiable(entries),
operationId = _requiredTrimmed(operationId, 'operationId');
/// Parent task that will own every created child.
final String parentTaskId;
/// Ordered child rows supplied by the caller.
final List<ChildTaskEntry> entries;
/// Idempotency key for the application command that creates the children.
final String operationId;
}

View file

@ -0,0 +1,37 @@
part of '../child_tasks.dart';
/// Result from breaking a parent task into direct children.
class ChildTaskBreakUpResult {
ChildTaskBreakUpResult({
required List<Task> tasks,
required this.parentTask,
required List<Task> childTasks,
required this.operationId,
List<String> createdChildTaskIds = const <String>[],
List<TaskActivity> activities = const <TaskActivity>[],
}) : tasks = List<Task>.unmodifiable(tasks),
childTasks = List<Task>.unmodifiable(childTasks),
createdChildTaskIds = List<String>.unmodifiable(createdChildTaskIds),
activities = List<TaskActivity>.unmodifiable(activities);
/// Replacement task list including newly created children.
final List<Task> tasks;
/// Parent that owns the created children.
final Task parentTask;
/// Created children in request order.
final List<Task> childTasks;
/// Idempotency key for the command.
final String operationId;
/// Child ids created by this command.
final List<String> createdChildTaskIds;
/// Activity records produced by this command.
///
/// V1 child creation is represented by task inserts, so this is currently
/// empty while still giving application use cases one atomic mutation shape.
final List<TaskActivity> activities;
}

View file

@ -0,0 +1,74 @@
part of '../child_tasks.dart';
/// Creates direct child tasks from ordered child-entry rows.
class ChildTaskBreakUpService {
const ChildTaskBreakUpService();
/// Break `request.parentTaskId` into direct child tasks.
///
/// This service validates the full requested set before returning any
/// mutations. Persistence and transaction wiring are handled by later blocks.
ChildTaskBreakUpResult breakUp({
required List<Task> tasks,
required ChildTaskBreakUpRequest request,
}) {
final parent = _taskById(tasks, request.parentTaskId);
if (parent == null) {
throw ArgumentError.value(
request.parentTaskId,
'parentTaskId',
'Parent not found.',
);
}
if (request.entries.isEmpty) {
throw ArgumentError.value(
request.entries,
'entries',
'At least one child task is required.',
);
}
final existingIds = tasks.map((task) => task.id).toSet();
final requestedIds = <String>{};
for (final entry in request.entries) {
final childId = entry.id.trim();
if (childId.isEmpty) {
throw ArgumentError.value(entry.id, 'id', 'Child id is required.');
}
if (childId == parent.id) {
throw ArgumentError.value(
entry.id,
'id',
'Child id cannot match the parent id.',
);
}
if (!requestedIds.add(childId)) {
throw ArgumentError.value(
entry.id,
'id',
'Child ids must be unique within one break-up command.',
);
}
if (existingIds.contains(childId)) {
throw ArgumentError.value(
entry.id,
'id',
'Child id is already used by another task.',
);
}
}
final childTasks = request.entries
.map((entry) => entry.toTask(parent: parent))
.toList(growable: false);
return ChildTaskBreakUpResult(
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),
parentTask: parent,
childTasks: childTasks,
operationId: request.operationId,
createdChildTaskIds:
childTasks.map((child) => child.id).toList(growable: false),
);
}
}

View file

@ -0,0 +1,50 @@
part of '../child_tasks.dart';
/// Result from child/parent completion propagation.
class ChildTaskCompletionResult {
ChildTaskCompletionResult({
required List<Task> tasks,
required List<String> changedTaskIds,
this.completedChildId,
this.completedParentId,
List<String> forceCompletedChildIds = const <String>[],
List<TaskActivity> activities = const <TaskActivity>[],
List<TaskTransitionOutcomeCode> transitionOutcomeCodes =
const <TaskTransitionOutcomeCode>[],
this.autoCompletedParent = false,
this.canCompleteParentExplicitly = false,
}) : tasks = List<Task>.unmodifiable(tasks),
changedTaskIds = List<String>.unmodifiable(changedTaskIds),
forceCompletedChildIds =
List<String>.unmodifiable(forceCompletedChildIds),
activities = List<TaskActivity>.unmodifiable(activities),
transitionOutcomeCodes = List<TaskTransitionOutcomeCode>.unmodifiable(
transitionOutcomeCodes);
/// Replacement task list after the completion action.
final List<Task> tasks;
/// Task ids whose completion state changed.
final List<String> changedTaskIds;
/// Child id completed by the initial child-complete action, when applicable.
final String? completedChildId;
/// Parent id completed by the action, when applicable.
final String? completedParentId;
/// Direct child ids completed by explicit parent-complete behavior.
final List<String> forceCompletedChildIds;
/// Internal activities produced by parent/child completion propagation.
final List<TaskActivity> activities;
/// Transition outcomes returned while applying completion propagation.
final List<TaskTransitionOutcomeCode> transitionOutcomeCodes;
/// Whether the parent was completed because all direct children are complete.
final bool autoCompletedParent;
/// Whether callers may offer an explicit parent-complete action.
final bool canCompleteParentExplicitly;
}

View file

@ -0,0 +1,267 @@
part of '../child_tasks.dart';
/// Applies direct parent/child completion rules.
///
/// This service intentionally handles only one ownership level. It does not walk
/// dependency graphs, and it only completes sibling children when the caller
/// explicitly completes the parent.
class ChildTaskCompletionService {
const ChildTaskCompletionService({
this.clock = const SystemClock(),
this.transitionService = const TaskTransitionService(),
});
/// Clock boundary used when a completion timestamp is not supplied.
final Clock clock;
/// Canonical lifecycle transition dependency.
final TaskTransitionService transitionService;
/// Complete one child and auto-complete its parent only when all children are complete.
ChildTaskCompletionResult completeChild({
required List<Task> tasks,
required String childTaskId,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final now = updatedAt ?? clock.now();
final child = _taskById(tasks, childTaskId);
if (child == null) {
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
}
final parentId = child.parentTaskId;
if (parentId == null) {
throw ArgumentError.value(
childTaskId, 'childTaskId', 'Task is not a child.');
}
final parent = _taskById(tasks, parentId);
if (parent == null) {
throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
}
final opId =
operationId ?? _defaultOperationId('complete-child', child.id, now);
final childTransition = _completeWithTransition(
task: child,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': 'child',
'parentTaskId': parent.id,
},
);
final withChildCompleted = _replaceTask(tasks, childTransition.task);
final view = ChildTaskView(tasks: withChildCompleted);
final shouldCompleteParent = parent.status != TaskStatus.completed &&
view.parentShouldAutoComplete(parent);
final activities = <TaskActivity>[...childTransition.activities];
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
if (!shouldCompleteParent) {
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(withChildCompleted),
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
completedChildId: child.id,
canCompleteParentExplicitly: parent.status != TaskStatus.completed,
activities: activities,
transitionOutcomeCodes: outcomes,
);
}
final parentTransition = _completeWithTransition(
task: parent,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': 'autoParentAfterLastChild',
'completedChildTaskId': child.id,
},
);
activities.addAll(parentTransition.activities);
outcomes.add(parentTransition.outcomeCode);
final updatedParent = parentTransition.task;
final completedTasks = _replaceTask(withChildCompleted, updatedParent);
final changedTaskIds = <String>[
if (childTransition.applied) child.id,
if (parentTransition.applied) parent.id,
];
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(completedTasks),
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
completedChildId: child.id,
completedParentId: parent.id,
autoCompletedParent: true,
activities: activities,
transitionOutcomeCodes: outcomes,
);
}
/// Complete a parent from one of its direct children and force-complete siblings.
ChildTaskCompletionResult completeParentFromChild({
required List<Task> tasks,
required String childTaskId,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final child = _taskById(tasks, childTaskId);
if (child == null) {
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
}
final parentId = child.parentTaskId;
if (parentId == null) {
throw ArgumentError.value(
childTaskId,
'childTaskId',
'Task is not a child.',
);
}
return completeParent(
tasks: tasks,
parentTaskId: parentId,
updatedAt: updatedAt,
operationId: operationId,
initiatingChildTaskId: child.id,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
);
}
/// Explicitly complete a parent and any direct children not already completed.
ChildTaskCompletionResult completeParent({
required List<Task> tasks,
required String parentTaskId,
DateTime? updatedAt,
String? operationId,
String? initiatingChildTaskId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final now = updatedAt ?? clock.now();
final parent = _taskById(tasks, parentTaskId);
if (parent == null) {
throw ArgumentError.value(
parentTaskId, 'parentTaskId', 'Parent not found.');
}
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
if (initiatingChildTaskId != null &&
!directChildren.any((child) => child.id == initiatingChildTaskId)) {
throw ArgumentError.value(
initiatingChildTaskId,
'initiatingChildTaskId',
'Initiating task must be a direct child of the parent.',
);
}
final opId =
operationId ?? _defaultOperationId('complete-parent', parent.id, now);
final forceCompletedChildIds = <String>[];
final changedTaskIds = <String>[];
final activities = <TaskActivity>[];
final outcomes = <TaskTransitionOutcomeCode>[];
final updatedTasks = <Task>[];
for (final task in tasks) {
if (task.id == parent.id) {
final transition = _completeWithTransition(
task: task,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource':
initiatingChildTaskId == null ? 'parent' : 'parentFromChild',
if (initiatingChildTaskId != null)
'initiatingChildTaskId': initiatingChildTaskId,
},
);
final completedParent = transition.task;
updatedTasks.add(completedParent);
activities.addAll(transition.activities);
outcomes.add(transition.outcomeCode);
if (transition.applied) {
changedTaskIds.add(task.id);
}
continue;
}
final isDirectChild = directChildren.any((child) => child.id == task.id);
if (isDirectChild && task.status != TaskStatus.completed) {
final transition = _completeWithTransition(
task: task,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': initiatingChildTaskId == null
? 'parentForce'
: 'childForceParent',
'parentTaskId': parent.id,
if (initiatingChildTaskId != null)
'initiatingChildTaskId': initiatingChildTaskId,
},
);
updatedTasks.add(transition.task);
activities.addAll(transition.activities);
outcomes.add(transition.outcomeCode);
if (transition.applied) {
forceCompletedChildIds.add(task.id);
changedTaskIds.add(task.id);
}
continue;
}
updatedTasks.add(task);
}
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(updatedTasks),
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
completedParentId: parent.id,
forceCompletedChildIds: List<String>.unmodifiable(forceCompletedChildIds),
activities: List<TaskActivity>.unmodifiable(activities),
transitionOutcomeCodes: List<TaskTransitionOutcomeCode>.unmodifiable(
outcomes,
),
);
}
TaskTransitionResult _completeWithTransition({
required Task task,
required String operationId,
required DateTime occurredAt,
required Iterable<TaskActivity> existingActivities,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata,
}) {
return transitionService.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: operationId,
activityId: _activityId(
operationId,
task.id,
TaskActivityCode.completed,
),
occurredAt: occurredAt,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: metadata,
);
}
}

View file

@ -0,0 +1,72 @@
part of '../child_tasks.dart';
/// Row-style input for creating one child task.
///
/// Priority is nullable on purpose. A null priority means the user did not set
/// one, so callers should preserve row insertion order instead of treating the
/// child as medium priority.
class ChildTaskEntry {
const ChildTaskEntry({
required this.id,
required this.title,
required this.createdAt,
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
this.durationMinutes,
this.projectId,
this.updatedAt,
});
/// Caller-generated child id.
final String id;
/// User-entered child title.
final String title;
/// Creation timestamp supplied by the caller.
final DateTime createdAt;
/// Optional explicit child priority.
final PriorityLevel? priority;
/// Optional explicit child reward. Missing reward stays `notSet`.
final RewardLevel reward;
/// Optional explicit child difficulty.
final DifficultyLevel difficulty;
/// Optional child duration estimate.
final int? durationMinutes;
/// Optional project override. Null means inherit the parent project.
final String? projectId;
/// Optional update timestamp.
final DateTime? updatedAt;
/// Convert this entry into a child [Task] owned by [parent].
Task toTask({
required Task parent,
}) {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw ArgumentError.value(title, 'title', 'Title is required.');
}
return Task(
id: id,
title: trimmedTitle,
projectId: projectId ?? parent.projectId,
type: TaskType.flexible,
status: TaskStatus.backlog,
priority: priority,
reward: reward,
difficulty: difficulty,
durationMinutes: durationMinutes,
parentTaskId: parent.id,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);
}
}

View file

@ -0,0 +1,86 @@
part of '../child_tasks.dart';
/// Status counts for a parent's direct children.
class ChildTaskSummary {
const ChildTaskSummary({
required this.totalCount,
required this.plannedCount,
required this.activeCount,
required this.completedCount,
required this.missedCount,
required this.cancelledCount,
required this.noLongerRelevantCount,
required this.backlogCount,
});
/// Build counts from [children].
factory ChildTaskSummary.fromChildren(List<Task> children) {
var plannedCount = 0;
var activeCount = 0;
var completedCount = 0;
var missedCount = 0;
var cancelledCount = 0;
var noLongerRelevantCount = 0;
var backlogCount = 0;
for (final child in children) {
switch (child.status) {
case TaskStatus.planned:
plannedCount += 1;
case TaskStatus.active:
activeCount += 1;
case TaskStatus.completed:
completedCount += 1;
case TaskStatus.missed:
missedCount += 1;
case TaskStatus.cancelled:
cancelledCount += 1;
case TaskStatus.noLongerRelevant:
noLongerRelevantCount += 1;
case TaskStatus.backlog:
backlogCount += 1;
}
}
return ChildTaskSummary(
totalCount: children.length,
plannedCount: plannedCount,
activeCount: activeCount,
completedCount: completedCount,
missedCount: missedCount,
cancelledCount: cancelledCount,
noLongerRelevantCount: noLongerRelevantCount,
backlogCount: backlogCount,
);
}
/// Total direct children counted.
final int totalCount;
/// Children in planned status.
final int plannedCount;
/// Children in active status.
final int activeCount;
/// Children in completed status.
final int completedCount;
/// Children in missed status.
final int missedCount;
/// Children in cancelled status.
final int cancelledCount;
/// Children marked no longer relevant.
final int noLongerRelevantCount;
/// Children currently in backlog.
final int backlogCount;
/// Whether at least one direct child exists.
bool get hasChildren => totalCount > 0;
/// Whether every direct child is completed.
bool get allChildrenCompleted => hasChildren && completedCount == totalCount;
}

View file

@ -0,0 +1,87 @@
part of '../child_tasks.dart';
/// Read-only parent/child projection over a task list.
///
/// This is intentionally not a scheduler. It answers ownership questions such
/// as "which tasks belong to this parent?" while leaving task placement and
/// completion rules to other domain services.
class ChildTaskView {
ChildTaskView({
required List<Task> tasks,
}) : tasks = List<Task>.unmodifiable(tasks);
/// Source task list supplied by the caller.
final List<Task> tasks;
/// Direct children owned by [parent], preserving source-list order.
List<Task> childrenOf(Task parent) {
return List<Task>.unmodifiable(
tasks.where((task) => task.parentTaskId == parent.id),
);
}
/// Direct children sorted by explicit priority while preserving row order ties.
///
/// Children without priority sort after explicit priorities. Within each
/// priority group, including the no-priority group, source-list order is kept.
List<Task> childrenOfSortedByPriority(Task parent) {
final indexedChildren = childrenOf(parent)
.asMap()
.entries
.map(
(entry) => _IndexedChild(
task: entry.value,
originalIndex: entry.key,
),
)
.toList(growable: false);
indexedChildren.sort((a, b) {
final priorityComparison = _priorityRank(b.task.priority)
.compareTo(_priorityRank(a.task.priority));
if (priorityComparison != 0) {
return priorityComparison;
}
return a.originalIndex.compareTo(b.originalIndex);
});
return List<Task>.unmodifiable(
indexedChildren.map((child) => child.task),
);
}
/// Parent task for [child], or null when the parent is not in [tasks].
Task? parentOf(Task child) {
final parentId = child.parentTaskId;
if (parentId == null) {
return null;
}
for (final task in tasks) {
if (task.id == parentId) {
return task;
}
}
return null;
}
/// Whether [child] is directly owned by [parent].
bool isChildOf({
required Task child,
required Task parent,
}) {
return child.parentTaskId == parent.id;
}
/// Aggregate direct child status counts for [parent].
ChildTaskSummary summaryFor(Task parent) {
return ChildTaskSummary.fromChildren(childrenOf(parent));
}
/// Whether this helper would auto-complete [parent].
bool parentShouldAutoComplete(Task parent) {
return summaryFor(parent).allChildrenCompleted;
}
}

View file

@ -0,0 +1,58 @@
part of '../child_tasks.dart';
class _IndexedChild {
const _IndexedChild({
required this.task,
required this.originalIndex,
});
final Task task;
final int originalIndex;
}
int _priorityRank(PriorityLevel? priority) {
return switch (priority) {
PriorityLevel.veryLow => 0,
PriorityLevel.low => 1,
PriorityLevel.medium => 2,
PriorityLevel.high => 3,
PriorityLevel.veryHigh => 4,
null => -1,
};
}
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}
List<Task> _replaceTask(List<Task> tasks, Task replacement) {
return tasks
.map((task) => task.id == replacement.id ? replacement : task)
.toList(growable: false);
}
String _defaultOperationId(String actionName, String taskId, DateTime at) {
return '$actionName:$taskId:${at.toIso8601String()}';
}
String _activityId(
String operationId,
String taskId,
TaskActivityCode activityCode,
) {
return '$operationId:$taskId:${activityCode.name}';
}
String _requiredTrimmed(String value, String name) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw ArgumentError.value(value, name, 'Value is required.');
}
return trimmed;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
part of '../document_mapping.dart';
/// Document mapping for [ApplicationOperationRecord].
abstract final class ApplicationOperationDocumentMapping {
static Map<String, Object?> toDocument(
ApplicationOperationRecord record, {
int revision = 1,
}) {
return {
..._commonFields(
id: record.operationId,
ownerId: record.ownerId,
revision: revision,
createdAt: record.committedAt,
updatedAt: record.committedAt,
),
ApplicationOperationDocumentFields.operationId: record.operationId,
ApplicationOperationDocumentFields.operationName: record.operationName,
ApplicationOperationDocumentFields.committedAt:
PersistenceDateTimeConvention.toStoredString(record.committedAt),
};
}
static ApplicationOperationRecord fromDocument(
Map<String, Object?> document,
) {
return _mapInvalidValues(() {
_readCommon(document);
return ApplicationOperationRecord(
operationId: _requiredString(
document,
ApplicationOperationDocumentFields.operationId,
),
ownerId: _requiredString(
document,
ApplicationOperationDocumentFields.ownerId,
),
operationName: _requiredString(
document,
ApplicationOperationDocumentFields.operationName,
),
committedAt: _requiredDateTime(
document,
ApplicationOperationDocumentFields.committedAt,
),
);
});
}
}

View file

@ -0,0 +1,36 @@
part of '../document_mapping.dart';
/// Document mapping for [BacklogStalenessSettings].
abstract final class BacklogStalenessDocumentMapping {
static Map<String, Object?> toDocument(BacklogStalenessSettings settings) {
return {
BacklogStalenessDocumentFields.greenMaxAgeDays:
settings.greenMaxAge.inDays,
BacklogStalenessDocumentFields.blueMaxAgeDays: settings.blueMaxAge.inDays,
};
}
static BacklogStalenessSettings fromDocument(Map<String, Object?> document) {
return _mapInvalidValues(() {
final greenDays = _requiredInt(
document,
BacklogStalenessDocumentFields.greenMaxAgeDays,
);
final blueDays = _requiredInt(
document,
BacklogStalenessDocumentFields.blueMaxAgeDays,
);
if (greenDays < 0 || blueDays < 0 || blueDays < greenDays) {
throw const DocumentMappingException(
code: DocumentMappingFailureCode.invalidValue,
fieldName: OwnerSettingsDocumentFields.backlogStaleness,
detailCode: 'invalidBacklogStalenessThresholds',
);
}
return BacklogStalenessSettings(
greenMaxAge: Duration(days: greenDays),
blueMaxAge: Duration(days: blueDays),
);
});
}
}

View file

@ -0,0 +1,25 @@
part of '../document_mapping.dart';
/// Typed mapping failure for malformed V1 documents.
class DocumentMappingException implements Exception {
const DocumentMappingException({
required this.code,
this.fieldName,
this.detailCode,
});
/// Stable category for caller branching.
final DocumentMappingFailureCode code;
/// Field related to the failure, when known.
final String? fieldName;
/// Optional narrower reason.
final String? detailCode;
@override
String toString() {
return 'DocumentMappingException($code, field: $fieldName, detail: '
'$detailCode)';
}
}

View file

@ -0,0 +1,11 @@
part of '../document_mapping.dart';
/// Stable document mapping failure categories.
enum DocumentMappingFailureCode {
missingField,
wrongType,
unknownCode,
invalidSchemaVersion,
invalidRevision,
invalidValue,
}

View file

@ -0,0 +1,18 @@
part of '../document_mapping.dart';
/// Common metadata decoded from a V1 top-level document.
class DocumentMetadata {
const DocumentMetadata({
required this.id,
required this.ownerId,
required this.revision,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String ownerId;
final int revision;
final DateTime createdAt;
final DateTime updatedAt;
}

View file

@ -0,0 +1,81 @@
part of '../document_mapping.dart';
/// Document mapping for [LockedBlock].
abstract final class LockedBlockDocumentMapping {
static Map<String, Object?> toDocument(
LockedBlock block, {
required String ownerId,
int revision = 1,
}) {
return {
..._commonFields(
id: block.id,
ownerId: ownerId,
revision: revision,
createdAt: block.createdAt,
updatedAt: block.updatedAt,
),
LockedBlockDocumentFields.name: block.name,
LockedBlockDocumentFields.startTime:
PersistenceWallTimeConvention.toStoredString(block.startTime),
LockedBlockDocumentFields.endTime:
PersistenceWallTimeConvention.toStoredString(block.endTime),
LockedBlockDocumentFields.date: _civilDateToStored(block.date),
LockedBlockDocumentFields.recurrence: block.recurrence == null
? null
: LockedBlockRecurrenceDocumentMapping.toDocument(
block.recurrence!,
),
LockedBlockDocumentFields.hiddenByDefault: block.hiddenByDefault,
LockedBlockDocumentFields.projectId: block.projectId,
LockedBlockDocumentFields.archivedAt: _dateTimeToStored(block.archivedAt),
};
}
static LockedBlock fromDocument(Map<String, Object?> document) {
return _mapInvalidValues(() {
_readCommon(document);
final recurrenceDocument = _requiredNullableMap(
document,
LockedBlockDocumentFields.recurrence,
);
return LockedBlock(
id: _requiredString(document, LockedBlockDocumentFields.id),
name: _requiredString(document, LockedBlockDocumentFields.name),
startTime: _requiredWallTime(
document,
LockedBlockDocumentFields.startTime,
),
endTime: _requiredWallTime(
document,
LockedBlockDocumentFields.endTime,
),
date: _requiredNullableCivilDate(
document,
LockedBlockDocumentFields.date,
),
recurrence: recurrenceDocument == null
? null
: LockedBlockRecurrenceDocumentMapping.fromDocument(
recurrenceDocument,
),
hiddenByDefault: _requiredBool(
document,
LockedBlockDocumentFields.hiddenByDefault,
),
projectId: _requiredNullableString(
document,
LockedBlockDocumentFields.projectId,
),
createdAt:
_requiredDateTime(document, LockedBlockDocumentFields.createdAt),
updatedAt:
_requiredDateTime(document, LockedBlockDocumentFields.updatedAt),
archivedAt: _requiredNullableDateTime(
document,
LockedBlockDocumentFields.archivedAt,
),
);
});
}
}

View file

@ -0,0 +1,81 @@
part of '../document_mapping.dart';
/// Document mapping for [LockedBlockOverride].
abstract final class LockedBlockOverrideDocumentMapping {
static Map<String, Object?> toDocument(
LockedBlockOverride override, {
required String ownerId,
int revision = 1,
}) {
return {
..._commonFields(
id: override.id,
ownerId: ownerId,
revision: revision,
createdAt: override.createdAt,
updatedAt: override.updatedAt,
),
LockedBlockOverrideDocumentFields.lockedBlockId: override.lockedBlockId,
LockedBlockOverrideDocumentFields.date:
PersistenceCivilDateConvention.toStoredString(override.date),
LockedBlockOverrideDocumentFields.type:
PersistenceEnumMapping.encodeLockedBlockOverrideType(override.type),
LockedBlockOverrideDocumentFields.name: override.name,
LockedBlockOverrideDocumentFields.startTime:
_wallTimeToStored(override.startTime),
LockedBlockOverrideDocumentFields.endTime:
_wallTimeToStored(override.endTime),
LockedBlockOverrideDocumentFields.hiddenByDefault:
override.hiddenByDefault,
LockedBlockOverrideDocumentFields.projectId: override.projectId,
};
}
static LockedBlockOverride fromDocument(Map<String, Object?> document) {
return _mapInvalidValues(() {
_readCommon(document);
return LockedBlockOverride(
id: _requiredString(document, LockedBlockOverrideDocumentFields.id),
date: _requiredCivilDate(
document,
LockedBlockOverrideDocumentFields.date,
),
type: PersistenceEnumMapping.decodeLockedBlockOverrideType(
_requiredString(document, LockedBlockOverrideDocumentFields.type),
),
createdAt: _requiredDateTime(
document,
LockedBlockOverrideDocumentFields.createdAt,
),
updatedAt: _requiredDateTime(
document,
LockedBlockOverrideDocumentFields.updatedAt,
),
lockedBlockId: _requiredNullableString(
document,
LockedBlockOverrideDocumentFields.lockedBlockId,
),
name: _requiredNullableString(
document,
LockedBlockOverrideDocumentFields.name,
),
startTime: _requiredNullableWallTime(
document,
LockedBlockOverrideDocumentFields.startTime,
),
endTime: _requiredNullableWallTime(
document,
LockedBlockOverrideDocumentFields.endTime,
),
hiddenByDefault: _requiredBool(
document,
LockedBlockOverrideDocumentFields.hiddenByDefault,
),
projectId: _requiredNullableString(
document,
LockedBlockOverrideDocumentFields.projectId,
),
);
});
}
}

View file

@ -0,0 +1,42 @@
part of '../document_mapping.dart';
/// Document mapping for [LockedBlockRecurrence].
abstract final class LockedBlockRecurrenceDocumentMapping {
static Map<String, Object?> toDocument(LockedBlockRecurrence recurrence) {
return {
LockedBlockRecurrenceDocumentFields.type: 'weekly',
LockedBlockRecurrenceDocumentFields.weekdays: recurrence.weekdays
.map(PersistenceEnumMapping.encodeLockedWeekday)
.toList(growable: false),
};
}
static LockedBlockRecurrence fromDocument(Map<String, Object?> document) {
return _mapInvalidValues(() {
final type = _requiredString(
document,
LockedBlockRecurrenceDocumentFields.type,
);
if (type != 'weekly') {
throw DocumentMappingException(
code: DocumentMappingFailureCode.unknownCode,
fieldName: LockedBlockRecurrenceDocumentFields.type,
detailCode: type,
);
}
final weekdays = _requiredList(
document,
LockedBlockRecurrenceDocumentFields.weekdays,
).map((value) {
if (value is! String) {
throw const DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: LockedBlockRecurrenceDocumentFields.weekdays,
);
}
return PersistenceEnumMapping.decodeLockedWeekday(value);
}).toSet();
return LockedBlockRecurrence.weekly(weekdays: weekdays);
});
}
}

View file

@ -0,0 +1,44 @@
part of '../document_mapping.dart';
/// Document mapping for [NoticeAcknowledgementRecord].
abstract final class NoticeAcknowledgementDocumentMapping {
static Map<String, Object?> toDocument(
NoticeAcknowledgementRecord record, {
int revision = 1,
}) {
return {
..._commonFields(
id: record.noticeId,
ownerId: record.ownerId,
revision: revision,
createdAt: record.acknowledgedAt,
updatedAt: record.acknowledgedAt,
),
NoticeAcknowledgementDocumentFields.noticeId: record.noticeId,
NoticeAcknowledgementDocumentFields.acknowledgedAt:
PersistenceDateTimeConvention.toStoredString(record.acknowledgedAt),
};
}
static NoticeAcknowledgementRecord fromDocument(
Map<String, Object?> document,
) {
return _mapInvalidValues(() {
_readCommon(document);
return NoticeAcknowledgementRecord(
noticeId: _requiredString(
document,
NoticeAcknowledgementDocumentFields.noticeId,
),
ownerId: _requiredString(
document,
NoticeAcknowledgementDocumentFields.ownerId,
),
acknowledgedAt: _requiredDateTime(
document,
NoticeAcknowledgementDocumentFields.acknowledgedAt,
),
);
});
}
}

View file

@ -0,0 +1,60 @@
part of '../document_mapping.dart';
/// Document mapping for [OwnerSettings].
abstract final class OwnerSettingsDocumentMapping {
static Map<String, Object?> toDocument(
OwnerSettings settings, {
required DateTime createdAt,
required DateTime updatedAt,
int revision = 1,
}) {
return {
..._commonFields(
id: settings.ownerId,
ownerId: settings.ownerId,
revision: revision,
createdAt: createdAt,
updatedAt: updatedAt,
),
OwnerSettingsDocumentFields.timeZoneId: settings.timeZoneId,
OwnerSettingsDocumentFields.dayStart:
PersistenceWallTimeConvention.toStoredString(settings.dayStart),
OwnerSettingsDocumentFields.dayEnd:
PersistenceWallTimeConvention.toStoredString(settings.dayEnd),
OwnerSettingsDocumentFields.compactModeEnabled:
settings.compactModeEnabled,
OwnerSettingsDocumentFields.backlogStaleness:
BacklogStalenessDocumentMapping.toDocument(
settings.backlogStalenessSettings,
),
};
}
static OwnerSettings fromDocument(Map<String, Object?> document) {
return _mapInvalidValues(() {
_readCommon(document);
return OwnerSettings(
ownerId: _requiredString(document, OwnerSettingsDocumentFields.ownerId),
timeZoneId: _requiredString(
document,
OwnerSettingsDocumentFields.timeZoneId,
),
dayStart: _requiredWallTime(
document,
OwnerSettingsDocumentFields.dayStart,
),
dayEnd: _requiredWallTime(
document,
OwnerSettingsDocumentFields.dayEnd,
),
compactModeEnabled: _requiredBool(
document,
OwnerSettingsDocumentFields.compactModeEnabled,
),
backlogStalenessSettings: BacklogStalenessDocumentMapping.fromDocument(
_requiredMap(document, OwnerSettingsDocumentFields.backlogStaleness),
),
);
});
}
}

View file

@ -0,0 +1,288 @@
part of '../document_mapping.dart';
/// Stable enum encode/decode helpers for V1 document maps.
abstract final class PersistenceEnumMapping {
static String? encodeNullable(Enum? value) {
return value == null ? null : encode(value);
}
static String encode(Enum value) {
if (value is TaskType) return encodeTaskType(value);
if (value is TaskStatus) return encodeTaskStatus(value);
if (value is PriorityLevel) return encodePriority(value);
if (value is RewardLevel) return encodeReward(value);
if (value is DifficultyLevel) return encodeDifficulty(value);
if (value is ReminderProfile) return encodeReminderProfile(value);
if (value is BacklogTag) return encodeBacklogTag(value);
if (value is LockedWeekday) return encodeLockedWeekday(value);
if (value is LockedBlockOverrideType) {
return encodeLockedBlockOverrideType(value);
}
if (value is TaskActivityCode) return encodeTaskActivityCode(value);
if (value is SchedulingNoticeType) return encodeSchedulingNoticeType(value);
if (value is SchedulingIssueCode) return encodeSchedulingIssueCode(value);
if (value is SchedulingMovementCode) {
return encodeSchedulingMovementCode(value);
}
if (value is SchedulingConflictCode) {
return encodeSchedulingConflictCode(value);
}
if (value is ProjectCompletionTimeBucket) {
return encodeProjectCompletionTimeBucket(value);
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.unknownCode,
detailCode: value.runtimeType.toString(),
);
}
static String encodeTaskType(TaskType value) => switch (value) {
TaskType.flexible => 'flexible',
TaskType.inflexible => 'inflexible',
TaskType.critical => 'critical',
TaskType.locked => 'locked',
TaskType.surprise => 'surprise',
TaskType.freeSlot => 'free_slot',
};
static TaskType decodeTaskType(String value) {
return _decodeCode(_taskTypeByCode, value, 'TaskType');
}
static String encodeTaskStatus(TaskStatus value) => switch (value) {
TaskStatus.planned => 'planned',
TaskStatus.active => 'active',
TaskStatus.completed => 'completed',
TaskStatus.missed => 'missed',
TaskStatus.cancelled => 'cancelled',
TaskStatus.noLongerRelevant => 'no_longer_relevant',
TaskStatus.backlog => 'backlog',
};
static TaskStatus decodeTaskStatus(String value) {
return _decodeCode(_taskStatusByCode, value, 'TaskStatus');
}
static String encodePriority(PriorityLevel value) => switch (value) {
PriorityLevel.veryLow => 'very_low',
PriorityLevel.low => 'low',
PriorityLevel.medium => 'medium',
PriorityLevel.high => 'high',
PriorityLevel.veryHigh => 'very_high',
};
static PriorityLevel? decodeNullablePriority(String? value) {
return value == null
? null
: _decodeCode(_priorityByCode, value, 'PriorityLevel');
}
static String encodeReward(RewardLevel value) => switch (value) {
RewardLevel.notSet => 'not_set',
RewardLevel.veryLow => 'very_low',
RewardLevel.low => 'low',
RewardLevel.medium => 'medium',
RewardLevel.high => 'high',
RewardLevel.veryHigh => 'very_high',
};
static RewardLevel decodeReward(String value) {
return _decodeCode(_rewardByCode, value, 'RewardLevel');
}
static String encodeDifficulty(DifficultyLevel value) => switch (value) {
DifficultyLevel.notSet => 'not_set',
DifficultyLevel.veryEasy => 'very_easy',
DifficultyLevel.easy => 'easy',
DifficultyLevel.medium => 'medium',
DifficultyLevel.hard => 'hard',
DifficultyLevel.veryHard => 'very_hard',
};
static DifficultyLevel decodeDifficulty(String value) {
return _decodeCode(_difficultyByCode, value, 'DifficultyLevel');
}
static String encodeReminderProfile(ReminderProfile value) => switch (value) {
ReminderProfile.silent => 'silent',
ReminderProfile.gentle => 'gentle',
ReminderProfile.persistent => 'persistent',
ReminderProfile.strict => 'strict',
};
static ReminderProfile? decodeNullableReminderProfile(String? value) {
return value == null
? null
: _decodeCode(_reminderProfileByCode, value, 'ReminderProfile');
}
static String encodeBacklogTag(BacklogTag value) => switch (value) {
BacklogTag.wishlist => 'wishlist',
};
static BacklogTag decodeBacklogTag(String value) {
return _decodeCode(_backlogTagByCode, value, 'BacklogTag');
}
static String encodeLockedWeekday(LockedWeekday value) => switch (value) {
LockedWeekday.monday => 'monday',
LockedWeekday.tuesday => 'tuesday',
LockedWeekday.wednesday => 'wednesday',
LockedWeekday.thursday => 'thursday',
LockedWeekday.friday => 'friday',
LockedWeekday.saturday => 'saturday',
LockedWeekday.sunday => 'sunday',
};
static LockedWeekday decodeLockedWeekday(String value) {
return _decodeCode(_lockedWeekdayByCode, value, 'LockedWeekday');
}
static String encodeLockedBlockOverrideType(
LockedBlockOverrideType value,
) =>
switch (value) {
LockedBlockOverrideType.remove => 'remove',
LockedBlockOverrideType.replace => 'replace',
LockedBlockOverrideType.add => 'add',
};
static LockedBlockOverrideType decodeLockedBlockOverrideType(String value) {
return _decodeCode(
_lockedBlockOverrideTypeByCode,
value,
'LockedBlockOverrideType',
);
}
static String encodeTaskActivityCode(TaskActivityCode value) =>
switch (value) {
TaskActivityCode.completed => 'completed',
TaskActivityCode.missed => 'missed',
TaskActivityCode.cancelled => 'cancelled',
TaskActivityCode.noLongerRelevant => 'no_longer_relevant',
TaskActivityCode.manuallyPushed => 'manually_pushed',
TaskActivityCode.automaticallyPushed => 'automatically_pushed',
TaskActivityCode.movedToBacklog => 'moved_to_backlog',
TaskActivityCode.restoredFromBacklog => 'restored_from_backlog',
TaskActivityCode.activated => 'activated',
};
static TaskActivityCode decodeTaskActivityCode(String value) {
return _decodeCode(_taskActivityCodeByCode, value, 'TaskActivityCode');
}
static String encodeSchedulingNoticeType(SchedulingNoticeType value) =>
switch (value) {
SchedulingNoticeType.info => 'info',
SchedulingNoticeType.moved => 'moved',
SchedulingNoticeType.overlap => 'overlap',
SchedulingNoticeType.noFit => 'no_fit',
SchedulingNoticeType.overflow => 'overflow',
};
static SchedulingNoticeType decodeSchedulingNoticeType(String value) {
return _decodeCode(
_schedulingNoticeTypeByCode,
value,
'SchedulingNoticeType',
);
}
static String encodeSchedulingIssueCode(SchedulingIssueCode value) =>
switch (value) {
SchedulingIssueCode.taskNotFound => 'task_not_found',
SchedulingIssueCode.invalidTaskState => 'invalid_task_state',
SchedulingIssueCode.missingDuration => 'missing_duration',
SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot',
SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration',
SchedulingIssueCode.noAvailableSlot => 'no_available_slot',
SchedulingIssueCode.unfinishedTasksCouldNotFit =>
'unfinished_tasks_could_not_fit',
SchedulingIssueCode.noUnfinishedFlexibleTasks =>
'no_unfinished_flexible_tasks',
SchedulingIssueCode.duplicateSurpriseLog => 'duplicate_surprise_log',
};
static SchedulingIssueCode? decodeNullableSchedulingIssueCode(String? value) {
return value == null
? null
: _decodeCode(
_schedulingIssueCodeByCode,
value,
'SchedulingIssueCode',
);
}
static String encodeSchedulingMovementCode(SchedulingMovementCode value) =>
switch (value) {
SchedulingMovementCode.backlogTaskInserted => 'backlog_task_inserted',
SchedulingMovementCode.flexibleTaskMovedToMakeRoom =>
'flexible_task_moved_to_make_room',
SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot =>
'flexible_task_pushed_to_next_available_slot',
SchedulingMovementCode.flexibleTaskMovedToTomorrow =>
'flexible_task_moved_to_tomorrow',
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver =>
'unfinished_flexible_tasks_rolled_over',
SchedulingMovementCode.flexibleTaskMovedToBacklog =>
'flexible_task_moved_to_backlog',
SchedulingMovementCode.requiredCommitmentScheduled =>
'required_commitment_scheduled',
};
static SchedulingMovementCode? decodeNullableSchedulingMovementCode(
String? value,
) {
return value == null
? null
: _decodeCode(
_schedulingMovementCodeByCode,
value,
'SchedulingMovementCode',
);
}
static String encodeSchedulingConflictCode(SchedulingConflictCode value) =>
switch (value) {
SchedulingConflictCode.flexibleTaskOverlapsBlockedTime =>
'flexible_task_overlaps_blocked_time',
SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime =>
'surprise_task_overlaps_required_visible_time',
SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot =>
'required_commitment_overlaps_protected_free_slot',
};
static SchedulingConflictCode? decodeNullableSchedulingConflictCode(
String? value,
) {
return value == null
? null
: _decodeCode(
_schedulingConflictCodeByCode,
value,
'SchedulingConflictCode',
);
}
static String encodeProjectCompletionTimeBucket(
ProjectCompletionTimeBucket value,
) =>
switch (value) {
ProjectCompletionTimeBucket.overnight => 'overnight',
ProjectCompletionTimeBucket.morning => 'morning',
ProjectCompletionTimeBucket.afternoon => 'afternoon',
ProjectCompletionTimeBucket.evening => 'evening',
ProjectCompletionTimeBucket.night => 'night',
};
static ProjectCompletionTimeBucket decodeProjectCompletionTimeBucket(
String value,
) {
return _decodeCode(
_projectCompletionTimeBucketByCode,
value,
'ProjectCompletionTimeBucket',
);
}
}

View file

@ -0,0 +1,72 @@
part of '../document_mapping.dart';
/// Document mapping for [ProjectProfile].
abstract final class ProjectDocumentMapping {
static Map<String, Object?> toDocument(
ProjectProfile project, {
required String ownerId,
required DateTime createdAt,
required DateTime updatedAt,
int revision = 1,
}) {
return {
..._commonFields(
id: project.id,
ownerId: ownerId,
revision: revision,
createdAt: createdAt,
updatedAt: updatedAt,
),
ProjectDocumentFields.name: project.name,
ProjectDocumentFields.colorKey: project.colorKey,
ProjectDocumentFields.defaultPriority:
PersistenceEnumMapping.encodePriority(project.defaultPriority),
ProjectDocumentFields.defaultReward:
PersistenceEnumMapping.encodeReward(project.defaultReward),
ProjectDocumentFields.defaultDifficulty:
PersistenceEnumMapping.encodeDifficulty(project.defaultDifficulty),
ProjectDocumentFields.defaultReminderProfile:
PersistenceEnumMapping.encodeReminderProfile(
project.defaultReminderProfile,
),
ProjectDocumentFields.defaultDurationMinutes:
project.defaultDurationMinutes,
ProjectDocumentFields.archivedAt: _dateTimeToStored(project.archivedAt),
};
}
static ProjectProfile fromDocument(Map<String, Object?> document) {
return _mapInvalidValues(() {
_readCommon(document);
return ProjectProfile(
id: _requiredString(document, ProjectDocumentFields.id),
name: _requiredString(document, ProjectDocumentFields.name),
colorKey: _requiredString(document, ProjectDocumentFields.colorKey),
defaultPriority: PersistenceEnumMapping.decodeNullablePriority(
_requiredString(document, ProjectDocumentFields.defaultPriority),
)!,
defaultReward: PersistenceEnumMapping.decodeReward(
_requiredString(document, ProjectDocumentFields.defaultReward),
),
defaultDifficulty: PersistenceEnumMapping.decodeDifficulty(
_requiredString(document, ProjectDocumentFields.defaultDifficulty),
),
defaultReminderProfile:
PersistenceEnumMapping.decodeNullableReminderProfile(
_requiredString(
document,
ProjectDocumentFields.defaultReminderProfile,
),
)!,
defaultDurationMinutes: _requiredNullableInt(
document,
ProjectDocumentFields.defaultDurationMinutes,
),
archivedAt: _requiredNullableDateTime(
document,
ProjectDocumentFields.archivedAt,
),
);
});
}
}

View file

@ -0,0 +1,577 @@
part of '../document_mapping.dart';
/// Convenience extension for converting [ProjectStatistics] into a document.
extension ProjectStatisticsDocumentExtension on ProjectStatistics {
Map<String, Object?> toDocument({
required String ownerId,
required DateTime createdAt,
required DateTime updatedAt,
int revision = 1,
Iterable<String> appliedActivityIds = const <String>[],
}) {
return ProjectStatisticsDocumentMapping.toDocument(
this,
ownerId: ownerId,
createdAt: createdAt,
updatedAt: updatedAt,
revision: revision,
appliedActivityIds: appliedActivityIds,
);
}
}
const _taskTypeByCode = <String, TaskType>{
'flexible': TaskType.flexible,
'inflexible': TaskType.inflexible,
'critical': TaskType.critical,
'locked': TaskType.locked,
'surprise': TaskType.surprise,
'free_slot': TaskType.freeSlot,
};
const _taskStatusByCode = <String, TaskStatus>{
'planned': TaskStatus.planned,
'active': TaskStatus.active,
'completed': TaskStatus.completed,
'missed': TaskStatus.missed,
'cancelled': TaskStatus.cancelled,
'no_longer_relevant': TaskStatus.noLongerRelevant,
'backlog': TaskStatus.backlog,
};
const _priorityByCode = <String, PriorityLevel>{
'very_low': PriorityLevel.veryLow,
'low': PriorityLevel.low,
'medium': PriorityLevel.medium,
'high': PriorityLevel.high,
'very_high': PriorityLevel.veryHigh,
};
const _rewardByCode = <String, RewardLevel>{
'not_set': RewardLevel.notSet,
'very_low': RewardLevel.veryLow,
'low': RewardLevel.low,
'medium': RewardLevel.medium,
'high': RewardLevel.high,
'very_high': RewardLevel.veryHigh,
};
const _difficultyByCode = <String, DifficultyLevel>{
'not_set': DifficultyLevel.notSet,
'very_easy': DifficultyLevel.veryEasy,
'easy': DifficultyLevel.easy,
'medium': DifficultyLevel.medium,
'hard': DifficultyLevel.hard,
'very_hard': DifficultyLevel.veryHard,
};
const _reminderProfileByCode = <String, ReminderProfile>{
'silent': ReminderProfile.silent,
'gentle': ReminderProfile.gentle,
'persistent': ReminderProfile.persistent,
'strict': ReminderProfile.strict,
};
const _backlogTagByCode = <String, BacklogTag>{
'wishlist': BacklogTag.wishlist,
};
const _lockedWeekdayByCode = <String, LockedWeekday>{
'monday': LockedWeekday.monday,
'tuesday': LockedWeekday.tuesday,
'wednesday': LockedWeekday.wednesday,
'thursday': LockedWeekday.thursday,
'friday': LockedWeekday.friday,
'saturday': LockedWeekday.saturday,
'sunday': LockedWeekday.sunday,
};
const _lockedBlockOverrideTypeByCode = <String, LockedBlockOverrideType>{
'remove': LockedBlockOverrideType.remove,
'replace': LockedBlockOverrideType.replace,
'add': LockedBlockOverrideType.add,
};
const _taskActivityCodeByCode = <String, TaskActivityCode>{
'completed': TaskActivityCode.completed,
'missed': TaskActivityCode.missed,
'cancelled': TaskActivityCode.cancelled,
'no_longer_relevant': TaskActivityCode.noLongerRelevant,
'manually_pushed': TaskActivityCode.manuallyPushed,
'automatically_pushed': TaskActivityCode.automaticallyPushed,
'moved_to_backlog': TaskActivityCode.movedToBacklog,
'restored_from_backlog': TaskActivityCode.restoredFromBacklog,
'activated': TaskActivityCode.activated,
};
const _schedulingNoticeTypeByCode = <String, SchedulingNoticeType>{
'info': SchedulingNoticeType.info,
'moved': SchedulingNoticeType.moved,
'overlap': SchedulingNoticeType.overlap,
'no_fit': SchedulingNoticeType.noFit,
'overflow': SchedulingNoticeType.overflow,
};
const _schedulingIssueCodeByCode = <String, SchedulingIssueCode>{
'task_not_found': SchedulingIssueCode.taskNotFound,
'invalid_task_state': SchedulingIssueCode.invalidTaskState,
'missing_duration': SchedulingIssueCode.missingDuration,
'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot,
'non_positive_duration': SchedulingIssueCode.nonPositiveDuration,
'no_available_slot': SchedulingIssueCode.noAvailableSlot,
'unfinished_tasks_could_not_fit':
SchedulingIssueCode.unfinishedTasksCouldNotFit,
'no_unfinished_flexible_tasks': SchedulingIssueCode.noUnfinishedFlexibleTasks,
'duplicate_surprise_log': SchedulingIssueCode.duplicateSurpriseLog,
};
const _schedulingMovementCodeByCode = <String, SchedulingMovementCode>{
'backlog_task_inserted': SchedulingMovementCode.backlogTaskInserted,
'flexible_task_moved_to_make_room':
SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
'flexible_task_pushed_to_next_available_slot':
SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot,
'flexible_task_moved_to_tomorrow':
SchedulingMovementCode.flexibleTaskMovedToTomorrow,
'unfinished_flexible_tasks_rolled_over':
SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
'flexible_task_moved_to_backlog':
SchedulingMovementCode.flexibleTaskMovedToBacklog,
'required_commitment_scheduled':
SchedulingMovementCode.requiredCommitmentScheduled,
};
const _schedulingConflictCodeByCode = <String, SchedulingConflictCode>{
'flexible_task_overlaps_blocked_time':
SchedulingConflictCode.flexibleTaskOverlapsBlockedTime,
'surprise_task_overlaps_required_visible_time':
SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime,
'required_commitment_overlaps_protected_free_slot':
SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot,
};
const _projectCompletionTimeBucketByCode =
<String, ProjectCompletionTimeBucket>{
'overnight': ProjectCompletionTimeBucket.overnight,
'morning': ProjectCompletionTimeBucket.morning,
'afternoon': ProjectCompletionTimeBucket.afternoon,
'evening': ProjectCompletionTimeBucket.evening,
'night': ProjectCompletionTimeBucket.night,
};
T _decodeCode<T>(Map<String, T> valuesByCode, String code, String fieldName) {
final value = valuesByCode[code];
if (value == null) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.unknownCode,
fieldName: fieldName,
detailCode: code,
);
}
return value;
}
Map<String, Object?> _commonFields({
required String id,
required String ownerId,
required int revision,
required DateTime createdAt,
required DateTime updatedAt,
}) {
if (revision <= 0) {
throw const DocumentMappingException(
code: DocumentMappingFailureCode.invalidRevision,
fieldName: DocumentFields.revision,
);
}
return {
DocumentFields.schemaVersion: v1SchemaVersion,
DocumentFields.id: id,
DocumentFields.ownerId: ownerId,
DocumentFields.revision: revision,
DocumentFields.createdAt:
PersistenceDateTimeConvention.toStoredString(createdAt),
DocumentFields.updatedAt:
PersistenceDateTimeConvention.toStoredString(updatedAt),
};
}
DocumentMetadata _readCommon(Map<String, Object?> document) {
final schemaVersion = _requiredInt(document, DocumentFields.schemaVersion);
if (schemaVersion != v1SchemaVersion) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.invalidSchemaVersion,
fieldName: DocumentFields.schemaVersion,
detailCode: schemaVersion.toString(),
);
}
final revision = _requiredInt(document, DocumentFields.revision);
if (revision <= 0) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.invalidRevision,
fieldName: DocumentFields.revision,
detailCode: revision.toString(),
);
}
return DocumentMetadata(
id: _requiredString(document, DocumentFields.id),
ownerId: _requiredString(document, DocumentFields.ownerId),
revision: revision,
createdAt: _requiredDateTime(document, DocumentFields.createdAt),
updatedAt: _requiredDateTime(document, DocumentFields.updatedAt),
);
}
T _mapInvalidValues<T>(T Function() read) {
try {
return read();
} on DocumentMappingException {
rethrow;
} on DomainValidationException catch (error) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.invalidValue,
fieldName: error.name,
detailCode: error.code.name,
);
} on FormatException catch (error) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.invalidValue,
detailCode: error.message,
);
} on ArgumentError catch (error) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.invalidValue,
fieldName: error.name,
);
}
}
String? _dateTimeToStored(DateTime? value) {
return value == null
? null
: PersistenceDateTimeConvention.toStoredString(value);
}
String? _civilDateToStored(CivilDate? value) {
return value == null
? null
: PersistenceCivilDateConvention.toStoredString(value);
}
String? _wallTimeToStored(WallTime? value) {
return value == null
? null
: PersistenceWallTimeConvention.toStoredString(value);
}
String _requiredString(Map<String, Object?> document, String fieldName) {
if (!document.containsKey(fieldName)) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.missingField,
fieldName: fieldName,
);
}
final value = document[fieldName];
if (value is String) {
return value;
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
String? _requiredNullableString(
Map<String, Object?> document,
String fieldName,
) {
if (!document.containsKey(fieldName)) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.missingField,
fieldName: fieldName,
);
}
final value = document[fieldName];
if (value == null || value is String) {
return value as String?;
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
int _requiredInt(Map<String, Object?> document, String fieldName) {
if (!document.containsKey(fieldName)) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.missingField,
fieldName: fieldName,
);
}
final value = document[fieldName];
if (value is int) {
return value;
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
int? _requiredNullableInt(
Map<String, Object?> document,
String fieldName,
) {
if (!document.containsKey(fieldName)) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.missingField,
fieldName: fieldName,
);
}
final value = document[fieldName];
if (value == null || value is int) {
return value as int?;
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
bool _requiredBool(Map<String, Object?> document, String fieldName) {
if (!document.containsKey(fieldName)) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.missingField,
fieldName: fieldName,
);
}
final value = document[fieldName];
if (value is bool) {
return value;
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
DateTime _requiredDateTime(Map<String, Object?> document, String fieldName) {
return PersistenceDateTimeConvention.fromStoredString(
_requiredString(document, fieldName),
);
}
DateTime? _requiredNullableDateTime(
Map<String, Object?> document,
String fieldName,
) {
final value = _requiredNullableString(document, fieldName);
return value == null
? null
: PersistenceDateTimeConvention.fromStoredString(value);
}
CivilDate _requiredCivilDate(Map<String, Object?> document, String fieldName) {
return PersistenceCivilDateConvention.fromStoredString(
_requiredString(document, fieldName),
);
}
CivilDate? _requiredNullableCivilDate(
Map<String, Object?> document,
String fieldName,
) {
final value = _requiredNullableString(document, fieldName);
return value == null
? null
: PersistenceCivilDateConvention.fromStoredString(value);
}
WallTime _requiredWallTime(Map<String, Object?> document, String fieldName) {
return PersistenceWallTimeConvention.fromStoredString(
_requiredString(document, fieldName),
);
}
WallTime? _requiredNullableWallTime(
Map<String, Object?> document,
String fieldName,
) {
final value = _requiredNullableString(document, fieldName);
return value == null
? null
: PersistenceWallTimeConvention.fromStoredString(value);
}
Map<String, Object?> _requiredMap(
Map<String, Object?> document,
String fieldName,
) {
if (!document.containsKey(fieldName)) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.missingField,
fieldName: fieldName,
);
}
final value = document[fieldName];
if (value is Map<String, Object?>) {
return value;
}
if (value is Map) {
return Map<String, Object?>.from(value);
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
Map<String, Object?>? _requiredNullableMap(
Map<String, Object?> document,
String fieldName,
) {
if (!document.containsKey(fieldName)) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.missingField,
fieldName: fieldName,
);
}
final value = document[fieldName];
if (value == null) {
return null;
}
if (value is Map<String, Object?>) {
return value;
}
if (value is Map) {
return Map<String, Object?>.from(value);
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
List<Object?> _requiredList(Map<String, Object?> document, String fieldName) {
if (!document.containsKey(fieldName)) {
throw DocumentMappingException(
code: DocumentMappingFailureCode.missingField,
fieldName: fieldName,
);
}
final value = document[fieldName];
if (value is List) {
return List<Object?>.unmodifiable(value);
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
List<T> _mappedList<T>(
Map<String, Object?> document,
String fieldName,
T Function(Map<String, Object?> document) decode,
) {
return _requiredList(document, fieldName).map((value) {
if (value is Map<String, Object?>) {
return decode(value);
}
if (value is Map) {
return decode(Map<String, Object?>.from(value));
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}).toList(growable: false);
}
Map<String, Object?> _intKeyCountMapToDocument(Map<int, int> counts) {
return {
for (final entry in counts.entries) entry.key.toString(): entry.value,
};
}
Map<String, Object?> _enumCountMapToDocument<T extends Enum>(
Map<T, int> counts,
String Function(T value) encode,
) {
return {
for (final entry in counts.entries) encode(entry.key): entry.value,
};
}
Map<int, int> _intKeyCountMapFromDocument(
Map<String, Object?> document,
String fieldName,
) {
final raw = _requiredMap(document, fieldName);
return {
for (final entry in raw.entries)
int.parse(entry.key): _countValue(entry.value, fieldName),
};
}
Map<T, int> _enumCountMapFromDocument<T extends Enum>(
Map<String, Object?> document,
String fieldName,
T Function(String value) decode,
) {
final raw = _requiredMap(document, fieldName);
return {
for (final entry in raw.entries)
decode(entry.key): _countValue(entry.value, fieldName),
};
}
int _countValue(Object? value, String fieldName) {
if (value is int) {
return value;
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
fieldName: fieldName,
);
}
Map<String, Object?> _plainDocument(Map<String, Object?> value) {
return Map<String, Object?>.unmodifiable(
value.map((key, value) => MapEntry(key, _plainDocumentValue(value))),
);
}
Object? _plainDocumentValue(Object? value) {
if (value == null ||
value is String ||
value is num ||
value is bool ||
value is DateTime) {
return value is DateTime
? PersistenceDateTimeConvention.toStoredString(value)
: value;
}
if (value is CivilDate) {
return PersistenceCivilDateConvention.toStoredString(value);
}
if (value is WallTime) {
return PersistenceWallTimeConvention.toStoredString(value);
}
if (value is Enum) {
return PersistenceEnumMapping.encode(value);
}
if (value is List) {
return value.map(_plainDocumentValue).toList(growable: false);
}
if (value is Map<String, Object?>) {
return _plainDocument(value);
}
if (value is Map) {
return _plainDocument(Map<String, Object?>.from(value));
}
throw DocumentMappingException(
code: DocumentMappingFailureCode.wrongType,
detailCode: value.runtimeType.toString(),
);
}

View file

@ -0,0 +1,101 @@
part of '../document_mapping.dart';
/// Document mapping for [ProjectStatistics].
abstract final class ProjectStatisticsDocumentMapping {
static Map<String, Object?> toDocument(
ProjectStatistics statistics, {
required String ownerId,
required DateTime createdAt,
required DateTime updatedAt,
int revision = 1,
Iterable<String> appliedActivityIds = const <String>[],
}) {
return {
..._commonFields(
id: statistics.projectId,
ownerId: ownerId,
revision: revision,
createdAt: createdAt,
updatedAt: updatedAt,
),
ProjectStatisticsDocumentFields.projectId: statistics.projectId,
ProjectStatisticsDocumentFields.completedTaskCount:
statistics.completedTaskCount,
ProjectStatisticsDocumentFields.durationMinuteCounts:
_intKeyCountMapToDocument(statistics.durationMinuteCounts),
ProjectStatisticsDocumentFields.completionTimeBucketCounts:
_enumCountMapToDocument(
statistics.completionTimeBucketCounts,
PersistenceEnumMapping.encodeProjectCompletionTimeBucket,
),
ProjectStatisticsDocumentFields.totalPushesBeforeCompletion:
statistics.totalPushesBeforeCompletion,
ProjectStatisticsDocumentFields.completedAfterPushCount:
statistics.completedAfterPushCount,
ProjectStatisticsDocumentFields.rewardCounts: _enumCountMapToDocument(
statistics.rewardCounts,
PersistenceEnumMapping.encodeReward,
),
ProjectStatisticsDocumentFields.difficultyCounts: _enumCountMapToDocument(
statistics.difficultyCounts,
PersistenceEnumMapping.encodeDifficulty,
),
ProjectStatisticsDocumentFields.reminderProfileCounts:
_enumCountMapToDocument(
statistics.reminderProfileCounts,
PersistenceEnumMapping.encodeReminderProfile,
),
ProjectStatisticsDocumentFields.appliedActivityIds:
appliedActivityIds.toList(growable: false),
};
}
static ProjectStatistics fromDocument(Map<String, Object?> document) {
return _mapInvalidValues(() {
_readCommon(document);
return ProjectStatistics(
projectId: _requiredString(
document,
ProjectStatisticsDocumentFields.projectId,
),
completedTaskCount: _requiredInt(
document,
ProjectStatisticsDocumentFields.completedTaskCount,
),
durationMinuteCounts: _intKeyCountMapFromDocument(
document,
ProjectStatisticsDocumentFields.durationMinuteCounts,
),
completionTimeBucketCounts: _enumCountMapFromDocument(
document,
ProjectStatisticsDocumentFields.completionTimeBucketCounts,
PersistenceEnumMapping.decodeProjectCompletionTimeBucket,
),
totalPushesBeforeCompletion: _requiredInt(
document,
ProjectStatisticsDocumentFields.totalPushesBeforeCompletion,
),
completedAfterPushCount: _requiredInt(
document,
ProjectStatisticsDocumentFields.completedAfterPushCount,
),
rewardCounts: _enumCountMapFromDocument(
document,
ProjectStatisticsDocumentFields.rewardCounts,
PersistenceEnumMapping.decodeReward,
),
difficultyCounts: _enumCountMapFromDocument(
document,
ProjectStatisticsDocumentFields.difficultyCounts,
PersistenceEnumMapping.decodeDifficulty,
),
reminderProfileCounts: _enumCountMapFromDocument(
document,
ProjectStatisticsDocumentFields.reminderProfileCounts,
(value) =>
PersistenceEnumMapping.decodeNullableReminderProfile(value)!,
),
).._validateProjectStatisticsDocumentMetadata(document);
});
}
}

View file

@ -0,0 +1,42 @@
part of '../document_mapping.dart';
/// Document mapping for [SchedulingChange].
abstract final class SchedulingChangeDocumentMapping {
static Map<String, Object?> toDocument(SchedulingChange change) {
return {
SchedulingChangeDocumentFields.taskId: change.taskId,
SchedulingChangeDocumentFields.previousStart:
_dateTimeToStored(change.previousStart),
SchedulingChangeDocumentFields.previousEnd:
_dateTimeToStored(change.previousEnd),
SchedulingChangeDocumentFields.nextStart:
_dateTimeToStored(change.nextStart),
SchedulingChangeDocumentFields.nextEnd: _dateTimeToStored(change.nextEnd),
};
}
static SchedulingChange fromDocument(Map<String, Object?> document) {
return _mapInvalidValues(() {
return SchedulingChange(
taskId:
_requiredString(document, SchedulingChangeDocumentFields.taskId),
previousStart: _requiredNullableDateTime(
document,
SchedulingChangeDocumentFields.previousStart,
),
previousEnd: _requiredNullableDateTime(
document,
SchedulingChangeDocumentFields.previousEnd,
),
nextStart: _requiredNullableDateTime(
document,
SchedulingChangeDocumentFields.nextStart,
),
nextEnd: _requiredNullableDateTime(
document,
SchedulingChangeDocumentFields.nextEnd,
),
);
});
}
}

Some files were not shown because too many files have changed in this diff Show more