diff --git a/drizzle/0003_finalize_occurrence_deadlines.sql b/drizzle/0003_finalize_occurrence_deadlines.sql new file mode 100644 index 0000000..524232a --- /dev/null +++ b/drizzle/0003_finalize_occurrence_deadlines.sql @@ -0,0 +1,17 @@ +ALTER TABLE `task_occurrences` ADD `closed_at` integer;--> statement-breakpoint +-- Recover the state at the deadline from the first correction after it, when present. +-- This also repairs expiry previously inferred from an uncheck made after completion. +UPDATE task_occurrences +SET closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id), + expired_at = CASE WHEN COALESCE( + (SELECT json_extract(e.before, '$.done') FROM progress_events e + WHERE e.occurrence_id = task_occurrences.id + AND e.created_at >= (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id) + ORDER BY e.created_at, e.id LIMIT 1), done) = 0 + THEN (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id) + ELSE NULL END +WHERE day_id IN ( + SELECT d.id FROM habit_days d + WHERE d.ends_at <= CAST(unixepoch('subsec') * 1000 AS INTEGER) + AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date) +); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..6c6df77 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,691 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0893a83a-3004-440a-8e89-3a30f26a578d", + "prevId": "79583aaf-5f48-44cd-b105-407d229c3ced", + "tables": { + "combined_charts": { + "name": "combined_charts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "habit_ids": { + "name": "habit_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "combined_charts_user_idx": { + "name": "combined_charts_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "combined_charts_user_id_users_id_fk": { + "name": "combined_charts_user_id_users_id_fk", + "tableFrom": "combined_charts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_calendar_settings": { + "name": "habit_calendar_settings", + "columns": { + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "habit_calendar_settings_habit_id_habits_id_fk": { + "name": "habit_calendar_settings_habit_id_habits_id_fk", + "tableFrom": "habit_calendar_settings", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_days": { + "name": "habit_days", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count_set": { + "name": "count_set", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "habit_days_lookup_idx": { + "name": "habit_days_lookup_idx", + "columns": [ + "habit_id", + "date", + "revision_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_days_habit_id_habits_id_fk": { + "name": "habit_days_habit_id_habits_id_fk", + "tableFrom": "habit_days", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "habit_days_revision_id_habit_revisions_id_fk": { + "name": "habit_days_revision_id_habit_revisions_id_fk", + "tableFrom": "habit_days", + "tableTo": "habit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_revisions": { + "name": "habit_revisions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_date": { + "name": "effective_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habit_revisions_date_idx": { + "name": "habit_revisions_date_idx", + "columns": [ + "habit_id", + "effective_date", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_revisions_habit_id_habits_id_fk": { + "name": "habit_revisions_habit_id_habits_id_fk", + "tableFrom": "habit_revisions", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habits": { + "name": "habits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_date": { + "name": "created_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "materialized_through": { + "name": "materialized_through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habits_user_idx": { + "name": "habits_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habits_user_id_users_id_fk": { + "name": "habits_user_id_users_id_fk", + "tableFrom": "habits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "progress_events": { + "name": "progress_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "progress_events_day_idx": { + "name": "progress_events_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "progress_events_day_id_habit_days_id_fk": { + "name": "progress_events_day_id_habit_days_id_fk", + "tableFrom": "progress_events", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_occurrences": { + "name": "task_occurrences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expired_at": { + "name": "expired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "task_occurrences_day_idx": { + "name": "task_occurrences_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_occurrences_day_id_habit_days_id_fk": { + "name": "task_occurrences_day_id_habit_days_id_fk", + "tableFrom": "task_occurrences", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_name": { + "name": "global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e40c063..f3b744f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1788505800600, "tag": "0002_optional_count_carryover", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1788507928967, + "tag": "0003_finalize_occurrence_deadlines", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts index 4aa0612..c6f6482 100644 --- a/src/db/migrations.test.ts +++ b/src/db/migrations.test.ts @@ -27,3 +27,27 @@ test('carryover migration preserves pre-existing counts and explicit zero correc expect(JSON.parse(config).carryPartialProgress).toBe(false); } finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); } }); + +test('deadline migration repairs historical expiry using the original state before corrections', () => { + const directory = mkdtempSync(join(tmpdir(), 'minabot-expiry-upgrade-')); + const sqlite = new Database(':memory:'); const db = drizzle(sqlite); + try { + mkdirSync(join(directory, 'meta')); + const journal = JSON.parse(readFileSync('drizzle/meta/_journal.json', 'utf8')); + journal.entries = journal.entries.slice(0, 3); + writeFileSync(join(directory, 'meta/_journal.json'), JSON.stringify(journal)); + for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(directory, `${entry.tag}.sql`)); + migrate(db, { migrationsFolder: directory }); + sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('u', 'u', 'u', 0, 0)"); + sqlite.exec("INSERT INTO habits (id, user_id, created_date, created_at) VALUES ('h', 'u', '2020-01-01', 0)"); + sqlite.query('INSERT INTO habit_revisions (habit_id, effective_date, config, created_at) VALUES (?, ?, ?, ?)').run('h', '2020-01-01', JSON.stringify({ name: 'Tasks', method: 'tasks', tasks: [], schedule: { type: 'daily' }, archived: false }), 0); + sqlite.exec("INSERT INTO habit_days (habit_id, revision_id, date, timezone, ends_at) VALUES ('h', 1, '2020-01-01', 'UTC', 100)"); + sqlite.exec("INSERT INTO task_occurrences (id, day_id, task_id, name, done, expired_at, updated_at) VALUES ('a', 1, 'a', 'Originally completed', 0, 100, 200), ('b', 1, 'b', 'Originally incomplete', 1, 100, 200)"); + sqlite.query('INSERT INTO progress_events (day_id, occurrence_id, before, after, created_at) VALUES (?, ?, ?, ?, ?)').run(1, 'a', '{"done":true}', '{"done":false}', 200); + sqlite.query('INSERT INTO progress_events (day_id, occurrence_id, before, after, created_at) VALUES (?, ?, ?, ?, ?)').run(1, 'b', '{"done":false}', '{"done":true}', 200); + migrate(db, { migrationsFolder: './drizzle' }); + expect(sqlite.query('SELECT id, expired_at, closed_at, done FROM task_occurrences ORDER BY id').all()).toEqual([ + { id: 'a', expired_at: null, closed_at: 100, done: 0 }, { id: 'b', expired_at: 100, closed_at: 100, done: 1 }, + ]); + } finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index 0bcfd02..98be8bf 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -37,7 +37,7 @@ export const habitDays = sqliteTable('habit_days', { }, t => [index('habit_days_lookup_idx').on(t.habitId, t.date, t.revisionId)]); export const taskOccurrences = sqliteTable('task_occurrences', { id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(), - done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), updatedAt: integer('updated_at').notNull(), + done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(), }, t => [index('task_occurrences_day_idx').on(t.dayId)]); export const progressEvents = sqliteTable('progress_events', { id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'), diff --git a/src/habits/regressions.test.ts b/src/habits/regressions.test.ts index b103f8e..c36f20f 100644 --- a/src/habits/regressions.test.ts +++ b/src/habits/regressions.test.ts @@ -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); +}); diff --git a/src/habits/service.ts b/src/habits/service.ts index 4f1a3e2..004ccc1 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -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;