aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--drizzle/0006_chunky_meteorite.sql19
-rw-r--r--drizzle/meta/0006_snapshot.json365
-rw-r--r--drizzle/meta/_journal.json7
-rw-r--r--scripts/seed-sheet1-routines.ts66
-rw-r--r--src/lib/server/db/schema.ts2
-rw-r--r--src/lib/server/services/routine-actions.ts15
-rw-r--r--src/lib/server/services/routines.test.ts46
-rw-r--r--src/lib/server/services/routines.ts12
-rw-r--r--src/lib/server/services/validation.ts10
-rw-r--r--src/lib/shared/frequency.test.ts125
-rw-r--r--src/lib/shared/frequency.ts114
-rw-r--r--src/lib/shared/routine-metrics.test.ts231
-rw-r--r--src/lib/shared/routine-metrics.ts285
-rw-r--r--src/routes/projects/[id]/+page.server.ts9
-rw-r--r--src/routes/projects/[id]/+page.svelte27
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte29
17 files changed, 1134 insertions, 236 deletions
diff --git a/README.md b/README.md
index 924ee2a..34c3f51 100644
--- a/README.md
+++ b/README.md
@@ -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}