aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-07 19:17:45 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-07 19:17:45 +0200
commit37573109a05a07ac50827759ea63793209baa73a (patch)
tree62c5004bb006eb6ec03830f069b0d26a112e72ab /src
parent1b96084da4ea317b06d92f4d493d102059599009 (diff)
Add routine detail page with editable description and health history
Diffstat (limited to 'src')
-rw-r--r--src/lib/server/db/schema.ts1
-rw-r--r--src/lib/server/services/routines.test.ts36
-rw-r--r--src/lib/server/services/routines.ts42
-rw-r--r--src/lib/shared/routine-metrics.test.ts46
-rw-r--r--src/lib/shared/routine-metrics.ts59
-rw-r--r--src/routes/projects/[id]/+page.server.ts7
-rw-r--r--src/routes/projects/[id]/+page.svelte12
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts80
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte440
9 files changed, 710 insertions, 13 deletions
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 23e10f5..bac1c46 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -52,6 +52,7 @@ export const routines = sqliteTable(
.references(() => projects.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
frequencyDays: integer('frequency_days').notNull(),
+ description: text('description').notNull().default(''),
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull(),
},
diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts
index 9e1f429..05abcaf 100644
--- a/src/lib/server/services/routines.test.ts
+++ b/src/lib/server/services/routines.test.ts
@@ -33,7 +33,11 @@ describe('routines service', () => {
it('edit inserts a new snapshot version and keeps history', async () => {
const r = await add(projectId, 'clean kitchen', 7, db);
- const updated = await edit(r.id, 'clean kitchen deeply', 3, db);
+ const updated = await edit(
+ r.id,
+ { name: 'clean kitchen deeply', frequencyDays: 3 },
+ db,
+ );
expect(updated.version).toBe(2);
expect(updated.frequencyDays).toBe(3);
@@ -43,6 +47,34 @@ describe('routines service', () => {
expect(versions[1].frequencyDays).toBe(3);
});
+ it('new routines start with an empty description', async () => {
+ const r = await add(projectId, 'clean kitchen', 7, db);
+ expect(r.description).toBe('');
+ });
+
+ it('edit is partial: name/frequency edits keep the description and vice-versa', async () => {
+ const r = await add(projectId, 'clean kitchen', 7, db);
+ const described = await edit(r.id, { description: 'wax on, wax off' }, db);
+ expect(described.name).toBe('clean kitchen');
+ expect(described.frequencyDays).toBe(7);
+ expect(described.description).toBe('wax on, wax off');
+
+ const renamed = await edit(r.id, { name: 'clean the whole kitchen' }, db);
+ expect(renamed.version).toBe(3);
+ expect(renamed.description).toBe('wax on, wax off');
+ expect(renamed.frequencyDays).toBe(7);
+
+ const refreq = await edit(r.id, { frequencyDays: 14 }, db);
+ expect(refreq.version).toBe(4);
+ expect(refreq.name).toBe('clean the whole kitchen');
+ expect(refreq.description).toBe('wax on, wax off');
+
+ const cleared = await edit(r.id, { description: '' }, db);
+ expect(cleared.version).toBe(5);
+ expect(cleared.description).toBe('');
+ expect(cleared.name).toBe('clean the whole kitchen');
+ });
+
it('archive inserts an archived snapshot without removing history', async () => {
const r = await add(projectId, 'clean kitchen', 7, db);
await archive(r.id, db);
@@ -58,7 +90,7 @@ describe('routines service', () => {
it('complete records the snapshot version it was made against', async () => {
const r = await add(projectId, 'clean kitchen', 7, db);
- await edit(r.id, 'clean kitchen', 5, db);
+ await edit(r.id, { frequencyDays: 5 }, db);
const doneAt = new Date().toISOString();
const completion = await complete(r.id, 'with vinegar', doneAt, db);
expect(completion.routineVersion).toBe(2);
diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts
index e9e0c45..c3a5eff 100644
--- a/src/lib/server/services/routines.ts
+++ b/src/lib/server/services/routines.ts
@@ -1,6 +1,6 @@
import { db as defaultDb } from '$lib/server/db';
import { routines, routineCompletions, projects } from '$lib/server/db/schema';
-import { and, eq, inArray, sql } from 'drizzle-orm';
+import { eq, inArray, desc, sql } from 'drizzle-orm';
import type { AppDatabase } from './groceries';
import type { Routine, RoutineCompletion } from './types';
import {
@@ -40,11 +40,15 @@ export async function add(
return inserted;
}
-/** Edit = insert a new snapshot row with version+1; history stays intact. */
+/**
+ * Edit = insert a new snapshot row with version+1; history stays intact.
+ * Only the given fields change; the others are copied from the current
+ * snapshot so a name/frequency edit never touches the description and
+ * vice-versa.
+ */
export async function edit(
id: number,
- name: string,
- frequencyDays: number,
+ fields: Partial<Pick<Routine, 'name' | 'frequencyDays' | 'description'>>,
db = defaultDb,
): Promise<Routine> {
const [current] = await db
@@ -61,8 +65,9 @@ export async function edit(
id: current.id,
version: current.version + 1,
projectId: current.projectId,
- name,
- frequencyDays,
+ name: fields.name ?? current.name,
+ frequencyDays: fields.frequencyDays ?? current.frequencyDays,
+ description: fields.description ?? current.description,
archived: current.archived,
createdAt: current.createdAt,
})
@@ -97,6 +102,31 @@ export async function loadAllVersions(
.orderBy(routines.version);
}
+/** Current (max-version) snapshot of a routine, or undefined when unknown. */
+export async function loadCurrent(
+ routineId: number,
+ db = defaultDb,
+): Promise<Routine | undefined> {
+ const [row] = await db
+ .select()
+ .from(routines)
+ .where(eq(routines.id, routineId))
+ .orderBy(sql`${routines.version} DESC`)
+ .limit(1);
+ return row;
+}
+
+export async function loadCompletions(
+ routineId: number,
+ db = defaultDb,
+): Promise<RoutineCompletion[]> {
+ return db
+ .select()
+ .from(routineCompletions)
+ .where(eq(routineCompletions.routineId, routineId))
+ .orderBy(desc(routineCompletions.doneAt));
+}
+
export async function complete(
routineId: number,
note: string | null,
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts
index 4a84eda..a63069c 100644
--- a/src/lib/shared/routine-metrics.test.ts
+++ b/src/lib/shared/routine-metrics.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
computeRoutineState,
computeHealthScore,
+ computeHealthSeries,
parseIntervalToDays,
} from './routine-metrics';
@@ -64,6 +65,51 @@ describe('computeRoutineState', () => {
});
});
+describe('computeHealthSeries', () => {
+ it('decays from 100 at creation to 0 two periods late when never done', () => {
+ const now = Date.parse(CREATED) + 15 * DAY_MS;
+ const series = computeHealthSeries(routine, [], now);
+ expect(series[0].health).toBe(100);
+ const at8d = series.find((p) => p.at === Date.parse(CREATED) + 8 * DAY_MS);
+ expect(at8d?.health).toBe(86); // 1/7 of a period overdue
+ const last = series[series.length - 1];
+ expect(last.at).toBe(now);
+ expect(last.health).toBe(0); // more than a full period overdue
+ });
+
+ it('resets to 100 right after a completion, then decays again (sawtooth)', () => {
+ const doneAt = Date.parse(CREATED) + 5 * DAY_MS;
+ const now = doneAt + 13 * DAY_MS; // 13 days after the completion
+ const series = computeHealthSeries(
+ routine,
+ [{ routineId: 1, doneAt: new Date(doneAt).toISOString() }],
+ now,
+ );
+ const rightAfter = series.find((p) => p.at >= doneAt);
+ expect(rightAfter?.health).toBe(100);
+ // 8 days after the completion: 1 day into the 7-day period overdue.
+ const at8d = series.find((p) => p.at === doneAt + 8 * DAY_MS);
+ expect(at8d?.health).toBe(86);
+ });
+
+ it('a second completion resets the decay again', () => {
+ const first = Date.parse(CREATED) + 3 * DAY_MS;
+ const second = Date.parse(CREATED) + 10 * DAY_MS;
+ const now = Date.parse(CREATED) + 11 * DAY_MS;
+ const series = computeHealthSeries(
+ routine,
+ [
+ { routineId: 1, doneAt: new Date(first).toISOString() },
+ { routineId: 1, doneAt: new Date(second).toISOString() },
+ ],
+ now,
+ );
+ // Just after the second completion the routine is back at full health.
+ const nearEnd = series[series.length - 1];
+ expect(nearEnd.health).toBe(100);
+ });
+});
+
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 2ac245e..99e5c56 100644
--- a/src/lib/shared/routine-metrics.ts
+++ b/src/lib/shared/routine-metrics.ts
@@ -84,6 +84,65 @@ export function computeHealthScore(states: { health: number }[]): number {
return Math.round(total / states.length);
}
+export interface HealthPoint {
+ /** Epoch ms of the sample. */
+ at: number;
+ /** Health (0-100) at that instant. */
+ health: number;
+}
+
+/**
+ * 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).
+ * Each sample anchors on the latest completion at or before it (or creation).
+ * Uses the routine's current frequency across the whole history.
+ */
+export function computeHealthSeries(
+ routine: RoutineInput,
+ completions: CompletionInput[],
+ now: number,
+): HealthPoint[] {
+ const createdMs = Date.parse(routine.createdAt);
+ const startMs = Number.isFinite(createdMs) ? createdMs : now;
+ const periodMs = routine.frequencyDays * DAY_MS;
+
+ const completionMs = completions
+ .filter((c) => c.routineId === routine.id)
+ .map((c) => Date.parse(c.doneAt))
+ .sort((a, b) => a - b);
+
+ const points: HealthPoint[] = [];
+ if (now <= startMs) {
+ return [{ at: now, health: 100 }];
+ }
+
+ // Reference anchor: latest completion at-or-before the sampled instant.
+ let refMs = startMs;
+ let nextCompletion = 0;
+ const sampleAt = (t: number): number => {
+ while (
+ nextCompletion < completionMs.length &&
+ completionMs[nextCompletion] <= t
+ ) {
+ refMs = completionMs[nextCompletion];
+ nextCompletion++;
+ }
+ const dueMs = refMs + periodMs;
+ const rawRatio = (t - dueMs) / periodMs;
+ return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 100));
+ };
+
+ for (let t = startMs; t <= now; t += DAY_MS) {
+ points.push({ at: t, health: sampleAt(t) });
+ }
+
+ // 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) });
+ }
+ 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 b20af6c..e3c4edf 100644
--- a/src/routes/projects/[id]/+page.server.ts
+++ b/src/routes/projects/[id]/+page.server.ts
@@ -60,11 +60,10 @@ export const actions: Actions = {
return fail(400, { error: 'Le nom est requis' });
}
try {
- await edit(
- numericId(data.get('id')),
+ await edit(numericId(data.get('id')), {
name,
- parseFrequencyDays(data.get('frequencyDays')),
- );
+ frequencyDays: parseFrequencyDays(data.get('frequencyDays')),
+ });
} catch {
return fail(400, { error: 'Modification invalide' });
}
diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte
index d9747a9..608bbbb 100644
--- a/src/routes/projects/[id]/+page.svelte
+++ b/src/routes/projects/[id]/+page.svelte
@@ -159,7 +159,11 @@
</form>
{:else}
<div class="routine-info">
- <span class="routine-name">{routine.name}</span>
+ <a
+ class="routine-name"
+ href="/projects/{data.project.id}/routine/{routine.id}"
+ >{routine.name}</a
+ >
<span class="routine-meta">
{formatFrequency(routine.frequencyDays)}
{#if routine.lastDoneAt}
@@ -229,6 +233,12 @@
.routine-name {
font-weight: 500;
+ color: var(--text);
+ text-decoration: none;
+ }
+
+ .routine-name:hover {
+ color: var(--accent);
}
.routine-meta {
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
new file mode 100644
index 0000000..83bac3c
--- /dev/null
+++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
@@ -0,0 +1,80 @@
+import { error, fail } from '@sveltejs/kit';
+import { getById } from '$lib/server/services/projects';
+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';
+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);
+
+ const project = await getById(projectId);
+ if (!project || project.archived) {
+ throw error(404, 'Projet introuvable');
+ }
+
+ const routine = await loadCurrent(routineId);
+ if (!routine || routine.archived || routine.projectId !== projectId) {
+ throw error(404, 'Routine introuvable');
+ }
+
+ const completions = await loadCompletions(routineId);
+ return {
+ project: { id: project.id, name: project.name },
+ routine,
+ completions,
+ state: computeRoutineState(routine, completions, Date.now()),
+ };
+};
+
+export const actions: Actions = {
+ edit: async ({ request, params }) => {
+ 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 };
+ },
+
+ complete: async ({ request, params }) => {
+ 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 };
+ },
+};
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
new file mode 100644
index 0000000..ab0774c
--- /dev/null
+++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
@@ -0,0 +1,440 @@
+<script lang="ts">
+ import { enhance } from '$app/forms';
+ import {
+ formatFrequency,
+ parseIntervalToDays,
+ STATUS_LABELS,
+ computeHealthSeries,
+ type RoutineState,
+ } 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;
+ const t = setTimeout(() => (saved = false), 2000);
+ return () => clearTimeout(t);
+ }
+ });
+
+ const frequencyDays = $derived(parseIntervalToDays(frequency));
+
+ const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
+ day: 'numeric',
+ month: 'short',
+ });
+ const dateTimeFormatter = new Intl.DateTimeFormat('fr-FR', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+
+ const series = $derived(
+ computeHealthSeries(data.routine, data.completions, Date.now()),
+ );
+
+ 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 };
+ });
+
+ 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>
+ <a class="back-link" href="/projects/{data.project.id}"
+ >← {data.project.name}</a
+ >
+
+ <header class="page-header">
+ <h1>{data.routine.name}</h1>
+ <span class={scoreClass(data.state.health)}>{data.state.health}</span>
+ </header>
+
+ <form
+ class="form-card"
+ method="POST"
+ action="?/edit"
+ use:enhance={() => {
+ return async ({ result, update }) => {
+ await update({ reset: false });
+ };
+ }}
+ >
+ {#if form?.error}
+ <p class="error">{form.error}</p>
+ {/if}
+ <label>
+ <span class="form-label">Nom</span>
+ <input
+ class="form-input"
+ type="text"
+ name="name"
+ bind:value={name}
+ autocomplete="off"
+ />
+ </label>
+ <label>
+ <span class="form-label">Intervalle</span>
+ <input
+ class="form-input frequency"
+ class:input-invalid={frequency !== '' && frequencyDays === null}
+ type="text"
+ name="frequencyDays"
+ bind:value={frequency}
+ placeholder="2w"
+ autocomplete="off"
+ aria-label="Intervalle (ex: 1d, 2w, 3m)"
+ />
+ </label>
+ <label>
+ <span class="form-label">Description</span>
+ <textarea
+ class="form-input desc"
+ name="description"
+ bind:value={description}
+ rows={3}
+ placeholder="Pourquoi, comment, quand..."
+ autocomplete="off"
+ ></textarea>
+ </label>
+ {#if frequency !== '' && frequencyDays === null}
+ <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p>
+ {/if}
+ <button
+ class="btn-primary"
+ type="submit"
+ disabled={!name.trim() || frequencyDays === null}
+ >
+ Enregistrer
+ </button>
+ <p class="save-status" class:visible={saved}>✓ Enregistré</p>
+ </form>
+
+ <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>
+ <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>
+ <span class="chart-meta">
+ {formatFrequency(data.routine.frequencyDays)}
+ {#if data.state.lastDoneAt}
+ · fait le {dateFormatter.format(new Date(data.state.lastDoneAt))}
+ {:else}
+ · jamais fait
+ {/if}
+ </span>
+ </div>
+ {:else}
+ <p class="empty">Pas encore assez d'historique pour tracer la courbe.</p>
+ {/if}
+ </div>
+
+ <h2 class="section-title">Historique</h2>
+ <form
+ class="form-card form-card--compact add-completion"
+ method="POST"
+ action="?/complete"
+ use:enhance={() => {
+ return async ({ update }) => {
+ note = '';
+ await update({ reset: false });
+ };
+ }}
+ >
+ <input
+ class="form-input"
+ type="text"
+ name="note"
+ bind:value={note}
+ placeholder="Note (optionnel)"
+ autocomplete="off"
+ />
+ <button class="btn-done" type="submit">Fait</button>
+ </form>
+
+ <ul class="item-list">
+ {#each data.completions as c (c.id)}
+ <li class="item-row item-row--card completion-row">
+ <span class="completion-date">
+ {dateTimeFormatter.format(new Date(c.doneAt))}
+ </span>
+ {#if c.note}
+ <span class="completion-note">{c.note}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+
+ {#if data.completions.length === 0}
+ <p class="empty">Aucune réalisation pour l'instant.</p>
+ {/if}
+</main>
+
+<style>
+ .back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ margin-top: 1rem;
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: color 0.2s;
+ }
+
+ .back-link:hover {
+ color: var(--accent);
+ }
+
+ .page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 0;
+ }
+
+ .page-header h1 {
+ overflow-wrap: anywhere;
+ }
+
+ .frequency {
+ max-width: 8rem;
+ }
+
+ .input-invalid {
+ border-color: var(--danger);
+ }
+
+ .desc {
+ width: 100%;
+ resize: vertical;
+ font-family: inherit;
+ }
+
+ .save-status {
+ font-size: 0.875rem;
+ color: var(--success);
+ font-weight: 500;
+ visibility: hidden;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ min-height: 1.25rem;
+ }
+
+ .save-status.visible {
+ visibility: visible;
+ opacity: 1;
+ }
+
+ .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;
+ }
+
+ .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;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+
+ .chart-meta {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ }
+
+ .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);
+ }
+
+ .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);
+ }
+
+ .add-completion {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ .add-completion .form-input {
+ flex: 1;
+ }
+
+ .btn-done {
+ flex-shrink: 0;
+ padding: 0.75rem 1.25rem;
+ border: none;
+ border-radius: 0.75rem;
+ background: var(--success);
+ color: var(--white);
+ font-size: 0.9375rem;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ }
+
+ .btn-done:hover {
+ filter: brightness(1.05);
+ transform: translateY(-1px);
+ }
+
+ .completion-row {
+ flex-wrap: wrap;
+ gap: 0.25rem 0.75rem;
+ }
+
+ .completion-date {
+ font-weight: 500;
+ }
+
+ .completion-note {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ }
+</style>