fix(habits): preserve task state and original occurrence expiry
This commit is contained in:
17
drizzle/0003_finalize_occurrence_deadlines.sql
Normal file
17
drizzle/0003_finalize_occurrence_deadlines.sql
Normal file
@@ -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)
|
||||
);
|
||||
691
drizzle/meta/0003_snapshot.json
Normal file
691
drizzle/meta/0003_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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