aboutsummaryrefslogtreecommitdiff
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
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
-rw-r--r--README.md8
-rw-r--r--drizzle/0005_curious_tomas.sql1
-rw-r--r--drizzle/meta/0005_snapshot.json363
-rw-r--r--drizzle/meta/_journal.json7
-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
12 files changed, 704 insertions, 88 deletions
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<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;
}