aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-07 22:31:23 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-07 22:31:23 +0200
commit992e40835af1600a0961ec2454fb78ff43aec3c6 (patch)
tree3c0602ae64a186ce5ac51be6b0795037488f552a /src
parente4a158965f3fcfe6fac65a1eba7b456d615e7dca (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.ts8
-rw-r--r--src/lib/server/db/schema.ts1
-rw-r--r--src/lib/server/services/routines.test.ts33
-rw-r--r--src/lib/server/services/routines.ts43
-rw-r--r--src/routes/projects/[id]/+page.server.ts3
-rw-r--r--src/routes/projects/[id]/+page.svelte118
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts21
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte186
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;
}