diff options
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | drizzle/0006_chunky_meteorite.sql | 19 | ||||
| -rw-r--r-- | drizzle/meta/0006_snapshot.json | 365 | ||||
| -rw-r--r-- | drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | scripts/seed-sheet1-routines.ts | 66 | ||||
| -rw-r--r-- | src/lib/server/db/schema.ts | 2 | ||||
| -rw-r--r-- | src/lib/server/services/routine-actions.ts | 15 | ||||
| -rw-r--r-- | src/lib/server/services/routines.test.ts | 46 | ||||
| -rw-r--r-- | src/lib/server/services/routines.ts | 12 | ||||
| -rw-r--r-- | src/lib/server/services/validation.ts | 10 | ||||
| -rw-r--r-- | src/lib/shared/frequency.test.ts | 125 | ||||
| -rw-r--r-- | src/lib/shared/frequency.ts | 114 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 231 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 285 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.server.ts | 9 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.svelte | 27 | ||||
| -rw-r--r-- | src/routes/projects/[projectId]/routine/[routineId]/+page.svelte | 29 |
17 files changed, 1134 insertions, 236 deletions
@@ -66,8 +66,11 @@ worker precaches assets; works offline after first visit. ### Projects & routines **Projects** ("Household", "Personal growth", …) group recurring **routines** -— chores are just routines in the household project. Each routine has an -interval like `1d`, `2w`, `3m` or `1y`. The home page lists projects with a +— chores are just routines in the household project. Each routine has a +frequency spec: an interval like `2w` (every 2 weeks), or a quota like `5/2w` +(5 completions counted per 2-week window anchored on the routine's creation +day — a closed window left unmet makes the routine En retard). The home page +lists projects with a health score (0–100); each project page shows its routines sorted most-urgent first (À jour / À faire / En retard / Très en retard) with one-tap "Fait" completion. A search box above the list filters it live — type `Cuisine/` to @@ -95,7 +98,6 @@ Seed from the paper sheet (one-off, idempotent): - [ ] https://automerge.org/docs/tutorial/ - [ ] Style like feed.kagamino.dev? - [ ] Shared > Modal actions -- [ ] Routine > Frequency = 5d/1w ## Deploy diff --git a/drizzle/0006_chunky_meteorite.sql b/drizzle/0006_chunky_meteorite.sql new file mode 100644 index 0000000..471abc5 --- /dev/null +++ b/drizzle/0006_chunky_meteorite.sql @@ -0,0 +1,19 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_routines` ( + `id` integer NOT NULL, + `version` integer NOT NULL, + `project_id` integer NOT NULL, + `name` text NOT NULL, + `frequency` text NOT NULL, + `description` text DEFAULT '' NOT NULL, + `archived` integer DEFAULT false NOT NULL, + `archived_at` text, + `created_at` text NOT NULL, + PRIMARY KEY(`id`, `version`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_routines`("id", "version", "project_id", "name", "frequency", "description", "archived", "archived_at", "created_at") SELECT "id", "version", "project_id", "name", "frequency_days" || 'd', "description", "archived", "archived_at", "created_at" FROM `routines`;--> statement-breakpoint +DROP TABLE `routines`;--> statement-breakpoint +ALTER TABLE `__new_routines` RENAME TO `routines`;--> statement-breakpoint +PRAGMA foreign_keys=ON;
\ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..b05dcb9 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,365 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9a66d36d-d7f6-42ef-9108-05fcfb21a1ce", + "prevId": "5489c6da-b9c9-4474-92f5-7dc94449677f", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groceries": { + "name": "groceries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groceries_name_unique": { + "name": "groceries_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "groceries_category_id_categories_id_fk": { + "name": "groceries_category_id_categories_id_fk", + "tableFrom": "groceries", + "tableTo": "categories", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "projects_name_unique": { + "name": "projects_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "references": { + "name": "references", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "grocery_id": { + "name": "grocery_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "references_grocery_id_groceries_id_fk": { + "name": "references_grocery_id_groceries_id_fk", + "tableFrom": "references", + "tableTo": "groceries", + "columnsFrom": ["grocery_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "routine_completions": { + "name": "routine_completions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "routine_id": { + "name": "routine_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "routine_version": { + "name": "routine_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done_at": { + "name": "done_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "routine_completions_routine_id_routine_version_routines_id_version_fk": { + "name": "routine_completions_routine_id_routine_version_routines_id_version_fk", + "tableFrom": "routine_completions", + "tableTo": "routines", + "columnsFrom": ["routine_id", "routine_version"], + "columnsTo": ["id", "version"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "routines": { + "name": "routines", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "routines_project_id_projects_id_fk": { + "name": "routines_project_id_projects_id_fk", + "tableFrom": "routines", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "routines_id_version_pk": { + "columns": ["id", "version"], + "name": "routines_id_version_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"routines\".\"frequency_days\"": "\"routines\".\"frequency\"" + } + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a91f988..9624f97 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1788810741865, "tag": "0005_curious_tomas", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1790747866343, + "tag": "0006_chunky_meteorite", + "breakpoints": true } ] } diff --git a/scripts/seed-sheet1-routines.ts b/scripts/seed-sheet1-routines.ts index 3e3a286..2e1df37 100644 --- a/scripts/seed-sheet1-routines.ts +++ b/scripts/seed-sheet1-routines.ts @@ -4,7 +4,7 @@ * What the photo shows: rows = tasks grouped by room, columns = ISO week * numbers of year 2026 (confirmed by user). An X = "done that week". * No explicit target frequencies are written anywhere, so every - * `frequencyDays` below is INFERRED from the gaps between X marks — each one + * `frequency` below is INFERRED from the gaps between X marks — each one * carries a `TODO(you)` comment with the observed weeks. Search for * `TODO(you)` and set the real intervals. * All routines live in the single 'Ménage' project; rooms are a 'Room/Task' @@ -42,7 +42,7 @@ import { createClient } from '@libsql/client'; type SeedRoutine = { name: string; // 'Room/Task' — slash prefix groups by room inside the single project - frequencyDays: number; + frequency: string; /** Last checked ISO week of 2026 read off the sheet; null = never checked. */ lastWeek2026: number | null; /** What the task covers (common-wisdom baseline, adapt freely). */ @@ -68,14 +68,14 @@ const ROUTINES: SeedRoutine[] = [ // ---- Ménage ---- { name: 'Aspirateur', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 37, description: 'Sols, tapis, plinthes et dessous des meubles. Vider le bac, nettoyer brosse et filtre quand l’aspiration baisse.', }, { name: 'Serpillère', - frequencyDays: 14, + frequency: '14d', lastWeek2026: 34, description: 'Après aspiration, serpillère bien essorée. Insister entrées et cuisine. Rincer la tête après usage.', @@ -83,7 +83,7 @@ const ROUTINES: SeedRoutine[] = [ // --- Bureau --- { name: 'Bureau/Vitres', - frequencyDays: 180, + frequency: '180d', lastWeek2026: 22, description: 'Cadres puis vitre, de haut en bas, par temps couvert. Rails et joints dépoussiérés.', @@ -91,13 +91,13 @@ const ROUTINES: SeedRoutine[] = [ // ---- Chambre rose ---- { name: 'Chambre rose/Draps', - frequencyDays: 14, + frequency: '14d', lastWeek2026: 31, description: 'Draps + taies changés, matelas aéré 30 min. Lavage à 60 °C.', }, { name: 'Chambre rose/Vitres', - frequencyDays: 180, + frequency: '180d', lastWeek2026: 22, description: 'Cadres puis vitre, de haut en bas, par temps couvert. Rails et joints dépoussiérés.', @@ -105,7 +105,7 @@ const ROUTINES: SeedRoutine[] = [ // --- Chambre verte --- { name: 'Chambre verte/Vitres', - frequencyDays: 180, + frequency: '180d', lastWeek2026: 22, description: 'Cadres puis vitre, de haut en bas, par temps couvert. Rails et joints dépoussiérés.', @@ -113,69 +113,69 @@ const ROUTINES: SeedRoutine[] = [ // ---- Cuisine ---- { name: 'Cuisine/Vitres', - frequencyDays: 180, + frequency: '180d', lastWeek2026: 22, description: 'Cadres puis vitre, de haut en bas, par temps couvert. Rails et joints dépoussiérés.', }, { name: 'Cuisine/Cafetière', - frequencyDays: 30, + frequency: '30d', lastWeek2026: 12, description: 'Détartrer puis rincer deux fois. Réservoir et porte-filtre lavés, filtre à eau selon notice.', }, { name: 'Cuisine/Lave-vaisselle', - frequencyDays: 30, + frequency: '30d', lastWeek2026: 32, description: 'Filtre et bras gicleurs nettoyés, joints essuyés, cycle vide à chaud.', }, { name: 'Cuisine/Placard', - frequencyDays: 90, + frequency: '90d', lastWeek2026: 19, description: 'Vider, trier dates, jeter les périmés, essuyer étagères. Lourds en bas.', }, { name: 'Cuisine/Four', - frequencyDays: 90, + frequency: '90d', lastWeek2026: 32, description: 'Pyrolyse ou produit avec temps de pose, vitre et lèchefrite à part. Grilles trempées au bicarbonate.', }, { name: 'Cuisine/Frigo', - frequencyDays: 30, + frequency: '30d', lastWeek2026: 22, description: 'Périmés jetés, clayettes lavées, température ~4 °C vérifiée, joints essuyés.', }, { name: 'Cuisine/Micro-ondes', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 32, description: 'Bol d’eau vinaigrée chauffé 3 à 5 min puis essuyage parois et plateau. Sans gratter à sec.', }, { name: 'Cuisine/Surfaces', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 32, description: 'Plan de travail et crédence dégraissés, rincés, séchés.', }, { name: 'Cuisine/Gazinière', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 32, description: 'Grilles et chapeaux trempés, plaque dégraissée, gicleurs débouchés si flamme irrégulière.', }, { name: 'Cuisine/Évier', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 32, description: 'Cuve et robinet détartrés et désinfectés, bonde et trop-plein nettoyés.', @@ -183,76 +183,76 @@ const ROUTINES: SeedRoutine[] = [ // ---- Toilettes ---- { name: 'Toilettes/Toilettes', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 34, description: 'Cuvette dedans et dehors, abattant et charnières, chasse et poignée, sol autour. Laisser agir le produit.', }, { name: 'Toilettes/Lavabo', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 34, description: 'Vasque et robinet, miroir, poubelle vidée, savon rechargé.', }, // ---- Salle de bain ---- { name: 'Salle de bain/Joints', - frequencyDays: 90, + frequency: '90d', lastWeek2026: 12, description: 'Brossés au bicarbonate + vinaigre, rincés, séchés. Aérer pour limiter les moisissures.', }, { name: 'Salle de bain/Calcaire', - frequencyDays: 14, + frequency: '14d', lastWeek2026: 34, description: 'Vinaigre sur paroi, robinetterie et pommeau (trempage), rinçage abondant.', }, { name: 'Salle de bain/Canalisations', - frequencyDays: 30, + frequency: '30d', lastWeek2026: 34, description: 'Bicarbonate + vinaigre + eau chaude, temps de pose. Pas de soude sauf bouchon avéré.', }, { name: 'Salle de bain/Porte-serviettes', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 34, description: 'Serviettes lavées, barre et crochets dépoussiérés et essuyés.', }, { name: 'Salle de bain/Baignoire', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 34, description: 'Parois et fond anti-calcaire, joints, bonde et trop-plein. Rincer et sécher.', }, { name: 'Salle de bain/Miroir', - frequencyDays: 14, + frequency: '14d', lastWeek2026: 34, description: 'Microfibre + eau vinaigrée, sans détremper cadre et bords.', }, { name: 'Salle de bain/Lavabo', - frequencyDays: 7, + frequency: '7d', lastWeek2026: 34, description: 'Vasque, robinet, bonde (cheveux), miroir, savon rechargé.', }, // ---- Salon ---- { name: 'Salon/Vitres', - frequencyDays: 180, + frequency: '180d', lastWeek2026: 22, description: 'Cadres puis vitre, de haut en bas, par temps couvert. Rails et joints dépoussiérés.', }, { name: 'Salon/Placards', - frequencyDays: 90, + frequency: '90d', lastWeek2026: null, // no marks → no completion description: 'Vider, trier (donner, jeter), dépoussiérer, ranger par usage.', @@ -260,7 +260,7 @@ const ROUTINES: SeedRoutine[] = [ // ---- Terrasse ---- { name: 'Terrasse/Sol', - frequencyDays: 60, + frequency: '60d', lastWeek2026: 25, description: 'Balayé puis lavé, joints désherbés, mobilier contrôlé selon saison.', @@ -306,11 +306,11 @@ for (const r of ROUTINES) { await client.execute({ sql: `INSERT INTO routines - (id, version, project_id, name, frequency_days, description, archived, archived_at, created_at) + (id, version, project_id, name, frequency, description, archived, archived_at, created_at) VALUES (?, 1, ?, ?, ?, ?, 0, NULL, ?)`, - args: [routineId, projectId, r.name, r.frequencyDays, r.description, now], + args: [routineId, projectId, r.name, r.frequency, r.description, now], }); - console.log(`add [${PROJECT}] ${r.name} every ${r.frequencyDays}d`); + console.log(`add [${PROJECT}] ${r.name} every ${r.frequency}`); createdRoutines++; } diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index a7fcd8f..b6404ce 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -50,7 +50,7 @@ export const routines = sqliteTable( .notNull() .references(() => projects.id, { onDelete: 'cascade' }), name: text('name').notNull(), - frequencyDays: integer('frequency_days').notNull(), + frequency: text('frequency').notNull(), description: text('description').notNull().default(''), archived: integer('archived', { mode: 'boolean' }).notNull().default(false), archivedAt: text('archived_at'), diff --git a/src/lib/server/services/routine-actions.ts b/src/lib/server/services/routine-actions.ts index 44ba2b8..1d7f8b5 100644 --- a/src/lib/server/services/routine-actions.ts +++ b/src/lib/server/services/routine-actions.ts @@ -1,7 +1,8 @@ import { fail, type ActionFailure } from '@sveltejs/kit'; import { archive, complete, edit, loadCurrent } from './routines'; -import { parseFormString, parseFrequencyDays } from './validation'; +import { parseFormString } from './validation'; import { instantFromDay } from '$lib/shared/temporal'; +import { Frequency } from '$lib/shared/frequency'; /** Shared routine edit/complete actions; rejects missing/archived routines. */ @@ -26,16 +27,20 @@ export async function editRoutineAction( return fail(400, { error: 'Le nom est requis' }); } - let frequencyDays: number; + let frequency: string; try { - frequencyDays = parseFrequencyDays(data.get('frequencyDays')); + frequency = Frequency.parse( + data.get('frequency')?.toString() ?? '', + ).toSpec(); } catch { return fail(400, { error: 'Fréquence invalide' }); } // Only touch description when the form sends one; list edits never wipe it. - const fields: { name: string; frequencyDays: number; description?: string } = - { name, frequencyDays }; + const fields: { name: string; frequency: string; description?: string } = { + name, + frequency, + }; const description = data.get('description'); if (description !== null) { fields.description = description.toString(); diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts index eda8824..2ad409a 100644 --- a/src/lib/server/services/routines.test.ts +++ b/src/lib/server/services/routines.test.ts @@ -24,46 +24,46 @@ describe('routines service', () => { }); it('creates a routine with version 1', async () => { - const r = await add(projectId, 'clean kitchen', 7, db); + const r = await add(projectId, 'clean kitchen', '7d', db); expect(r.id).toBe(1); expect(r.version).toBe(1); - expect(r.frequencyDays).toBe(7); + expect(r.frequency).toBe('7d'); }); it('edit inserts a new snapshot version and keeps history', async () => { - const r = await add(projectId, 'clean kitchen', 7, db); + const r = await add(projectId, 'clean kitchen', '7d', db); const updated = await edit( r.id, - { name: 'clean kitchen deeply', frequencyDays: 3 }, + { name: 'clean kitchen deeply', frequency: '3d' }, db, ); expect(updated.version).toBe(2); - expect(updated.frequencyDays).toBe(3); + expect(updated.frequency).toBe('3d'); const versions = await loadAllVersions(r.id, db); expect(versions).toHaveLength(2); - expect(versions[0].frequencyDays).toBe(7); - expect(versions[1].frequencyDays).toBe(3); + expect(versions[0].frequency).toBe('7d'); + expect(versions[1].frequency).toBe('3d'); }); it('new routines start with an empty description', async () => { - const r = await add(projectId, 'clean kitchen', 7, db); + const r = await add(projectId, 'clean kitchen', '7d', 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 r = await add(projectId, 'clean kitchen', '7d', 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.frequency).toBe('7d'); 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); + expect(renamed.frequency).toBe('7d'); - const refreq = await edit(r.id, { frequencyDays: 14 }, db); + const refreq = await edit(r.id, { frequency: '14d' }, db); expect(refreq.version).toBe(4); expect(refreq.name).toBe('clean the whole kitchen'); expect(refreq.description).toBe('wax on, wax off'); @@ -75,7 +75,7 @@ describe('routines service', () => { }); it('archive inserts an archived snapshot without removing history', async () => { - const r = await add(projectId, 'clean kitchen', 7, db); + const r = await add(projectId, 'clean kitchen', '7d', db); await archive(r.id, db); const versions = await loadAllVersions(r.id, db); expect(versions).toHaveLength(2); @@ -92,8 +92,8 @@ describe('routines service', () => { }); it('loadArchived lists archived routines; edits keep the archive date', async () => { - const a = await add(projectId, 'clean kitchen', 7, db); - await add(projectId, 'water plants', 3, db); + const a = await add(projectId, 'clean kitchen', '7d', db); + await add(projectId, 'water plants', '3d', db); await archive(a.id, db); const stamped = (await loadAllVersions(a.id, db))[1].archivedAt; expect(stamped).toBeTruthy(); @@ -105,13 +105,13 @@ describe('routines service', () => { const archived = await loadArchived(projectId, db); expect(archived.map((r) => r.id)).toEqual([a.id]); expect(archived[0].name).toBe('clean kitchen deeply'); - expect(archived[0].frequencyDays).toBe(7); + expect(archived[0].frequency).toBe('7d'); expect(archived[0].archivedAt).toBe(stamped); }); it('loadArchived sorts most recently archived first', async () => { - const a = await add(projectId, 'first', 1, db); - const b = await add(projectId, 'second', 1, db); + const a = await add(projectId, 'first', '1d', db); + const b = await add(projectId, 'second', '1d', db); await archive(a.id, db); await archive(b.id, db); @@ -126,8 +126,8 @@ 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, { frequencyDays: 5 }, db); + const r = await add(projectId, 'clean kitchen', '7d', db); + await edit(r.id, { frequency: '5d' }, db); const doneAt = Temporal.Now.instant().toString({ smallestUnit: 'millisecond', }); @@ -137,7 +137,7 @@ describe('routines service', () => { }); it('dashboard computes urgency and health from completions', async () => { - const r = await add(projectId, 'clean kitchen', 7, db); + const r = await add(projectId, 'clean kitchen', '7d', db); const created = Temporal.Instant.from(r.createdAt); await complete( r.id, @@ -157,9 +157,9 @@ describe('routines service', () => { }); it('overview aggregates per-project health and a global score', async () => { - await add(projectId, 'clean kitchen', 7, db); + await add(projectId, 'clean kitchen', '7d', db); const growth = await addProject('Personal growth', db); - await add(growth.id, 'read 30 minutes', 1, db); + await add(growth.id, 'read 30 minutes', '1d', db); const now = Temporal.Now.instant(); const overview = await loadOverview(now, db); diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts index 57611d6..b1e210b 100644 --- a/src/lib/server/services/routines.ts +++ b/src/lib/server/services/routines.ts @@ -23,7 +23,7 @@ export async function nextRoutineId(db: AppDatabase): Promise<number> { export async function add( projectId: number, name: string, - frequencyDays: number, + frequency: string, db = defaultDb, ): Promise<Routine> { const id = await nextRoutineId(db); @@ -34,7 +34,7 @@ export async function add( version: 1, projectId, name, - frequencyDays, + frequency, createdAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond', }), @@ -46,7 +46,7 @@ export async function add( /** Edit = insert a version+1 snapshot, copying unchanged fields forward. */ export async function edit( id: number, - fields: Partial<Pick<Routine, 'name' | 'frequencyDays' | 'description'>>, + fields: Partial<Pick<Routine, 'name' | 'frequency' | 'description'>>, db = defaultDb, ): Promise<Routine> { const [current] = await db @@ -64,7 +64,7 @@ export async function edit( version: current.version + 1, projectId: current.projectId, name: fields.name ?? current.name, - frequencyDays: fields.frequencyDays ?? current.frequencyDays, + frequency: fields.frequency ?? current.frequency, description: fields.description ?? current.description, archived: current.archived, archivedAt: current.archivedAt, @@ -96,7 +96,7 @@ export async function archive(id: number, db = defaultDb): Promise<void> { export interface ArchivedRoutine { id: number; name: string; - frequencyDays: number; + frequency: string; createdAt: string; archivedAt: string; } @@ -125,7 +125,7 @@ export async function loadArchived( .map((r) => ({ id: r.id, name: r.name, - frequencyDays: r.frequencyDays, + frequency: r.frequency, createdAt: r.createdAt, archivedAt: r.archivedAt ?? r.createdAt, })) diff --git a/src/lib/server/services/validation.ts b/src/lib/server/services/validation.ts index b0a515c..a8cebd9 100644 --- a/src/lib/server/services/validation.ts +++ b/src/lib/server/services/validation.ts @@ -1,4 +1,3 @@ -import { parseIntervalToDays } from '$lib/shared/routine-metrics'; import * as v from 'valibot'; export const IdSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); @@ -44,12 +43,3 @@ export function parseOptionalNumber( if (!Number.isFinite(n)) return null; return n; } - -/** Parses an interval like '2w' into days; throws on invalid format. */ -export function parseFrequencyDays(value: FormDataEntryValue | null): number { - const days = parseIntervalToDays(value?.toString() ?? ''); - if (days === null) { - throw new Error('Invalid interval'); - } - return days; -} diff --git a/src/lib/shared/frequency.test.ts b/src/lib/shared/frequency.test.ts new file mode 100644 index 0000000..e33dfd7 --- /dev/null +++ b/src/lib/shared/frequency.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { Frequency } from './frequency'; + +// Branding is compile-time: construction and forgery must not type-check. +// Checked by `npm run check`, inert at runtime. +function _assertUnforgeable(): void { + // @ts-expect-error — Frequency's constructor is private + new Frequency('5/2w'); + // @ts-expect-error — the private brand makes object literals non-assignable + const forged: Frequency = { count: 5, length: 2, unit: 'w' }; + void forged; +} +void _assertUnforgeable; + +describe('Frequency grammar', () => { + it('accepts interval specs', () => { + for (const spec of ['1d', '2w', '3m', '1y', '7d', '07d', '180d']) { + expect(Frequency.safeParse(spec)?.toSpec()).toBe(spec); + } + }); + + it('accepts quota specs with an explicit period length', () => { + for (const spec of ['5/2w', '5/1w', '1/1d', '2/3m', '1/1y', '5/14d']) { + expect(Frequency.safeParse(spec)?.toSpec()).toBe(spec); + } + }); + + it('normalizes case and surrounding whitespace only', () => { + expect(Frequency.safeParse(' 5/2W ')?.toSpec()).toBe('5/2w'); + expect(Frequency.safeParse('2W')?.toSpec()).toBe('2w'); + expect(Frequency.safeParse('07D')?.toSpec()).toBe('07d'); + }); + + it('rejects invalid specs', () => { + const invalid = [ + '', + '2', + 'w', + '2x', + '-1d', + '+2w', + '1.5d', + '0d', + '00d', + '5/w', + '5/14', + '5/2', + '2w/5', + '0/2w', + '5/0w', + '5 /2w', + '1/2/3w', + '2w', + ]; + for (const spec of invalid) { + expect(Frequency.safeParse(spec), spec).toBeNull(); + expect(() => Frequency.parse(spec), spec).toThrow(); + } + }); +}); + +describe('spec round-trip', () => { + it('keeps the spec exactly as written', () => { + for (const spec of ['5/2w', '2w', '07d', '5/02w', '1/1y']) { + expect(Frequency.parse(spec).toSpec()).toBe(spec); + } + }); +}); + +describe('parsed fields', () => { + it('extracts interval parts', () => { + const freq = Frequency.parse('2w'); + expect(freq.count).toBeNull(); + expect(freq.length).toBe(2); + expect(freq.unit).toBe('w'); + }); + + it('extracts quota parts', () => { + const freq = Frequency.parse('5/2w'); + expect(freq.count).toBe(5); + expect(freq.length).toBe(2); + expect(freq.unit).toBe('w'); + }); +}); + +describe('branding', () => { + it('returns branded instances', () => { + expect(Frequency.parse('5/2w')).toBeInstanceOf(Frequency); + expect(Frequency.safeParse('2w')).toBeInstanceOf(Frequency); + }); +}); + +describe('format', () => { + it('keeps day-based interval labels (legacy display)', () => { + expect(Frequency.parse('1d').format()).toBe('chaque jour'); + expect(Frequency.parse('7d').format()).toBe('chaque semaine'); + expect(Frequency.parse('1w').format()).toBe('chaque semaine'); + expect(Frequency.parse('30d').format()).toBe('chaque mois'); + expect(Frequency.parse('365d').format()).toBe('chaque année'); + expect(Frequency.parse('2w').format()).toBe('tous les 14 jours'); + expect(Frequency.parse('14d').format()).toBe('tous les 14 jours'); + expect(Frequency.parse('180d').format()).toBe('tous les 180 jours'); + }); + + it('labels quotas as completions per period', () => { + expect(Frequency.parse('5/2w').format()).toBe('5 fois par 2 semaines'); + expect(Frequency.parse('5/1w').format()).toBe('5 fois par semaine'); + expect(Frequency.parse('3/1d').format()).toBe('3 fois par jour'); + expect(Frequency.parse('2/3m').format()).toBe('2 fois par 3 mois'); + expect(Frequency.parse('1/1y').format()).toBe('1 fois par an'); + expect(Frequency.parse('4/2y').format()).toBe('4 fois par 2 ans'); + expect(Frequency.parse('5/14d').format()).toBe('5 fois par 14 jours'); + }); +}); + +describe('periodDays', () => { + it('extracts day counts for compute only', () => { + expect(Frequency.parse('1d').periodDays()).toBe(1); + expect(Frequency.parse('2w').periodDays()).toBe(14); + expect(Frequency.parse('5/2w').periodDays()).toBe(14); + expect(Frequency.parse('5/1m').periodDays()).toBe(30); + expect(Frequency.parse('2/3w').periodDays()).toBe(21); + expect(Frequency.parse('1y').periodDays()).toBe(365); + }); +}); diff --git a/src/lib/shared/frequency.ts b/src/lib/shared/frequency.ts new file mode 100644 index 0000000..0a82e41 --- /dev/null +++ b/src/lib/shared/frequency.ts @@ -0,0 +1,114 @@ +import * as v from 'valibot'; + +export type FrequencyUnit = 'd' | 'w' | 'm' | 'y'; + +/** Days per unit — the same approximation the interval parser has always used. */ +const UNIT_DAYS: Record<FrequencyUnit, number> = { d: 1, w: 7, m: 30, y: 365 }; + +const UNIT_LABELS: Record<FrequencyUnit, [singular: string, plural: string]> = { + d: ['jour', 'jours'], + w: ['semaine', 'semaines'], + m: ['mois', 'mois'], + y: ['an', 'ans'], +}; + +/** Day-based interval labels, identical to the legacy display. */ +const INTERVAL_LABELS: Record<number, string> = { + 1: 'chaque jour', + 7: 'chaque semaine', + 30: 'chaque mois', + 365: 'chaque année', +}; + +/** `2w` = every 2 weeks; `5/2w` = 5 completions per 2-week window. */ +const SPEC_RE = /^(?:(\d+)\/)?(\d+)([dwmy])$/; + +interface Parts { + /** Quota completions per window; null for interval specs. */ + count: number | null; + length: number; + unit: FrequencyUnit; +} + +function partsOf(spec: string): Parts | null { + const m = SPEC_RE.exec(spec); + if (!m) return null; + const rawCount: string | undefined = m[1]; + const length = Number(m[2]); + if (length < 1) return null; + if (rawCount !== undefined && Number(rawCount) < 1) return null; + return { + count: rawCount === undefined ? null : Number(rawCount), + length, + unit: m[3] as FrequencyUnit, + }; +} + +const SpecSchema = v.pipe( + v.string(), + v.trim(), + v.toLowerCase(), + v.check((spec) => partsOf(spec) !== null), +); + +/** + * Branded frequency value object: parsed values only come from `parse`/`safeParse` + * and are nominally distinct from raw spec strings. The spec text is the value; + * numeric extraction happens here for compute and display only. + */ +export class Frequency { + declare private readonly __brand: 'Frequency'; + + /** Quota completions per window; null for interval specs. */ + readonly count: number | null; + readonly length: number; + readonly unit: FrequencyUnit; + /** Normalized spec exactly as written (trim + lowercase only). */ + private readonly spec: string; + + private constructor(spec: string, parts: Parts) { + this.spec = spec; + this.count = parts.count; + this.length = parts.length; + this.unit = parts.unit; + } + + /** Parses a spec like `2w` or `5/2w`; throws when invalid. */ + static parse(input: string): Frequency { + const frequency = Frequency.safeParse(input); + if (frequency === null) { + throw new Error(`Invalid frequency spec: ${input}`); + } + return frequency; + } + + /** Parses a spec; null when invalid (live form validation). */ + static safeParse(input: string): Frequency | null { + const result = v.safeParse(SpecSchema, input); + if (!result.success) return null; + const parts = partsOf(result.output); + if (parts === null) return null; + return new Frequency(result.output, parts); + } + + /** Day count of one interval period / quota window. */ + periodDays(): number { + return this.length * UNIT_DAYS[this.unit]; + } + + /** The spec text, exactly as written. */ + toSpec(): string { + return this.spec; + } + + /** French label; interval labels stay day-based (legacy display). */ + format(): string { + const days = this.periodDays(); + if (this.count === null) { + return INTERVAL_LABELS[days] ?? `tous les ${days} jours`; + } + const [one, many] = UNIT_LABELS[this.unit]; + const period = this.length > 1 ? `${this.length} ${many}` : one; + return `${this.count} fois par ${period}`; + } +} diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index 5bc0a78..88d0498 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -4,7 +4,7 @@ import { computeHealthScore, computeHealthSeries, computeProjectHealthSeries, - parseIntervalToDays, + type HealthPoint, } from './routine-metrics'; const CREATED = '2026-09-01T00:00:00.000Z'; const created = Temporal.Instant.from(CREATED); @@ -24,10 +24,22 @@ const routine = { id: 1, version: 1, name: 'clean kitchen', - frequencyDays: 7, + frequency: '7d', createdAt: CREATED, }; +const quotaRoutine = { + id: 1, + version: 1, + name: 'hoover', + frequency: '5/2w', + createdAt: '2026-09-07T00:00:00.000Z', // windows: [09-07, 09-21), [09-21, 10-05), … +}; + +/** Completion inputs for routine 1 from ISO stamps. */ +const done = (...stamps: string[]) => + stamps.map((doneAt) => ({ routineId: 1, doneAt })); + describe('computeRoutineState', () => { it('is due immediately at creation when never done (created_at = due)', () => { const now = plusDays(created, 3); @@ -47,14 +59,14 @@ describe('computeRoutineState', () => { }); it('is due the calendar day after completion for a daily routine', () => { - const daily = { ...routine, frequencyDays: 1 }; + const daily = { ...routine, frequency: '1d' }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening const now = Temporal.Instant.from('2026-09-08T08:00:00.000Z'); // <24h later const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now); expect(state.status).toBe('due'); }); it('a daily done yesterday is late tomorrow, not today', () => { - const daily = { ...routine, frequencyDays: 1 }; + const daily = { ...routine, frequency: '1d' }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening const completions = [{ routineId: 1, doneAt }]; const today = computeRoutineState( @@ -72,7 +84,7 @@ describe('computeRoutineState', () => { }); it('is ok later the same day it was done for a daily routine', () => { - const daily = { ...routine, frequencyDays: 1 }; + const daily = { ...routine, frequency: '1d' }; const doneAt = '2026-09-08T08:00:00.000Z'; const now = Temporal.Instant.from('2026-09-08T20:00:00.000Z'); const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now); @@ -80,6 +92,154 @@ describe('computeRoutineState', () => { }); }); +describe('computeRoutineState (quota)', () => { + it('is ok at quota met within the window', () => { + const state = computeRoutineState( + quotaRoutine, + done( + '2026-09-07T08:00:00.000Z', + '2026-09-08T08:00:00.000Z', + '2026-09-09T08:00:00.000Z', + '2026-09-10T08:00:00.000Z', + '2026-09-11T08:00:00.000Z', + ), + Temporal.Instant.from('2026-09-12T12:00:00.000Z'), + ); + expect(state.status).toBe('ok'); + expect(state.health).toBe(100); + expect(state.overdueRatio).toBe(0); + expect(state.overdueDays).toBe(0); + expect(state.frequency).toBe('5/2w'); + expect(state.dueAt).toBe('2026-10-05T00:00:00.000Z'); + }); + + it('is due while the window is open and the quota unmet', () => { + const state = computeRoutineState( + quotaRoutine, + done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), + Temporal.Instant.from('2026-09-09T12:00:00.000Z'), + ); + expect(state.status).toBe('due'); + expect(state.health).toBe(91); // 2.5/14 of the window elapsed + expect(state.overdueDays).toBe(0); // deadline 09-21 still ahead + expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); + }); + + it('is late as soon as the window closes unmet', () => { + const state = computeRoutineState( + quotaRoutine, + done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), + Temporal.Instant.from('2026-09-21T00:05:00.000Z'), + ); + expect(state.status).toBe('late'); + expect(state.health).toBe(50); // one whole window behind + expect(state.overdueDays).toBe(0); + expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); + }); + + it('accumulates overdue days while behind', () => { + const state = computeRoutineState( + quotaRoutine, + done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), + Temporal.Instant.from('2026-09-22T12:00:00.000Z'), + ); + expect(state.status).toBe('late'); + expect(state.health).toBe(45); // 1.5/14 of the window elapsed behind + expect(state.overdueDays).toBe(1); + expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); + }); + + it('is very late after two closed unmet windows', () => { + const state = computeRoutineState( + quotaRoutine, + done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), + Temporal.Instant.from('2026-10-05T00:00:05.000Z'), + ); + expect(state.status).toBe('veryLate'); + expect(state.health).toBe(0); + expect(state.overdueDays).toBe(14); + expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); + }); + + it('quota met beats a previous window shortfall', () => { + const state = computeRoutineState( + quotaRoutine, + done( + '2026-09-08T08:00:00.000Z', + '2026-09-09T08:00:00.000Z', + '2026-09-10T08:00:00.000Z', + '2026-09-22T08:00:00.000Z', + '2026-09-23T08:00:00.000Z', + '2026-09-24T08:00:00.000Z', + '2026-09-25T08:00:00.000Z', + '2026-09-26T08:00:00.000Z', + ), + Temporal.Instant.from('2026-09-26T12:00:00.000Z'), + ); + expect(state.status).toBe('ok'); + expect(state.health).toBe(100); + expect(state.dueAt).toBe('2026-10-19T00:00:00.000Z'); + }); + + it('counts a completion at the window start in the new window', () => { + const state = computeRoutineState( + quotaRoutine, + done( + '2026-09-08T08:00:00.000Z', + '2026-09-09T08:00:00.000Z', + '2026-09-10T08:00:00.000Z', + '2026-09-11T08:00:00.000Z', + '2026-09-21T00:00:00.000Z', + ), + Temporal.Instant.from('2026-09-21T00:05:00.000Z'), + ); + // 4/5 in window 0 (missed); the fifth landed in window 1. + expect(state.status).toBe('late'); + expect(state.health).toBe(50); + }); + + it('counts a completion at the window end in the old window', () => { + const state = computeRoutineState( + quotaRoutine, + done( + '2026-09-08T08:00:00.000Z', + '2026-09-09T08:00:00.000Z', + '2026-09-10T08:00:00.000Z', + '2026-09-11T08:00:00.000Z', + '2026-09-20T23:59:59.999Z', + ), + Temporal.Instant.from('2026-09-21T00:05:00.000Z'), + ); + // 5/5 in window 0: window 1 starts unmet but not behind. + expect(state.status).toBe('due'); + expect(state.health).toBe(100); + expect(state.dueAt).toBe('2026-10-05T00:00:00.000Z'); + }); + + it('counts backdated completions in the window containing doneAt', () => { + const twoPerFortnight = { ...quotaRoutine, frequency: '2/2w' }; + const state = computeRoutineState( + twoPerFortnight, + done('2026-09-08T08:00:00.000Z', '2026-09-09T09:00:00.000Z'), + Temporal.Instant.from('2026-09-22T12:00:00.000Z'), + ); + // Window 0 is met (2/2) even though both were logged long after; + // window 1 is open and unmet. + expect(state.status).toBe('due'); + expect(state.health).toBe(95); + }); + + it('throws on a spec that cannot parse', () => { + expect(() => + computeRoutineState( + { ...quotaRoutine, frequency: '5/w' }, + [], + Temporal.Instant.from('2026-09-09T12:00:00.000Z'), + ), + ).toThrow('Invalid frequency spec'); + }); +}); + describe('computeHealthSeries', () => { it('decays from 100 at creation to 0 two periods overdue when never done', () => { const now = plusDays(created, 22); @@ -132,6 +292,40 @@ describe('computeHealthSeries', () => { }); }); +describe('computeHealthSeries (quota)', () => { + const healthOn = (series: HealthPoint[], iso: string) => + series.find((p) => p.at === Temporal.Instant.from(iso).epochMilliseconds) + ?.health; + + it('decays within a window, then drops a whole window per miss', () => { + const now = Temporal.Instant.from('2026-10-05T00:00:00.000Z'); + const series = computeHealthSeries(quotaRoutine, [], now); + expect(series[0].health).toBe(100); // window open at creation + expect(healthOn(series, '2026-09-14T00:00:00.000Z')).toBe(75); // half window + expect(healthOn(series, '2026-09-21T00:00:00.000Z')).toBe(50); // 1 window missed + expect(healthOn(series, '2026-09-28T00:00:00.000Z')).toBe(25); + expect(healthOn(series, '2026-10-05T00:00:00.000Z')).toBe(0); // 2 missed + }); + + it('resets to 100 once the quota is met', () => { + const now = Temporal.Instant.from('2026-09-13T00:00:00.000Z'); + const series = computeHealthSeries( + quotaRoutine, + done( + '2026-09-07T00:00:00.000Z', + '2026-09-08T00:00:00.000Z', + '2026-09-09T00:00:00.000Z', + '2026-09-10T00:00:00.000Z', + '2026-09-11T00:00:00.000Z', + ), + now, + ); + expect(healthOn(series, '2026-09-10T00:00:00.000Z')).toBe(89); // 4/5 + expect(healthOn(series, '2026-09-11T00:00:00.000Z')).toBe(100); // 5/5 + expect(healthOn(series, '2026-09-12T00:00:00.000Z')).toBe(100); // met + }); +}); + describe('computeProjectHealthSeries', () => { it('is empty with no routines', () => { expect(computeProjectHealthSeries([], [], created)).toEqual([]); @@ -171,26 +365,15 @@ describe('computeProjectHealthSeries', () => { const single = computeHealthSeries(routine, [], plusDays(created, 5)); expect(beforeLate?.health).toBe(single[single.length - 1].health); }); -}); - -describe('parseIntervalToDays', () => { - it('converts units to days', () => { - expect(parseIntervalToDays('1d')).toBe(1); - expect(parseIntervalToDays('2w')).toBe(14); - expect(parseIntervalToDays('3m')).toBe(90); - expect(parseIntervalToDays('1y')).toBe(365); - }); - - it('accepts whitespace and uppercase', () => { - expect(parseIntervalToDays(' 2W ')).toBe(14); - }); - it('rejects invalid input', () => { - expect(parseIntervalToDays('2')).toBeNull(); - expect(parseIntervalToDays('w')).toBeNull(); - expect(parseIntervalToDays('2x')).toBeNull(); - expect(parseIntervalToDays('-1d')).toBeNull(); - expect(parseIntervalToDays('')).toBeNull(); + it('averages quota routines with the same formula', () => { + const now = Temporal.Instant.from('2026-09-09T12:00:00.000Z'); + const series = computeProjectHealthSeries([quotaRoutine], [], now); + const states = [computeRoutineState(quotaRoutine, [], now)]; + const last = series[series.length - 1]; + expect(last.at).toBe(now.epochMilliseconds); + expect(last.health).toBe(computeHealthScore(states)); + expect(last.health).toBe(91); }); }); diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts index 4cff240..d3c1470 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -1,17 +1,24 @@ // Pure routine metrics shared by server and UI; ISO timestamps, UTC calendar. +import { Frequency } from './frequency'; + const UTC = 'UTC'; +/** Parse an ISO stamp, null when it is not a valid instant. */ +function rawInstant(value: string): Temporal.Instant | null { + try { + return Temporal.Instant.from(value); + } catch { + return null; + } +} + /** Parse an ISO stamp, falling back when it is not a valid instant. */ function parseInstant( value: string, fallback: Temporal.Instant, ): Temporal.Instant { - try { - return Temporal.Instant.from(value); - } catch { - return fallback; - } + return rawInstant(value) ?? fallback; } /** Millisecond-precision ISO stamp, byte-identical to Date.toISOString(). */ @@ -38,11 +45,9 @@ function parseCompletions( const mine: Temporal.Instant[] = []; for (const c of completions) { if (c.routineId !== routineId) continue; - try { - mine.push(Temporal.Instant.from(c.doneAt)); - } catch { - // Skip corrupt stamps instead of poisoning the anchor. - } + const t = rawInstant(c.doneAt); + // Skip corrupt stamps instead of poisoning the anchor. + if (t !== null) mine.push(t); } return mine.sort(Temporal.Instant.compare); } @@ -53,7 +58,8 @@ export interface RoutineInput { id: number; version: number; name: string; - frequencyDays: number; + /** Spec text ("7d" interval, "5/2w" quota); parsed at compute time. */ + frequency: string; createdAt: string; } @@ -66,13 +72,17 @@ export interface RoutineState { id: number; version: number; name: string; - frequencyDays: number; + /** Spec text, as stored. */ + frequency: string; lastDoneAt: string | null; - /** When the routine should next be done. */ + /** When the pending obligation must be done (window end for quotas). */ dueAt: string; - /** Days past dueAt (0 if not yet due). */ + /** Days past dueAt (0 while the deadline is still ahead). */ overdueDays: number; - /** Time past due, in units of the routine's own frequency (0 if not due). */ + /** + * Overdue severity in units of the routine's own period: interval = time + * past due; quota = trailing missed windows + elapsed window fraction. + */ overdueRatio: number; status: RoutineStatus; /** 100 on schedule, 50 one period overdue, 0 at two periods. */ @@ -84,14 +94,25 @@ export function computeRoutineState( completions: CompletionInput[], now: Temporal.Instant, ): RoutineState { - const lastDone = maxInstant(parseCompletions(routine.id, completions)); + const freq = Frequency.parse(routine.frequency); + const doneAt = parseCompletions(routine.id, completions); + const lastDone = maxInstant(doneAt); + if (freq.count !== null) { + const days = freq.periodDays(); + const origin = parseInstant(routine.createdAt, now) + .toZonedDateTimeISO(UTC) + .startOfDay(); + const q = quotaProgress(freq.count, days, doneAt, now, origin.toInstant()); + return quotaState(routine, freq.count, days, q, now, lastDone, origin); + } + const days = freq.periodDays(); const reference = lastDone ?? parseInstant(routine.createdAt, now); - const period = periodMs(routine.frequencyDays); + const period = periodMs(days); // Calendar-day anchor: the period starts at the reference day's midnight. const due = reference .toZonedDateTimeISO(UTC) .startOfDay() - .add({ days: lastDone === null ? 0 : routine.frequencyDays }) + .add({ days: lastDone === null ? 0 : days }) .toInstant(); const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period; const overdueRatio = Math.max(0, rawRatio); @@ -108,10 +129,105 @@ export function computeRoutineState( id: routine.id, version: routine.version, name: routine.name, - frequencyDays: routine.frequencyDays, + frequency: routine.frequency, + lastDoneAt: lastDone !== null ? iso(lastDone) : null, + dueAt: iso(due), + overdueDays: Math.floor(overdueRatio * days), + overdueRatio, + status, + health: Math.max(0, Math.round(100 - overdueRatio * 50)), + }; +} + +interface QuotaProgress { + /** Completions inside the current window (by doneAt; backdating-safe). */ + done: number; + /** Consecutive unmet windows ending just before the current one. */ + missed: number; + /** Elapsed fraction of the current window. */ + elapsed: number; + /** Index of the current window since creation day. */ + window: number; +} + +/** + * Routine-anchored quota windows: window j spans + * [startOfDay(createdAt) + j·days, +days). A completion counts toward the + * window containing its doneAt; completions before creation count toward + * window 0 and future-dated ones are ignored. + */ +function quotaProgress( + quota: number, + days: number, + doneAt: Temporal.Instant[], + t: Temporal.Instant, + origin: Temporal.Instant, +): QuotaProgress { + const period = periodMs(days); + const window = Math.max( + 0, + Math.floor((t.epochMilliseconds - origin.epochMilliseconds) / period), + ); + const counts = new Map<number, number>(); + for (const d of doneAt) { + if (Temporal.Instant.compare(d, t) > 0) continue; + const j = Math.max( + 0, + Math.floor((d.epochMilliseconds - origin.epochMilliseconds) / period), + ); + counts.set(j, (counts.get(j) ?? 0) + 1); + } + let missed = 0; + for (let j = window - 1; j >= 0; j--) { + if ((counts.get(j) ?? 0) >= quota) break; + missed++; + } + return { + done: counts.get(window) ?? 0, + missed, + elapsed: + (t.epochMilliseconds - origin.epochMilliseconds - window * period) / + period, + window, + }; +} + +function quotaState( + routine: RoutineInput, + quota: number, + days: number, + q: QuotaProgress, + now: Temporal.Instant, + lastDone: Temporal.Instant | null, + origin: Temporal.ZonedDateTime, +): RoutineState { + const met = q.done >= quota; + const overdueRatio = met ? 0 : Math.max(0, q.missed + q.elapsed); + const status: RoutineStatus = met + ? 'ok' + : overdueRatio < 1 + ? 'due' + : overdueRatio < 2 + ? 'late' + : 'veryLate'; + // Deadline of the pending obligation: the oldest unmet window's end while + // behind, the current window's end while catching up, the next one when met. + const due = origin + .add({ + days: (met ? q.window + 2 : q.window - q.missed + 1) * days, + }) + .toInstant(); + return { + id: routine.id, + version: routine.version, + name: routine.name, + frequency: routine.frequency, lastDoneAt: lastDone !== null ? iso(lastDone) : null, dueAt: iso(due), - overdueDays: Math.floor(overdueRatio * routine.frequencyDays), + overdueDays: Math.max( + 0, + Math.floor((now.epochMilliseconds - due.epochMilliseconds) / periodMs(1)), + ), overdueRatio, status, health: Math.max(0, Math.round(100 - overdueRatio * 50)), @@ -149,12 +265,37 @@ function healthAtPoint( return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 50)); } +/** Health at t: interval anchor formula or quota window formula. */ +function healthAt( + freq: Frequency, + doneAt: Temporal.Instant[], + t: Temporal.Instant, + created: Temporal.Instant, +): number { + if (freq.count !== null) { + const origin = created.toZonedDateTimeISO(UTC).startOfDay().toInstant(); + const q = quotaProgress(freq.count, freq.periodDays(), doneAt, t, origin); + const overdueRatio = + q.done >= freq.count ? 0 : Math.max(0, q.missed + q.elapsed); + return Math.max(0, Math.round(100 - overdueRatio * 50)); + } + let reference = created; + let neverDone = true; + for (const d of doneAt) { + if (Temporal.Instant.compare(d, t) > 0) break; + reference = d; + neverDone = false; + } + return healthAtPoint(reference, freq.periodDays(), t, neverDone); +} + /** Daily health samples: 100 at completion, decaying while overdue. */ export function computeHealthSeries( routine: RoutineInput, completions: CompletionInput[], now: Temporal.Instant, ): HealthPoint[] { + const freq = Frequency.parse(routine.frequency); const start = parseInstant(routine.createdAt, now); const doneAt = parseCompletions(routine.id, completions); @@ -163,36 +304,23 @@ export function computeHealthSeries( return [{ at: now.epochMilliseconds, health: 100 }]; } - // Reference anchor: latest completion at-or-before the sampled instant. - let reference = start; - let nextCompletion = 0; - const sampleAt = (t: Temporal.Instant): number => { - while ( - nextCompletion < doneAt.length && - Temporal.Instant.compare(doneAt[nextCompletion], t) <= 0 - ) { - reference = doneAt[nextCompletion]; - nextCompletion++; - } - return healthAtPoint( - reference, - routine.frequencyDays, - t, - nextCompletion === 0, - ); - }; - for ( let t = start; Temporal.Instant.compare(t, now) <= 0; t = t.toZonedDateTimeISO(UTC).add({ days: 1 }).toInstant() ) { - points.push({ at: t.epochMilliseconds, health: sampleAt(t) }); + points.push({ + at: t.epochMilliseconds, + health: healthAt(freq, doneAt, t, start), + }); } // Final unaligned sample so the curve ends at the present instant. if (points[points.length - 1].at < now.epochMilliseconds) { - points.push({ at: now.epochMilliseconds, health: sampleAt(now) }); + points.push({ + at: now.epochMilliseconds, + health: healthAt(freq, doneAt, now, start), + }); } return points; } @@ -208,48 +336,38 @@ export function computeProjectHealthSeries( const completionsByRoutine = new Map<number, Temporal.Instant[]>(); for (const c of completions) { if (!ids.has(c.routineId)) continue; - try { - const t = Temporal.Instant.from(c.doneAt); - completionsByRoutine.set(c.routineId, [ - ...(completionsByRoutine.get(c.routineId) ?? []), - t, - ]); - } catch { - // Skip corrupt stamps instead of poisoning the anchor. - } + const t = rawInstant(c.doneAt); + if (t === null) continue; // Skip corrupt stamps instead of poisoning the anchor. + completionsByRoutine.set(c.routineId, [ + ...(completionsByRoutine.get(c.routineId) ?? []), + t, + ]); } for (const times of completionsByRoutine.values()) times.sort(Temporal.Instant.compare); - const createdOrNow = (r: RoutineInput): Temporal.Instant => - parseInstant(r.createdAt, now); + const parsed = routines.map((r) => ({ + born: rawInstant(r.createdAt), + freq: Frequency.parse(r.frequency), + created: parseInstant(r.createdAt, now), + doneAt: completionsByRoutine.get(r.id) ?? [], + })); + // Mean per-routine health across the routines alive at t. const sampleAt = (t: Temporal.Instant): number | null => { - const alive = routines.filter((r) => { - try { - return ( - Temporal.Instant.compare(Temporal.Instant.from(r.createdAt), t) <= 0 - ); - } catch { - return false; - } - }); + const alive = parsed.filter( + (p) => p.born !== null && Temporal.Instant.compare(p.born, t) <= 0, + ); if (alive.length === 0) return null; - const total = alive.reduce((sum, r) => { - const doneAt = (completionsByRoutine.get(r.id) ?? []).filter( - (d) => Temporal.Instant.compare(d, t) <= 0, - ); - const anchor = - doneAt.length > 0 ? (maxInstant(doneAt) ?? now) : createdOrNow(r); - return ( - sum + healthAtPoint(anchor, r.frequencyDays, t, doneAt.length === 0) - ); - }, 0); + const total = alive.reduce( + (sum, p) => sum + healthAt(p.freq, p.doneAt, t, p.created), + 0, + ); return Math.round(total / alive.length); }; - const start = routines - .map(createdOrNow) + const start = parsed + .map((p) => p.created) .reduce((a, b) => (Temporal.Instant.compare(a, b) <= 0 ? a : b)); if (Temporal.Instant.compare(now, start) <= 0) return [{ at: now.epochMilliseconds, health: 100 }]; @@ -272,27 +390,6 @@ export function computeProjectHealthSeries( return points; } -export function formatFrequency(days: number): string { - if (days === 1) return 'chaque jour'; - if (days === 7) return 'chaque semaine'; - if (days === 30) return 'chaque mois'; - if (days === 365) return 'chaque année'; - return `tous les ${days} jours`; -} - -const UNIT_DAYS: Record<string, number> = { d: 1, w: 7, m: 30, y: 365 }; - -/** Parses "1d | 2w | 3m | 1y" into days; returns null when invalid. */ -export function parseIntervalToDays(input: string): number | null { - const match = input - .trim() - .toLowerCase() - .match(/^(\d+)([dwmy])$/); - if (!match) return null; - const days = Number(match[1]) * UNIT_DAYS[match[2]]; - return days >= 1 ? days : null; -} - export const STATUS_LABELS: Record<RoutineStatus, string> = { ok: 'À jour', due: 'À faire', diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts index 3321bcd..83d7571 100644 --- a/src/routes/projects/[id]/+page.server.ts +++ b/src/routes/projects/[id]/+page.server.ts @@ -9,11 +9,8 @@ import { loadArchived, } from '$lib/server/services/routines'; import { completeRoutineAction } from '$lib/server/services/routine-actions'; -import { - numericId, - parseFormString, - parseFrequencyDays, -} from '$lib/server/services/validation'; +import { numericId, parseFormString } from '$lib/server/services/validation'; +import { Frequency } from '$lib/shared/frequency'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ params }) => { @@ -44,7 +41,7 @@ export const actions: Actions = { await add( numericId(params.id), name, - parseFrequencyDays(data.get('frequencyDays')), + Frequency.parse(data.get('frequency')?.toString() ?? '').toSpec(), ); } catch { return fail(400, { error: 'Fréquence invalide' }); diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte index 7d9a146..f93e018 100644 --- a/src/routes/projects/[id]/+page.svelte +++ b/src/routes/projects/[id]/+page.svelte @@ -6,11 +6,8 @@ import StatusBadge from '$lib/components/StatusBadge.svelte'; import Button from '$lib/components/Button.svelte'; import Row from '$lib/components/Row.svelte'; - import { - formatFrequency, - parseIntervalToDays, - type RoutineState, - } from '$lib/shared/routine-metrics'; + import type { RoutineState } from '$lib/shared/routine-metrics'; + import { Frequency } from '$lib/shared/frequency'; import SectionTitle from '$lib/components/SectionTitle.svelte'; import { formatDayMonth } from '$lib/shared/temporal'; import { filterByName } from '$lib/shared/filter-items'; @@ -20,7 +17,7 @@ let name = $state(''); let frequency = $state(''); - const frequencyDays = $derived(parseIntervalToDays(frequency)); + const freq = $derived(Frequency.safeParse(frequency)); let showArchived = $state(false); let archiveFilter = $state(''); @@ -83,25 +80,25 @@ /> <input class="form-input frequency-input" - class:input-invalid={frequency !== '' && frequencyDays === null} + class:input-invalid={frequency !== '' && freq === null} type="text" - name="frequencyDays" + name="frequency" bind:value={frequency} - placeholder="2w" + placeholder="2w, 5/2w" autocomplete="off" - aria-label="Intervalle (ex: 1d, 2w, 3m)" + aria-label="Fréquence (ex : 2w, 5/2w)" /> <Button variant="accent" - disabled={!name.trim() || frequencyDays === null} + disabled={!name.trim() || freq === null} ariaLabel="Ajouter" > + </Button> </form> - {#if frequency !== '' && frequencyDays === null} - <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p> + {#if frequency !== '' && freq === null} + <p class="error">Fréquence invalide — ex : 1d, 2w, 5/2w</p> {/if} {#if form?.error} @@ -136,7 +133,7 @@ >{routine.name}</a > <span class="routine-meta"> - {formatFrequency(routine.frequencyDays)} + {Frequency.parse(routine.frequency).format()} {#if routine.lastDoneAt} · fait le {formatDayMonth(routine.lastDoneAt)} {:else} @@ -184,7 +181,7 @@ >{routine.name}</a > <span class="archived-date"> - {formatFrequency(routine.frequencyDays)} · archivée le + {Frequency.parse(routine.frequency).format()} · archivée le {formatDayMonth(routine.archivedAt)} </span> </Row> diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte index 359fd5b..bd74b90 100644 --- a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte @@ -5,11 +5,8 @@ import StatusBadge from '$lib/components/StatusBadge.svelte'; import Button from '$lib/components/Button.svelte'; import Row from '$lib/components/Row.svelte'; - import { - formatFrequency, - parseIntervalToDays, - computeHealthSeries, - } from '$lib/shared/routine-metrics'; + import { computeHealthSeries } from '$lib/shared/routine-metrics'; + import { Frequency } from '$lib/shared/frequency'; import { formatDayMonth, formatDayMonthYear } from '$lib/shared/temporal'; import SectionTitle from '$lib/components/SectionTitle.svelte'; let { data, form } = $props(); @@ -31,7 +28,7 @@ }); $effect(() => { name = data.routine.name; - frequency = `${data.routine.frequencyDays}d`; + frequency = data.routine.frequency; description = data.routine.description; }); $effect(() => { @@ -41,7 +38,7 @@ return () => clearTimeout(t); } }); - const frequencyDays = $derived(parseIntervalToDays(frequency)); + const freq = $derived(Frequency.safeParse(frequency)); const frozenAt = $derived( data.routine.archived ? Temporal.Instant.from(data.routine.archivedAt ?? data.routine.createdAt) @@ -99,16 +96,16 @@ /> </label> <label> - <span class="form-label">Intervalle</span> + <span class="form-label">Fréquence</span> <input class="form-input frequency" - class:input-invalid={frequency !== '' && frequencyDays === null} + class:input-invalid={frequency !== '' && freq === null} type="text" - name="frequencyDays" + name="frequency" bind:value={frequency} - placeholder="2w" + placeholder="2w, 5/2w" autocomplete="off" - aria-label="Intervalle (ex: 1d, 2w, 3m)" + aria-label="Fréquence (ex : 2w, 5/2w)" /> </label> <label> @@ -122,11 +119,11 @@ autocomplete="off" ></textarea> </label> - {#if frequency !== '' && frequencyDays === null} - <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p> + {#if frequency !== '' && freq === null} + <p class="error">Fréquence invalide — ex : 1d, 2w, 5/2w</p> {/if} <Button - disabled={!name.trim() || frequencyDays === null} + disabled={!name.trim() || freq === null} style="position: relative; padding-right: 2.75rem;" > <span>Enregistrer</span> @@ -151,7 +148,7 @@ overdueDays={data.state.overdueDays} /> <span class="chart-meta"> - {formatFrequency(data.routine.frequencyDays)} + {Frequency.parse(data.routine.frequency).format()} {#if data.state.lastDoneAt} · fait le {formatDayMonth(data.state.lastDoneAt)} {:else} |
