From 992e40835af1600a0961ec2454fb78ff43aec3c6 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 7 Sep 2026 22:31:23 +0200 Subject: Add routine archives: archive date, searchable archived list, frozen page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stamp archived_at on the archived snapshot; copy it on edit - loadArchived(projectId) + collapsible fuzzy-searched "Archivées" section on the project page - Archived routine pages open read-only with the health chart ending at the archive date; edit/complete actions reject archived routines --- README.md | 8 +- drizzle/0005_curious_tomas.sql | 1 + drizzle/meta/0005_snapshot.json | 363 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/lib/components/fuzzy-search.ts | 8 +- src/lib/server/db/schema.ts | 1 + src/lib/server/services/routines.test.ts | 33 ++ src/lib/server/services/routines.ts | 43 +++ src/routes/projects/[id]/+page.server.ts | 3 + src/routes/projects/[id]/+page.svelte | 118 +++++++ .../routine/[routineId]/+page.server.ts | 21 +- .../[projectId]/routine/[routineId]/+page.svelte | 186 ++++++----- 12 files changed, 704 insertions(+), 88 deletions(-) create mode 100644 drizzle/0005_curious_tomas.sql create mode 100644 drizzle/meta/0005_snapshot.json diff --git a/README.md b/README.md index 44d08a2..2205b12 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,10 @@ completion. Editing a routine creates a new immutable version, and every completion records the exact version it was made against, so the past is never rewritten. Each routine has its own page (the name links from the project) with an editable description, a chart of its health score over time, -and its completion log. +and its completion log. Archiving a routine stamps the date it was archived +and removes it from the active list: it stays reachable in a collapsible +"Archivées" section on its project page (with a fuzzy search) and opens as a +read-only page whose health chart runs from creation to the archive date. ## TODO @@ -82,7 +85,8 @@ and its completion log. - [ ] https://automerge.org/docs/tutorial/ - [ ] Style like feed.kagamino.dev - [ ] Shared > Modal actions -- [ ] Routines > Archive on date - Routine that was tracked between d1 and d2 +- [x] Routines > Archive on date - Routine that was tracked between d1 and d2 +- [ ] Routine > Frequency = 5d/1w ## Deploy diff --git a/drizzle/0005_curious_tomas.sql b/drizzle/0005_curious_tomas.sql new file mode 100644 index 0000000..e8d3943 --- /dev/null +++ b/drizzle/0005_curious_tomas.sql @@ -0,0 +1 @@ +ALTER TABLE `routines` ADD `archived_at` text; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..5d1b3d0 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,363 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5489c6da-b9c9-4474-92f5-7dc94449677f", + "prevId": "d8d18f4f-0ed5-4256-90fb-f16073a48623", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groceries": { + "name": "groceries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groceries_name_unique": { + "name": "groceries_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "groceries_category_id_categories_id_fk": { + "name": "groceries_category_id_categories_id_fk", + "tableFrom": "groceries", + "tableTo": "categories", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "references": { + "name": "references", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "grocery_id": { + "name": "grocery_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "references_grocery_id_groceries_id_fk": { + "name": "references_grocery_id_groceries_id_fk", + "tableFrom": "references", + "tableTo": "groceries", + "columnsFrom": ["grocery_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "routine_completions": { + "name": "routine_completions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "routine_id": { + "name": "routine_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "routine_version": { + "name": "routine_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done_at": { + "name": "done_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "routine_completions_routine_id_routine_version_routines_id_version_fk": { + "name": "routine_completions_routine_id_routine_version_routines_id_version_fk", + "tableFrom": "routine_completions", + "tableTo": "routines", + "columnsFrom": ["routine_id", "routine_version"], + "columnsTo": ["id", "version"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "routines": { + "name": "routines", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "frequency_days": { + "name": "frequency_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "routines_project_id_projects_id_fk": { + "name": "routines_project_id_projects_id_fk", + "tableFrom": "routines", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "routines_id_version_pk": { + "columns": ["id", "version"], + "name": "routines_id_version_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 37d3b8b..a91f988 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1788800176176, "tag": "0004_worried_tiger_shark", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1788810741865, + "tag": "0005_curious_tomas", + "breakpoints": true } ] } diff --git a/src/lib/components/fuzzy-search.ts b/src/lib/components/fuzzy-search.ts index 31597b0..ade48fe 100644 --- a/src/lib/components/fuzzy-search.ts +++ b/src/lib/components/fuzzy-search.ts @@ -5,19 +5,19 @@ export type FuzzyItem = { id: number; name: string }; export const FUZZ_LIMIT = 8; export const FUZZ_THRESHOLD = 0.2; -export type FuzzyMatch = KeyResult; +export type FuzzyMatch = KeyResult; /** Fuzzy-match `items` against `query`, ranked best-first. Empty/whitespace * query returns no matches. Results carry `.score`, `.target`, `.highlight()` * and `.obj` (the matched item). */ -export function matchItems( - items: FuzzyItem[], +export function matchItems( + items: T[], query: string, { limit = FUZZ_LIMIT, threshold = FUZZ_THRESHOLD, }: { limit?: number; threshold?: number } = {}, -): FuzzyMatch[] { +): FuzzyMatch[] { const q = query.trim(); if (q === '') return []; const snap = fuzzysort.snapshot(items, { key: 'name' }); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index bac1c46..c5daac5 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -54,6 +54,7 @@ export const routines = sqliteTable( frequencyDays: integer('frequency_days').notNull(), description: text('description').notNull().default(''), archived: integer('archived', { mode: 'boolean' }).notNull().default(false), + archivedAt: text('archived_at'), createdAt: text('created_at').notNull(), }, (t) => [primaryKey({ columns: [t.id, t.version] })], diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts index 05abcaf..e8c4f35 100644 --- a/src/lib/server/services/routines.test.ts +++ b/src/lib/server/services/routines.test.ts @@ -7,6 +7,7 @@ import { archive, complete, loadAllVersions, + loadArchived, loadProjectDashboard, loadOverview, } from './routines'; @@ -88,6 +89,38 @@ describe('routines service', () => { expect(dashboard.healthScore).toBe(100); }); + it('loadArchived lists archived routines; edits keep the archive date', async () => { + const a = await add(projectId, 'clean kitchen', 7, db); + await add(projectId, 'water plants', 3, db); + await archive(a.id, db); + const stamped = (await loadAllVersions(a.id, db))[1].archivedAt; + expect(stamped).toBeTruthy(); + + const edited = await edit(a.id, { name: 'clean kitchen deeply' }, db); + expect(edited.archived).toBe(true); + expect(edited.archivedAt).toBe(stamped); + + const archived = await loadArchived(projectId, db); + expect(archived.map((r) => r.id)).toEqual([a.id]); + expect(archived[0].name).toBe('clean kitchen deeply'); + expect(archived[0].frequencyDays).toBe(7); + expect(archived[0].archivedAt).toBe(stamped); + }); + + it('loadArchived sorts most recently archived first', async () => { + const a = await add(projectId, 'first', 1, db); + const b = await add(projectId, 'second', 1, db); + await archive(a.id, db); + await archive(b.id, db); + + const archived = await loadArchived(projectId, db); + expect(archived).toHaveLength(2); + // Descending archive date; equal timestamps keep insertion order. + expect(new Date(archived[0].archivedAt).getTime()).toBeGreaterThanOrEqual( + new Date(archived[1].archivedAt).getTime(), + ); + }); + it('complete records the snapshot version it was made against', async () => { const r = await add(projectId, 'clean kitchen', 7, db); await edit(r.id, { frequencyDays: 5 }, db); diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts index c3a5eff..b314d50 100644 --- a/src/lib/server/services/routines.ts +++ b/src/lib/server/services/routines.ts @@ -69,6 +69,7 @@ export async function edit( frequencyDays: fields.frequencyDays ?? current.frequencyDays, description: fields.description ?? current.description, archived: current.archived, + archivedAt: current.archivedAt, createdAt: current.createdAt, }) .returning(); @@ -88,9 +89,51 @@ export async function archive(id: number, db = defaultDb): Promise { ...current, version: current.version + 1, archived: true, + archivedAt: new Date().toISOString(), }); } +export interface ArchivedRoutine { + id: number; + name: string; + frequencyDays: number; + createdAt: string; + archivedAt: string; +} + +/** Archived routines of a project, most recently archived first. */ +export async function loadArchived( + projectId: number, + db = defaultDb, +): Promise { + const versions = await db + .select() + .from(routines) + .where(eq(routines.projectId, projectId)); + + // Keep only the max-version row per routine id (same rule as the + // dashboard): archived = its current snapshot is archived. + const current = new Map(); + for (const r of versions) { + const existing = current.get(r.id); + if (!existing || r.version > existing.version) current.set(r.id, r); + } + return ( + [...current.values()] + .filter((r) => r.archived) + // Legacy rows archived before archived_at existed fall back to their + // creation date. + .sort((a, b) => ((a.archivedAt ?? '') < (b.archivedAt ?? '') ? 1 : -1)) + .map((r) => ({ + id: r.id, + name: r.name, + frequencyDays: r.frequencyDays, + createdAt: r.createdAt, + archivedAt: r.archivedAt ?? r.createdAt, + })) + ); +} + export async function loadAllVersions( routineId: number, db = defaultDb, diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts index e3c4edf..a23d834 100644 --- a/src/routes/projects/[id]/+page.server.ts +++ b/src/routes/projects/[id]/+page.server.ts @@ -6,6 +6,7 @@ import { archive, complete, loadProjectDashboard, + loadArchived, } from '$lib/server/services/routines'; import { numericId, parseFormString } from '$lib/server/services/validation'; import { parseIntervalToDays } from '$lib/shared/routine-metrics'; @@ -27,10 +28,12 @@ export const load: PageServerLoad = async ({ params }) => { throw error(404, 'Projet introuvable'); } const dashboard = await loadProjectDashboard(id, Date.now()); + const archived = await loadArchived(id); return { project: { id: project.id, name: project.name }, routines: dashboard.routines, healthScore: dashboard.healthScore, + archived, }; }; diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte index c69d066..3d1aafe 100644 --- a/src/routes/projects/[id]/+page.svelte +++ b/src/routes/projects/[id]/+page.svelte @@ -1,5 +1,6 @@