aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--drizzle/0003_mixed_captain_midlands.sql27
-rw-r--r--drizzle/meta/0003_snapshot.json348
-rw-r--r--drizzle/meta/_journal.json7
-rw-r--r--src/lib/server/db/schema.ts50
-rw-r--r--src/lib/server/services/projects.ts38
-rw-r--r--src/lib/server/services/routines.test.ts93
-rw-r--r--src/lib/server/services/routines.ts192
-rw-r--r--src/lib/server/services/types.ts12
-rw-r--r--src/lib/shared/routine-metrics.test.ts105
-rw-r--r--src/lib/shared/routine-metrics.ts113
-rw-r--r--src/routes/+page.server.ts20
-rw-r--r--src/routes/+page.svelte144
-rw-r--r--src/routes/projects/[id]/+page.server.ts102
-rw-r--r--src/routes/projects/[id]/+page.svelte395
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">
+ &times;
+ </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>