feat(data): add MongoDB document mapping helpers

This commit is contained in:
Ashley Venn 2026-06-24 14:16:30 -07:00
parent 9537e06c3c
commit b0e790f8a6
5 changed files with 479 additions and 2 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 09 — Persistence Preparation # 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. 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 cover intentional clearing of nullable fields if helper behavior is added.
- Tests confirm `not set` reward remains distinct from very low reward through mapping. - 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. BREAKPOINT: Stop here. Confirm `low` mode before documenting the explicit non-sync boundary.
## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary ## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary

View file

@ -1,6 +1,6 @@
# V1 Block 09 — Persistence Preparation # 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. 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 cover intentional clearing of nullable fields if helper behavior is added.
- Tests confirm `not set` reward remains distinct from very low reward through mapping. - 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. BREAKPOINT: Stop here. Confirm `low` mode before documenting the explicit non-sync boundary.
## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary ## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary

View file

@ -19,6 +19,7 @@
export 'src/models.dart'; export 'src/models.dart';
export 'src/backlog.dart'; export 'src/backlog.dart';
export 'src/child_tasks.dart'; export 'src/child_tasks.dart';
export 'src/document_mapping.dart';
export 'src/locked_time.dart'; export 'src/locked_time.dart';
export 'src/persistence_contract.dart'; export 'src/persistence_contract.dart';
export 'src/quick_capture.dart'; export 'src/quick_capture.dart';

View file

@ -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<T extends Enum>(
Iterable<T> 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<String, Object?> 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<String, Object?> 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<BacklogTag> _backlogTagsFromDocument(
Map<String, Object?> document,
) {
final rawTags = document[TaskDocumentFields.backlogTags];
if (rawTags == null) {
return const <BacklogTag>{};
}
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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> toDocument() {
return TaskStatisticsDocumentMapping.toDocument(this);
}
}
String? _dateTimeToStored(DateTime? value) {
return value == null
? null
: PersistenceDateTimeConvention.toStoredString(value);
}
String _requiredString(Map<String, Object?> document, String fieldName) {
final value = document[fieldName];
if (value is String) {
return value;
}
throw ArgumentError.value(value, fieldName, 'Expected a string.');
}
String? _nullableString(Map<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> document, String fieldName) {
return PersistenceDateTimeConvention.fromStoredString(
_requiredString(document, fieldName),
);
}
DateTime? _nullableDateTime(Map<String, Object?> document, String fieldName) {
final value = _nullableString(document, fieldName);
return value == null
? null
: PersistenceDateTimeConvention.fromStoredString(value);
}
Map<String, Object?> _requiredMap(
Map<String, Object?> document,
String fieldName,
) {
final value = document[fieldName];
if (value is Map<String, Object?>) {
return value;
}
if (value is Map) {
return Map<String, Object?>.from(value);
}
throw ArgumentError.value(value, fieldName, 'Expected a document map.');
}

View file

@ -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)),
);
}