Merge branch 'worktree-agent-aca389e2' into Develop
Some checks failed
CI / ci (push) Failing after 4m28s

This commit is contained in:
2026-03-24 09:50:16 +01:00
4 changed files with 91 additions and 21 deletions

View File

@@ -240,9 +240,6 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
AppLocalizations l10n, AppLocalizations l10n,
ThemeData theme, ThemeData theme,
) { ) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final isFuture = state.selectedDate.isAfter(today);
final showRoomTag = widget.roomId == null; final showRoomTag = widget.roomId == null;
final items = <Widget>[]; final items = <Widget>[];
@@ -268,7 +265,7 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
tw, tw,
isOverdue: false, isOverdue: false,
showRoomTag: showRoomTag, showRoomTag: showRoomTag,
canComplete: !isFuture, canComplete: true,
)); ));
} }

View File

@@ -35,8 +35,10 @@ class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
/// Mark a task as done: records completion and calculates next due date. /// Mark a task as done: records completion and calculates next due date.
/// ///
/// Uses scheduling utility for date calculation. Next due is calculated /// Uses scheduling utility for date calculation. If completing on the due
/// from the original due date (not completion date) to keep rhythm stable. /// date, next due is calculated from the original due date (keeps rhythm).
/// If completing on a different day (early or late), next due is calculated
/// from today (per D-02: matches user mental model "I did it now, schedule next from now").
/// If the calculated next due is in the past, catch-up advances to present. /// If the calculated next due is in the past, catch-up advances to present.
/// ///
/// [now] parameter allows injection of current time for testing. /// [now] parameter allows injection of current time for testing.
@@ -54,23 +56,24 @@ class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
completedAt: currentTime, completedAt: currentTime,
)); ));
// 3. Calculate next due date (from original due date, not today) // 3. Calculate next due date
// If completing on the due date, use original due date as base (keeps rhythm).
// If completing on a different day (early or late), use today as base (per D-02).
final todayStart = DateTime(currentTime.year, currentTime.month, currentTime.day);
final taskDueDay = DateTime(task.nextDueDate.year, task.nextDueDate.month, task.nextDueDate.day);
final baseDate = todayStart == taskDueDay ? task.nextDueDate : todayStart;
var nextDue = calculateNextDueDate( var nextDue = calculateNextDueDate(
currentDueDate: task.nextDueDate, currentDueDate: baseDate,
intervalType: task.intervalType, intervalType: task.intervalType,
intervalDays: task.intervalDays, intervalDays: task.intervalDays,
anchorDay: task.anchorDay, anchorDay: task.anchorDay,
); );
// 4. Catch up if next due is still in the past // 4. Catch up if next due is still in the past
final todayDateOnly = DateTime(
currentTime.year,
currentTime.month,
currentTime.day,
);
nextDue = catchUpToPresent( nextDue = catchUpToPresent(
nextDue: nextDue, nextDue: nextDue,
today: todayDateOnly, today: todayStart,
intervalType: task.intervalType, intervalType: task.intervalType,
intervalDays: task.intervalDays, intervalDays: task.intervalDays,
anchorDay: task.anchorDay, anchorDay: task.anchorDay,

View File

@@ -42,7 +42,6 @@ class TaskRow extends ConsumerWidget {
task.nextDueDate.day, task.nextDueDate.day,
); );
final isOverdue = dueDate.isBefore(today); final isOverdue = dueDate.isBefore(today);
final isFuture = dueDate.isAfter(today);
// Format relative due date in German // Format relative due date in German
final relativeDateText = formatRelativeDate(task.nextDueDate, now); final relativeDateText = formatRelativeDate(task.nextDueDate, now);
@@ -57,12 +56,10 @@ class TaskRow extends ConsumerWidget {
return ListTile( return ListTile(
leading: Checkbox( leading: Checkbox(
value: false, // Always unchecked -- completion is immediate + reschedule value: false, // Always unchecked -- completion is immediate + reschedule
onChanged: isFuture onChanged: (_) {
? null // Future tasks cannot be completed yet // Mark done immediately (optimistic UI, no undo per user decision)
: (_) { ref.read(taskActionsProvider.notifier).completeTask(task.id);
// Mark done immediately (optimistic UI, no undo per user decision) },
ref.read(taskActionsProvider.notifier).completeTask(task.id);
},
), ),
title: Text( title: Text(
task.name, task.name,

View File

@@ -317,6 +317,79 @@ void main() {
expect(overdueCount, 1); expect(overdueCount, 1);
}); });
test('completeTask on due date preserves rhythm', () async {
// Weekly task due 2026-03-24, completed on 2026-03-24: next due = 2026-03-31
final id = await db.tasksDao.insertTask(
TasksCompanion.insert(
roomId: roomId,
name: 'Weekly On Due Day',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 24),
),
);
await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 24));
final tasks = await db.tasksDao.watchTasksInRoom(roomId).first;
expect(tasks.first.nextDueDate, DateTime(2026, 3, 31));
});
test('completeTask before due date recalculates from today', () async {
// Weekly task due 2026-03-28, completed on 2026-03-24 (Tuesday): next due = 2026-03-31 (7 days from today)
final id = await db.tasksDao.insertTask(
TasksCompanion.insert(
roomId: roomId,
name: 'Weekly Before Due Day',
intervalType: IntervalType.weekly,
effortLevel: EffortLevel.medium,
nextDueDate: DateTime(2026, 3, 28),
),
);
await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 24));
final tasks = await db.tasksDao.watchTasksInRoom(roomId).first;
expect(tasks.first.nextDueDate, DateTime(2026, 3, 31));
});
test('completeTask daily task on non-due day recalculates from today', () async {
// Daily task due 2026-03-26, completed on 2026-03-24: next due = 2026-03-25 (tomorrow)
final id = await db.tasksDao.insertTask(
TasksCompanion.insert(
roomId: roomId,
name: 'Daily Non-Due Day',
intervalType: IntervalType.daily,
effortLevel: EffortLevel.low,
nextDueDate: DateTime(2026, 3, 26),
),
);
await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 24));
final tasks = await db.tasksDao.watchTasksInRoom(roomId).first;
expect(tasks.first.nextDueDate, DateTime(2026, 3, 25));
});
test('completeTask monthly task early preserves anchor', () async {
// Monthly task due 2026-03-28 anchorDay=28, completed on 2026-03-24: next due = 2026-04-28
final id = await db.tasksDao.insertTask(
TasksCompanion.insert(
roomId: roomId,
name: 'Monthly Early Completion',
intervalType: IntervalType.monthly,
effortLevel: EffortLevel.high,
nextDueDate: DateTime(2026, 3, 28),
anchorDay: const Value(28),
),
);
await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 24));
final tasks = await db.tasksDao.watchTasksInRoom(roomId).first;
expect(tasks.first.nextDueDate, DateTime(2026, 4, 28));
});
test('hard deleteTask still removes task and its completions', () async { test('hard deleteTask still removes task and its completions', () async {
final id = await db.tasksDao.insertTask( final id = await db.tasksDao.insertTask(
TasksCompanion.insert( TasksCompanion.insert(