diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/components/HealthChart.svelte | 110 | ||||
| -rw-r--r-- | src/lib/server/services/routines.test.ts | 3 | ||||
| -rw-r--r-- | src/lib/server/services/routines.ts | 12 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 49 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 72 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.server.ts | 1 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.svelte | 28 | ||||
| -rw-r--r-- | src/routes/projects/[projectId]/routine/[routineId]/+page.svelte | 87 |
8 files changed, 278 insertions, 84 deletions
diff --git a/src/lib/components/HealthChart.svelte b/src/lib/components/HealthChart.svelte new file mode 100644 index 0000000..f1f87ed --- /dev/null +++ b/src/lib/components/HealthChart.svelte @@ -0,0 +1,110 @@ +<script lang="ts"> + import { AreaChart, Area, Points, Text } from 'layerchart'; + import type { HealthPoint } from '$lib/shared/routine-metrics'; + + let { + series, + markers = [], + ariaLabel, + }: { + series: HealthPoint[]; + markers?: number[]; + ariaLabel: string; + } = $props(); + + const dateFormatter = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'short', + }); + + const data = $derived( + series.map((p) => ({ date: new Date(p.at), health: p.health })), + ); + const dots = $derived( + markers.map((t) => ({ date: new Date(t), health: 100 })), + ); + + // Up to 5 x ticks spread across the series, skipping repeats (two samples + // the same day would print the same label twice). + const xTicks = $derived.by(() => { + if (data.length < 2) return undefined; + const step = Math.max(1, Math.floor((data.length - 1) / 4)); + const ticks: Date[] = []; + for (let i = 0; i < data.length; i += step) { + const last = i + step >= data.length; + const date = last ? data[data.length - 1].date : data[i].date; + const label = dateFormatter.format(date); + if ( + ticks.length > 0 && + dateFormatter.format(ticks[ticks.length - 1]) === label + ) + continue; + ticks.push(date); + if (last) break; + } + return ticks; + }); +</script> + +{#if data.length >= 2} + <div class="health-chart" role="img" aria-label={ariaLabel}> + <AreaChart + {data} + x="date" + y="health" + yDomain={[0, 100]} + series={[{ key: 'santé', value: 'health', color: 'var(--accent)' }]} + axis + grid={{ y: true, yTicks: [25, 50, 75, 100] }} + rule={false} + props={{ + xAxis: { + ticks: xTicks, + format: (d: Date) => dateFormatter.format(d), + tickLabel: xTickLabel, + }, + yAxis: { ticks: [25, 50, 75, 100] }, + }} + marks={chartMarks} + /> + </div> +{:else} + <p class="empty">Pas encore assez d'historique pour tracer la courbe.</p> +{/if} + +{#snippet xTickLabel({ + props, + index, +}: { + props: Record<string, unknown>; + index: number; +})} + <Text + {...props} + textAnchor={index === 0 + ? 'start' + : index === (xTicks?.length ?? 0) - 1 + ? 'end' + : 'middle'} + /> +{/snippet} + +{#snippet chartMarks()} + <Area seriesKey="santé" fillOpacity={0.2} line={{ strokeWidth: 2 }} /> + <Points + data={dots} + x="date" + y="health" + r={4} + fill="var(--success)" + stroke="var(--white)" + strokeWidth={1.5} + /> +{/snippet} + +<style> + .health-chart { + width: 100%; + height: 11rem; + } +</style> diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts index e8c4f35..04151c2 100644 --- a/src/lib/server/services/routines.test.ts +++ b/src/lib/server/services/routines.test.ts @@ -141,6 +141,9 @@ describe('routines service', () => { expect(dashboard.routines).toHaveLength(1); expect(dashboard.routines[0].status).toBe('veryLate'); expect(dashboard.healthScore).toBe(0); + const last = dashboard.healthSeries[dashboard.healthSeries.length - 1]; + expect(last.at).toBe(now); + expect(last.health).toBe(dashboard.healthScore); }); it('overview aggregates per-project health and a global score', async () => { diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts index b314d50..ec03c50 100644 --- a/src/lib/server/services/routines.ts +++ b/src/lib/server/services/routines.ts @@ -6,7 +6,9 @@ import type { Routine, RoutineCompletion } from './types'; import { computeRoutineState, computeHealthScore, + computeProjectHealthSeries, type RoutineState, + type HealthPoint, } from '$lib/shared/routine-metrics'; // ponytail: next routine id via MAX(id)+1, safe enough for a single-user app; @@ -199,6 +201,7 @@ export async function complete( export interface ProjectDashboard { routines: RoutineState[]; healthScore: number; + healthSeries: HealthPoint[]; } export async function loadProjectDashboard( @@ -219,7 +222,8 @@ export async function loadProjectDashboard( if (!existing || r.version > existing.version) current.set(r.id, r); } const routines_ = [...current.values()].filter((r) => !r.archived); - if (routines_.length === 0) return { routines: [], healthScore: 100 }; + if (routines_.length === 0) + return { routines: [], healthScore: 100, healthSeries: [] }; const completions = await db .select() @@ -232,7 +236,11 @@ export async function loadProjectDashboard( ); const states = routines_.map((r) => computeRoutineState(r, completions, now)); - return { routines: states, healthScore: computeHealthScore(states) }; + return { + routines: states, + healthScore: computeHealthScore(states), + healthSeries: computeProjectHealthSeries(routines_, completions, now), + }; } export interface ProjectsOverview { diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index ccc06ba..57b8c38 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -3,6 +3,7 @@ import { computeRoutineState, computeHealthScore, computeHealthSeries, + computeProjectHealthSeries, parseIntervalToDays, } from './routine-metrics'; @@ -125,6 +126,54 @@ describe('computeHealthSeries', () => { }); }); +describe('computeProjectHealthSeries', () => { + it('is empty with no routines', () => { + expect(computeProjectHealthSeries([], [], Date.parse(CREATED))).toEqual([]); + }); + + it('averages per-routine health and ends at the current score', () => { + const second = { ...routine, id: 2, name: 'read' }; + const doneAt = Date.parse(CREATED) + 5 * DAY_MS; + const now = Date.parse(CREATED) + 8 * DAY_MS; + const series = computeProjectHealthSeries( + [routine, second], + [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }], + now, + ); + const states = [ + computeRoutineState(routine, [], now), + computeRoutineState( + second, + [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }], + now, + ), + ]; + const last = series[series.length - 1]; + expect(last.at).toBe(now); + expect(last.health).toBe(computeHealthScore(states)); + }); + + it('only counts routines alive that day', () => { + const late = { + ...routine, + id: 2, + name: 'latecomer', + createdAt: new Date(Date.parse(CREATED) + 10 * DAY_MS).toISOString(), + }; + const now = Date.parse(CREATED) + 11 * DAY_MS; + const series = computeProjectHealthSeries([routine, late], [], now); + const beforeLate = series.find( + (p) => p.at === Date.parse(CREATED) + 5 * DAY_MS, + ); + const single = computeHealthSeries( + routine, + [], + Date.parse(CREATED) + 5 * DAY_MS, + ); + expect(beforeLate?.health).toBe(single[single.length - 1].health); + }); +}); + 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 4af989d..891a0c9 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -103,6 +103,13 @@ export interface HealthPoint { health: number; } +/** Health at instant t given the reference anchor (creation or last completion). */ +function healthAtPoint(refMs: number, periodMs: number, t: number): number { + const dueMs = startOfUtcDay(refMs) + periodMs; + const rawRatio = (t - dueMs) / periodMs; + return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 100)); +} + /** * 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). @@ -139,9 +146,7 @@ export function computeHealthSeries( refMs = completionMs[nextCompletion]; nextCompletion++; } - const dueMs = startOfUtcDay(refMs) + periodMs; - const rawRatio = (t - dueMs) / periodMs; - return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 100)); + return healthAtPoint(refMs, periodMs, t); }; for (let t = startMs; t <= now; t += DAY_MS) { @@ -155,6 +160,67 @@ export function computeHealthSeries( return points; } +/** + * Project health series: daily mean of per-routine health across the routines + * alive that day (createdAt <= t). Same sawtooth as the per-routine series, + * so the project curve ends at the current health score. Empty when no + * routines. + */ +export function computeProjectHealthSeries( + routines: RoutineInput[], + completions: CompletionInput[], + now: number, +): HealthPoint[] { + if (routines.length === 0) return []; + const ids = new Set(routines.map((r) => r.id)); + const completionsByRoutine = new Map<number, number[]>(); + for (const c of completions) { + if (!ids.has(c.routineId)) continue; + const t = Date.parse(c.doneAt); + if (!Number.isFinite(t)) continue; + completionsByRoutine.set(c.routineId, [ + ...(completionsByRoutine.get(c.routineId) ?? []), + t, + ]); + } + for (const times of completionsByRoutine.values()) + times.sort((a, b) => a - b); + + const createdOrNow = (r: RoutineInput): number => { + const t = Date.parse(r.createdAt); + return Number.isFinite(t) ? t : now; + }; + // Mean per-routine health across the routines alive at t. + const sampleAt = (t: number): number | null => { + const alive = routines.filter((r) => Date.parse(r.createdAt) <= t); + if (alive.length === 0) return null; + const total = alive.reduce((sum, r) => { + const doneAt = (completionsByRoutine.get(r.id) ?? []).filter( + (d) => d <= t, + ); + const anchor = doneAt.length > 0 ? Math.max(...doneAt) : createdOrNow(r); + return sum + healthAtPoint(anchor, r.frequencyDays * DAY_MS, t); + }, 0); + return Math.round(total / alive.length); + }; + + const startMs = Math.min(...routines.map(createdOrNow)); + if (now <= startMs) return [{ at: now, health: 100 }]; + + const points: HealthPoint[] = []; + for (let t = startMs; t <= now; t += DAY_MS) { + const health = sampleAt(t); + if (health !== null) points.push({ at: t, health }); + } + if (points.length === 0) return [{ at: now, health: 100 }]; + + // 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) ?? 100 }); + } + 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 66cb9f3..7cb90ff 100644 --- a/src/routes/projects/[id]/+page.server.ts +++ b/src/routes/projects/[id]/+page.server.ts @@ -32,6 +32,7 @@ export const load: PageServerLoad = async ({ params }) => { project: { id: project.id, name: project.name }, routines: dashboard.routines, healthScore: dashboard.healthScore, + healthSeries: dashboard.healthSeries, archived, }; }; diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte index b67ff45..a58aeba 100644 --- a/src/routes/projects/[id]/+page.svelte +++ b/src/routes/projects/[id]/+page.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { enhance } from '$app/forms'; import { matchItems } from '$lib/components/fuzzy-search'; + import HealthChart from '$lib/components/HealthChart.svelte'; import ScoreBadge from '$lib/components/ScoreBadge.svelte'; import StatusBadge from '$lib/components/StatusBadge.svelte'; import { @@ -53,6 +54,18 @@ <ScoreBadge score={data.healthScore} /> </header> + {#if data.healthSeries.length > 0} + <section> + <h2 class="section-title">Santé</h2> + <div class="form-card chart-card"> + <HealthChart + series={data.healthSeries} + ariaLabel="Évolution de la santé du projet dans le temps" + /> + </div> + </section> + {/if} + <form class="add-bar" method="POST" @@ -251,6 +264,21 @@ </main> <style> + .section-title { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + margin: 1.5rem 0 0.5rem; + padding-left: 0.5rem; + border-left: 3px solid var(--accent); + } + + .chart-card { + align-items: stretch; + } + .routine-row { flex-wrap: wrap; padding: 0.75rem 1rem; diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte index bcdca79..ab2644d 100644 --- a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { enhance } from '$app/forms'; + import HealthChart from '$lib/components/HealthChart.svelte'; import ScoreBadge from '$lib/components/ScoreBadge.svelte'; import StatusBadge from '$lib/components/StatusBadge.svelte'; import { @@ -7,21 +8,17 @@ parseIntervalToDays, computeHealthSeries, } from '$lib/shared/routine-metrics'; - let { data, form } = $props(); - let name = $state(''); let frequency = $state(''); let description = $state(''); let note = $state(''); let saved = $state(false); - $effect(() => { name = data.routine.name; frequency = `${data.routine.frequencyDays}d`; description = data.routine.description; }); - $effect(() => { if (form?.success && form?.type === 'edit') { saved = true; @@ -29,9 +26,7 @@ return () => clearTimeout(t); } }); - const frequencyDays = $derived(parseIntervalToDays(frequency)); - const dateFormatter = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short', @@ -41,39 +36,15 @@ month: 'short', 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, frozenAt), ); - - const PAD = 10; - const W = 800; - const H = 140; - const geometry = $derived.by(() => { - if (series.length < 2) return null; - const minT = series[0].at; - const maxT = series[series.length - 1].at; - const span = Math.max(1, maxT - minT); - const x = (t: number) => PAD + ((t - minT) / span) * (W - 2 * PAD); - const y = (h: number) => H - PAD - (h / 100) * (H - 2 * PAD); - const points = series - .map((p) => `${x(p.at).toFixed(1)},${y(p.health).toFixed(1)}`) - .join(' '); - const grid = [0, 25, 50, 75, 100].map((h) => ({ y: y(h), label: h })); - const markers = data.completions - .map((c) => { - const t = Date.parse(c.doneAt); - return t >= minT && t <= maxT ? { x: x(t), y: y(100) } : null; - }) - .filter((m): m is { x: number; y: number } => m !== null); - return { points, grid, markers }; - }); + const markers = $derived(data.completions.map((c) => Date.parse(c.doneAt))); </script> <main> @@ -159,28 +130,12 @@ <h2 class="section-title">Santé</h2> <div class="form-card chart-card"> - {#if geometry} - <svg - class="health-chart" - viewBox="0 0 {W} {H}" - preserveAspectRatio="none" - role="img" - aria-label="Évolution de la santé de la routine dans le temps" - > - {#each geometry.grid as line (line.label)} - <line - x1={PAD} - y1={line.y} - x2={W - PAD} - y2={line.y} - class="chart-grid" - /> - {/each} - <polyline points={geometry.points} class="chart-line" fill="none" /> - {#each geometry.markers as m (m.x)} - <circle cx={m.x} cy={m.y} r="4" class="chart-marker" /> - {/each} - </svg> + <HealthChart + {series} + {markers} + ariaLabel="Évolution de la santé de la routine dans le temps" + /> + {#if series.length >= 2} <div class="chart-footer"> <StatusBadge status={data.state.status} @@ -195,8 +150,6 @@ {/if} </span> </div> - {:else} - <p class="empty">Pas encore assez d'historique pour tracer la courbe.</p> {/if} </div> @@ -280,30 +233,6 @@ align-items: stretch; } - .health-chart { - width: 100%; - height: 9rem; - } - - .chart-grid { - stroke: var(--border); - stroke-width: 1; - stroke-dasharray: 2 4; - } - - .chart-line { - stroke: var(--accent); - stroke-width: 2; - stroke-linejoin: round; - stroke-linecap: round; - } - - .chart-marker { - fill: var(--success); - stroke: var(--white); - stroke-width: 1.5; - } - .chart-footer { display: flex; align-items: center; |
