feat(02-01): Drift tables, DAOs, scheduling utility, domain models with tests

- Add Rooms, Tasks, TaskCompletions Drift tables with schema v2 migration
- Create RoomsDao with CRUD, watchAll, watchWithStats, cascade delete, reorder
- Create TasksDao with CRUD, watchInRoom (sorted by due), completeTask, overdue detection
- Implement calculateNextDueDate and catchUpToPresent pure scheduling functions
- Define IntervalType enum (8 types), EffortLevel enum, FrequencyInterval model
- Add formatRelativeDate German formatter and curatedRoomIcons icon list
- Enable PRAGMA foreign_keys in beforeOpen migration strategy
- All 30 unit tests passing (17 scheduling + 6 rooms DAO + 7 tasks DAO)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 21:50:12 +01:00
parent 515304b432
commit d2e452655c
18 changed files with 4082 additions and 6 deletions

View File

@@ -0,0 +1,23 @@
/// Effort level for tasks.
///
/// IMPORTANT: Never reorder or remove values - intEnum stores the .index.
/// Always add new values at the END.
enum EffortLevel {
low, // 0
medium, // 1
high, // 2
}
/// German display labels for effort levels.
extension EffortLevelLabel on EffortLevel {
String label() {
switch (this) {
case EffortLevel.low:
return 'Gering';
case EffortLevel.medium:
return 'Mittel';
case EffortLevel.high:
return 'Hoch';
}
}
}

View File

@@ -0,0 +1,62 @@
/// Frequency interval types for recurring tasks.
///
/// IMPORTANT: Never reorder or remove values - intEnum stores the .index.
/// Always add new values at the END.
enum IntervalType {
daily, // 0
everyNDays, // 1
weekly, // 2
biweekly, // 3
monthly, // 4
everyNMonths, // 5
quarterly, // 6
yearly, // 7
}
/// A frequency interval combining a type with an optional multiplier.
class FrequencyInterval {
final IntervalType intervalType;
final int days;
const FrequencyInterval({required this.intervalType, this.days = 1});
/// German display label for this interval.
String label() {
switch (intervalType) {
case IntervalType.daily:
return 'Taeglich';
case IntervalType.everyNDays:
if (days == 7) return 'Woechentlich';
if (days == 14) return 'Alle 2 Wochen';
return 'Alle $days Tage';
case IntervalType.weekly:
return 'Woechentlich';
case IntervalType.biweekly:
return 'Alle 2 Wochen';
case IntervalType.monthly:
return 'Monatlich';
case IntervalType.everyNMonths:
if (days == 3) return 'Vierteljaehrlich';
if (days == 6) return 'Halbjaehrlich';
return 'Alle $days Monate';
case IntervalType.quarterly:
return 'Vierteljaehrlich';
case IntervalType.yearly:
return 'Jaehrlich';
}
}
/// All preset frequency intervals per TASK-04.
static const List<FrequencyInterval> presets = [
FrequencyInterval(intervalType: IntervalType.daily),
FrequencyInterval(intervalType: IntervalType.everyNDays, days: 2),
FrequencyInterval(intervalType: IntervalType.everyNDays, days: 3),
FrequencyInterval(intervalType: IntervalType.weekly),
FrequencyInterval(intervalType: IntervalType.biweekly),
FrequencyInterval(intervalType: IntervalType.monthly),
FrequencyInterval(intervalType: IntervalType.everyNMonths, days: 2),
FrequencyInterval(intervalType: IntervalType.quarterly),
FrequencyInterval(intervalType: IntervalType.everyNMonths, days: 6),
FrequencyInterval(intervalType: IntervalType.yearly),
];
}

View File

@@ -0,0 +1,18 @@
/// Format a due date relative to today in German.
///
/// Returns labels like "Heute", "Morgen", "in X Tagen",
/// "Uberfaellig seit 1 Tag", "Uberfaellig seit X Tagen".
///
/// Both [dueDate] and [today] are compared as date-only (ignoring time).
String formatRelativeDate(DateTime dueDate, DateTime today) {
final diff =
DateTime(dueDate.year, dueDate.month, dueDate.day)
.difference(DateTime(today.year, today.month, today.day))
.inDays;
if (diff == 0) return 'Heute';
if (diff == 1) return 'Morgen';
if (diff > 1) return 'in $diff Tagen';
if (diff == -1) return 'Uberfaellig seit 1 Tag';
return 'Uberfaellig seit ${diff.abs()} Tagen';
}

View File

@@ -0,0 +1,66 @@
import 'frequency.dart';
/// Calculate the next due date from the current due date and interval config.
///
/// For calendar-anchored intervals, [anchorDay] is the original day-of-month.
/// Day-count intervals use pure arithmetic (Duration).
/// Calendar-anchored intervals use month arithmetic with clamping.
DateTime calculateNextDueDate({
required DateTime currentDueDate,
required IntervalType intervalType,
required int intervalDays,
int? anchorDay,
}) {
switch (intervalType) {
// Day-count: pure arithmetic
case IntervalType.daily:
return currentDueDate.add(const Duration(days: 1));
case IntervalType.everyNDays:
return currentDueDate.add(Duration(days: intervalDays));
case IntervalType.weekly:
return currentDueDate.add(const Duration(days: 7));
case IntervalType.biweekly:
return currentDueDate.add(const Duration(days: 14));
// Calendar-anchored: month arithmetic with clamping
case IntervalType.monthly:
return _addMonths(currentDueDate, 1, anchorDay);
case IntervalType.everyNMonths:
return _addMonths(currentDueDate, intervalDays, anchorDay);
case IntervalType.quarterly:
return _addMonths(currentDueDate, 3, anchorDay);
case IntervalType.yearly:
return _addMonths(currentDueDate, 12, anchorDay);
}
}
/// Add months with day-of-month clamping.
/// [anchorDay] remembers the original day for correct clamping.
DateTime _addMonths(DateTime date, int months, int? anchorDay) {
final targetMonth = date.month + months;
final targetYear = date.year + (targetMonth - 1) ~/ 12;
final normalizedMonth = ((targetMonth - 1) % 12) + 1;
final day = anchorDay ?? date.day;
// Last day of target month: day 0 of next month
final lastDay = DateTime(targetYear, normalizedMonth + 1, 0).day;
final clampedDay = day > lastDay ? lastDay : day;
return DateTime(targetYear, normalizedMonth, clampedDay);
}
/// Catch-up: if next due is in the past, keep adding intervals until future/today.
DateTime catchUpToPresent({
required DateTime nextDue,
required DateTime today,
required IntervalType intervalType,
required int intervalDays,
int? anchorDay,
}) {
while (nextDue.isBefore(today)) {
nextDue = calculateNextDueDate(
currentDueDate: nextDue,
intervalType: intervalType,
intervalDays: intervalDays,
anchorDay: anchorDay,
);
}
return nextDue;
}