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 4666bec..19fd59a 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 — Chunk 9.1 complete +Status: In progress — Chunks 9.1 and 9.2 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. @@ -84,6 +84,21 @@ Acceptance criteria: - Any unresolved persistence detail is documented as a TODO in the plan or architecture notes. - No alternative-database references remain in the active persistence plan. +Completed: + +- Added persistence edge-case regression tests for stable task, project, and locked-block IDs across copy/update helpers. +- Added UTC ISO-8601 DateTime persistence convention helpers and tests. +- Added enum persistence-name mapping tests, including `RewardLevel.notSet` remaining distinct from `RewardLevel.veryLow`. +- Added nullable-field preservation and explicit schedule-clearing tests. +- Added backlog age source tests documenting `Task.createdAt` as the current staleness source. +- Added in-memory repository round-trip and unmodifiable-read tests. +- Added MongoDB-oriented document field-name constants and tests for task/statistics, project, locked-block, override, scheduling snapshot, and embedded scheduling structures. +- Verified with `dart format lib test`, `dart analyze`, and `dart test`. + +TODO for Chunk 9.3: + +- Define document mapping behavior for clearing nullable non-schedule fields before adding model serialization helpers. Current explicit clear behavior only covers task schedule placement through `Task.copyWith(clearSchedule: true)`. + BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes. ## Chunk 9.3 — MongoDB-document-safe models 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 4666bec..19fd59a 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 — Chunk 9.1 complete +Status: In progress — Chunks 9.1 and 9.2 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. @@ -84,6 +84,21 @@ Acceptance criteria: - Any unresolved persistence detail is documented as a TODO in the plan or architecture notes. - No alternative-database references remain in the active persistence plan. +Completed: + +- Added persistence edge-case regression tests for stable task, project, and locked-block IDs across copy/update helpers. +- Added UTC ISO-8601 DateTime persistence convention helpers and tests. +- Added enum persistence-name mapping tests, including `RewardLevel.notSet` remaining distinct from `RewardLevel.veryLow`. +- Added nullable-field preservation and explicit schedule-clearing tests. +- Added backlog age source tests documenting `Task.createdAt` as the current staleness source. +- Added in-memory repository round-trip and unmodifiable-read tests. +- Added MongoDB-oriented document field-name constants and tests for task/statistics, project, locked-block, override, scheduling snapshot, and embedded scheduling structures. +- Verified with `dart format lib test`, `dart analyze`, and `dart test`. + +TODO for Chunk 9.3: + +- Define document mapping behavior for clearing nullable non-schedule fields before adding model serialization helpers. Current explicit clear behavior only covers task schedule placement through `Task.copyWith(clearSchedule: true)`. + BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes. ## Chunk 9.3 — MongoDB-document-safe models diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 2153565..4e8606d 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -20,6 +20,7 @@ export 'src/models.dart'; export 'src/backlog.dart'; export 'src/child_tasks.dart'; export 'src/locked_time.dart'; +export 'src/persistence_contract.dart'; export 'src/quick_capture.dart'; export 'src/repositories.dart'; export 'src/scheduling_engine.dart'; diff --git a/lib/src/locked_time.dart b/lib/src/locked_time.dart index 30a7f78..a035637 100644 --- a/lib/src/locked_time.dart +++ b/lib/src/locked_time.dart @@ -136,6 +136,33 @@ class LockedBlock { /// Convenience check for whether this block expands through recurrence. bool get isRecurring => recurrence != null; + + /// Return a copy with selected locked-block details changed. + LockedBlock copyWith({ + String? id, + String? name, + ClockTime? startTime, + ClockTime? endTime, + DateTime? date, + LockedBlockRecurrence? recurrence, + bool? hiddenByDefault, + String? projectId, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return LockedBlock( + id: id ?? this.id, + name: name ?? this.name, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + date: date ?? this.date, + recurrence: recurrence ?? this.recurrence, + hiddenByDefault: hiddenByDefault ?? this.hiddenByDefault, + projectId: projectId ?? this.projectId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } } /// Concrete locked-time occurrence for one calendar day. diff --git a/lib/src/models.dart b/lib/src/models.dart index bbb5a6d..c931cac 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -464,6 +464,31 @@ class ProjectProfile { updatedAt: updatedAt ?? createdAt, ); } + + /// Return a copy with selected project defaults changed. + ProjectProfile copyWith({ + String? id, + String? name, + String? colorKey, + PriorityLevel? defaultPriority, + RewardLevel? defaultReward, + DifficultyLevel? defaultDifficulty, + ReminderProfile? defaultReminderProfile, + int? defaultDurationMinutes, + }) { + return ProjectProfile( + id: id ?? this.id, + name: name ?? this.name, + colorKey: colorKey ?? this.colorKey, + defaultPriority: defaultPriority ?? this.defaultPriority, + defaultReward: defaultReward ?? this.defaultReward, + defaultDifficulty: defaultDifficulty ?? this.defaultDifficulty, + defaultReminderProfile: + defaultReminderProfile ?? this.defaultReminderProfile, + defaultDurationMinutes: + defaultDurationMinutes ?? this.defaultDurationMinutes, + ); + } } /// Starter time range value used by scheduling helpers. diff --git a/lib/src/persistence_contract.dart b/lib/src/persistence_contract.dart new file mode 100644 index 0000000..5899269 --- /dev/null +++ b/lib/src/persistence_contract.dart @@ -0,0 +1,319 @@ +// MongoDB-oriented persistence contract helpers. +// +// This file defines stable names and conventions that future document mapping +// code should use. It deliberately does not serialize domain models or import a +// MongoDB client. + +/// DateTime storage convention for future document persistence. +abstract final class PersistenceDateTimeConvention { + /// Human-readable convention kept close to the helper for tests and docs. + static const description = + 'Store DateTime values as UTC ISO-8601 strings and compare as UTC instants.'; + + /// Convert a DateTime into the persisted string convention. + static String toStoredString(DateTime value) { + return value.toUtc().toIso8601String(); + } + + /// Parse a persisted DateTime string back into a UTC DateTime. + static DateTime fromStoredString(String value) { + return DateTime.parse(value).toUtc(); + } + + /// Compare two DateTime values by their UTC instant. + static int compare(DateTime left, DateTime right) { + return left.toUtc().compareTo(right.toUtc()); + } +} + +/// Stable enum-name mapping plan for future document persistence. +extension PersistenceEnumName on Enum { + /// Persist enum values by their Dart enum name. + String get persistenceName => name; +} + +/// Shared MongoDB document field names. +abstract final class DocumentFields { + /// MongoDB primary id field. + static const id = '_id'; +} + +/// Document field names for [Task] documents. +abstract final class TaskDocumentFields { + static const id = DocumentFields.id; + static const title = 'title'; + static const projectId = 'projectId'; + static const type = 'type'; + static const status = 'status'; + static const priority = 'priority'; + static const reward = 'reward'; + static const difficulty = 'difficulty'; + static const durationMinutes = 'durationMinutes'; + static const scheduledStart = 'scheduledStart'; + static const scheduledEnd = 'scheduledEnd'; + static const parentTaskId = 'parentTaskId'; + static const backlogTags = 'backlogTags'; + static const createdAt = 'createdAt'; + static const updatedAt = 'updatedAt'; + static const stats = 'stats'; + + static const all = { + id, + title, + projectId, + type, + status, + priority, + reward, + difficulty, + durationMinutes, + scheduledStart, + scheduledEnd, + parentTaskId, + backlogTags, + createdAt, + updatedAt, + stats, + }; +} + +/// Document field names for [TaskStatistics] embedded documents. +abstract final class TaskStatisticsDocumentFields { + static const skippedDuringBurnoutCount = 'skippedDuringBurnoutCount'; + static const manuallyPushedCount = 'manuallyPushedCount'; + static const autoPushedCount = 'autoPushedCount'; + static const movedToBacklogCount = 'movedToBacklogCount'; + static const restoredFromBacklogCount = 'restoredFromBacklogCount'; + static const missedCount = 'missedCount'; + static const cancelledCount = 'cancelledCount'; + static const completedLateCount = 'completedLateCount'; + static const completedDuringLockedHoursCount = + 'completedDuringLockedHoursCount'; + static const completedDuringLockedHoursMinutes = + 'completedDuringLockedHoursMinutes'; + + static const all = { + skippedDuringBurnoutCount, + manuallyPushedCount, + autoPushedCount, + movedToBacklogCount, + restoredFromBacklogCount, + missedCount, + cancelledCount, + completedLateCount, + completedDuringLockedHoursCount, + completedDuringLockedHoursMinutes, + }; +} + +/// Document field names for [ProjectProfile] documents. +abstract final class ProjectDocumentFields { + static const id = DocumentFields.id; + static const name = 'name'; + static const colorKey = 'colorKey'; + static const defaultPriority = 'defaultPriority'; + static const defaultReward = 'defaultReward'; + static const defaultDifficulty = 'defaultDifficulty'; + static const defaultReminderProfile = 'defaultReminderProfile'; + static const defaultDurationMinutes = 'defaultDurationMinutes'; + + static const all = { + id, + name, + colorKey, + defaultPriority, + defaultReward, + defaultDifficulty, + defaultReminderProfile, + defaultDurationMinutes, + }; +} + +/// Document field names for [ClockTime] embedded documents. +abstract final class ClockTimeDocumentFields { + static const hour = 'hour'; + static const minute = 'minute'; + + static const all = { + hour, + minute, + }; +} + +/// Document field names for [LockedBlockRecurrence] embedded documents. +abstract final class LockedBlockRecurrenceDocumentFields { + static const weekdays = 'weekdays'; + + static const all = { + weekdays, + }; +} + +/// Document field names for [LockedBlock] documents. +abstract final class LockedBlockDocumentFields { + static const id = DocumentFields.id; + static const name = 'name'; + static const startTime = 'startTime'; + static const endTime = 'endTime'; + static const date = 'date'; + static const recurrence = 'recurrence'; + static const hiddenByDefault = 'hiddenByDefault'; + static const projectId = 'projectId'; + static const createdAt = 'createdAt'; + static const updatedAt = 'updatedAt'; + + static const all = { + id, + name, + startTime, + endTime, + date, + recurrence, + hiddenByDefault, + projectId, + createdAt, + updatedAt, + }; +} + +/// Document field names for [LockedBlockOverride] documents. +abstract final class LockedBlockOverrideDocumentFields { + static const id = DocumentFields.id; + static const lockedBlockId = 'lockedBlockId'; + static const date = 'date'; + static const type = 'type'; + static const name = 'name'; + static const startTime = 'startTime'; + static const endTime = 'endTime'; + static const hiddenByDefault = 'hiddenByDefault'; + static const projectId = 'projectId'; + static const createdAt = 'createdAt'; + static const updatedAt = 'updatedAt'; + + static const all = { + id, + lockedBlockId, + date, + type, + name, + startTime, + endTime, + hiddenByDefault, + projectId, + createdAt, + updatedAt, + }; +} + +/// Document field names for [SchedulingStateSnapshot] documents. +abstract final class SchedulingSnapshotDocumentFields { + static const id = DocumentFields.id; + static const capturedAt = 'capturedAt'; + static const window = 'window'; + static const tasks = 'tasks'; + static const lockedIntervals = 'lockedIntervals'; + static const requiredVisibleIntervals = 'requiredVisibleIntervals'; + static const notices = 'notices'; + static const changes = 'changes'; + static const overlaps = 'overlaps'; + static const operationName = 'operationName'; + + static const all = { + id, + capturedAt, + window, + tasks, + lockedIntervals, + requiredVisibleIntervals, + notices, + changes, + overlaps, + operationName, + }; +} + +/// Document field names for [SchedulingWindow] embedded documents. +abstract final class SchedulingWindowDocumentFields { + static const start = 'start'; + static const end = 'end'; + + static const all = { + start, + end, + }; +} + +/// Document field names for [TimeInterval] embedded documents. +abstract final class TimeIntervalDocumentFields { + static const start = 'start'; + static const end = 'end'; + static const label = 'label'; + + static const all = { + start, + end, + label, + }; +} + +/// Document field names for [SchedulingNotice] embedded documents. +abstract final class SchedulingNoticeDocumentFields { + static const message = 'message'; + static const type = 'type'; + static const taskId = 'taskId'; + + static const all = { + message, + type, + taskId, + }; +} + +/// Document field names for [SchedulingChange] embedded documents. +abstract final class SchedulingChangeDocumentFields { + static const taskId = 'taskId'; + static const previousStart = 'previousStart'; + static const previousEnd = 'previousEnd'; + static const nextStart = 'nextStart'; + static const nextEnd = 'nextEnd'; + + static const all = { + taskId, + previousStart, + previousEnd, + nextStart, + nextEnd, + }; +} + +/// Document field names for [SchedulingOverlap] embedded documents. +abstract final class SchedulingOverlapDocumentFields { + static const taskId = 'taskId'; + static const taskInterval = 'taskInterval'; + static const blockedInterval = 'blockedInterval'; + + static const all = { + taskId, + taskInterval, + blockedInterval, + }; +} + +/// Every field-name set currently committed for future document mapping. +abstract final class PersistenceDocumentFieldSets { + static const all = >[ + TaskDocumentFields.all, + TaskStatisticsDocumentFields.all, + ProjectDocumentFields.all, + ClockTimeDocumentFields.all, + LockedBlockRecurrenceDocumentFields.all, + LockedBlockDocumentFields.all, + LockedBlockOverrideDocumentFields.all, + SchedulingSnapshotDocumentFields.all, + SchedulingWindowDocumentFields.all, + TimeIntervalDocumentFields.all, + SchedulingNoticeDocumentFields.all, + SchedulingChangeDocumentFields.all, + SchedulingOverlapDocumentFields.all, + ]; +} diff --git a/test/persistence_edge_cases_test.dart b/test/persistence_edge_cases_test.dart new file mode 100644 index 0000000..c00c18c --- /dev/null +++ b/test/persistence_edge_cases_test.dart @@ -0,0 +1,280 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Persistence edge-case regressions', () { + final createdAt = DateTime.utc(2026, 6, 22, 8); + + test('task ids remain stable across copy and update operations', () { + final original = scheduledTask( + id: 'task-1', + createdAt: createdAt, + ); + + final updated = original.copyWith( + title: 'Updated title', + status: TaskStatus.active, + updatedAt: createdAt.add(const Duration(minutes: 5)), + ); + + expect(updated.id, original.id); + expect(updated.title, 'Updated title'); + expect(updated.status, TaskStatus.active); + }); + + test('project ids remain stable across copy and update operations', () { + const original = ProjectProfile( + id: 'project-1', + name: 'Project', + colorKey: 'project-blue', + ); + + final updated = original.copyWith( + name: 'Renamed project', + colorKey: 'project-green', + ); + + expect(updated.id, original.id); + expect(updated.name, 'Renamed project'); + expect(updated.colorKey, 'project-green'); + }); + + test('locked block ids remain stable across copy and update operations', + () { + final original = LockedBlock( + id: 'locked-1', + name: 'Work', + startTime: const ClockTime(hour: 9, minute: 0), + endTime: const ClockTime(hour: 17, minute: 0), + recurrence: const LockedBlockRecurrence.weekly( + weekdays: {LockedWeekday.monday}, + ), + createdAt: createdAt, + updatedAt: createdAt, + ); + + final updated = original.copyWith( + name: 'Adjusted work', + hiddenByDefault: false, + updatedAt: createdAt.add(const Duration(minutes: 10)), + ); + + expect(updated.id, original.id); + expect(updated.name, 'Adjusted work'); + expect(updated.hiddenByDefault, isFalse); + }); + + test('DateTime persistence uses UTC ISO-8601 strings and UTC comparison', + () { + final pacificTime = DateTime.parse('2026-06-22T09:00:00-07:00'); + final sameInstantUtc = DateTime.utc(2026, 6, 22, 16); + + final stored = PersistenceDateTimeConvention.toStoredString(pacificTime); + final restored = PersistenceDateTimeConvention.fromStoredString(stored); + + expect(PersistenceDateTimeConvention.description, contains('UTC')); + expect(stored, '2026-06-22T16:00:00.000Z'); + expect(restored, sameInstantUtc); + expect( + PersistenceDateTimeConvention.compare(pacificTime, sameInstantUtc), + 0, + ); + }); + + test('nullable task fields are preserved unless explicitly cleared', () { + final original = scheduledTask( + id: 'nullable-task', + createdAt: createdAt, + ).copyWith( + priority: PriorityLevel.high, + parentTaskId: 'parent', + ); + + final preserved = original.copyWith(title: 'Updated'); + final clearedSchedule = original.copyWith(clearSchedule: true); + + expect(preserved.priority, PriorityLevel.high); + expect(preserved.durationMinutes, original.durationMinutes); + expect(preserved.scheduledStart, original.scheduledStart); + expect(preserved.scheduledEnd, original.scheduledEnd); + expect(preserved.parentTaskId, 'parent'); + + expect(clearedSchedule.scheduledStart, isNull); + expect(clearedSchedule.scheduledEnd, isNull); + expect(clearedSchedule.id, original.id); + }); + + test('enum persistence names stay stable and distinguish not set values', + () { + expect(TaskType.flexible.persistenceName, 'flexible'); + expect(TaskStatus.backlog.persistenceName, 'backlog'); + expect(PriorityLevel.veryHigh.persistenceName, 'veryHigh'); + expect(RewardLevel.notSet.persistenceName, 'notSet'); + expect(RewardLevel.veryLow.persistenceName, 'veryLow'); + expect(DifficultyLevel.veryHard.persistenceName, 'veryHard'); + expect(ReminderProfile.gentle.persistenceName, 'gentle'); + expect(BacklogTag.wishlist.persistenceName, 'wishlist'); + expect(LockedWeekday.monday.persistenceName, 'monday'); + expect(LockedBlockOverrideType.replace.persistenceName, 'replace'); + + expect( + RewardLevel.notSet.persistenceName, + isNot(RewardLevel.veryLow.persistenceName), + ); + }); + + test('backlog age behavior uses task creation timestamp', () { + final oldButRecentlyUpdated = Task.quickCapture( + id: 'old', + title: 'Old backlog idea', + createdAt: DateTime.utc(2026, 5, 1), + updatedAt: DateTime.utc(2026, 6, 21), + ); + final view = BacklogView( + tasks: [oldButRecentlyUpdated], + now: DateTime.utc(2026, 6, 22), + ); + + expect(view.filter(BacklogFilter.stale), [oldButRecentlyUpdated]); + expect( + view.stalenessMarkerFor(oldButRecentlyUpdated), + BacklogStalenessMarker.purple, + ); + }); + + test('in-memory repositories round-trip scheduling fields', () async { + final task = scheduledTask( + id: 'scheduled', + createdAt: createdAt, + ); + final repository = InMemoryTaskRepository(); + + await repository.save(task); + final saved = await repository.findById(task.id); + + expect(saved, isNotNull); + expect(saved!.id, task.id); + expect(saved.scheduledStart, task.scheduledStart); + expect(saved.scheduledEnd, task.scheduledEnd); + expect(saved.durationMinutes, task.durationMinutes); + expect(saved.projectId, task.projectId); + expect(saved.reward, task.reward); + expect(saved.difficulty, task.difficulty); + }); + + test('repository read operations do not expose mutable collections', + () async { + final original = scheduledTask( + id: 'immutable', + createdAt: createdAt, + ); + final repository = InMemoryTaskRepository([original]); + + final savedList = await repository.findAll(); + + expect(() => savedList.add(original), throwsUnsupportedError); + expect((await repository.findAll()).map((task) => task.id), [ + 'immutable', + ]); + expect(original.scheduledStart, DateTime.utc(2026, 6, 22, 9)); + }); + + test('document field sets preserve all task and statistics fields', () { + expect(TaskDocumentFields.all, { + '_id', + 'title', + 'projectId', + 'type', + 'status', + 'priority', + 'reward', + 'difficulty', + 'durationMinutes', + 'scheduledStart', + 'scheduledEnd', + 'parentTaskId', + 'backlogTags', + 'createdAt', + 'updatedAt', + 'stats', + }); + expect(TaskStatisticsDocumentFields.all, { + 'skippedDuringBurnoutCount', + 'manuallyPushedCount', + 'autoPushedCount', + 'movedToBacklogCount', + 'restoredFromBacklogCount', + 'missedCount', + 'cancelledCount', + 'completedLateCount', + 'completedDuringLockedHoursCount', + 'completedDuringLockedHoursMinutes', + }); + }); + + test('document field sets cover project locked and snapshot documents', () { + expect(ProjectDocumentFields.all, containsAll(['_id', 'colorKey'])); + expect( + LockedBlockDocumentFields.all, + containsAll(['_id', 'startTime', 'recurrence', 'hiddenByDefault']), + ); + expect( + LockedBlockOverrideDocumentFields.all, + containsAll(['_id', 'lockedBlockId', 'type']), + ); + expect( + SchedulingSnapshotDocumentFields.all, + containsAll([ + '_id', + 'capturedAt', + 'window', + 'tasks', + 'lockedIntervals', + 'requiredVisibleIntervals', + 'notices', + 'changes', + 'overlaps', + 'operationName', + ]), + ); + }); + + test('document field names are stable and migration-friendly', () { + for (final fieldSet in PersistenceDocumentFieldSets.all) { + expect(fieldSet, isNotEmpty); + for (final fieldName in fieldSet) { + expect(fieldName, isNotEmpty); + expect(fieldName.trim(), fieldName); + expect(fieldName.contains('.'), isFalse, reason: fieldName); + expect(fieldName.startsWith(r'$'), isFalse, reason: fieldName); + } + } + + expect(DocumentFields.id, '_id'); + expect(TaskDocumentFields.projectId, 'projectId'); + expect(TaskDocumentFields.scheduledStart, 'scheduledStart'); + expect(TaskDocumentFields.scheduledEnd, 'scheduledEnd'); + }); + }); +} + +Task scheduledTask({ + required String id, + required DateTime createdAt, +}) { + return Task( + id: id, + title: 'Scheduled task', + projectId: 'project-1', + type: TaskType.flexible, + status: TaskStatus.planned, + priority: PriorityLevel.medium, + reward: RewardLevel.high, + difficulty: DifficultyLevel.medium, + durationMinutes: 30, + scheduledStart: DateTime.utc(2026, 6, 22, 9), + scheduledEnd: DateTime.utc(2026, 6, 22, 9, 30), + createdAt: createdAt, + updatedAt: createdAt, + ); +}