From 37573109a05a07ac50827759ea63793209baa73a Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 7 Sep 2026 19:17:45 +0200 Subject: Add routine detail page with editable description and health history --- src/lib/server/db/schema.ts | 1 + src/lib/server/services/routines.test.ts | 36 +- src/lib/server/services/routines.ts | 42 +- src/lib/shared/routine-metrics.test.ts | 46 +++ src/lib/shared/routine-metrics.ts | 59 +++ src/routes/projects/[id]/+page.server.ts | 7 +- src/routes/projects/[id]/+page.svelte | 12 +- .../routine/[routineId]/+page.server.ts | 80 ++++ .../[projectId]/routine/[routineId]/+page.svelte | 440 +++++++++++++++++++++ 9 files changed, 710 insertions(+), 13 deletions(-) create mode 100644 src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts create mode 100644 src/routes/projects/[projectId]/routine/[routineId]/+page.svelte (limited to 'src') diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 23e10f5..bac1c46 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -52,6 +52,7 @@ export const routines = sqliteTable( .references(() => projects.id, { onDelete: 'cascade' }), name: text('name').notNull(), frequencyDays: integer('frequency_days').notNull(), + description: text('description').notNull().default(''), archived: integer('archived', { mode: 'boolean' }).notNull().default(false), createdAt: text('created_at').notNull(), }, diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts index 9e1f429..05abcaf 100644 --- a/src/lib/server/services/routines.test.ts +++ b/src/lib/server/services/routines.test.ts @@ -33,7 +33,11 @@ describe('routines service', () => { it('edit inserts a new snapshot version and keeps history', async () => { const r = await add(projectId, 'clean kitchen', 7, db); - const updated = await edit(r.id, 'clean kitchen deeply', 3, db); + const updated = await edit( + r.id, + { name: 'clean kitchen deeply', frequencyDays: 3 }, + db, + ); expect(updated.version).toBe(2); expect(updated.frequencyDays).toBe(3); @@ -43,6 +47,34 @@ describe('routines service', () => { expect(versions[1].frequencyDays).toBe(3); }); + it('new routines start with an empty description', async () => { + const r = await add(projectId, 'clean kitchen', 7, db); + expect(r.description).toBe(''); + }); + + it('edit is partial: name/frequency edits keep the description and vice-versa', async () => { + const r = await add(projectId, 'clean kitchen', 7, db); + const described = await edit(r.id, { description: 'wax on, wax off' }, db); + expect(described.name).toBe('clean kitchen'); + expect(described.frequencyDays).toBe(7); + expect(described.description).toBe('wax on, wax off'); + + const renamed = await edit(r.id, { name: 'clean the whole kitchen' }, db); + expect(renamed.version).toBe(3); + expect(renamed.description).toBe('wax on, wax off'); + expect(renamed.frequencyDays).toBe(7); + + const refreq = await edit(r.id, { frequencyDays: 14 }, db); + expect(refreq.version).toBe(4); + expect(refreq.name).toBe('clean the whole kitchen'); + expect(refreq.description).toBe('wax on, wax off'); + + const cleared = await edit(r.id, { description: '' }, db); + expect(cleared.version).toBe(5); + expect(cleared.description).toBe(''); + expect(cleared.name).toBe('clean the whole kitchen'); + }); + it('archive inserts an archived snapshot without removing history', async () => { const r = await add(projectId, 'clean kitchen', 7, db); await archive(r.id, db); @@ -58,7 +90,7 @@ describe('routines service', () => { it('complete records the snapshot version it was made against', async () => { const r = await add(projectId, 'clean kitchen', 7, db); - await edit(r.id, 'clean kitchen', 5, db); + await edit(r.id, { frequencyDays: 5 }, db); const doneAt = new Date().toISOString(); const completion = await complete(r.id, 'with vinegar', doneAt, db); expect(completion.routineVersion).toBe(2); diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts index e9e0c45..c3a5eff 100644 --- a/src/lib/server/services/routines.ts +++ b/src/lib/server/services/routines.ts @@ -1,6 +1,6 @@ import { db as defaultDb } from '$lib/server/db'; import { routines, routineCompletions, projects } from '$lib/server/db/schema'; -import { and, eq, inArray, sql } from 'drizzle-orm'; +import { eq, inArray, desc, sql } from 'drizzle-orm'; import type { AppDatabase } from './groceries'; import type { Routine, RoutineCompletion } from './types'; import { @@ -40,11 +40,15 @@ export async function add( return inserted; } -/** Edit = insert a new snapshot row with version+1; history stays intact. */ +/** + * Edit = insert a new snapshot row with version+1; history stays intact. + * Only the given fields change; the others are copied from the current + * snapshot so a name/frequency edit never touches the description and + * vice-versa. + */ export async function edit( id: number, - name: string, - frequencyDays: number, + fields: Partial>, db = defaultDb, ): Promise { const [current] = await db @@ -61,8 +65,9 @@ export async function edit( id: current.id, version: current.version + 1, projectId: current.projectId, - name, - frequencyDays, + name: fields.name ?? current.name, + frequencyDays: fields.frequencyDays ?? current.frequencyDays, + description: fields.description ?? current.description, archived: current.archived, createdAt: current.createdAt, }) @@ -97,6 +102,31 @@ export async function loadAllVersions( .orderBy(routines.version); } +/** Current (max-version) snapshot of a routine, or undefined when unknown. */ +export async function loadCurrent( + routineId: number, + db = defaultDb, +): Promise { + const [row] = await db + .select() + .from(routines) + .where(eq(routines.id, routineId)) + .orderBy(sql`${routines.version} DESC`) + .limit(1); + return row; +} + +export async function loadCompletions( + routineId: number, + db = defaultDb, +): Promise { + return db + .select() + .from(routineCompletions) + .where(eq(routineCompletions.routineId, routineId)) + .orderBy(desc(routineCompletions.doneAt)); +} + export async function complete( routineId: number, note: string | null, diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index 4a84eda..a63069c 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { computeRoutineState, computeHealthScore, + computeHealthSeries, parseIntervalToDays, } from './routine-metrics'; @@ -64,6 +65,51 @@ describe('computeRoutineState', () => { }); }); +describe('computeHealthSeries', () => { + it('decays from 100 at creation to 0 two periods late when never done', () => { + const now = Date.parse(CREATED) + 15 * DAY_MS; + const series = computeHealthSeries(routine, [], now); + expect(series[0].health).toBe(100); + const at8d = series.find((p) => p.at === Date.parse(CREATED) + 8 * DAY_MS); + expect(at8d?.health).toBe(86); // 1/7 of a period overdue + const last = series[series.length - 1]; + expect(last.at).toBe(now); + expect(last.health).toBe(0); // more than a full period overdue + }); + + it('resets to 100 right after a completion, then decays again (sawtooth)', () => { + const doneAt = Date.parse(CREATED) + 5 * DAY_MS; + const now = doneAt + 13 * DAY_MS; // 13 days after the completion + const series = computeHealthSeries( + routine, + [{ routineId: 1, doneAt: new Date(doneAt).toISOString() }], + now, + ); + const rightAfter = series.find((p) => p.at >= doneAt); + expect(rightAfter?.health).toBe(100); + // 8 days after the completion: 1 day into the 7-day period overdue. + const at8d = series.find((p) => p.at === doneAt + 8 * DAY_MS); + expect(at8d?.health).toBe(86); + }); + + it('a second completion resets the decay again', () => { + const first = Date.parse(CREATED) + 3 * DAY_MS; + const second = Date.parse(CREATED) + 10 * DAY_MS; + const now = Date.parse(CREATED) + 11 * DAY_MS; + const series = computeHealthSeries( + routine, + [ + { routineId: 1, doneAt: new Date(first).toISOString() }, + { routineId: 1, doneAt: new Date(second).toISOString() }, + ], + now, + ); + // Just after the second completion the routine is back at full health. + const nearEnd = series[series.length - 1]; + expect(nearEnd.health).toBe(100); + }); +}); + describe('parseIntervalToDays', () => { it('converts units to days', () => { expect(parseIntervalToDays('1d')).toBe(1); diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts index 2ac245e..99e5c56 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -84,6 +84,65 @@ export function computeHealthScore(states: { health: number }[]): number { return Math.round(total / states.length); } +export interface HealthPoint { + /** Epoch ms of the sample. */ + at: number; + /** Health (0-100) at that instant. */ + health: number; +} + +/** + * Daily health samples from the routine's creation to now: 100 right after a + * completion, then a linear decay to 0 once a full period is overdue (sawtooth). + * Each sample anchors on the latest completion at or before it (or creation). + * Uses the routine's current frequency across the whole history. + */ +export function computeHealthSeries( + routine: RoutineInput, + completions: CompletionInput[], + now: number, +): HealthPoint[] { + const createdMs = Date.parse(routine.createdAt); + const startMs = Number.isFinite(createdMs) ? createdMs : now; + const periodMs = routine.frequencyDays * DAY_MS; + + const completionMs = completions + .filter((c) => c.routineId === routine.id) + .map((c) => Date.parse(c.doneAt)) + .sort((a, b) => a - b); + + const points: HealthPoint[] = []; + if (now <= startMs) { + return [{ at: now, health: 100 }]; + } + + // Reference anchor: latest completion at-or-before the sampled instant. + let refMs = startMs; + let nextCompletion = 0; + const sampleAt = (t: number): number => { + while ( + nextCompletion < completionMs.length && + completionMs[nextCompletion] <= t + ) { + refMs = completionMs[nextCompletion]; + nextCompletion++; + } + const dueMs = refMs + periodMs; + const rawRatio = (t - dueMs) / periodMs; + return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 100)); + }; + + for (let t = startMs; t <= now; t += DAY_MS) { + points.push({ at: t, health: sampleAt(t) }); + } + + // Final unaligned sample so the curve ends at the present instant. + if (points[points.length - 1].at < now) { + points.push({ at: now, health: sampleAt(now) }); + } + return points; +} + export function formatFrequency(days: number): string { if (days === 1) return 'chaque jour'; if (days === 7) return 'chaque semaine'; diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts index b20af6c..e3c4edf 100644 --- a/src/routes/projects/[id]/+page.server.ts +++ b/src/routes/projects/[id]/+page.server.ts @@ -60,11 +60,10 @@ export const actions: Actions = { return fail(400, { error: 'Le nom est requis' }); } try { - await edit( - numericId(data.get('id')), + await edit(numericId(data.get('id')), { name, - parseFrequencyDays(data.get('frequencyDays')), - ); + frequencyDays: parseFrequencyDays(data.get('frequencyDays')), + }); } catch { return fail(400, { error: 'Modification invalide' }); } diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte index d9747a9..608bbbb 100644 --- a/src/routes/projects/[id]/+page.svelte +++ b/src/routes/projects/[id]/+page.svelte @@ -159,7 +159,11 @@ {:else}
- {routine.name} + {routine.name} {formatFrequency(routine.frequencyDays)} {#if routine.lastDoneAt} @@ -229,6 +233,12 @@ .routine-name { font-weight: 500; + color: var(--text); + text-decoration: none; + } + + .routine-name:hover { + color: var(--accent); } .routine-meta { diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts new file mode 100644 index 0000000..83bac3c --- /dev/null +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts @@ -0,0 +1,80 @@ +import { error, fail } from '@sveltejs/kit'; +import { getById } from '$lib/server/services/projects'; +import { + edit, + complete, + loadCurrent, + loadCompletions, +} from '$lib/server/services/routines'; +import { numericId, parseFormString } from '$lib/server/services/validation'; +import { + parseIntervalToDays, + computeRoutineState, +} from '$lib/shared/routine-metrics'; +import type { Actions, PageServerLoad } from './$types'; + +function parseFrequencyDays(value: FormDataEntryValue | null): number { + const days = parseIntervalToDays(value?.toString() ?? ''); + if (days === null) { + throw new Error('Invalid interval'); + } + return days; +} + +export const load: PageServerLoad = async ({ params }) => { + const projectId = numericId(params.projectId); + const routineId = numericId(params.routineId); + + const project = await getById(projectId); + if (!project || project.archived) { + throw error(404, 'Projet introuvable'); + } + + const routine = await loadCurrent(routineId); + if (!routine || routine.archived || routine.projectId !== projectId) { + throw error(404, 'Routine introuvable'); + } + + const completions = await loadCompletions(routineId); + return { + project: { id: project.id, name: project.name }, + routine, + completions, + state: computeRoutineState(routine, completions, Date.now()), + }; +}; + +export const actions: Actions = { + edit: async ({ request, params }) => { + const data = await request.formData(); + const name = parseFormString(data.get('name')); + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + const description = data.get('description')?.toString() ?? ''; + try { + await edit(numericId(params.routineId), { + name, + frequencyDays: parseFrequencyDays(data.get('frequencyDays')), + description, + }); + } catch { + return fail(400, { error: 'Fréquence invalide' }); + } + return { success: true, type: 'edit' as const }; + }, + + complete: async ({ request, params }) => { + const data = await request.formData(); + try { + await complete( + numericId(params.routineId), + parseFormString(data.get('note')), + new Date().toISOString(), + ); + } catch { + return fail(400, { error: 'Routine introuvable' }); + } + return { success: true }; + }, +}; diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte new file mode 100644 index 0000000..ab0774c --- /dev/null +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte @@ -0,0 +1,440 @@ + + +
+ ← {data.project.name} + + + +
{ + return async ({ result, update }) => { + await update({ reset: false }); + }; + }} + > + {#if form?.error} +

{form.error}

+ {/if} + + + + {#if frequency !== '' && frequencyDays === null} +

Intervalle invalide — ex : 1d, 2w, 3m, 1y

+ {/if} + +

✓ Enregistré

+
+ +

Santé

+
+ {#if geometry} + + {#each geometry.grid as line (line.label)} + + {/each} + + {#each geometry.markers as m (m.x)} + + {/each} + + + {:else} +

Pas encore assez d'historique pour tracer la courbe.

+ {/if} +
+ +

Historique

+
{ + return async ({ update }) => { + note = ''; + await update({ reset: false }); + }; + }} + > + + +
+ +
    + {#each data.completions as c (c.id)} +
  • + + {dateTimeFormatter.format(new Date(c.doneAt))} + + {#if c.note} + {c.note} + {/if} +
  • + {/each} +
+ + {#if data.completions.length === 0} +

Aucune réalisation pour l'instant.

+ {/if} +
+ + -- cgit v1.3