feat(11-02): add DAO queries, update CalendarDayState, implement pre-population provider
- Add watchAllActiveRecurringTasks() and watchAllActiveRecurringTasksInRoom() to CalendarDao - Add watchCompletionsInRange() for period-completion filtering - Extend CalendarDayState with prePopulatedTasks field (default empty, backward compat) - Update isEmpty getter to include prePopulatedTasks.isEmpty - Add _isInCurrentIntervalWindow() and _calculatePreviousDueDate() helpers - Rewrite calendarDayProvider and roomCalendarDayProvider with pre-population logic - Fix _subtractMonths() year-boundary bug using total-month arithmetic - Add 9 new DAO tests for watchAllActiveRecurringTasks, watchAllActiveRecurringTasksInRoom, watchCompletionsInRange
This commit is contained in:
@@ -165,4 +165,59 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
final result = await query.getSingle();
|
final result = await query.getSingle();
|
||||||
return result.read(countExp) ?? 0;
|
return result.read(countExp) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Watch ALL active tasks with their rooms.
|
||||||
|
///
|
||||||
|
/// Used by the pre-population logic to determine which tasks should appear
|
||||||
|
/// on days within their current interval window (before their next due date).
|
||||||
|
Stream<List<TaskWithRoom>> watchAllActiveRecurringTasks() {
|
||||||
|
final query = select(tasks).join([
|
||||||
|
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
|
||||||
|
]);
|
||||||
|
query.where(tasks.isActive.equals(true));
|
||||||
|
query.orderBy([OrderingTerm.asc(tasks.name)]);
|
||||||
|
|
||||||
|
return query.watch().map((rows) {
|
||||||
|
return rows.map((row) {
|
||||||
|
final task = row.readTable(tasks);
|
||||||
|
final room = row.readTable(rooms);
|
||||||
|
return TaskWithRoom(task: task, roomName: room.name, roomId: room.id);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch ALL active tasks with their rooms, filtered to a specific [roomId].
|
||||||
|
///
|
||||||
|
/// Room-scoped variant of [watchAllActiveRecurringTasks], used by
|
||||||
|
/// [roomCalendarDayProvider] to pre-populate tasks for a single room.
|
||||||
|
Stream<List<TaskWithRoom>> watchAllActiveRecurringTasksInRoom(int roomId) {
|
||||||
|
final query = select(tasks).join([
|
||||||
|
innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)),
|
||||||
|
]);
|
||||||
|
query.where(tasks.isActive.equals(true) & tasks.roomId.equals(roomId));
|
||||||
|
query.orderBy([OrderingTerm.asc(tasks.name)]);
|
||||||
|
|
||||||
|
return query.watch().map((rows) {
|
||||||
|
return rows.map((row) {
|
||||||
|
final task = row.readTable(tasks);
|
||||||
|
final room = row.readTable(rooms);
|
||||||
|
return TaskWithRoom(task: task, roomName: room.name, roomId: room.id);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch completions for a given [taskId] within a date range [start]..[end].
|
||||||
|
///
|
||||||
|
/// Used for period-completion filtering: if a task was completed in the
|
||||||
|
/// current interval window, it should not appear as a pre-populated task.
|
||||||
|
/// [start] is inclusive; [end] is exclusive.
|
||||||
|
Stream<List<TaskCompletion>> watchCompletionsInRange(
|
||||||
|
int taskId, DateTime start, DateTime end) {
|
||||||
|
return (select(taskCompletions)
|
||||||
|
..where((c) =>
|
||||||
|
c.taskId.equals(taskId) &
|
||||||
|
c.completedAt.isBiggerOrEqualValue(start) &
|
||||||
|
c.completedAt.isSmallerThanValue(end)))
|
||||||
|
.watch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
|
||||||
/// State for the calendar day view: tasks for the selected date + overdue tasks.
|
/// State for the calendar day view: tasks for the selected date + overdue tasks
|
||||||
|
/// + pre-populated tasks within the current interval window.
|
||||||
class CalendarDayState {
|
class CalendarDayState {
|
||||||
final DateTime selectedDate;
|
final DateTime selectedDate;
|
||||||
final List<TaskWithRoom> dayTasks;
|
final List<TaskWithRoom> dayTasks;
|
||||||
final List<TaskWithRoom> overdueTasks;
|
final List<TaskWithRoom> overdueTasks;
|
||||||
|
|
||||||
|
/// Tasks visible via pre-population: recurring tasks whose nextDueDate is in
|
||||||
|
/// the future but whose current interval window includes [selectedDate].
|
||||||
|
/// These are shown with muted styling to distinguish them from due-today tasks.
|
||||||
|
final List<TaskWithRoom> prePopulatedTasks;
|
||||||
|
|
||||||
/// Total number of tasks in the database (across all days/rooms).
|
/// Total number of tasks in the database (across all days/rooms).
|
||||||
/// Used by the UI to distinguish first-run empty state (no tasks exist at all)
|
/// Used by the UI to distinguish first-run empty state (no tasks exist at all)
|
||||||
/// from celebration state (tasks exist but today's are all done).
|
/// from celebration state (tasks exist but today's are all done).
|
||||||
@@ -15,11 +21,11 @@ class CalendarDayState {
|
|||||||
required this.selectedDate,
|
required this.selectedDate,
|
||||||
required this.dayTasks,
|
required this.dayTasks,
|
||||||
required this.overdueTasks,
|
required this.overdueTasks,
|
||||||
|
this.prePopulatedTasks = const [],
|
||||||
required this.totalTaskCount,
|
required this.totalTaskCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// True when both day tasks and overdue tasks are empty.
|
/// True when day tasks, overdue tasks, and pre-populated tasks are all empty.
|
||||||
/// Determined by the UI layer (completion state vs. no tasks at all
|
bool get isEmpty =>
|
||||||
/// is handled in the widget based on this flag and history context).
|
dayTasks.isEmpty && overdueTasks.isEmpty && prePopulatedTasks.isEmpty;
|
||||||
bool get isEmpty => dayTasks.isEmpty && overdueTasks.isEmpty;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:household_keeper/core/providers/database_provider.dart';
|
import 'package:household_keeper/core/providers/database_provider.dart';
|
||||||
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
import 'package:household_keeper/features/home/domain/calendar_models.dart';
|
||||||
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
|
||||||
|
import 'package:household_keeper/features/tasks/domain/frequency.dart';
|
||||||
import 'package:household_keeper/features/tasks/domain/task_sort_option.dart';
|
import 'package:household_keeper/features/tasks/domain/task_sort_option.dart';
|
||||||
import 'package:household_keeper/features/tasks/presentation/sort_preference_notifier.dart';
|
import 'package:household_keeper/features/tasks/presentation/sort_preference_notifier.dart';
|
||||||
|
|
||||||
|
import '../../../core/database/database.dart';
|
||||||
|
|
||||||
/// Notifier that manages the currently selected date in the calendar strip.
|
/// Notifier that manages the currently selected date in the calendar strip.
|
||||||
///
|
///
|
||||||
/// Defaults to today (start of day, time zeroed out).
|
/// Defaults to today (start of day, time zeroed out).
|
||||||
@@ -60,13 +63,82 @@ List<TaskWithRoom> _sortTasks(
|
|||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reactive calendar day state: tasks for the selected date + overdue tasks.
|
/// Determines whether [task] should appear on [selectedDate] via pre-population.
|
||||||
|
///
|
||||||
|
/// Per D-07: A task shows on all days within the current interval window
|
||||||
|
/// leading up to its nextDueDate. For example:
|
||||||
|
/// - weekly task due Monday: shows on all 7 days (Tue-Mon) before nextDueDate
|
||||||
|
/// - monthly task due 15th: shows on all ~30 days leading up to the 15th
|
||||||
|
/// - daily task: shows every day (interval window = 1 day, always due today)
|
||||||
|
bool _isInCurrentIntervalWindow(Task task, DateTime selectedDate) {
|
||||||
|
final dueDate = DateTime(
|
||||||
|
task.nextDueDate.year, task.nextDueDate.month, task.nextDueDate.day);
|
||||||
|
final selected =
|
||||||
|
DateTime(selectedDate.year, selectedDate.month, selectedDate.day);
|
||||||
|
|
||||||
|
// If selected date IS the due date, it is a "due today" task (not pre-populated)
|
||||||
|
if (selected == dueDate) return false;
|
||||||
|
// If selected date is after the due date, task is overdue (handled separately)
|
||||||
|
if (selected.isAfter(dueDate)) return false;
|
||||||
|
|
||||||
|
// Calculate the start of the current interval window:
|
||||||
|
// The previous due date = current nextDueDate minus one interval
|
||||||
|
final previousDue = _calculatePreviousDueDate(task);
|
||||||
|
final prevDay = DateTime(
|
||||||
|
previousDue.year, previousDue.month, previousDue.day);
|
||||||
|
|
||||||
|
// Selected date must be AFTER the previous due date (exclusive)
|
||||||
|
// and BEFORE the next due date (exclusive — due date itself is "dayTasks" not pre-pop)
|
||||||
|
return selected.isAfter(prevDay) && selected.isBefore(dueDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse-calculate the previous due date by subtracting one interval.
|
||||||
|
/// This gives the start of the current interval window.
|
||||||
|
DateTime _calculatePreviousDueDate(Task task) {
|
||||||
|
switch (task.intervalType) {
|
||||||
|
case IntervalType.daily:
|
||||||
|
return task.nextDueDate.subtract(const Duration(days: 1));
|
||||||
|
case IntervalType.everyNDays:
|
||||||
|
return task.nextDueDate.subtract(Duration(days: task.intervalDays));
|
||||||
|
case IntervalType.weekly:
|
||||||
|
return task.nextDueDate.subtract(const Duration(days: 7));
|
||||||
|
case IntervalType.biweekly:
|
||||||
|
return task.nextDueDate.subtract(const Duration(days: 14));
|
||||||
|
case IntervalType.monthly:
|
||||||
|
return _subtractMonths(task.nextDueDate, 1, task.anchorDay);
|
||||||
|
case IntervalType.everyNMonths:
|
||||||
|
return _subtractMonths(task.nextDueDate, task.intervalDays, task.anchorDay);
|
||||||
|
case IntervalType.quarterly:
|
||||||
|
return _subtractMonths(task.nextDueDate, 3, task.anchorDay);
|
||||||
|
case IntervalType.yearly:
|
||||||
|
return _subtractMonths(task.nextDueDate, 12, task.anchorDay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtract [months] from [date], respecting [anchorDay] clamping.
|
||||||
|
///
|
||||||
|
/// Uses total-month arithmetic to handle year-boundary crossings correctly
|
||||||
|
/// (e.g. January - 1 month = December of the previous year).
|
||||||
|
DateTime _subtractMonths(DateTime date, int months, int? anchorDay) {
|
||||||
|
// Convert date to total months from year 0 (0-indexed month), subtract, convert back.
|
||||||
|
final totalMonths = date.year * 12 + (date.month - 1) - months;
|
||||||
|
final targetYear = totalMonths ~/ 12;
|
||||||
|
final normalizedMonth = (totalMonths % 12) + 1;
|
||||||
|
final day = anchorDay ?? date.day;
|
||||||
|
final lastDay = DateTime(targetYear, normalizedMonth + 1, 0).day;
|
||||||
|
final clampedDay = day > lastDay ? lastDay : day;
|
||||||
|
return DateTime(targetYear, normalizedMonth, clampedDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reactive calendar day state: tasks for the selected date + overdue tasks
|
||||||
|
/// + pre-populated virtual instances within the current interval window.
|
||||||
///
|
///
|
||||||
/// Overdue tasks are only included when the selected date is today.
|
/// Overdue tasks are only included when the selected date is today.
|
||||||
/// Past and future dates show only tasks originally due on that day.
|
/// Past and future dates show only tasks originally due on that day.
|
||||||
///
|
///
|
||||||
/// dayTasks are sorted in-memory according to the active [sortPreferenceProvider].
|
/// dayTasks are sorted in-memory according to the active [sortPreferenceProvider].
|
||||||
/// overdueTasks retain their existing order (pinned at top, unsorted per design).
|
/// overdueTasks retain their existing order (pinned at top, unsorted per design).
|
||||||
|
/// prePopulatedTasks show tasks visible via interval-window pre-population.
|
||||||
///
|
///
|
||||||
/// Defined manually (not @riverpod) because riverpod_generator has trouble
|
/// Defined manually (not @riverpod) because riverpod_generator has trouble
|
||||||
/// with drift's generated [Task] type. Same pattern as [dailyPlanProvider].
|
/// with drift's generated [Task] type. Same pattern as [dailyPlanProvider].
|
||||||
@@ -81,6 +153,7 @@ final calendarDayProvider =
|
|||||||
final isToday = selectedDate == today;
|
final isToday = selectedDate == today;
|
||||||
|
|
||||||
final dayTasksStream = db.calendarDao.watchTasksForDate(selectedDate);
|
final dayTasksStream = db.calendarDao.watchTasksForDate(selectedDate);
|
||||||
|
final allTasksStream = db.calendarDao.watchAllActiveRecurringTasks();
|
||||||
|
|
||||||
return dayTasksStream.asyncMap((dayTasks) async {
|
return dayTasksStream.asyncMap((dayTasks) async {
|
||||||
final List<TaskWithRoom> overdueTasks;
|
final List<TaskWithRoom> overdueTasks;
|
||||||
@@ -94,12 +167,47 @@ final calendarDayProvider =
|
|||||||
overdueTasks = const [];
|
overdueTasks = const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all active tasks for pre-population filtering
|
||||||
|
final allTasks = await allTasksStream.first;
|
||||||
|
|
||||||
|
// IDs of tasks already showing as due-today or overdue
|
||||||
|
final dueTodayIds = dayTasks.map((t) => t.task.id).toSet();
|
||||||
|
final overdueIds = overdueTasks.map((t) => t.task.id).toSet();
|
||||||
|
|
||||||
|
// Filter for pre-populated tasks
|
||||||
|
final prePopulated = <TaskWithRoom>[];
|
||||||
|
for (final tw in allTasks) {
|
||||||
|
// Skip if already showing as due-today or overdue
|
||||||
|
if (dueTodayIds.contains(tw.task.id)) continue;
|
||||||
|
if (overdueIds.contains(tw.task.id)) continue;
|
||||||
|
|
||||||
|
// Check if in current interval window
|
||||||
|
if (!_isInCurrentIntervalWindow(tw.task, selectedDate)) continue;
|
||||||
|
|
||||||
|
// Check if already completed in current period (D-09, D-10)
|
||||||
|
final prevDue = _calculatePreviousDueDate(tw.task);
|
||||||
|
final completions = await db.calendarDao
|
||||||
|
.watchCompletionsInRange(
|
||||||
|
tw.task.id,
|
||||||
|
DateTime(prevDue.year, prevDue.month, prevDue.day),
|
||||||
|
DateTime(tw.task.nextDueDate.year, tw.task.nextDueDate.month,
|
||||||
|
tw.task.nextDueDate.day)
|
||||||
|
.add(const Duration(days: 1)),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
|
||||||
|
if (completions.isEmpty) {
|
||||||
|
prePopulated.add(tw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final totalTaskCount = await db.calendarDao.getTaskCount();
|
final totalTaskCount = await db.calendarDao.getTaskCount();
|
||||||
|
|
||||||
return CalendarDayState(
|
return CalendarDayState(
|
||||||
selectedDate: selectedDate,
|
selectedDate: selectedDate,
|
||||||
dayTasks: _sortTasks(dayTasks, sortOption),
|
dayTasks: _sortTasks(dayTasks, sortOption),
|
||||||
overdueTasks: overdueTasks,
|
overdueTasks: overdueTasks,
|
||||||
|
prePopulatedTasks: _sortTasks(prePopulated, sortOption),
|
||||||
totalTaskCount: totalTaskCount,
|
totalTaskCount: totalTaskCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -122,6 +230,8 @@ final roomCalendarDayProvider =
|
|||||||
|
|
||||||
final dayTasksStream =
|
final dayTasksStream =
|
||||||
db.calendarDao.watchTasksForDateInRoom(selectedDate, roomId);
|
db.calendarDao.watchTasksForDateInRoom(selectedDate, roomId);
|
||||||
|
final allTasksStream =
|
||||||
|
db.calendarDao.watchAllActiveRecurringTasksInRoom(roomId);
|
||||||
|
|
||||||
return dayTasksStream.asyncMap((dayTasks) async {
|
return dayTasksStream.asyncMap((dayTasks) async {
|
||||||
final List<TaskWithRoom> overdueTasks;
|
final List<TaskWithRoom> overdueTasks;
|
||||||
@@ -134,12 +244,47 @@ final roomCalendarDayProvider =
|
|||||||
overdueTasks = const [];
|
overdueTasks = const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all active tasks in room for pre-population filtering
|
||||||
|
final allTasks = await allTasksStream.first;
|
||||||
|
|
||||||
|
// IDs of tasks already showing as due-today or overdue
|
||||||
|
final dueTodayIds = dayTasks.map((t) => t.task.id).toSet();
|
||||||
|
final overdueIds = overdueTasks.map((t) => t.task.id).toSet();
|
||||||
|
|
||||||
|
// Filter for pre-populated tasks
|
||||||
|
final prePopulated = <TaskWithRoom>[];
|
||||||
|
for (final tw in allTasks) {
|
||||||
|
// Skip if already showing as due-today or overdue
|
||||||
|
if (dueTodayIds.contains(tw.task.id)) continue;
|
||||||
|
if (overdueIds.contains(tw.task.id)) continue;
|
||||||
|
|
||||||
|
// Check if in current interval window
|
||||||
|
if (!_isInCurrentIntervalWindow(tw.task, selectedDate)) continue;
|
||||||
|
|
||||||
|
// Check if already completed in current period (D-09, D-10)
|
||||||
|
final prevDue = _calculatePreviousDueDate(tw.task);
|
||||||
|
final completions = await db.calendarDao
|
||||||
|
.watchCompletionsInRange(
|
||||||
|
tw.task.id,
|
||||||
|
DateTime(prevDue.year, prevDue.month, prevDue.day),
|
||||||
|
DateTime(tw.task.nextDueDate.year, tw.task.nextDueDate.month,
|
||||||
|
tw.task.nextDueDate.day)
|
||||||
|
.add(const Duration(days: 1)),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
|
||||||
|
if (completions.isEmpty) {
|
||||||
|
prePopulated.add(tw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final totalTaskCount = await db.calendarDao.getTaskCountInRoom(roomId);
|
final totalTaskCount = await db.calendarDao.getTaskCountInRoom(roomId);
|
||||||
|
|
||||||
return CalendarDayState(
|
return CalendarDayState(
|
||||||
selectedDate: selectedDate,
|
selectedDate: selectedDate,
|
||||||
dayTasks: _sortTasks(dayTasks, sortOption),
|
dayTasks: _sortTasks(dayTasks, sortOption),
|
||||||
overdueTasks: overdueTasks,
|
overdueTasks: overdueTasks,
|
||||||
|
prePopulatedTasks: _sortTasks(prePopulated, sortOption),
|
||||||
totalTaskCount: totalTaskCount,
|
totalTaskCount: totalTaskCount,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:household_keeper/core/database/database.dart';
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
@@ -484,6 +485,225 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('CalendarDao.watchAllActiveRecurringTasks', () {
|
||||||
|
test('returns all active tasks', () async {
|
||||||
|
// Insert 2 active tasks and 1 inactive task
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Aktive Aufgabe 1',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 20),
|
||||||
|
));
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room2Id,
|
||||||
|
name: 'Aktive Aufgabe 2',
|
||||||
|
intervalType: IntervalType.monthly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 25),
|
||||||
|
));
|
||||||
|
// Insert inactive task (isActive defaults to true; manually set false via update)
|
||||||
|
final inactiveId = await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Inaktive Aufgabe',
|
||||||
|
intervalType: IntervalType.daily,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 16),
|
||||||
|
));
|
||||||
|
await (db.update(db.tasks)..where((t) => t.id.equals(inactiveId)))
|
||||||
|
.write(const TasksCompanion(isActive: Value(false)));
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await db.calendarDao.watchAllActiveRecurringTasks().first;
|
||||||
|
expect(result.length, 2);
|
||||||
|
final names = result.map((t) => t.task.name).toList();
|
||||||
|
expect(names, contains('Aktive Aufgabe 1'));
|
||||||
|
expect(names, contains('Aktive Aufgabe 2'));
|
||||||
|
expect(names, isNot(contains('Inaktive Aufgabe')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns tasks sorted alphabetically by name', () async {
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Zuletzt',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 20),
|
||||||
|
));
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room2Id,
|
||||||
|
name: 'Als erstes',
|
||||||
|
intervalType: IntervalType.monthly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 25),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await db.calendarDao.watchAllActiveRecurringTasks().first;
|
||||||
|
expect(result.length, 2);
|
||||||
|
expect(result[0].task.name, 'Als erstes');
|
||||||
|
expect(result[1].task.name, 'Zuletzt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty list when no active tasks exist', () async {
|
||||||
|
final result =
|
||||||
|
await db.calendarDao.watchAllActiveRecurringTasks().first;
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CalendarDao.watchAllActiveRecurringTasksInRoom', () {
|
||||||
|
test('filters tasks by room', () async {
|
||||||
|
// Tasks in room1
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Kueche Aufgabe',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 20),
|
||||||
|
));
|
||||||
|
// Task in room2 (should NOT appear for room1)
|
||||||
|
await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room2Id,
|
||||||
|
name: 'Bad Aufgabe',
|
||||||
|
intervalType: IntervalType.monthly,
|
||||||
|
effortLevel: EffortLevel.medium,
|
||||||
|
nextDueDate: DateTime(2026, 3, 25),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await db.calendarDao.watchAllActiveRecurringTasksInRoom(room1Id).first;
|
||||||
|
expect(result.length, 1);
|
||||||
|
expect(result.first.task.name, 'Kueche Aufgabe');
|
||||||
|
expect(result.first.roomId, room1Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty list when room has no active tasks', () async {
|
||||||
|
final result =
|
||||||
|
await db.calendarDao.watchAllActiveRecurringTasksInRoom(room1Id).first;
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CalendarDao.watchCompletionsInRange', () {
|
||||||
|
test('returns completions within date range', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Woechentliche Aufgabe',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 23),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Completion within range (March 18)
|
||||||
|
await db.into(db.taskCompletions).insert(TaskCompletionsCompanion.insert(
|
||||||
|
taskId: taskId,
|
||||||
|
completedAt: DateTime(2026, 3, 18, 10),
|
||||||
|
));
|
||||||
|
// Completion within range (March 20)
|
||||||
|
await db.into(db.taskCompletions).insert(TaskCompletionsCompanion.insert(
|
||||||
|
taskId: taskId,
|
||||||
|
completedAt: DateTime(2026, 3, 20, 14),
|
||||||
|
));
|
||||||
|
// Completion OUTSIDE range (before start — March 15)
|
||||||
|
await db.into(db.taskCompletions).insert(TaskCompletionsCompanion.insert(
|
||||||
|
taskId: taskId,
|
||||||
|
completedAt: DateTime(2026, 3, 15),
|
||||||
|
));
|
||||||
|
// Completion OUTSIDE range (after end — March 25)
|
||||||
|
await db.into(db.taskCompletions).insert(TaskCompletionsCompanion.insert(
|
||||||
|
taskId: taskId,
|
||||||
|
completedAt: DateTime(2026, 3, 25),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Range: March 16 (inclusive) to March 24 (exclusive)
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchCompletionsInRange(
|
||||||
|
taskId,
|
||||||
|
DateTime(2026, 3, 16),
|
||||||
|
DateTime(2026, 3, 24),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
expect(result.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty list when no completions in range', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Keine Completions',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 23),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchCompletionsInRange(
|
||||||
|
taskId,
|
||||||
|
DateTime(2026, 3, 16),
|
||||||
|
DateTime(2026, 3, 24),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty list for wrong taskId', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Aufgabe mit Completion',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 23),
|
||||||
|
));
|
||||||
|
// Insert completion for taskId
|
||||||
|
await db.into(db.taskCompletions).insert(TaskCompletionsCompanion.insert(
|
||||||
|
taskId: taskId,
|
||||||
|
completedAt: DateTime(2026, 3, 18),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Query for a different task ID
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchCompletionsInRange(
|
||||||
|
taskId + 999,
|
||||||
|
DateTime(2026, 3, 16),
|
||||||
|
DateTime(2026, 3, 24),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start is inclusive, end is exclusive', () async {
|
||||||
|
final taskId = await db.tasksDao.insertTask(TasksCompanion.insert(
|
||||||
|
roomId: room1Id,
|
||||||
|
name: 'Grenzen Test',
|
||||||
|
intervalType: IntervalType.weekly,
|
||||||
|
effortLevel: EffortLevel.low,
|
||||||
|
nextDueDate: DateTime(2026, 3, 23),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Completion exactly at start boundary (inclusive)
|
||||||
|
await db.into(db.taskCompletions).insert(TaskCompletionsCompanion.insert(
|
||||||
|
taskId: taskId,
|
||||||
|
completedAt: DateTime(2026, 3, 16),
|
||||||
|
));
|
||||||
|
// Completion exactly at end boundary (exclusive — should NOT be included)
|
||||||
|
await db.into(db.taskCompletions).insert(TaskCompletionsCompanion.insert(
|
||||||
|
taskId: taskId,
|
||||||
|
completedAt: DateTime(2026, 3, 24),
|
||||||
|
));
|
||||||
|
|
||||||
|
final result = await db.calendarDao
|
||||||
|
.watchCompletionsInRange(
|
||||||
|
taskId,
|
||||||
|
DateTime(2026, 3, 16),
|
||||||
|
DateTime(2026, 3, 24),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
// Only the start-boundary completion should be included
|
||||||
|
expect(result.length, 1);
|
||||||
|
expect(result.first.completedAt, DateTime(2026, 3, 16));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('CalendarDao.getTaskCountInRoom', () {
|
group('CalendarDao.getTaskCountInRoom', () {
|
||||||
test('returns 0 when room has no tasks', () async {
|
test('returns 0 when room has no tasks', () async {
|
||||||
final count = await db.calendarDao.getTaskCountInRoom(room1Id);
|
final count = await db.calendarDao.getTaskCountInRoom(room1Id);
|
||||||
|
|||||||
Reference in New Issue
Block a user