aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 08:57:08 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 08:57:08 +0200
commit4b2b964283075712de98743720ff3e31370ddc8e (patch)
tree7200e83869dbd42d5f20318a07a6ca328e5ac59f
parent992e40835af1600a0961ec2454fb78ff43aec3c6 (diff)
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
-rw-r--r--src/lib/components/ScoreBadge.svelte13
-rw-r--r--src/lib/components/StatusBadge.svelte26
-rw-r--r--src/lib/server/services/routine-actions.ts86
-rw-r--r--src/lib/server/services/validation.ts10
-rw-r--r--src/routes/+page.svelte11
-rw-r--r--src/routes/projects/[id]/+page.server.ts63
-rw-r--r--src/routes/projects/[id]/+page.svelte27
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts69
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte27
9 files changed, 181 insertions, 151 deletions
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 @@
+<script lang="ts">
+ let { score }: { score: number } = $props();
+
+ const className = $derived(
+ score >= 80
+ ? 'score bg-success'
+ : score >= 50
+ ? 'score bg-accent'
+ : 'score bg-danger',
+ );
+</script>
+
+<span class={className}>{score}</span>
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 @@
+<script lang="ts">
+ import {
+ STATUS_LABELS,
+ type RoutineStatus,
+ } from '$lib/shared/routine-metrics';
+
+ let {
+ status,
+ overdueDays = 0,
+ }: { status: RoutineStatus; overdueDays?: number } = $props();
+
+ const className = $derived(
+ status === 'ok'
+ ? 'status bg-success text-white'
+ : status === 'due'
+ ? 'status bg-accent-soft text-accent'
+ : status === 'late'
+ ? 'status bg-accent text-white'
+ : 'status bg-danger text-white',
+ );
+</script>
+
+<span class={className}>
+ {STATUS_LABELS[status]}{#if overdueDays > 0}
+ ({overdueDays} j){/if}
+</span>
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<EditRoutineResult> {
+ 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 @@
<script lang="ts">
import { enhance } from '$app/forms';
+ import ScoreBadge from '$lib/components/ScoreBadge.svelte';
let { data, form } = $props();
let name = $state('');
-
- function scoreClass(score: number): string {
- if (score >= 80) return 'score bg-success';
- if (score >= 50) return 'score bg-accent';
- return 'score bg-danger';
- }
</script>
<main>
<header class="page-header">
<h1>Projets</h1>
- <span class={scoreClass(data.globalScore)}>{data.globalScore}</span>
+ <ScoreBadge score={data.globalScore} />
</header>
<form class="add-bar" method="POST" action="?/addProject" use:enhance>
@@ -40,7 +35,7 @@
<a class="project-link" href="/projects/{entry.project.id}"
>{entry.project.name}</a
>
- <span class={scoreClass(entry.healthScore)}>{entry.healthScore}</span>
+ <ScoreBadge score={entry.healthScore} />
</li>
{/each}
</ul>
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 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { matchItems } from '$lib/components/fuzzy-search';
+ import ScoreBadge from '$lib/components/ScoreBadge.svelte';
+ import StatusBadge from '$lib/components/StatusBadge.svelte';
import {
formatFrequency,
parseIntervalToDays,
- STATUS_LABELS,
type RoutineState,
} from '$lib/shared/routine-metrics';
@@ -44,25 +45,12 @@
editName = routine.name;
editFrequency = `${routine.frequencyDays}d`;
}
-
- function scoreClass(score: number): string {
- if (score >= 80) return 'score bg-success';
- if (score >= 50) return 'score bg-accent';
- return 'score bg-danger';
- }
-
- function statusClass(status: RoutineState['status']): string {
- if (status === 'ok') return 'status bg-success text-white';
- if (status === 'due') return 'status bg-accent-soft text-accent';
- if (status === 'late') return 'status bg-accent text-white';
- return 'status bg-danger text-white';
- }
</script>
<main>
<header class="page-header">
<h1>{data.project.name}</h1>
- <span class={scoreClass(data.healthScore)}>{data.healthScore}</span>
+ <ScoreBadge score={data.healthScore} />
</header>
<form
@@ -183,11 +171,10 @@
{/if}
</span>
</div>
- <span class={statusClass(routine.status)}>
- {STATUS_LABELS[routine.status]}
- {#if routine.overdueDays > 0}({routine.overdueDays}
- j){/if}
- </span>
+ <StatusBadge
+ status={routine.status}
+ overdueDays={routine.overdueDays}
+ />
<form method="POST" action="?/complete" use:enhance>
<input type="hidden" name="id" value={routine.id} />
<button class="btn-done" type="submit">Fait</button>
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 @@
<script lang="ts">
import { enhance } from '$app/forms';
+ import ScoreBadge from '$lib/components/ScoreBadge.svelte';
+ import StatusBadge from '$lib/components/StatusBadge.svelte';
import {
formatFrequency,
parseIntervalToDays,
- STATUS_LABELS,
computeHealthSeries,
- type RoutineState,
} from '$lib/shared/routine-metrics';
let { data, form } = $props();
@@ -74,19 +74,6 @@
.filter((m): m is { x: number; y: number } => m !== null);
return { points, grid, markers };
});
-
- function scoreClass(score: number): string {
- if (score >= 80) return 'score bg-success';
- if (score >= 50) return 'score bg-accent';
- return 'score bg-danger';
- }
-
- function statusClass(status: RoutineState['status']): string {
- if (status === 'ok') return 'status bg-success text-white';
- if (status === 'due') return 'status bg-accent-soft text-accent';
- if (status === 'late') return 'status bg-accent text-white';
- return 'status bg-danger text-white';
- }
</script>
<main>
@@ -96,7 +83,7 @@
<header class="page-header">
<h1>{data.routine.name}</h1>
- <span class={scoreClass(data.state.health)}>{data.state.health}</span>
+ <ScoreBadge score={data.state.health} />
</header>
{#if data.routine.archived}
@@ -195,10 +182,10 @@
{/each}
</svg>
<div class="chart-footer">
- <span class={statusClass(data.state.status)}>
- {STATUS_LABELS[data.state.status]}
- {#if data.state.overdueDays > 0}({data.state.overdueDays} j){/if}
- </span>
+ <StatusBadge
+ status={data.state.status}
+ overdueDays={data.state.overdueDays}
+ />
<span class="chart-meta">
{formatFrequency(data.routine.frequencyDays)}
{#if data.state.lastDoneAt}