diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 22:31:23 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 22:31:23 +0200 |
| commit | 992e40835af1600a0961ec2454fb78ff43aec3c6 (patch) | |
| tree | 3c0602ae64a186ce5ac51be6b0795037488f552a /src | |
| parent | e4a158965f3fcfe6fac65a1eba7b456d615e7dca (diff) | |
Add routine archives: archive date, searchable archived list, frozen page
- 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
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/components/fuzzy-search.ts | 8 | ||||
| -rw-r--r-- | src/lib/server/db/schema.ts | 1 | ||||
| -rw-r--r-- | src/lib/server/services/routines.test.ts | 33 | ||||
| -rw-r--r-- | src/lib/server/services/routines.ts | 43 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.server.ts | 3 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.svelte | 118 | ||||
| -rw-r--r-- | src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts | 21 | ||||
| -rw-r--r-- | src/routes/projects/[projectId]/routine/[routineId]/+page.svelte | 186 |
8 files changed, 327 insertions, 86 deletions
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<FuzzyItem>; +export type FuzzyMatch<T extends FuzzyItem = FuzzyItem> = KeyResult<T>; /** 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<T extends FuzzyItem>( + items: T[], query: string, { limit = FUZZ_LIMIT, threshold = FUZZ_THRESHOLD, }: { limit?: number; threshold?: number } = {}, -): FuzzyMatch[] { +): FuzzyMatch<T>[] { 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<void> { ...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<ArchivedRoutine[]> { + 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<number, Routine>(); + 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 @@ <script lang="ts"> import { enhance } from '$app/forms'; + import { matchItems } from '$lib/components/fuzzy-search'; import { formatFrequency, parseIntervalToDays, @@ -18,6 +19,15 @@ const frequencyDays = $derived(parseIntervalToDays(frequency)); const editFrequencyDays = $derived(parseIntervalToDays(editFrequency)); + let showArchived = $state(false); + let archiveFilter = $state(''); + + const filteredArchived = $derived( + archiveFilter.trim() === '' + ? data.archived + : matchItems(data.archived, archiveFilter).map((m) => m.obj), + ); + const dateFormatter = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short', @@ -203,6 +213,48 @@ <p class="empty">Aucune routine</p> {/if} + {#if data.archived.length > 0} + <section class="archived-section"> + <button + class="archived-toggle" + type="button" + onclick={() => (showArchived = !showArchived)} + aria-expanded={showArchived} + > + <span class="chevron">{showArchived ? '▾' : '▸'}</span> + Archivées + <span class="count">{data.archived.length}</span> + </button> + {#if showArchived} + <input + class="form-input archived-search" + type="search" + bind:value={archiveFilter} + placeholder="Rechercher une routine archivée..." + autocomplete="off" + aria-label="Rechercher parmi les routines archivées" + /> + <ul class="item-list"> + {#each filteredArchived as routine (routine.id)} + <li class="item-row item-row--card archived-row"> + <a + class="routine-name" + href="/projects/{data.project.id}/routine/{routine.id}" + >{routine.name}</a + > + <span class="archived-date"> + archivée le {dateFormatter.format(new Date(routine.archivedAt))} + </span> + </li> + {/each} + </ul> + {#if filteredArchived.length === 0} + <p class="empty archived-empty">Aucune correspondance.</p> + {/if} + {/if} + </section> + {/if} + <footer class="page-footer"> <form method="POST" action="?/archiveProject" use:enhance> <button class="btn-archive" type="submit">Archiver le projet</button> @@ -308,6 +360,72 @@ color: var(--danger); } + .archived-section { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .archived-toggle { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + border: none; + border-radius: 0.625rem; + background: transparent; + color: var(--text-secondary); + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + } + + .archived-toggle:hover { + background: var(--surface); + color: var(--text); + } + + .archived-toggle .chevron { + font-size: 0.75rem; + } + + .archived-toggle .count { + min-width: 1.25rem; + padding: 0 0.375rem; + border-radius: 9999px; + background: var(--surface); + text-align: center; + font-size: 0.75rem; + font-weight: 600; + } + + .archived-search { + width: 100%; + } + + .archived-row .routine-name { + flex-grow: 1; + color: var(--text-secondary); + } + + .archived-row .routine-name:hover { + color: var(--accent); + } + + .archived-date { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--text-secondary); + } + + .archived-empty { + padding: 1rem 0; + } + .empty { margin-top: 1rem; padding: 2rem; diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts index 83bac3c..f2b6551 100644 --- a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts @@ -31,21 +31,31 @@ export const load: PageServerLoad = async ({ params }) => { } const routine = await loadCurrent(routineId); - if (!routine || routine.archived || routine.projectId !== projectId) { + if (!routine || routine.projectId !== projectId) { throw error(404, 'Routine introuvable'); } const completions = await loadCompletions(routineId); + // An archived routine is frozen at its archive instant: state and chart + // stop there instead of now. + const at = routine.archivedAt ? Date.parse(routine.archivedAt) : Date.now(); return { project: { id: project.id, name: project.name }, routine, completions, - state: computeRoutineState(routine, completions, Date.now()), + state: computeRoutineState(routine, completions, at), }; }; export const actions: Actions = { edit: async ({ request, params }) => { + const current = await loadCurrent(numericId(params.routineId)); + if (!current) { + return fail(400, { error: 'Routine introuvable' }); + } + if (current.archived) { + return fail(400, { error: 'Routine archivée' }); + } const data = await request.formData(); const name = parseFormString(data.get('name')); if (!name) { @@ -65,6 +75,13 @@ export const actions: Actions = { }, complete: async ({ request, params }) => { + const current = await loadCurrent(numericId(params.routineId)); + if (!current) { + return fail(400, { error: 'Routine introuvable' }); + } + if (current.archived) { + return fail(400, { error: 'Routine archivée' }); + } const data = await request.formData(); try { await complete( diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte index d57ed79..5f57de4 100644 --- a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte @@ -42,8 +42,14 @@ year: 'numeric', }); + const frozenAt = $derived( + data.routine.archived + ? Date.parse(data.routine.archivedAt ?? data.routine.createdAt) + : Date.now(), + ); + const series = $derived( - computeHealthSeries(data.routine, data.completions, Date.now()), + computeHealthSeries(data.routine, data.completions, frozenAt), ); const PAD = 10; @@ -93,65 +99,76 @@ <span class={scoreClass(data.state.health)}>{data.state.health}</span> </header> - <form - class="form-card" - method="POST" - action="?/edit" - use:enhance={() => { - return async ({ result, update }) => { - await update({ reset: false }); - }; - }} - > - {#if form?.error} - <p class="error">{form.error}</p> - {/if} - <label> - <span class="form-label">Nom</span> - <input - class="form-input" - type="text" - name="name" - bind:value={name} - autocomplete="off" - /> - </label> - <label> - <span class="form-label">Intervalle</span> - <input - class="form-input frequency" - class:input-invalid={frequency !== '' && frequencyDays === null} - type="text" - name="frequencyDays" - bind:value={frequency} - placeholder="2w" - autocomplete="off" - aria-label="Intervalle (ex: 1d, 2w, 3m)" - /> - </label> - <label> - <span class="form-label">Description</span> - <textarea - class="form-input desc" - name="description" - bind:value={description} - rows={3} - placeholder="Pourquoi, comment, quand..." - autocomplete="off" - ></textarea> - </label> - {#if frequency !== '' && frequencyDays === null} - <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p> - {/if} - <button - class="btn-primary" - type="submit" - disabled={!name.trim() || frequencyDays === null} + {#if data.routine.archived} + <p class="archived-badge"> + Archivée le + {dateTimeFormatter.format( + new Date(data.routine.archivedAt ?? data.routine.createdAt), + )} + </p> + {/if} + + {#if !data.routine.archived} + <form + class="form-card" + method="POST" + action="?/edit" + use:enhance={() => { + return async ({ result, update }) => { + await update({ reset: false }); + }; + }} > - Enregistrer - </button> - <p class="save-status" class:visible={saved}>✓ Enregistré</p> - </form> + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + <label> + <span class="form-label">Nom</span> + <input + class="form-input" + type="text" + name="name" + bind:value={name} + autocomplete="off" + /> + </label> + <label> + <span class="form-label">Intervalle</span> + <input + class="form-input frequency" + class:input-invalid={frequency !== '' && frequencyDays === null} + type="text" + name="frequencyDays" + bind:value={frequency} + placeholder="2w" + autocomplete="off" + aria-label="Intervalle (ex: 1d, 2w, 3m)" + /> + </label> + <label> + <span class="form-label">Description</span> + <textarea + class="form-input desc" + name="description" + bind:value={description} + rows={3} + placeholder="Pourquoi, comment, quand..." + autocomplete="off" + ></textarea> + </label> + {#if frequency !== '' && frequencyDays === null} + <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p> + {/if} + <button + class="btn-primary" + type="submit" + disabled={!name.trim() || frequencyDays === null} + > + Enregistrer + </button> + <p class="save-status" class:visible={saved}>✓ Enregistré</p> + </form> + {/if} <h2 class="section-title">Santé</h2> <div class="form-card chart-card"> @@ -197,27 +214,29 @@ </div> <h2 class="section-title">Historique</h2> - <form - class="form-card form-card--compact add-completion" - method="POST" - action="?/complete" - use:enhance={() => { - return async ({ update }) => { - note = ''; - await update({ reset: false }); - }; - }} - > - <input - class="form-input" - type="text" - name="note" - bind:value={note} - placeholder="Note (optionnel)" - autocomplete="off" - /> - <button class="btn-done" type="submit">Fait</button> - </form> + {#if !data.routine.archived} + <form + class="form-card form-card--compact add-completion" + method="POST" + action="?/complete" + use:enhance={() => { + return async ({ update }) => { + note = ''; + await update({ reset: false }); + }; + }} + > + <input + class="form-input" + type="text" + name="note" + bind:value={note} + placeholder="Note (optionnel)" + autocomplete="off" + /> + <button class="btn-done" type="submit">Fait</button> + </form> + {/if} <ul class="item-list"> {#each data.completions as c (c.id)} @@ -305,6 +324,13 @@ border-left: 3px solid var(--accent); } + .archived-badge { + margin: 0 0 1rem; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + } + .chart-card { align-items: stretch; } |
