fix(habits): preserve task state and original occurrence expiry
This commit is contained in:
@@ -18,3 +18,34 @@ test('chart PATCH preserves omitted settings and rejects an empty patch', async
|
||||
expect((await f.request(`/charts/${c.id}`, 'PATCH', {})).status).toBe(422);
|
||||
expect((await f.json(`/charts/${c.id}`, 'PATCH', { settings: {} })).settings.mainColor).toBe('#196127');
|
||||
});
|
||||
test('archiving and restoring retains same-day task states and combined completion', async () => {
|
||||
const h = await taskHabit(); await tick(h, true);
|
||||
const c = await f.json('/charts', 'POST', { name: 'Combined', habitIds: [h.id] }, 201);
|
||||
await f.request(`/habits/${h.id}`, 'DELETE');
|
||||
expect((await detail(h.id)).due).toBe(false);
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
|
||||
expect((await detail(h.id)).value).toBe(1);
|
||||
expect((await f.json(`/charts/${c.id}/days/2026-09-04`)).ratio).toBe(1);
|
||||
await tick(h, false); await f.request(`/habits/${h.id}`, 'DELETE'); await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
|
||||
expect((await detail(h.id)).value).toBe(0);
|
||||
});
|
||||
test('temporarily disabling the schedule retains same-day occurrence progress', async () => {
|
||||
const h = await taskHabit(); await tick(h, true);
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
|
||||
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'daily' } });
|
||||
expect((await detail(h.id)).value).toBe(1);
|
||||
});
|
||||
test('post-deadline corrections never invent expiry for an originally completed occurrence', async () => {
|
||||
const h = await taskHabit(); await tick(h, true); f.setTime('2026-09-05T12:00Z');
|
||||
expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
|
||||
await tick(h, false);
|
||||
for (let i = 0; i < 2; i++) expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
|
||||
await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
|
||||
});
|
||||
test('expired occurrences preserve original expiry through repeated historical corrections', async () => {
|
||||
const h = await taskHabit(); f.setTime('2026-09-05T12:00Z');
|
||||
const expired = (await detail(h.id)).tasks[0].expiredAt;
|
||||
expect(expired).toBe(Date.parse('2026-09-04T22:00Z'));
|
||||
await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
|
||||
await tick(h, false); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
|
||||
});
|
||||
|
||||
@@ -104,8 +104,10 @@ export class HabitService {
|
||||
this.db.transaction(() => {
|
||||
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit);
|
||||
// Record the original deadline even after downtime. Only active day snapshots expire.
|
||||
this.db.run(sql`UPDATE task_occurrences SET expired_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
|
||||
WHERE done = 0 AND expired_at IS NULL AND day_id IN (
|
||||
this.db.run(sql`UPDATE task_occurrences SET
|
||||
expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END,
|
||||
closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
|
||||
WHERE closed_at IS NULL AND day_id IN (
|
||||
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id
|
||||
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
|
||||
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
|
||||
@@ -150,11 +152,16 @@ export class HabitService {
|
||||
timezone: existing?.timezone ?? this.user.timezone, endsAt: existing?.endsAt ?? this.deadline(date),
|
||||
countSet: existing && !sameMethod && revision.config.method === 'count' ? true : sameMethod ? existing!.countSet : false, count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get();
|
||||
if (revision.config.method === 'tasks' && !revision.config.archived && scheduled(revision.config.schedule, date)) {
|
||||
const previous = existing ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, existing.id)).all() : [];
|
||||
// A temporary archive or schedule change can leave the immediately prior snapshot empty.
|
||||
// Stable task IDs recover the latest occurrence from this date only.
|
||||
const previous = existing ? this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences)
|
||||
.innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id))
|
||||
.where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date)))
|
||||
.orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : [];
|
||||
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
|
||||
const old = previous.find(t => t.taskId === task.id);
|
||||
this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
|
||||
done: old?.done ?? false, expiredAt: old?.expiredAt ?? (day.endsAt <= this.now && !old?.done ? day.endsAt : null), updatedAt: this.now }).run();
|
||||
done: old?.done ?? false, expiredAt: old?.expiredAt ?? (day.endsAt <= this.now && !old?.done ? day.endsAt : null), closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +194,7 @@ export class HabitService {
|
||||
const future = date > this.today;
|
||||
const tasks = config?.method === 'tasks' && !config.archived && scheduled(config.schedule, date)
|
||||
? record ? this.occurrences(id).get(record.id) ?? []
|
||||
: config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, updatedAt: null })) : [];
|
||||
: config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, closedAt: null, updatedAt: null })) : [];
|
||||
const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0);
|
||||
const counted = config?.method === 'count' && !future ? this.counts(id).get(date) : undefined;
|
||||
const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
|
||||
|
||||
Reference in New Issue
Block a user