diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 11:32:41 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 11:32:41 +0200 |
| commit | 18a0f9c874aaa09c0f71252dad853c23f9511ed6 (patch) | |
| tree | 93103b83f6d0057c40f0bab9c9473bf04292e9c4 | |
| parent | 113415ce357a9cea1759d0452ebae6352f8fe36b (diff) | |
Add projects with routines, urgency tracking and health scores
Projects group recurring routines (chores included) with an append-only
snapshot table (id, version) so edits never rewrite history; completions
reference the exact snapshot. Pure metrics compute due dates, urgency
buckets (ok/due/late/veryLate) and 0-100 health scores per routine,
project, and globally. Home page lists projects; project dashboard sorts
routines most-urgent first with one-tap completion and interval parser
inputs (1d/2w/3m/1y).
| -rw-r--r-- | drizzle/0003_mixed_captain_midlands.sql | 27 | ||||
| -rw-r--r-- | drizzle/meta/0003_snapshot.json | 348 | ||||
| -rw-r--r-- | drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | src/lib/server/db/schema.ts | 50 | ||||
| -rw-r--r-- | src/lib/server/services/projects.ts | 38 | ||||
| -rw-r--r-- | src/lib/server/services/routines.test.ts | 93 | ||||
| -rw-r--r-- | src/lib/server/services/routines.ts | 192 | ||||
| -rw-r--r-- | src/lib/server/services/types.ts | 12 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 105 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 113 | ||||
| -rw-r--r-- | src/routes/+page.server.ts | 20 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 144 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.server.ts | 102 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.svelte | 395 |
14 files changed, 1640 insertions, 6 deletions
diff --git a/drizzle/0003_mixed_captain_midlands.sql b/drizzle/0003_mixed_captain_midlands.sql new file mode 100644 index 0000000..af499d3 --- /dev/null +++ b/drizzle/0003_mixed_captain_midlands.sql @@ -0,0 +1,27 @@ +CREATE TABLE `projects` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `archived` integer DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `projects_name_unique` ON `projects` (`name`);--> statement-breakpoint +CREATE TABLE `routine_completions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `routine_id` integer NOT NULL, + `routine_version` integer NOT NULL, + `done_at` text NOT NULL, + `note` text, + FOREIGN KEY (`routine_id`,`routine_version`) REFERENCES `routines`(`id`,`version`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `routines` ( + `id` integer NOT NULL, + `version` integer NOT NULL, + `project_id` integer NOT NULL, + `name` text NOT NULL, + `frequency_days` integer NOT NULL, + `archived` integer DEFAULT false NOT NULL, + `created_at` text NOT NULL, + PRIMARY KEY(`id`, `version`), + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..f55b2d3 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,348 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "abfcf814-414d-4ed9-9203-c9841a0aa5d7", + "prevId": "9fa05004-a34b-48ee-80c5-c97293f55154", + "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_days": { + "name": "frequency_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 08d6c41..870d2f7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1785314243051, "tag": "0002_rainy_wind_dancer", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1788771513039, + "tag": "0003_mixed_captain_midlands", + "breakpoints": true } ] } diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 31c2f65..23e10f5 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,4 +1,11 @@ -import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; +import { + sqliteTable, + text, + integer, + real, + primaryKey, + foreignKey, +} from 'drizzle-orm/sqlite-core'; export const categories = sqliteTable('categories', { id: integer('id').primaryKey({ autoIncrement: true }), @@ -26,3 +33,44 @@ export const references = sqliteTable('references', { unit: text('unit'), qualityScore: integer('quality_score'), }); + +export const projects = sqliteTable('projects', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + archived: integer('archived', { mode: 'boolean' }).notNull().default(false), +}); + +// Append-only snapshot table: an edit inserts a new row with version+1, the +// current routine is the row with the max version for its id. +export const routines = sqliteTable( + 'routines', + { + id: integer('id').notNull(), + version: integer('version').notNull(), + projectId: integer('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + frequencyDays: integer('frequency_days').notNull(), + archived: integer('archived', { mode: 'boolean' }).notNull().default(false), + createdAt: text('created_at').notNull(), + }, + (t) => [primaryKey({ columns: [t.id, t.version] })], +); + +export const routineCompletions = sqliteTable( + 'routine_completions', + { + id: integer('id').primaryKey({ autoIncrement: true }), + routineId: integer('routine_id').notNull(), + routineVersion: integer('routine_version').notNull(), + doneAt: text('done_at').notNull(), + note: text('note'), + }, + (t) => [ + foreignKey({ + columns: [t.routineId, t.routineVersion], + foreignColumns: [routines.id, routines.version], + }).onDelete('cascade'), + ], +); diff --git a/src/lib/server/services/projects.ts b/src/lib/server/services/projects.ts new file mode 100644 index 0000000..b58c2f1 --- /dev/null +++ b/src/lib/server/services/projects.ts @@ -0,0 +1,38 @@ +import { db as defaultDb } from '$lib/server/db'; +import { projects } from '$lib/server/db/schema'; +import { asc, eq } from 'drizzle-orm'; +import type { AppDatabase } from './groceries'; +import type { Project } from './types'; + +export async function loadAll(db = defaultDb): Promise<Project[]> { + return db + .select() + .from(projects) + .where(eq(projects.archived, false)) + .orderBy(asc(projects.name)); +} + +export async function add(name: string, db = defaultDb): Promise<Project> { + const [inserted] = await db.insert(projects).values({ name }).returning(); + return inserted; +} + +export async function rename( + id: number, + name: string, + db = defaultDb, +): Promise<void> { + await db.update(projects).set({ name }).where(eq(projects.id, id)); +} + +export async function archive(id: number, db = defaultDb): Promise<void> { + await db.update(projects).set({ archived: true }).where(eq(projects.id, id)); +} + +export async function getById( + id: number, + db = defaultDb, +): Promise<Project | undefined> { + const [project] = await db.select().from(projects).where(eq(projects.id, id)); + return project; +} diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts new file mode 100644 index 0000000..9e1f429 --- /dev/null +++ b/src/lib/server/services/routines.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from '$lib/server/db/test-helper'; +import { add as addProject } from './projects'; +import { + add, + edit, + archive, + complete, + loadAllVersions, + loadProjectDashboard, + loadOverview, +} from './routines'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('routines service', () => { + let db: Awaited<ReturnType<typeof createTestDb>>['db']; + let projectId: number; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + const project = await addProject('Household', db); + projectId = project.id; + }); + + it('creates a routine with version 1', async () => { + const r = await add(projectId, 'clean kitchen', 7, db); + expect(r.id).toBe(1); + expect(r.version).toBe(1); + expect(r.frequencyDays).toBe(7); + }); + + it('edit inserts a new snapshot version and keeps history', async () => { + const r = await add(projectId, 'clean kitchen', 7, db); + const updated = await edit(r.id, 'clean kitchen deeply', 3, db); + expect(updated.version).toBe(2); + expect(updated.frequencyDays).toBe(3); + + const versions = await loadAllVersions(r.id, db); + expect(versions).toHaveLength(2); + expect(versions[0].frequencyDays).toBe(7); + expect(versions[1].frequencyDays).toBe(3); + }); + + it('archive inserts an archived snapshot without removing history', async () => { + const r = await add(projectId, 'clean kitchen', 7, db); + await archive(r.id, db); + const versions = await loadAllVersions(r.id, db); + expect(versions).toHaveLength(2); + expect(versions[0].archived).toBe(false); + expect(versions[1].archived).toBe(true); + + const dashboard = await loadProjectDashboard(projectId, Date.now(), db); + expect(dashboard.routines).toHaveLength(0); + expect(dashboard.healthScore).toBe(100); + }); + + it('complete records the snapshot version it was made against', async () => { + const r = await add(projectId, 'clean kitchen', 7, db); + await edit(r.id, 'clean kitchen', 5, db); + const doneAt = new Date().toISOString(); + const completion = await complete(r.id, 'with vinegar', doneAt, db); + expect(completion.routineVersion).toBe(2); + expect(completion.note).toBe('with vinegar'); + }); + + it('dashboard computes urgency and health from completions', async () => { + const r = await add(projectId, 'clean kitchen', 7, db); + const createdAt = Date.parse(r.createdAt); + await complete(r.id, null, new Date(createdAt).toISOString(), db); + + // 15 days after completion with a 7-day routine => very late + const now = createdAt + 15 * DAY_MS; + const dashboard = await loadProjectDashboard(projectId, now, db); + expect(dashboard.routines).toHaveLength(1); + expect(dashboard.routines[0].status).toBe('veryLate'); + expect(dashboard.healthScore).toBe(0); + }); + + it('overview aggregates per-project health and a global score', async () => { + await add(projectId, 'clean kitchen', 7, db); + const growth = await addProject('Personal growth', db); + await add(growth.id, 'read 30 minutes', 1, db); + + const now = Date.now(); + const overview = await loadOverview(now, db); + expect(overview.projects).toHaveLength(2); + expect(overview.globalScore).toBe( + Math.round(overview.projects.reduce((s, p) => s + p.healthScore, 0) / 2), + ); + }); +}); diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts new file mode 100644 index 0000000..e9e0c45 --- /dev/null +++ b/src/lib/server/services/routines.ts @@ -0,0 +1,192 @@ +import { db as defaultDb } from '$lib/server/db'; +import { routines, routineCompletions, projects } from '$lib/server/db/schema'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import type { AppDatabase } from './groceries'; +import type { Routine, RoutineCompletion } from './types'; +import { + computeRoutineState, + computeHealthScore, + type RoutineState, +} from '$lib/shared/routine-metrics'; + +// ponytail: next routine id via MAX(id)+1, safe enough for a single-user app; +// switch to a dedicated id table or transaction if concurrent writes appear. + +export async function nextRoutineId(db: AppDatabase): Promise<number> { + const [{ maxId }] = await db + .select({ maxId: sql<number>`COALESCE(MAX(${routines.id}), 0)` }) + .from(routines); + return (maxId ?? 0) + 1; +} + +export async function add( + projectId: number, + name: string, + frequencyDays: number, + db = defaultDb, +): Promise<Routine> { + const id = await nextRoutineId(db); + const [inserted] = await db + .insert(routines) + .values({ + id, + version: 1, + projectId, + name, + frequencyDays, + createdAt: new Date().toISOString(), + }) + .returning(); + return inserted; +} + +/** Edit = insert a new snapshot row with version+1; history stays intact. */ +export async function edit( + id: number, + name: string, + frequencyDays: number, + db = defaultDb, +): Promise<Routine> { + const [current] = await db + .select() + .from(routines) + .where(eq(routines.id, id)) + .orderBy(sql`${routines.version} DESC`) + .limit(1); + if (!current) throw new Error('Routine not found'); + + const [inserted] = await db + .insert(routines) + .values({ + id: current.id, + version: current.version + 1, + projectId: current.projectId, + name, + frequencyDays, + archived: current.archived, + createdAt: current.createdAt, + }) + .returning(); + return inserted; +} + +export async function archive(id: number, db = defaultDb): Promise<void> { + const [current] = await db + .select() + .from(routines) + .where(eq(routines.id, id)) + .orderBy(sql`${routines.version} DESC`) + .limit(1); + if (!current) throw new Error('Routine not found'); + + await db.insert(routines).values({ + ...current, + version: current.version + 1, + archived: true, + }); +} + +export async function loadAllVersions( + routineId: number, + db = defaultDb, +): Promise<Routine[]> { + return db + .select() + .from(routines) + .where(eq(routines.id, routineId)) + .orderBy(routines.version); +} + +export async function complete( + routineId: number, + note: string | null, + doneAt: string, + db = defaultDb, +): Promise<RoutineCompletion> { + const [current] = await db + .select() + .from(routines) + .where(eq(routines.id, routineId)) + .orderBy(sql`${routines.version} DESC`) + .limit(1); + if (!current) throw new Error('Routine not found'); + + const [inserted] = await db + .insert(routineCompletions) + .values({ + routineId: current.id, + routineVersion: current.version, + doneAt, + note, + }) + .returning(); + return inserted; +} + +export interface ProjectDashboard { + routines: RoutineState[]; + healthScore: number; +} + +export async function loadProjectDashboard( + projectId: number, + now: number, + db = defaultDb, +): Promise<ProjectDashboard> { + const versions = await db + .select() + .from(routines) + .where(eq(routines.projectId, projectId)); + + // Keep only the max-version row per routine id, then drop archived ones: + // an archived routine's max version is archived, so it disappears. + const current = new Map<number, Routine>(); + for (const r of versions) { + const existing = current.get(r.id); + if (!existing || r.version > existing.version) current.set(r.id, r); + } + const routines_ = [...current.values()].filter((r) => !r.archived); + if (routines_.length === 0) return { routines: [], healthScore: 100 }; + + const completions = await db + .select() + .from(routineCompletions) + .where( + inArray( + routineCompletions.routineId, + routines_.map((r) => r.id), + ), + ); + + const states = routines_.map((r) => computeRoutineState(r, completions, now)); + return { routines: states, healthScore: computeHealthScore(states) }; +} + +export interface ProjectsOverview { + projects: { project: { id: number; name: string }; healthScore: number }[]; + globalScore: number; +} + +export async function loadOverview( + now: number, + db = defaultDb, +): Promise<ProjectsOverview> { + const activeProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.archived, false)); + + const entries = await Promise.all( + activeProjects.map(async (p) => ({ + project: p, + healthScore: (await loadProjectDashboard(p.id, now, db)).healthScore, + })), + ); + + return { + projects: entries, + globalScore: computeHealthScore( + entries.map((e) => ({ health: e.healthScore })), + ), + }; +} diff --git a/src/lib/server/services/types.ts b/src/lib/server/services/types.ts index e5e3918..aa4817a 100644 --- a/src/lib/server/services/types.ts +++ b/src/lib/server/services/types.ts @@ -1,6 +1,16 @@ import type { InferSelectModel } from 'drizzle-orm'; -import type { categories, groceries, references } from '$lib/server/db/schema'; +import type { + categories, + groceries, + references, + projects, + routines, + routineCompletions, +} from '$lib/server/db/schema'; export type Category = InferSelectModel<typeof categories>; export type Grocery = InferSelectModel<typeof groceries>; export type Reference = InferSelectModel<typeof references>; +export type Project = InferSelectModel<typeof projects>; +export type Routine = InferSelectModel<typeof routines>; +export type RoutineCompletion = InferSelectModel<typeof routineCompletions>; diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts new file mode 100644 index 0000000..4a84eda --- /dev/null +++ b/src/lib/shared/routine-metrics.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { + computeRoutineState, + computeHealthScore, + parseIntervalToDays, +} from './routine-metrics'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const CREATED = '2026-09-01T00:00:00.000Z'; + +const routine = { + id: 1, + version: 1, + name: 'clean kitchen', + frequencyDays: 7, + createdAt: CREATED, +}; + +describe('computeRoutineState', () => { + it('is ok and healthy before the due date', () => { + const now = Date.parse(CREATED) + 3 * DAY_MS; + const state = computeRoutineState(routine, [], now); + expect(state.status).toBe('ok'); + expect(state.overdueDays).toBe(0); + expect(state.health).toBe(100); + expect(state.dueAt).toBe( + new Date(Date.parse(CREATED) + 7 * DAY_MS).toISOString(), + ); + }); + + it('uses the last completion as reference once done', () => { + const doneAt = new Date(Date.parse(CREATED) + 5 * DAY_MS).toISOString(); + const now = Date.parse(doneAt) + 2 * DAY_MS; + const state = computeRoutineState(routine, [{ routineId: 1, doneAt }], now); + expect(state.lastDoneAt).toBe(doneAt); + expect(state.status).toBe('ok'); + }); + + it('is due at exactly the frequency', () => { + const now = Date.parse(CREATED) + 7 * DAY_MS; + const state = computeRoutineState(routine, [], now); + expect(state.status).toBe('due'); + expect(state.health).toBe(100); + }); + + it('is very urgent (veryLate) when two weeks old for a weekly routine', () => { + const now = Date.parse(CREATED) + 15 * DAY_MS; + const state = computeRoutineState(routine, [], now); + expect(state.status).toBe('veryLate'); + expect(state.overdueDays).toBe(8); + expect(state.health).toBe(0); + }); + + it('is late when overdue by half a frequency', () => { + const now = Date.parse(CREATED) + 10.5 * DAY_MS; + const state = computeRoutineState(routine, [], now); + expect(state.status).toBe('late'); + }); + + it('health of 75 means a quarter of the period overdue', () => { + const now = Date.parse(CREATED) + 7 * DAY_MS + 1.75 * DAY_MS; + const state = computeRoutineState(routine, [], now); + expect(state.health).toBe(75); + }); +}); + +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(); + }); +}); + +describe('computeHealthScore', () => { + it('is 100 with no routines', () => { + expect(computeHealthScore([])).toBe(100); + }); + + it('averages routine healths', () => { + const now = Date.parse(CREATED) + 8 * DAY_MS; + const a = computeRoutineState(routine, [], now); // overdue 1 day + const b = computeRoutineState( + routine, + [], + Date.parse(CREATED) + 3 * DAY_MS, + ); + expect(computeHealthScore([a, b])).toBe( + Math.round((a.health + b.health) / 2), + ); + }); +}); diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts new file mode 100644 index 0000000..2ac245e --- /dev/null +++ b/src/lib/shared/routine-metrics.ts @@ -0,0 +1,113 @@ +// Pure routine metrics, shared by server services and UI. No db access. +// All timestamps are ISO strings; all math is done in ms then converted to days. + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type RoutineStatus = 'ok' | 'due' | 'late' | 'veryLate'; + +export interface RoutineInput { + id: number; + version: number; + name: string; + frequencyDays: number; + createdAt: string; +} + +export interface CompletionInput { + routineId: number; + doneAt: string; +} + +export interface RoutineState { + id: number; + version: number; + name: string; + frequencyDays: number; + lastDoneAt: string | null; + /** When the routine should next be done. */ + dueAt: string; + /** Days past dueAt (0 if not yet due). */ + overdueDays: number; + /** Time past due, in units of the routine's own frequency (0 if not due). */ + overdueRatio: number; + status: RoutineStatus; + /** 100 on schedule, 0 when overdue by a full frequency, floor at 0. */ + health: number; +} + +export function computeRoutineState( + routine: RoutineInput, + completions: CompletionInput[], + now: number, +): RoutineState { + const mine = completions + .filter((c) => c.routineId === routine.id) + .map((c) => Date.parse(c.doneAt)); + const lastDoneMs = mine.length > 0 ? Math.max(...mine) : null; + const createdMs = Date.parse(routine.createdAt); + const referenceMs = + lastDoneMs ?? (Number.isFinite(createdMs) ? createdMs : now); + const periodMs = routine.frequencyDays * DAY_MS; + const dueMs = referenceMs + periodMs; + const rawRatio = (now - dueMs) / periodMs; + const overdueRatio = Math.max(0, rawRatio); + // Buckets on overdue time in units of the routine's own frequency: + // not due / due now / late (half a period overdue) / very late (a full + // period overdue — a weekly routine two weeks old lands here). + const status: RoutineStatus = + rawRatio < 0 + ? 'ok' + : overdueRatio < 0.5 + ? 'due' + : overdueRatio < 1 + ? 'late' + : 'veryLate'; + + return { + id: routine.id, + version: routine.version, + name: routine.name, + frequencyDays: routine.frequencyDays, + lastDoneAt: lastDoneMs !== null ? new Date(lastDoneMs).toISOString() : null, + dueAt: new Date(dueMs).toISOString(), + overdueDays: Math.floor(overdueRatio * routine.frequencyDays), + overdueRatio, + status, + health: Math.max(0, Math.round(100 - overdueRatio * 100)), + }; +} + +/** Average health across routines; 100 when there is nothing to do. */ +export function computeHealthScore(states: { health: number }[]): number { + if (states.length === 0) return 100; + const total = states.reduce((sum, s) => sum + s.health, 0); + return Math.round(total / states.length); +} + +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', + late: 'En retard', + veryLate: 'Très en retard', +}; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..fc160df --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,20 @@ +import { fail } from '@sveltejs/kit'; +import { loadOverview } from '$lib/server/services/routines'; +import { add as addProject } from '$lib/server/services/projects'; +import { parseFormString } from '$lib/server/services/validation'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return loadOverview(Date.now()); +}; + +export const actions: Actions = { + addProject: async ({ request }) => { + const name = parseFormString((await request.formData()).get('name')); + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + await addProject(name); + return { success: true }; + }, +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5bdee46..4d4777a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,4 +1,140 @@ -<p>Accueil — à venir : projets et routines.</p> -<ul> - <li><a href="/groceries">Courses</a></li> -</ul> +<script lang="ts"> + import { enhance } from '$app/forms'; + + let { data, form } = $props(); + + let name = $state(''); + + function scoreClass(score: number): string { + if (score >= 80) return 'score score--good'; + if (score >= 50) return 'score score--mid'; + return 'score score--bad'; + } +</script> + +<main> + <header class="page-header"> + <h1>Projets</h1> + <span class={scoreClass(data.globalScore)}>{data.globalScore}</span> + </header> + + <form class="cat-add-bar" method="POST" action="?/addProject" use:enhance> + <input + class="form-input" + type="text" + name="name" + bind:value={name} + placeholder="Nouveau projet..." + autocomplete="off" + /> + <button class="btn-accent" type="submit" aria-label="Ajouter">+</button> + </form> + + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + + <ul class="item-list"> + {#each data.projects as entry (entry.project.id)} + <li class="item-row item-row--card project-row"> + <a class="project-link" href="/projects/{entry.project.id}" + >{entry.project.name}</a + > + <span class={scoreClass(entry.healthScore)}>{entry.healthScore}</span> + </li> + {/each} + </ul> + + {#if data.projects.length === 0} + <p class="empty">Aucun projet</p> + {/if} +</main> + +<style> + .page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 0; + } + + .project-row { + padding: 0.875rem 1rem; + } + + .project-link { + flex-grow: 1; + font-weight: 500; + color: var(--text); + text-decoration: none; + } + + .project-link:hover { + color: var(--accent); + } + + .score { + flex-shrink: 0; + min-width: 2.75rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + text-align: center; + font-size: 0.875rem; + font-weight: 700; + color: var(--white); + } + + .score--good { + background: var(--success); + } + + .score--mid { + background: var(--accent); + } + + .score--bad { + background: var(--danger); + } + + .cat-add-bar { + display: flex; + gap: 0.5rem; + padding: 0.75rem 0; + } + + .cat-add-bar input { + flex: 1; + background: var(--surface); + } + + .btn-accent { + width: 3rem; + height: 3rem; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 0.75rem; + background: var(--accent); + color: var(--white); + font-size: 1.25rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); + } + + .btn-accent:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px var(--shadow-accent-lg); + } + + .empty { + margin-top: 1rem; + padding: 2rem; + } +</style> diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts new file mode 100644 index 0000000..b20af6c --- /dev/null +++ b/src/routes/projects/[id]/+page.server.ts @@ -0,0 +1,102 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { getById } from '$lib/server/services/projects'; +import { + add, + edit, + archive, + complete, + loadProjectDashboard, +} from '$lib/server/services/routines'; +import { numericId, parseFormString } from '$lib/server/services/validation'; +import { parseIntervalToDays } from '$lib/shared/routine-metrics'; +import type { Actions, PageServerLoad } from './$types'; + +function parseFrequencyDays(value: FormDataEntryValue | null): number { + const raw = value?.toString() ?? ''; + const days = parseIntervalToDays(raw); + if (days === null) { + throw new Error('Invalid interval'); + } + return days; +} + +export const load: PageServerLoad = async ({ params }) => { + const id = numericId(params.id); + const project = await getById(id); + if (!project || project.archived) { + throw error(404, 'Projet introuvable'); + } + const dashboard = await loadProjectDashboard(id, Date.now()); + return { + project: { id: project.id, name: project.name }, + routines: dashboard.routines, + healthScore: dashboard.healthScore, + }; +}; + +export const actions: Actions = { + addRoutine: async ({ request, params }) => { + const data = await request.formData(); + const name = parseFormString(data.get('name')); + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + try { + await add( + numericId(params.id), + name, + parseFrequencyDays(data.get('frequencyDays')), + ); + } catch { + return fail(400, { error: 'Fréquence invalide' }); + } + return { success: true }; + }, + + editRoutine: async ({ request }) => { + const data = await request.formData(); + const name = parseFormString(data.get('name')); + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + try { + await edit( + numericId(data.get('id')), + name, + parseFrequencyDays(data.get('frequencyDays')), + ); + } catch { + return fail(400, { error: 'Modification invalide' }); + } + return { success: true }; + }, + + complete: async ({ request }) => { + const data = await request.formData(); + try { + await complete( + numericId(data.get('id')), + parseFormString(data.get('note')), + new Date().toISOString(), + ); + } catch { + return fail(400, { error: 'Routine introuvable' }); + } + return { success: true }; + }, + + archiveRoutine: async ({ request }) => { + const data = await request.formData(); + try { + await archive(numericId(data.get('id'))); + } catch { + return fail(400, { error: 'Routine introuvable' }); + } + return { success: true }; + }, + + archiveProject: async ({ params }) => { + await archive(numericId(params.id)); + throw redirect(303, '/'); + }, +}; diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte new file mode 100644 index 0000000..d9747a9 --- /dev/null +++ b/src/routes/projects/[id]/+page.svelte @@ -0,0 +1,395 @@ +<script lang="ts"> + import { enhance } from '$app/forms'; + import { + formatFrequency, + parseIntervalToDays, + STATUS_LABELS, + type RoutineState, + } from '$lib/shared/routine-metrics'; + + let { data, form } = $props(); + + let name = $state(''); + let frequency = $state(''); + let editingId = $state<number | null>(null); + let editName = $state(''); + let editFrequency = $state(''); + + const frequencyDays = $derived(parseIntervalToDays(frequency)); + const editFrequencyDays = $derived(parseIntervalToDays(editFrequency)); + + const dateFormatter = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'short', + }); + + function sorted(routines: RoutineState[]): RoutineState[] { + return [...routines].sort( + (a, b) => b.overdueRatio - a.overdueRatio || a.name.localeCompare(b.name), + ); + } + + function startEdit(routine: RoutineState) { + editingId = routine.id; + editName = routine.name; + editFrequency = `${routine.frequencyDays}d`; + } + + function scoreClass(score: number): string { + if (score >= 80) return 'score score--good'; + if (score >= 50) return 'score score--mid'; + return 'score score--bad'; + } + + function statusClass(status: RoutineState['status']): string { + if (status === 'ok') return 'status status--ok'; + if (status === 'due') return 'status status--due'; + if (status === 'late') return 'status status--late'; + return 'status status--very-late'; + } +</script> + +<main> + <header class="page-header"> + <h1>{data.project.name}</h1> + <span class={scoreClass(data.healthScore)}>{data.healthScore}</span> + </header> + + <form + class="cat-add-bar" + method="POST" + action="?/addRoutine" + use:enhance={() => { + return async ({ update }) => { + name = ''; + frequency = ''; + await update({ reset: false }); + }; + }} + > + <input + class="form-input" + type="text" + name="name" + bind:value={name} + placeholder="Nouvelle routine..." + autocomplete="off" + /> + <input + class="form-input frequency-input" + class:input-invalid={frequency !== '' && frequencyDays === null} + type="text" + name="frequencyDays" + bind:value={frequency} + placeholder="2w" + autocomplete="off" + aria-label="Intervalle (ex: 1d, 2w, 3m)" + /> + <button + class="btn-accent" + type="submit" + disabled={!name.trim() || frequencyDays === null} + aria-label="Ajouter" + > + + + </button> + </form> + + {#if frequency !== '' && frequencyDays === null} + <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p> + {/if} + + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + + <ul class="item-list"> + {#each sorted(data.routines) as routine (routine.id)} + <li class="item-row item-row--card routine-row"> + {#if editingId === routine.id} + <form + class="edit-form" + method="POST" + action="?/editRoutine" + use:enhance={() => { + return async ({ result, update }) => { + if (result.type === 'failure') { + // Keep the edit form open so the error is visible. + await update({ reset: false }); + return; + } + editingId = null; + await update({ reset: false }); + }; + }} + > + {#if form?.error} + <p class="error edit-error">{form.error}</p> + {/if} + <input type="hidden" name="id" value={routine.id} /> + <input + class="form-input" + type="text" + name="name" + bind:value={editName} + /> + <input + class="form-input frequency-input" + class:input-invalid={editFrequency !== '' && + editFrequencyDays === null} + type="text" + name="frequencyDays" + bind:value={editFrequency} + placeholder="2w" + autocomplete="off" + aria-label="Intervalle (ex: 1d, 2w, 3m)" + /> + <button + class="btn-primary" + type="submit" + disabled={!editName.trim() || editFrequencyDays === null} + > + OK + </button> + <button + class="btn-ghost" + type="button" + onclick={() => (editingId = null)}>✕</button + > + </form> + {:else} + <div class="routine-info"> + <span class="routine-name">{routine.name}</span> + <span class="routine-meta"> + {formatFrequency(routine.frequencyDays)} + {#if routine.lastDoneAt} + · fait le {dateFormatter.format(new Date(routine.lastDoneAt))} + {:else} + · jamais fait + {/if} + </span> + </div> + <span class={statusClass(routine.status)}> + {STATUS_LABELS[routine.status]} + {#if routine.overdueDays > 0}({routine.overdueDays} + j){/if} + </span> + <form method="POST" action="?/complete" use:enhance> + <input type="hidden" name="id" value={routine.id} /> + <button class="btn-done" type="submit">Fait</button> + </form> + <button + class="btn-ghost" + type="button" + aria-label="Modifier" + onclick={() => startEdit(routine)}>✎</button + > + <form method="POST" action="?/archiveRoutine" use:enhance> + <input type="hidden" name="id" value={routine.id} /> + <button class="btn-ghost" type="submit" aria-label="Archiver"> + × + </button> + </form> + {/if} + </li> + {/each} + </ul> + + {#if data.routines.length === 0} + <p class="empty">Aucune routine</p> + {/if} + + <footer class="page-footer"> + <form method="POST" action="?/archiveProject" use:enhance> + <button class="btn-archive" type="submit">Archiver le projet</button> + </form> + </footer> +</main> + +<style> + .page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 0; + } + + .routine-row { + flex-wrap: wrap; + padding: 0.75rem 1rem; + } + + .routine-info { + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.2; + } + + .routine-name { + font-weight: 500; + } + + .routine-meta { + font-size: 0.75rem; + color: var(--text-secondary); + } + + .edit-form { + display: flex; + flex-grow: 1; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + } + + .edit-error { + width: 100%; + } + + .edit-form .form-input { + flex: 1; + padding: 0.5rem 0.75rem; + } + + .frequency-input { + width: 4.5rem; + flex-shrink: 0; + } + + .input-invalid { + border-color: var(--danger); + } + + .status { + flex-shrink: 0; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + } + + .status--ok { + background: var(--success); + color: var(--white); + } + + .status--due { + background: var(--shadow-accent); + color: var(--accent); + } + + .status--late { + background: var(--accent); + color: var(--white); + } + + .status--very-late { + background: var(--danger); + color: var(--white); + } + + .btn-done { + flex-shrink: 0; + padding: 0.5rem 0.875rem; + border: none; + border-radius: 0.625rem; + background: var(--success); + color: var(--white); + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + } + + .btn-done:hover { + filter: brightness(1.05); + transform: translateY(-1px); + } + + .score { + flex-shrink: 0; + min-width: 2.75rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + text-align: center; + font-size: 0.875rem; + font-weight: 700; + color: var(--white); + } + + .score--good { + background: var(--success); + } + + .score--mid { + background: var(--accent); + } + + .score--bad { + background: var(--danger); + } + + .cat-add-bar { + display: flex; + gap: 0.5rem; + padding: 0.75rem 0; + } + + .cat-add-bar input:first-child { + flex: 1; + background: var(--surface); + min-width: 0; + } + + .btn-accent { + width: 3rem; + height: 3rem; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 0.75rem; + background: var(--accent); + color: var(--white); + font-size: 1.25rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); + } + + .btn-accent:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px var(--shadow-accent-lg); + } + + .page-footer { + margin-top: 2rem; + display: flex; + justify-content: center; + } + + .btn-archive { + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 0.875rem; + text-decoration: underline; + cursor: pointer; + } + + .btn-archive:hover { + color: var(--danger); + } + + .empty { + margin-top: 1rem; + padding: 2rem; + } +</style> |
