aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 13:44:59 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 13:44:59 +0200
commit212c3adc8a584e6517a2816fc6683688d5ac5d7a (patch)
tree59cccb6155a739376fce05faa833076ac4b52ad4 /src
parente1d80af9899e078f53d2232d2d7ef4680a14bf1c (diff)
Project health chart on LayerChart
Project page gets a Sante chart: daily mean of per-routine health via computeProjectHealthSeries, ending at the current health score. Both charts share a HealthChart component built on LayerChart's AreaChart (time x-axis, fixed 0-100 y-axis, completion dots).
Diffstat (limited to 'src')
-rw-r--r--src/lib/components/HealthChart.svelte110
-rw-r--r--src/lib/server/services/routines.test.ts3
-rw-r--r--src/lib/server/services/routines.ts12
-rw-r--r--src/lib/shared/routine-metrics.test.ts49
-rw-r--r--src/lib/shared/routine-metrics.ts72
-rw-r--r--src/routes/projects/[id]/+page.server.ts1
-rw-r--r--src/routes/projects/[id]/+page.svelte28
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte87
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;