diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 11:32:41 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 11:32:41 +0200 |
| commit | 18a0f9c874aaa09c0f71252dad853c23f9511ed6 (patch) | |
| tree | 93103b83f6d0057c40f0bab9c9473bf04292e9c4 /src/routes | |
| parent | 113415ce357a9cea1759d0452ebae6352f8fe36b (diff) | |
Add projects with routines, urgency tracking and health scores
Projects group recurring routines (chores included) with an append-only
snapshot table (id, version) so edits never rewrite history; completions
reference the exact snapshot. Pure metrics compute due dates, urgency
buckets (ok/due/late/veryLate) and 0-100 health scores per routine,
project, and globally. Home page lists projects; project dashboard sorts
routines most-urgent first with one-tap completion and interval parser
inputs (1d/2w/3m/1y).
Diffstat (limited to 'src/routes')
| -rw-r--r-- | src/routes/+page.server.ts | 20 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 144 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.server.ts | 102 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.svelte | 395 |
4 files changed, 657 insertions, 4 deletions
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..fc160df --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,20 @@ +import { fail } from '@sveltejs/kit'; +import { loadOverview } from '$lib/server/services/routines'; +import { add as addProject } from '$lib/server/services/projects'; +import { parseFormString } from '$lib/server/services/validation'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return loadOverview(Date.now()); +}; + +export const actions: Actions = { + addProject: async ({ request }) => { + const name = parseFormString((await request.formData()).get('name')); + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + await addProject(name); + return { success: true }; + }, +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5bdee46..4d4777a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,4 +1,140 @@ -<p>Accueil — à venir : projets et routines.</p> -<ul> - <li><a href="/groceries">Courses</a></li> -</ul> +<script lang="ts"> + import { enhance } from '$app/forms'; + + let { data, form } = $props(); + + let name = $state(''); + + function scoreClass(score: number): string { + if (score >= 80) return 'score score--good'; + if (score >= 50) return 'score score--mid'; + return 'score score--bad'; + } +</script> + +<main> + <header class="page-header"> + <h1>Projets</h1> + <span class={scoreClass(data.globalScore)}>{data.globalScore}</span> + </header> + + <form class="cat-add-bar" method="POST" action="?/addProject" use:enhance> + <input + class="form-input" + type="text" + name="name" + bind:value={name} + placeholder="Nouveau projet..." + autocomplete="off" + /> + <button class="btn-accent" type="submit" aria-label="Ajouter">+</button> + </form> + + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + + <ul class="item-list"> + {#each data.projects as entry (entry.project.id)} + <li class="item-row item-row--card project-row"> + <a class="project-link" href="/projects/{entry.project.id}" + >{entry.project.name}</a + > + <span class={scoreClass(entry.healthScore)}>{entry.healthScore}</span> + </li> + {/each} + </ul> + + {#if data.projects.length === 0} + <p class="empty">Aucun projet</p> + {/if} +</main> + +<style> + .page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 0; + } + + .project-row { + padding: 0.875rem 1rem; + } + + .project-link { + flex-grow: 1; + font-weight: 500; + color: var(--text); + text-decoration: none; + } + + .project-link:hover { + color: var(--accent); + } + + .score { + flex-shrink: 0; + min-width: 2.75rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + text-align: center; + font-size: 0.875rem; + font-weight: 700; + color: var(--white); + } + + .score--good { + background: var(--success); + } + + .score--mid { + background: var(--accent); + } + + .score--bad { + background: var(--danger); + } + + .cat-add-bar { + display: flex; + gap: 0.5rem; + padding: 0.75rem 0; + } + + .cat-add-bar input { + flex: 1; + background: var(--surface); + } + + .btn-accent { + width: 3rem; + height: 3rem; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 0.75rem; + background: var(--accent); + color: var(--white); + font-size: 1.25rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); + } + + .btn-accent:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px var(--shadow-accent-lg); + } + + .empty { + margin-top: 1rem; + padding: 2rem; + } +</style> diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts new file mode 100644 index 0000000..b20af6c --- /dev/null +++ b/src/routes/projects/[id]/+page.server.ts @@ -0,0 +1,102 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { getById } from '$lib/server/services/projects'; +import { + add, + edit, + archive, + complete, + loadProjectDashboard, +} from '$lib/server/services/routines'; +import { numericId, parseFormString } from '$lib/server/services/validation'; +import { parseIntervalToDays } from '$lib/shared/routine-metrics'; +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); + if (!project || project.archived) { + throw error(404, 'Projet introuvable'); + } + const dashboard = await loadProjectDashboard(id, Date.now()); + return { + project: { id: project.id, name: project.name }, + routines: dashboard.routines, + healthScore: dashboard.healthScore, + }; +}; + +export const actions: Actions = { + addRoutine: async ({ request, params }) => { + const data = await request.formData(); + const name = parseFormString(data.get('name')); + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + try { + await add( + numericId(params.id), + name, + parseFrequencyDays(data.get('frequencyDays')), + ); + } catch { + return fail(400, { error: 'Fréquence invalide' }); + } + return { success: true }; + }, + + 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, + parseFrequencyDays(data.get('frequencyDays')), + ); + } catch { + return fail(400, { error: 'Modification invalide' }); + } + return { success: true }; + }, + + 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 }; + }, + + 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 }; + }, + + archiveProject: async ({ params }) => { + await archive(numericId(params.id)); + throw redirect(303, '/'); + }, +}; diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte new file mode 100644 index 0000000..d9747a9 --- /dev/null +++ b/src/routes/projects/[id]/+page.svelte @@ -0,0 +1,395 @@ +<script lang="ts"> + import { enhance } from '$app/forms'; + import { + formatFrequency, + parseIntervalToDays, + STATUS_LABELS, + type RoutineState, + } from '$lib/shared/routine-metrics'; + + let { data, form } = $props(); + + let name = $state(''); + let frequency = $state(''); + let editingId = $state<number | null>(null); + let editName = $state(''); + let editFrequency = $state(''); + + const frequencyDays = $derived(parseIntervalToDays(frequency)); + const editFrequencyDays = $derived(parseIntervalToDays(editFrequency)); + + const dateFormatter = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'short', + }); + + function sorted(routines: RoutineState[]): RoutineState[] { + return [...routines].sort( + (a, b) => b.overdueRatio - a.overdueRatio || a.name.localeCompare(b.name), + ); + } + + function startEdit(routine: RoutineState) { + editingId = routine.id; + editName = routine.name; + editFrequency = `${routine.frequencyDays}d`; + } + + function scoreClass(score: number): string { + if (score >= 80) return 'score score--good'; + if (score >= 50) return 'score score--mid'; + return 'score score--bad'; + } + + function statusClass(status: RoutineState['status']): string { + if (status === 'ok') return 'status status--ok'; + if (status === 'due') return 'status status--due'; + if (status === 'late') return 'status status--late'; + return 'status status--very-late'; + } +</script> + +<main> + <header class="page-header"> + <h1>{data.project.name}</h1> + <span class={scoreClass(data.healthScore)}>{data.healthScore}</span> + </header> + + <form + class="cat-add-bar" + method="POST" + action="?/addRoutine" + use:enhance={() => { + return async ({ update }) => { + name = ''; + frequency = ''; + await update({ reset: false }); + }; + }} + > + <input + class="form-input" + type="text" + name="name" + bind:value={name} + placeholder="Nouvelle routine..." + autocomplete="off" + /> + <input + class="form-input frequency-input" + class:input-invalid={frequency !== '' && frequencyDays === null} + type="text" + name="frequencyDays" + bind:value={frequency} + placeholder="2w" + autocomplete="off" + aria-label="Intervalle (ex: 1d, 2w, 3m)" + /> + <button + class="btn-accent" + type="submit" + disabled={!name.trim() || frequencyDays === null} + aria-label="Ajouter" + > + + + </button> + </form> + + {#if frequency !== '' && frequencyDays === null} + <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p> + {/if} + + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + + <ul class="item-list"> + {#each sorted(data.routines) as routine (routine.id)} + <li class="item-row item-row--card routine-row"> + {#if editingId === routine.id} + <form + class="edit-form" + method="POST" + action="?/editRoutine" + use:enhance={() => { + return async ({ result, update }) => { + if (result.type === 'failure') { + // Keep the edit form open so the error is visible. + await update({ reset: false }); + return; + } + editingId = null; + await update({ reset: false }); + }; + }} + > + {#if form?.error} + <p class="error edit-error">{form.error}</p> + {/if} + <input type="hidden" name="id" value={routine.id} /> + <input + class="form-input" + type="text" + name="name" + bind:value={editName} + /> + <input + class="form-input frequency-input" + class:input-invalid={editFrequency !== '' && + editFrequencyDays === null} + type="text" + name="frequencyDays" + bind:value={editFrequency} + placeholder="2w" + autocomplete="off" + aria-label="Intervalle (ex: 1d, 2w, 3m)" + /> + <button + class="btn-primary" + type="submit" + disabled={!editName.trim() || editFrequencyDays === null} + > + OK + </button> + <button + class="btn-ghost" + type="button" + onclick={() => (editingId = null)}>✕</button + > + </form> + {:else} + <div class="routine-info"> + <span class="routine-name">{routine.name}</span> + <span class="routine-meta"> + {formatFrequency(routine.frequencyDays)} + {#if routine.lastDoneAt} + · fait le {dateFormatter.format(new Date(routine.lastDoneAt))} + {:else} + · jamais fait + {/if} + </span> + </div> + <span class={statusClass(routine.status)}> + {STATUS_LABELS[routine.status]} + {#if routine.overdueDays > 0}({routine.overdueDays} + j){/if} + </span> + <form method="POST" action="?/complete" use:enhance> + <input type="hidden" name="id" value={routine.id} /> + <button class="btn-done" type="submit">Fait</button> + </form> + <button + class="btn-ghost" + type="button" + aria-label="Modifier" + onclick={() => startEdit(routine)}>✎</button + > + <form method="POST" action="?/archiveRoutine" use:enhance> + <input type="hidden" name="id" value={routine.id} /> + <button class="btn-ghost" type="submit" aria-label="Archiver"> + × + </button> + </form> + {/if} + </li> + {/each} + </ul> + + {#if data.routines.length === 0} + <p class="empty">Aucune routine</p> + {/if} + + <footer class="page-footer"> + <form method="POST" action="?/archiveProject" use:enhance> + <button class="btn-archive" type="submit">Archiver le projet</button> + </form> + </footer> +</main> + +<style> + .page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 0; + } + + .routine-row { + flex-wrap: wrap; + padding: 0.75rem 1rem; + } + + .routine-info { + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.2; + } + + .routine-name { + font-weight: 500; + } + + .routine-meta { + font-size: 0.75rem; + color: var(--text-secondary); + } + + .edit-form { + display: flex; + flex-grow: 1; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + } + + .edit-error { + width: 100%; + } + + .edit-form .form-input { + flex: 1; + padding: 0.5rem 0.75rem; + } + + .frequency-input { + width: 4.5rem; + flex-shrink: 0; + } + + .input-invalid { + border-color: var(--danger); + } + + .status { + flex-shrink: 0; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + } + + .status--ok { + background: var(--success); + color: var(--white); + } + + .status--due { + background: var(--shadow-accent); + color: var(--accent); + } + + .status--late { + background: var(--accent); + color: var(--white); + } + + .status--very-late { + background: var(--danger); + color: var(--white); + } + + .btn-done { + flex-shrink: 0; + padding: 0.5rem 0.875rem; + border: none; + border-radius: 0.625rem; + background: var(--success); + color: var(--white); + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + } + + .btn-done:hover { + filter: brightness(1.05); + transform: translateY(-1px); + } + + .score { + flex-shrink: 0; + min-width: 2.75rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + text-align: center; + font-size: 0.875rem; + font-weight: 700; + color: var(--white); + } + + .score--good { + background: var(--success); + } + + .score--mid { + background: var(--accent); + } + + .score--bad { + background: var(--danger); + } + + .cat-add-bar { + display: flex; + gap: 0.5rem; + padding: 0.75rem 0; + } + + .cat-add-bar input:first-child { + flex: 1; + background: var(--surface); + min-width: 0; + } + + .btn-accent { + width: 3rem; + height: 3rem; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 0.75rem; + background: var(--accent); + color: var(--white); + font-size: 1.25rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); + } + + .btn-accent:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px var(--shadow-accent-lg); + } + + .page-footer { + margin-top: 2rem; + display: flex; + justify-content: center; + } + + .btn-archive { + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 0.875rem; + text-decoration: underline; + cursor: pointer; + } + + .btn-archive:hover { + color: var(--danger); + } + + .empty { + margin-top: 1rem; + padding: 2rem; + } +</style> |
