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 9ac3e22..4666bec 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,8 +1,10 @@ # V1 Block 09 — Persistence Preparation -Status: Planned +Status: In progress — Chunk 9.1 complete -Purpose: Prepare the domain layer for local persistence without overbuilding sync or database implementation too early. +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. + +MongoDB is the committed persistence target. This block should make the core persistence-friendly and MongoDB-document-friendly while keeping the scheduling engine independent from database APIs. ## Chunk 9.1 — Repository interface @@ -19,16 +21,27 @@ Define repository interfaces for: Rules: -- Do not couple scheduling engine directly to SQLite. -- Use interfaces that a future Drift/SQLite layer can implement. -- Keep current implementation in-memory if needed. +- Do not couple the scheduling engine directly to MongoDB APIs. +- Use interfaces that a future MongoDB adapter can implement. +- Keep the current implementation in-memory if needed. - Repository interfaces should preserve domain rules rather than bypassing services. +- Do not introduce alternative database assumptions. Acceptance criteria: - Domain services can depend on interfaces. - Tests can use fake/in-memory repositories. -- Interfaces do not import Drift, SQLite, Flutter, or platform-specific APIs. +- Interfaces do not import MongoDB, Flutter, platform-specific APIs, or network/client APIs. +- Interface names and method shapes are compatible with document-style persistence. + +Completed: + +- Added pure Dart repository interfaces for tasks, projects, locked blocks/overrides, and scheduling state snapshots. +- Added in-memory repository implementations for tests and early app wiring. +- Added scheduling snapshot state that can rebuild `SchedulingInput` and `SchedulingResult` without persistence APIs. +- Exported repository interfaces through the public core library. +- Added repository tests covering task/project/locked-block/snapshot fake usage. +- Verified with `dart format lib test`, `dart analyze`, and `dart test`. BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the persistence edge-case test suite. @@ -53,70 +66,87 @@ Cover these cases: - backlog age behavior has a documented source timestamp or TODO before database work - in-memory fake repositories can round-trip saved records without losing scheduling fields - repository operations do not mutate input objects unexpectedly +- document-shaped maps preserve all fields needed by future MongoDB persistence +- generated document field names are stable and migration-friendly Rules: - This chunk may add tests and small model helpers needed to make persistence behavior testable. -- Do not add Drift/SQLite implementation yet. +- Do not add a MongoDB driver, MongoDB client, Atlas setup, connection string, local MongoDB service requirement, or database adapter yet. +- Do not add alternative database mappings or non-MongoDB terminology. - Do not add sync, networking, cloud accounts, or background services. - Do not mark this chunk complete unless `dart analyze` and `dart test` pass. Acceptance criteria: - Persistence-sensitive test group exists. -- Tests capture stable IDs, enum mapping, nullable-field behavior, and DateTime convention. +- Tests capture stable IDs, enum mapping, nullable-field behavior, DateTime convention, and document-shaped mapping expectations. - 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. BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes. -## Chunk 9.3 — Serialization-safe models +## Chunk 9.3 — MongoDB-document-safe models Recommended Codex level: high Tasks: -Make models persistence-friendly: +Make models persistence-friendly and MongoDB-document-friendly: - stable IDs - enum serialization helpers or clear mapping plan - DateTime handling convention - nullable fields documented - nullable field clear helpers where needed -- migration-safe field names where possible -- map/json-like conversion helpers only if they do not over-couple the domain layer +- migration-safe document field names where possible +- document-shaped map conversion helpers only if they do not over-couple the domain layer +- clear mapping rules for nested task statistics and future child-task ownership fields + +Rules: + +- Keep model helpers independent from MongoDB client libraries. +- Prefer plain Dart map/document shapes that a later adapter can translate into MongoDB documents. +- Do not introduce database connection logic in this chunk. +- Do not add table assumptions, joins, or relational schema requirements. Acceptance criteria: -- Models can round-trip through map/json-like structures or have a clear TODO for Drift mappings. +- Models can round-trip through document-shaped map/json-like structures or have a clear TODO for MongoDB document mappings. - Tests cover at least task serialization if implemented. - Tests cover enum persistence mapping if implemented. - 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. BREAKPOINT: Stop here. Confirm `low` mode before documenting the explicit non-sync boundary. -## Chunk 9.4 — Explicit non-sync boundary +## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary Recommended Codex level: low Tasks: -Document that V1 persistence is local-first and sync is out of scope. +Document that V1 persistence work prepares for MongoDB but does not implement runtime database access, sync, accounts, or background services. Rules: - No network sync code. - No cloud assumptions. +- No MongoDB connection strings. +- No Atlas setup. +- No local MongoDB server requirement. - No background service implementation. - No mobile notification or background reconciliation implementation in this block. Acceptance criteria: -- README or architecture doc states sync is future work. -- Docs distinguish local persistence preparation from actual sync. +- README or architecture doc states MongoDB is the committed persistence target. +- README or architecture doc states this block does not require a running MongoDB instance. +- Docs distinguish MongoDB persistence preparation from actual sync or database adapter implementation. - Wishlist/future notes contain sync as a later feature, not a V1 requirement. Commit suggestion: ```text -feat(data): prepare persistence interfaces for scheduling core +feat(data): prepare MongoDB-friendly persistence interfaces ``` 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 8ae4c82..4666bec 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: Planned +Status: In progress — Chunk 9.1 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. @@ -34,6 +34,15 @@ Acceptance criteria: - Interfaces do not import MongoDB, Flutter, platform-specific APIs, or network/client APIs. - Interface names and method shapes are compatible with document-style persistence. +Completed: + +- Added pure Dart repository interfaces for tasks, projects, locked blocks/overrides, and scheduling state snapshots. +- Added in-memory repository implementations for tests and early app wiring. +- Added scheduling snapshot state that can rebuild `SchedulingInput` and `SchedulingResult` without persistence APIs. +- Exported repository interfaces through the public core library. +- Added repository tests covering task/project/locked-block/snapshot fake usage. +- Verified with `dart format lib test`, `dart analyze`, and `dart test`. + BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the persistence edge-case test suite. ## Chunk 9.2 — Persistence edge-case regression test suite @@ -64,7 +73,7 @@ Rules: - This chunk may add tests and small model helpers needed to make persistence behavior testable. - Do not add a MongoDB driver, MongoDB client, Atlas setup, connection string, local MongoDB service requirement, or database adapter yet. -- Do not add alternative database mappings or SQL-specific terminology. +- Do not add alternative database mappings or non-MongoDB terminology. - Do not add sync, networking, cloud accounts, or background services. - Do not mark this chunk complete unless `dart analyze` and `dart test` pass. @@ -99,7 +108,7 @@ Rules: - Keep model helpers independent from MongoDB client libraries. - Prefer plain Dart map/document shapes that a later adapter can translate into MongoDB documents. - Do not introduce database connection logic in this chunk. -- Do not add SQL-style table assumptions, joins, or relational schema requirements. +- Do not add table assumptions, joins, or relational schema requirements. Acceptance criteria: diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index f001895..2153565 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -21,6 +21,7 @@ export 'src/backlog.dart'; export 'src/child_tasks.dart'; export 'src/locked_time.dart'; export 'src/quick_capture.dart'; +export 'src/repositories.dart'; export 'src/scheduling_engine.dart'; export 'src/task_actions.dart'; export 'src/task_statistics.dart'; diff --git a/lib/src/repositories.dart b/lib/src/repositories.dart new file mode 100644 index 0000000..a1ac240 --- /dev/null +++ b/lib/src/repositories.dart @@ -0,0 +1,300 @@ +// Persistence boundary interfaces for the scheduling core. +// +// These interfaces are intentionally database-client-free. A future MongoDB +// adapter can implement them with document storage while the domain layer and +// tests keep using the same pure Dart contracts. + +import 'locked_time.dart'; +import 'models.dart'; +import 'scheduling_engine.dart'; + +/// Repository contract for task documents. +abstract interface class TaskRepository { + /// Return a task by stable id, or null when it does not exist. + Future findById(String id); + + /// Return all tasks currently known to the repository. + Future> findAll(); + + /// Return tasks with a lifecycle status. + Future> findByStatus(TaskStatus status); + + /// Return tasks that overlap [window] by scheduled start/end. + Future> findScheduledInWindow(SchedulingWindow window); + + /// Save or replace [task] by stable id. + Future save(Task task); + + /// Save or replace every task by stable id. + Future saveAll(Iterable tasks); +} + +/// Repository contract for project profile documents. +abstract interface class ProjectRepository { + /// Return a project by stable id, or null when it does not exist. + Future findById(String id); + + /// Return all projects currently known to the repository. + Future> findAll(); + + /// Save or replace [project] by stable id. + Future save(ProjectProfile project); +} + +/// Repository contract for locked-block and one-day override documents. +abstract interface class LockedBlockRepository { + /// Return a locked block by stable id, or null when it does not exist. + Future findBlockById(String id); + + /// Return all locked block definitions. + Future> findAllBlocks(); + + /// Return an override by stable id, or null when it does not exist. + Future findOverrideById(String id); + + /// Return all one-day overrides. + Future> findAllOverrides(); + + /// Save or replace [block] by stable id. + Future saveBlock(LockedBlock block); + + /// Save or replace [override] by stable id. + Future saveOverride(LockedBlockOverride override); +} + +/// Snapshot of one scheduling operation or persisted planning state. +class SchedulingStateSnapshot { + const SchedulingStateSnapshot({ + required this.id, + required this.capturedAt, + required this.window, + required this.tasks, + this.lockedIntervals = const [], + this.requiredVisibleIntervals = const [], + this.notices = const [], + this.changes = const [], + this.overlaps = const [], + this.operationName, + }); + + /// Stable snapshot id. + final String id; + + /// Time this snapshot was created. + final DateTime capturedAt; + + /// Planning window represented by the snapshot. + final SchedulingWindow window; + + /// Task documents visible to the operation. + final List tasks; + + /// Locked intervals supplied to the scheduler. + final List lockedIntervals; + + /// Extra visible required intervals supplied to the scheduler. + final List requiredVisibleIntervals; + + /// Human-readable scheduling notices. + final List notices; + + /// Machine-readable task movements. + final List changes; + + /// Analysis-only overlap findings. + final List overlaps; + + /// Optional operation label, such as `push` or `rollover`. + final String? operationName; + + /// Rebuild scheduler input from the persisted state shape. + SchedulingInput get input { + return SchedulingInput( + tasks: List.unmodifiable(tasks), + window: window, + lockedIntervals: List.unmodifiable(lockedIntervals), + requiredVisibleIntervals: + List.unmodifiable(requiredVisibleIntervals), + ); + } + + /// Rebuild scheduler result details from the persisted state shape. + SchedulingResult get result { + return SchedulingResult( + tasks: List.unmodifiable(tasks), + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + overlaps: List.unmodifiable(overlaps), + ); + } +} + +/// Repository contract for scheduling operation/state snapshot documents. +abstract interface class SchedulingSnapshotRepository { + /// Return a snapshot by stable id, or null when it does not exist. + Future findById(String id); + + /// Return all snapshots overlapping [window]. + Future> findInWindow(SchedulingWindow window); + + /// Save or replace [snapshot] by stable id. + Future save(SchedulingStateSnapshot snapshot); +} + +/// In-memory task repository useful for tests and early application wiring. +class InMemoryTaskRepository implements TaskRepository { + InMemoryTaskRepository([Iterable initialTasks = const []]) + : _tasksById = { + for (final task in initialTasks) task.id: task, + }; + + final Map _tasksById; + + @override + Future findById(String id) async { + return _tasksById[id]; + } + + @override + Future> findAll() async { + return List.unmodifiable(_tasksById.values); + } + + @override + Future> findByStatus(TaskStatus status) async { + return List.unmodifiable( + _tasksById.values.where((task) => task.status == status), + ); + } + + @override + Future> findScheduledInWindow(SchedulingWindow window) async { + return List.unmodifiable( + _tasksById.values.where((task) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return TimeInterval(start: start, end: end).overlaps(window.interval); + }), + ); + } + + @override + Future save(Task task) async { + _tasksById[task.id] = task; + } + + @override + Future saveAll(Iterable tasks) async { + for (final task in tasks) { + _tasksById[task.id] = task; + } + } +} + +/// In-memory project repository useful for tests and early application wiring. +class InMemoryProjectRepository implements ProjectRepository { + InMemoryProjectRepository( + [Iterable initialProjects = const []]) + : _projectsById = { + for (final project in initialProjects) project.id: project, + }; + + final Map _projectsById; + + @override + Future findById(String id) async { + return _projectsById[id]; + } + + @override + Future> findAll() async { + return List.unmodifiable(_projectsById.values); + } + + @override + Future save(ProjectProfile project) async { + _projectsById[project.id] = project; + } +} + +/// In-memory locked-time repository useful for tests and early wiring. +class InMemoryLockedBlockRepository implements LockedBlockRepository { + InMemoryLockedBlockRepository({ + Iterable initialBlocks = const [], + Iterable initialOverrides = const [], + }) : _blocksById = { + for (final block in initialBlocks) block.id: block, + }, + _overridesById = { + for (final override in initialOverrides) override.id: override, + }; + + final Map _blocksById; + final Map _overridesById; + + @override + Future findBlockById(String id) async { + return _blocksById[id]; + } + + @override + Future> findAllBlocks() async { + return List.unmodifiable(_blocksById.values); + } + + @override + Future findOverrideById(String id) async { + return _overridesById[id]; + } + + @override + Future> findAllOverrides() async { + return List.unmodifiable(_overridesById.values); + } + + @override + Future saveBlock(LockedBlock block) async { + _blocksById[block.id] = block; + } + + @override + Future saveOverride(LockedBlockOverride override) async { + _overridesById[override.id] = override; + } +} + +/// In-memory snapshot repository useful for tests and early application wiring. +class InMemorySchedulingSnapshotRepository + implements SchedulingSnapshotRepository { + InMemorySchedulingSnapshotRepository([ + Iterable initialSnapshots = const [], + ]) : _snapshotsById = { + for (final snapshot in initialSnapshots) snapshot.id: snapshot, + }; + + final Map _snapshotsById; + + @override + Future findById(String id) async { + return _snapshotsById[id]; + } + + @override + Future> findInWindow( + SchedulingWindow window, + ) async { + return List.unmodifiable( + _snapshotsById.values.where( + (snapshot) => snapshot.window.interval.overlaps(window.interval), + ), + ); + } + + @override + Future save(SchedulingStateSnapshot snapshot) async { + _snapshotsById[snapshot.id] = snapshot; + } +} diff --git a/test/repositories_test.dart b/test/repositories_test.dart new file mode 100644 index 0000000..fcde21b --- /dev/null +++ b/test/repositories_test.dart @@ -0,0 +1,147 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Repository interfaces', () { + final createdAt = DateTime(2026, 6, 21, 8); + final window = SchedulingWindow( + start: DateTime(2026, 6, 21, 9), + end: DateTime(2026, 6, 21, 17), + ); + + test('task repository supports in-memory fakes for domain services', + () async { + final backlogTask = task( + id: 'backlog', + title: 'Backlog task', + status: TaskStatus.backlog, + createdAt: createdAt, + ); + final scheduledTask = task( + id: 'scheduled', + title: 'Scheduled task', + status: TaskStatus.planned, + createdAt: createdAt, + scheduledStart: DateTime(2026, 6, 21, 10), + scheduledEnd: DateTime(2026, 6, 21, 10, 30), + ); + final repository = InMemoryTaskRepository([backlogTask]); + + await repository.save(scheduledTask); + + expect(await repository.findById('backlog'), same(backlogTask)); + expect(await repository.findByStatus(TaskStatus.backlog), [backlogTask]); + expect( + (await repository.findScheduledInWindow(window)).map((task) => task.id), + ['scheduled'], + ); + }); + + test('project repository stores project profiles by stable id', () async { + const project = ProjectProfile( + id: 'home', + name: 'Home', + colorKey: 'project-home', + ); + final repository = InMemoryProjectRepository(); + + await repository.save(project); + + expect(await repository.findById('home'), same(project)); + expect(await repository.findAll(), [project]); + }); + + test('locked block repository separates blocks from overrides', () async { + final block = LockedBlock( + id: 'work', + 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 override = LockedBlockOverride.remove( + id: 'holiday', + lockedBlockId: block.id, + date: DateTime(2026, 6, 22), + createdAt: createdAt, + ); + final repository = InMemoryLockedBlockRepository(); + + await repository.saveBlock(block); + await repository.saveOverride(override); + + expect(await repository.findBlockById('work'), same(block)); + expect(await repository.findOverrideById('holiday'), same(override)); + expect(await repository.findAllBlocks(), [block]); + expect(await repository.findAllOverrides(), [override]); + }); + + test('scheduling snapshot repository preserves scheduler input shape', + () async { + final scheduledTask = task( + id: 'scheduled', + title: 'Scheduled task', + status: TaskStatus.planned, + createdAt: createdAt, + scheduledStart: DateTime(2026, 6, 21, 10), + scheduledEnd: DateTime(2026, 6, 21, 10, 30), + ); + final lockedInterval = TimeInterval( + start: DateTime(2026, 6, 21, 12), + end: DateTime(2026, 6, 21, 13), + label: 'Lunch', + ); + final snapshot = SchedulingStateSnapshot( + id: 'snapshot-1', + capturedAt: DateTime(2026, 6, 21, 8, 30), + window: window, + tasks: [scheduledTask], + lockedIntervals: [lockedInterval], + notices: const [ + SchedulingNotice('Saved schedule'), + ], + operationName: 'load-day', + ); + final repository = InMemorySchedulingSnapshotRepository(); + + await repository.save(snapshot); + + final saved = await repository.findById('snapshot-1'); + expect(saved, same(snapshot)); + expect(saved!.input.tasks, [scheduledTask]); + expect(saved.input.lockedIntervals, [lockedInterval]); + expect(saved.result.tasks, [scheduledTask]); + expect(saved.result.notices.single.message, 'Saved schedule'); + expect( + (await repository.findInWindow(window)).map((snapshot) => snapshot.id), + ['snapshot-1'], + ); + }); + }); +} + +Task task({ + required String id, + required String title, + required TaskStatus status, + required DateTime createdAt, + DateTime? scheduledStart, + DateTime? scheduledEnd, +}) { + return Task( + id: id, + title: title, + projectId: 'inbox', + type: TaskType.flexible, + status: status, + durationMinutes: 30, + scheduledStart: scheduledStart, + scheduledEnd: scheduledEnd, + createdAt: createdAt, + updatedAt: createdAt, + ); +}