forked from eva/focus-flow
refactor: split package source files
This commit is contained in:
parent
cbefe201d3
commit
891bbec1fd
396 changed files with 24606 additions and 23881 deletions
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
part of '../application_layer.dart';
|
||||
|
||||
/// Stable application-layer result categories.
|
||||
enum ApplicationFailureCode {
|
||||
validation,
|
||||
notFound,
|
||||
conflict,
|
||||
staleRevision,
|
||||
noSlot,
|
||||
duplicateOperation,
|
||||
persistenceFailure,
|
||||
unexpected,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
part of '../application_layer.dart';
|
||||
|
||||
/// Exception representing persistence/driver failures at repository boundaries.
|
||||
class ApplicationPersistenceException implements Exception {
|
||||
const ApplicationPersistenceException();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
part of '../application_management.dart';
|
||||
|
||||
/// Typed warning codes for settings changes that reinterpret local dates/times.
|
||||
enum OwnerSettingsWarningCode {
|
||||
timeZoneChanged,
|
||||
planningWindowChanged,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
part of '../application_recovery.dart';
|
||||
|
||||
/// Stable app-open recovery outcomes.
|
||||
enum AppOpenRecoveryOutcome {
|
||||
rolledOver,
|
||||
noUnfinishedFlexibleTasks,
|
||||
alreadyProcessed,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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()}';
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
27
packages/scheduler_core/lib/src/backlog/backlog_filter.dart
Normal file
27
packages/scheduler_core/lib/src/backlog/backlog_filter.dart
Normal 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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
172
packages/scheduler_core/lib/src/backlog/backlog_view.dart
Normal file
172
packages/scheduler_core/lib/src/backlog/backlog_view.dart
Normal 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;
|
||||
}
|
||||
12
packages/scheduler_core/lib/src/backlog/indexed_task.dart
Normal file
12
packages/scheduler_core/lib/src/backlog/indexed_task.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
part of '../document_mapping.dart';
|
||||
|
||||
/// Stable document mapping failure categories.
|
||||
enum DocumentMappingFailureCode {
|
||||
missingField,
|
||||
wrongType,
|
||||
unknownCode,
|
||||
invalidSchemaVersion,
|
||||
invalidRevision,
|
||||
invalidValue,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue