From 4b2b964283075712de98743720ff3e31370ddc8e Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 8 Sep 2026 08:57:08 +0200 Subject: Deduplicate routine actions and badges across project routes - Share edit/complete/archive form actions in routine-actions.ts; route servers become thin wrappers (strictest guard semantics in one place) - Move parseFrequencyDays into validation.ts - Extract ScoreBadge/StatusBadge components; use on project pages and the home page instead of duplicated scoreClass/statusClass helpers - Fix archiveProject calling the routine archive with the project id --- src/lib/components/ScoreBadge.svelte | 13 ++++ src/lib/components/StatusBadge.svelte | 26 +++++++ src/lib/server/services/routine-actions.ts | 86 ++++++++++++++++++++++ src/lib/server/services/validation.ts | 10 +++ src/routes/+page.svelte | 11 +-- src/routes/projects/[id]/+page.server.ts | 63 +++++----------- src/routes/projects/[id]/+page.svelte | 27 ++----- .../routine/[routineId]/+page.server.ts | 69 +++-------------- .../[projectId]/routine/[routineId]/+page.svelte | 27 ++----- 9 files changed, 181 insertions(+), 151 deletions(-) create mode 100644 src/lib/components/ScoreBadge.svelte create mode 100644 src/lib/components/StatusBadge.svelte create mode 100644 src/lib/server/services/routine-actions.ts diff --git a/src/lib/components/ScoreBadge.svelte b/src/lib/components/ScoreBadge.svelte new file mode 100644 index 0000000..6384667 --- /dev/null +++ b/src/lib/components/ScoreBadge.svelte @@ -0,0 +1,13 @@ + + +{score} diff --git a/src/lib/components/StatusBadge.svelte b/src/lib/components/StatusBadge.svelte new file mode 100644 index 0000000..babcbfa --- /dev/null +++ b/src/lib/components/StatusBadge.svelte @@ -0,0 +1,26 @@ + + + + {STATUS_LABELS[status]}{#if overdueDays > 0} + ({overdueDays} j){/if} + diff --git a/src/lib/server/services/routine-actions.ts b/src/lib/server/services/routine-actions.ts new file mode 100644 index 0000000..b546ec7 --- /dev/null +++ b/src/lib/server/services/routine-actions.ts @@ -0,0 +1,86 @@ +import { fail, type ActionFailure } from '@sveltejs/kit'; +import { archive, complete, edit, loadCurrent } from './routines'; +import { parseFormString, parseFrequencyDays } from './validation'; + +/** + * Shared implementations of the routine edit/complete form actions, used by + * both the project dashboard (/projects/[id]) and the routine page + * (/projects/[projectId]/routine/[routineId]). Guarding and failure messages + * are the strictest common denominator: a missing or archived routine is + * rejected before any write. + */ + +export type EditRoutineResult = + ActionFailure<{ error: string }> | { success: boolean; type?: 'edit' }; + +export async function editRoutineAction( + data: FormData, + routineId: number, + extra?: { type: 'edit' }, +): Promise { + const current = await loadCurrent(routineId); + if (!current) { + return fail(400, { error: 'Routine introuvable' }); + } + if (current.archived) { + return fail(400, { error: 'Routine archivée' }); + } + + const name = parseFormString(data.get('name')); + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + + let frequencyDays: number; + try { + frequencyDays = parseFrequencyDays(data.get('frequencyDays')); + } catch { + return fail(400, { error: 'Fréquence invalide' }); + } + + // The dashboard row form has no description field; only touch it when the + // form actually sends one, so list edits never wipe a description. + const fields: { name: string; frequencyDays: number; description?: string } = + { name, frequencyDays }; + const description = data.get('description'); + if (description !== null) { + fields.description = description.toString(); + } + + try { + await edit(routineId, fields); + } catch { + return fail(400, { error: 'Routine introuvable' }); + } + return { success: true, type: extra?.type }; +} + +export async function completeRoutineAction(data: FormData, routineId: number) { + const current = await loadCurrent(routineId); + if (!current) { + return fail(400, { error: 'Routine introuvable' }); + } + if (current.archived) { + return fail(400, { error: 'Routine archivée' }); + } + + try { + await complete( + routineId, + parseFormString(data.get('note')), + new Date().toISOString(), + ); + } catch { + return fail(400, { error: 'Routine introuvable' }); + } + return { success: true }; +} + +export async function archiveRoutineAction(routineId: number) { + try { + await archive(routineId); + } catch { + return fail(400, { error: 'Routine introuvable' }); + } + return { success: true }; +} diff --git a/src/lib/server/services/validation.ts b/src/lib/server/services/validation.ts index a8cebd9..69820ea 100644 --- a/src/lib/server/services/validation.ts +++ b/src/lib/server/services/validation.ts @@ -1,3 +1,4 @@ +import { parseIntervalToDays } from '$lib/shared/routine-metrics'; import * as v from 'valibot'; export const IdSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); @@ -43,3 +44,12 @@ export function parseOptionalNumber( if (!Number.isFinite(n)) return null; return n; } + +/** Parses an interval like '2w' into days; throws when the format is invalid. */ +export function parseFrequencyDays(value: FormDataEntryValue | null): number { + const days = parseIntervalToDays(value?.toString() ?? ''); + if (days === null) { + throw new Error('Invalid interval'); + } + return days; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fd987ff..35ff5d7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,21 +1,16 @@
@@ -40,7 +35,7 @@ {entry.project.name} - {entry.healthScore} + {/each} diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts index a23d834..66cb9f3 100644 --- a/src/routes/projects/[id]/+page.server.ts +++ b/src/routes/projects/[id]/+page.server.ts @@ -1,26 +1,25 @@ import { error, fail, redirect } from '@sveltejs/kit'; -import { getById } from '$lib/server/services/projects'; +import { + archive as archiveProject, + getById, +} from '$lib/server/services/projects'; import { add, - edit, - archive, - complete, loadProjectDashboard, loadArchived, } from '$lib/server/services/routines'; -import { numericId, parseFormString } from '$lib/server/services/validation'; -import { parseIntervalToDays } from '$lib/shared/routine-metrics'; +import { + archiveRoutineAction, + completeRoutineAction, + editRoutineAction, +} from '$lib/server/services/routine-actions'; +import { + numericId, + parseFormString, + parseFrequencyDays, +} from '$lib/server/services/validation'; import type { Actions, PageServerLoad } from './$types'; -function parseFrequencyDays(value: FormDataEntryValue | null): number { - const raw = value?.toString() ?? ''; - const days = parseIntervalToDays(raw); - if (days === null) { - throw new Error('Invalid interval'); - } - return days; -} - export const load: PageServerLoad = async ({ params }) => { const id = numericId(params.id); const project = await getById(id); @@ -58,47 +57,21 @@ export const actions: Actions = { editRoutine: async ({ request }) => { const data = await request.formData(); - const name = parseFormString(data.get('name')); - if (!name) { - return fail(400, { error: 'Le nom est requis' }); - } - try { - await edit(numericId(data.get('id')), { - name, - frequencyDays: parseFrequencyDays(data.get('frequencyDays')), - }); - } catch { - return fail(400, { error: 'Modification invalide' }); - } - return { success: true }; + return editRoutineAction(data, numericId(data.get('id'))); }, complete: async ({ request }) => { const data = await request.formData(); - try { - await complete( - numericId(data.get('id')), - parseFormString(data.get('note')), - new Date().toISOString(), - ); - } catch { - return fail(400, { error: 'Routine introuvable' }); - } - return { success: true }; + return completeRoutineAction(data, numericId(data.get('id'))); }, archiveRoutine: async ({ request }) => { const data = await request.formData(); - try { - await archive(numericId(data.get('id'))); - } catch { - return fail(400, { error: 'Routine introuvable' }); - } - return { success: true }; + return archiveRoutineAction(numericId(data.get('id'))); }, archiveProject: async ({ params }) => { - await archive(numericId(params.id)); + await archiveProject(numericId(params.id)); throw redirect(303, '/'); }, }; diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte index 3d1aafe..e324f45 100644 --- a/src/routes/projects/[id]/+page.svelte +++ b/src/routes/projects/[id]/+page.svelte @@ -1,10 +1,11 @@
- - {STATUS_LABELS[routine.status]} - {#if routine.overdueDays > 0}({routine.overdueDays} - j){/if} - + diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts index f2b6551..df4c2cc 100644 --- a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts @@ -1,26 +1,14 @@ -import { error, fail } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import { getById } from '$lib/server/services/projects'; +import { loadCurrent, loadCompletions } from '$lib/server/services/routines'; 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'; + completeRoutineAction, + editRoutineAction, +} from '$lib/server/services/routine-actions'; +import { numericId } from '$lib/server/services/validation'; +import { 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); @@ -49,49 +37,14 @@ export const load: PageServerLoad = async ({ params }) => { 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) { - 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 }; + return editRoutineAction(data, numericId(params.routineId), { + type: 'edit', + }); }, 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( - numericId(params.routineId), - parseFormString(data.get('note')), - new Date().toISOString(), - ); - } catch { - return fail(400, { error: 'Routine introuvable' }); - } - return { success: true }; + return completeRoutineAction(data, numericId(params.routineId)); }, }; diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte index 5f57de4..19713ae 100644 --- a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte @@ -1,11 +1,11 @@
@@ -96,7 +83,7 @@ {#if data.routine.archived} @@ -195,10 +182,10 @@ {/each}