aboutsummaryrefslogtreecommitdiff
path: root/src/lib
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/lib
parent1b96084da4ea317b06d92f4d493d102059599009 (diff)
Add routine detail page with editable description and health history
Diffstat (limited to 'src/lib')
-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
5 files changed, 176 insertions, 8 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';