diff --git a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_09_Persistence_Preparation.md b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_09_Persistence_Preparation.md index 19fd59a..11bc784 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_09_Persistence_Preparation.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_09_Persistence_Preparation.md @@ -1,6 +1,6 @@ # V1 Block 09 — Persistence Preparation -Status: In progress — Chunks 9.1 and 9.2 complete +Status: In progress — Chunks 9.1, 9.2, and 9.3 complete Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early. @@ -133,6 +133,16 @@ Acceptance criteria: - Tests cover intentional clearing of nullable fields if helper behavior is added. - Tests confirm `not set` reward remains distinct from very low reward through mapping. +Completed: + +- Added pure Dart task and task-statistics document mapping helpers using MongoDB-friendly map shapes. +- Added task round-trip tests covering scheduling fields, child ownership, backlog tags, timestamps, and nested statistics. +- Added enum decode helpers with tests for stable persisted names and unknown-name rejection. +- Added explicit schedule-clearing document mapping behavior through `Task.toDocument(clearSchedule: true)`. +- Confirmed `RewardLevel.notSet` and `RewardLevel.veryLow` remain distinct through document mapping. +- Kept mapping helpers independent from MongoDB client libraries and database runtime requirements. +- Verified with `dart format lib test`, `dart analyze`, and `dart test`. + BREAKPOINT: Stop here. Confirm `low` mode before documenting the explicit non-sync boundary. ## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_09_Persistence_Preparation.md b/Codex Documentation/Current Software Plan/V1_BLOCK_09_Persistence_Preparation.md index 19fd59a..11bc784 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_09_Persistence_Preparation.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_09_Persistence_Preparation.md @@ -1,6 +1,6 @@ # V1 Block 09 — Persistence Preparation -Status: In progress — Chunks 9.1 and 9.2 complete +Status: In progress — Chunks 9.1, 9.2, and 9.3 complete Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early. @@ -133,6 +133,16 @@ Acceptance criteria: - Tests cover intentional clearing of nullable fields if helper behavior is added. - Tests confirm `not set` reward remains distinct from very low reward through mapping. +Completed: + +- Added pure Dart task and task-statistics document mapping helpers using MongoDB-friendly map shapes. +- Added task round-trip tests covering scheduling fields, child ownership, backlog tags, timestamps, and nested statistics. +- Added enum decode helpers with tests for stable persisted names and unknown-name rejection. +- Added explicit schedule-clearing document mapping behavior through `Task.toDocument(clearSchedule: true)`. +- Confirmed `RewardLevel.notSet` and `RewardLevel.veryLow` remain distinct through document mapping. +- Kept mapping helpers independent from MongoDB client libraries and database runtime requirements. +- Verified with `dart format lib test`, `dart analyze`, and `dart test`. + BREAKPOINT: Stop here. Confirm `low` mode before documenting the explicit non-sync boundary. ## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 4e8606d..89edfd2 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -19,6 +19,7 @@ export 'src/models.dart'; export 'src/backlog.dart'; export 'src/child_tasks.dart'; +export 'src/document_mapping.dart'; export 'src/locked_time.dart'; export 'src/persistence_contract.dart'; export 'src/quick_capture.dart'; diff --git a/lib/src/document_mapping.dart b/lib/src/document_mapping.dart new file mode 100644 index 0000000..ab37b27 --- /dev/null +++ b/lib/src/document_mapping.dart @@ -0,0 +1,305 @@ +// MongoDB-friendly document mapping helpers. +// +// These helpers use plain Dart map shapes only. They do not import MongoDB APIs, +// create clients, or perform database I/O. + +import 'models.dart'; +import 'persistence_contract.dart'; +import 'task_statistics.dart'; + +/// Stable enum encode/decode helpers for document maps. +abstract final class PersistenceEnumMapping { + static String? encodeNullable(Enum? value) { + return value?.persistenceName; + } + + static TaskType decodeTaskType(String value) { + return _decodeEnum(TaskType.values, value, 'TaskType'); + } + + static TaskStatus decodeTaskStatus(String value) { + return _decodeEnum(TaskStatus.values, value, 'TaskStatus'); + } + + static PriorityLevel? decodeNullablePriority(String? value) { + return value == null + ? null + : _decodeEnum(PriorityLevel.values, value, 'PriorityLevel'); + } + + static RewardLevel decodeReward(String value) { + return _decodeEnum(RewardLevel.values, value, 'RewardLevel'); + } + + static DifficultyLevel decodeDifficulty(String value) { + return _decodeEnum(DifficultyLevel.values, value, 'DifficultyLevel'); + } + + static BacklogTag decodeBacklogTag(String value) { + return _decodeEnum(BacklogTag.values, value, 'BacklogTag'); + } + + static T _decodeEnum( + Iterable values, + String persistedName, + String enumName, + ) { + for (final value in values) { + if (value.persistenceName == persistedName) { + return value; + } + } + + throw ArgumentError.value( + persistedName, + enumName, + 'Unknown persistence enum name.', + ); + } +} + +/// Document mapping for [Task]. +abstract final class TaskDocumentMapping { + /// Convert [task] to a MongoDB-friendly document-shaped map. + static Map toDocument( + Task task, { + bool clearSchedule = false, + }) { + final scheduledStart = clearSchedule ? null : task.scheduledStart; + final scheduledEnd = clearSchedule ? null : task.scheduledEnd; + + return { + TaskDocumentFields.id: task.id, + TaskDocumentFields.title: task.title, + TaskDocumentFields.projectId: task.projectId, + TaskDocumentFields.type: task.type.persistenceName, + TaskDocumentFields.status: task.status.persistenceName, + TaskDocumentFields.priority: + PersistenceEnumMapping.encodeNullable(task.priority), + TaskDocumentFields.reward: task.reward.persistenceName, + TaskDocumentFields.difficulty: task.difficulty.persistenceName, + TaskDocumentFields.durationMinutes: task.durationMinutes, + TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart), + TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), + TaskDocumentFields.parentTaskId: task.parentTaskId, + TaskDocumentFields.backlogTags: + task.backlogTags.map((tag) => tag.persistenceName).toList(), + TaskDocumentFields.createdAt: + PersistenceDateTimeConvention.toStoredString(task.createdAt), + TaskDocumentFields.updatedAt: + PersistenceDateTimeConvention.toStoredString(task.updatedAt), + TaskDocumentFields.stats: task.stats.toDocument(), + }; + } + + /// Rebuild a [Task] from a document-shaped map. + static Task fromDocument(Map document) { + return Task( + id: _requiredString(document, TaskDocumentFields.id), + title: _requiredString(document, TaskDocumentFields.title), + projectId: _requiredString(document, TaskDocumentFields.projectId), + type: PersistenceEnumMapping.decodeTaskType( + _requiredString(document, TaskDocumentFields.type), + ), + status: PersistenceEnumMapping.decodeTaskStatus( + _requiredString(document, TaskDocumentFields.status), + ), + priority: PersistenceEnumMapping.decodeNullablePriority( + _nullableString(document, TaskDocumentFields.priority), + ), + reward: PersistenceEnumMapping.decodeReward( + _requiredString(document, TaskDocumentFields.reward), + ), + difficulty: PersistenceEnumMapping.decodeDifficulty( + _requiredString(document, TaskDocumentFields.difficulty), + ), + durationMinutes: + _nullableInt(document, TaskDocumentFields.durationMinutes), + scheduledStart: + _nullableDateTime(document, TaskDocumentFields.scheduledStart), + scheduledEnd: + _nullableDateTime(document, TaskDocumentFields.scheduledEnd), + parentTaskId: _nullableString(document, TaskDocumentFields.parentTaskId), + backlogTags: _backlogTagsFromDocument(document), + createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), + updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), + stats: TaskStatisticsDocumentMapping.fromDocument( + _requiredMap(document, TaskDocumentFields.stats), + ), + ); + } + + static Set _backlogTagsFromDocument( + Map document, + ) { + final rawTags = document[TaskDocumentFields.backlogTags]; + if (rawTags == null) { + return const {}; + } + if (rawTags is! Iterable) { + throw ArgumentError.value( + rawTags, + TaskDocumentFields.backlogTags, + 'Expected an iterable of persisted backlog tag names.', + ); + } + return rawTags + .map((tag) => PersistenceEnumMapping.decodeBacklogTag(tag as String)) + .toSet(); + } +} + +/// Document mapping for [TaskStatistics]. +abstract final class TaskStatisticsDocumentMapping { + /// Convert [stats] to a MongoDB-friendly embedded document-shaped map. + static Map toDocument(TaskStatistics stats) { + return { + TaskStatisticsDocumentFields.skippedDuringBurnoutCount: + stats.skippedDuringBurnoutCount, + TaskStatisticsDocumentFields.manuallyPushedCount: + stats.manuallyPushedCount, + TaskStatisticsDocumentFields.autoPushedCount: stats.autoPushedCount, + TaskStatisticsDocumentFields.movedToBacklogCount: + stats.movedToBacklogCount, + TaskStatisticsDocumentFields.restoredFromBacklogCount: + stats.restoredFromBacklogCount, + TaskStatisticsDocumentFields.missedCount: stats.missedCount, + TaskStatisticsDocumentFields.cancelledCount: stats.cancelledCount, + TaskStatisticsDocumentFields.completedLateCount: stats.completedLateCount, + TaskStatisticsDocumentFields.completedDuringLockedHoursCount: + stats.completedDuringLockedHoursCount, + TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes: + stats.completedDuringLockedHoursMinutes, + }; + } + + /// Rebuild [TaskStatistics] from an embedded document-shaped map. + static TaskStatistics fromDocument(Map document) { + return TaskStatistics( + skippedDuringBurnoutCount: _intOrZero( + document, + TaskStatisticsDocumentFields.skippedDuringBurnoutCount, + ), + manuallyPushedCount: _intOrZero( + document, + TaskStatisticsDocumentFields.manuallyPushedCount, + ), + autoPushedCount: _intOrZero( + document, + TaskStatisticsDocumentFields.autoPushedCount, + ), + movedToBacklogCount: _intOrZero( + document, + TaskStatisticsDocumentFields.movedToBacklogCount, + ), + restoredFromBacklogCount: _intOrZero( + document, + TaskStatisticsDocumentFields.restoredFromBacklogCount, + ), + missedCount: _intOrZero( + document, + TaskStatisticsDocumentFields.missedCount, + ), + cancelledCount: _intOrZero( + document, + TaskStatisticsDocumentFields.cancelledCount, + ), + completedLateCount: _intOrZero( + document, + TaskStatisticsDocumentFields.completedLateCount, + ), + completedDuringLockedHoursCount: _intOrZero( + document, + TaskStatisticsDocumentFields.completedDuringLockedHoursCount, + ), + completedDuringLockedHoursMinutes: _intOrZero( + document, + TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, + ), + ); + } +} + +/// Convenience extension for converting a [Task] into a document map. +extension TaskDocumentExtension on Task { + /// Convert this task to a MongoDB-friendly document-shaped map. + Map toDocument({bool clearSchedule = false}) { + return TaskDocumentMapping.toDocument(this, clearSchedule: clearSchedule); + } +} + +/// Convenience extension for converting [TaskStatistics] into an embedded map. +extension TaskStatisticsDocumentExtension on TaskStatistics { + /// Convert this statistics value to a MongoDB-friendly embedded document. + Map toDocument() { + return TaskStatisticsDocumentMapping.toDocument(this); + } +} + +String? _dateTimeToStored(DateTime? value) { + return value == null + ? null + : PersistenceDateTimeConvention.toStoredString(value); +} + +String _requiredString(Map document, String fieldName) { + final value = document[fieldName]; + if (value is String) { + return value; + } + throw ArgumentError.value(value, fieldName, 'Expected a string.'); +} + +String? _nullableString(Map document, String fieldName) { + final value = document[fieldName]; + if (value == null || value is String) { + return value as String?; + } + throw ArgumentError.value(value, fieldName, 'Expected a string or null.'); +} + +int? _nullableInt(Map document, String fieldName) { + final value = document[fieldName]; + if (value == null || value is int) { + return value as int?; + } + throw ArgumentError.value(value, fieldName, 'Expected an int or null.'); +} + +int _intOrZero(Map document, String fieldName) { + final value = document[fieldName]; + if (value == null) { + return 0; + } + if (value is int) { + return value; + } + throw ArgumentError.value(value, fieldName, 'Expected an int.'); +} + +DateTime _requiredDateTime(Map document, String fieldName) { + return PersistenceDateTimeConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +DateTime? _nullableDateTime(Map document, String fieldName) { + final value = _nullableString(document, fieldName); + return value == null + ? null + : PersistenceDateTimeConvention.fromStoredString(value); +} + +Map _requiredMap( + Map document, + String fieldName, +) { + final value = document[fieldName]; + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + throw ArgumentError.value(value, fieldName, 'Expected a document map.'); +} diff --git a/test/document_mapping_test.dart b/test/document_mapping_test.dart new file mode 100644 index 0000000..ce2f76f --- /dev/null +++ b/test/document_mapping_test.dart @@ -0,0 +1,151 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('MongoDB document mapping helpers', () { + final createdAt = DateTime.utc(2026, 6, 23, 8); + + test('task documents round-trip every scheduling field', () { + final task = scheduledTask( + id: 'task-1', + createdAt: createdAt, + reward: RewardLevel.high, + difficulty: DifficultyLevel.hard, + ).copyWith( + stats: const TaskStatistics().incrementManualPush(), + ); + + final document = task.toDocument(); + final restored = TaskDocumentMapping.fromDocument(document); + + expect(document[TaskDocumentFields.id], task.id); + expect(document[TaskDocumentFields.type], 'flexible'); + expect(document[TaskDocumentFields.status], 'planned'); + expect(document[TaskDocumentFields.reward], 'high'); + expect(document[TaskDocumentFields.difficulty], 'hard'); + expect(document[TaskDocumentFields.scheduledStart], + '2026-06-23T09:00:00.000Z'); + expect(restored.id, task.id); + expect(restored.title, task.title); + expect(restored.projectId, task.projectId); + expect(restored.type, task.type); + expect(restored.status, task.status); + expect(restored.priority, task.priority); + expect(restored.reward, task.reward); + expect(restored.difficulty, task.difficulty); + expect(restored.durationMinutes, task.durationMinutes); + expect(restored.scheduledStart, task.scheduledStart); + expect(restored.scheduledEnd, task.scheduledEnd); + expect(restored.parentTaskId, task.parentTaskId); + expect(restored.backlogTags, task.backlogTags); + expect(restored.createdAt, task.createdAt); + expect(restored.updatedAt, task.updatedAt); + expect(restored.stats.manuallyPushedCount, 1); + }); + + test('task documents preserve not-set reward distinctly from very low', () { + final noReward = scheduledTask( + id: 'no-reward', + createdAt: createdAt, + reward: RewardLevel.notSet, + ); + final veryLowReward = scheduledTask( + id: 'very-low', + createdAt: createdAt, + reward: RewardLevel.veryLow, + ); + + final noRewardDocument = noReward.toDocument(); + final veryLowRewardDocument = veryLowReward.toDocument(); + + expect(noRewardDocument[TaskDocumentFields.reward], 'notSet'); + expect(veryLowRewardDocument[TaskDocumentFields.reward], 'veryLow'); + expect( + TaskDocumentMapping.fromDocument(noRewardDocument).reward, + RewardLevel.notSet, + ); + expect( + TaskDocumentMapping.fromDocument(veryLowRewardDocument).reward, + RewardLevel.veryLow, + ); + }); + + test('task documents support explicit nullable schedule clearing', () { + final task = scheduledTask( + id: 'scheduled', + createdAt: createdAt, + ); + + final clearedDocument = task.toDocument(clearSchedule: true); + final restored = TaskDocumentMapping.fromDocument(clearedDocument); + + expect(clearedDocument[TaskDocumentFields.scheduledStart], isNull); + expect(clearedDocument[TaskDocumentFields.scheduledEnd], isNull); + expect(restored.scheduledStart, isNull); + expect(restored.scheduledEnd, isNull); + expect(restored.id, task.id); + }); + + test('task statistics document mapping preserves every counter', () { + const stats = TaskStatistics( + skippedDuringBurnoutCount: 1, + manuallyPushedCount: 2, + autoPushedCount: 3, + movedToBacklogCount: 4, + restoredFromBacklogCount: 5, + missedCount: 6, + cancelledCount: 7, + completedLateCount: 8, + completedDuringLockedHoursCount: 9, + completedDuringLockedHoursMinutes: 10, + ); + + final document = stats.toDocument(); + final restored = TaskStatisticsDocumentMapping.fromDocument(document); + + expect(document.keys.toSet(), TaskStatisticsDocumentFields.all); + expect(restored.skippedDuringBurnoutCount, 1); + expect(restored.manuallyPushedCount, 2); + expect(restored.autoPushedCount, 3); + expect(restored.movedToBacklogCount, 4); + expect(restored.restoredFromBacklogCount, 5); + expect(restored.missedCount, 6); + expect(restored.cancelledCount, 7); + expect(restored.completedLateCount, 8); + expect(restored.completedDuringLockedHoursCount, 9); + expect(restored.completedDuringLockedHoursMinutes, 10); + }); + + test('enum document mapping rejects unknown persistence names', () { + expect( + () => PersistenceEnumMapping.decodeTaskType('unknown'), + throwsArgumentError, + ); + }); + }); +} + +Task scheduledTask({ + required String id, + required DateTime createdAt, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.medium, +}) { + return Task( + id: id, + title: 'Scheduled task', + projectId: 'project-1', + type: TaskType.flexible, + status: TaskStatus.planned, + priority: PriorityLevel.medium, + reward: reward, + difficulty: difficulty, + durationMinutes: 30, + scheduledStart: DateTime.utc(2026, 6, 23, 9), + scheduledEnd: DateTime.utc(2026, 6, 23, 9, 30), + parentTaskId: 'parent-1', + backlogTags: const {BacklogTag.wishlist}, + createdAt: createdAt, + updatedAt: createdAt.add(const Duration(minutes: 5)), + ); +}