From 4efed80a2f8dc238b420eadf97d56c3eccaadb76 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 18 Oct 2026 22:22:55 +0200 Subject: refactor: inject the database into every service Groceries, projects, references and routines become classes holding the Drizzle handle, wired once in services/index.ts alongside categories. Tests build their own instance on an in-memory database. Drops the unused projects.loadAll and projects.rename. --- src/lib/server/services/routines.ts | 443 +++++++++++++++++------------------- 1 file changed, 213 insertions(+), 230 deletions(-) (limited to 'src/lib/server/services/routines.ts') diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts index 809d3f3..0114296 100644 --- a/src/lib/server/services/routines.ts +++ b/src/lib/server/services/routines.ts @@ -1,4 +1,3 @@ -import { db as defaultDb } from '$lib/server/db'; import { routines, routineCompletions, projects } from '$lib/server/db/schema'; import { eq, inArray, desc, sql } from 'drizzle-orm'; import type { AppDatabase } from '$lib/server/db/types'; @@ -11,88 +10,6 @@ import { type HealthPoint, } from '$lib/shared/routine-metrics'; -// ponytail: next routine id via MAX(id)+1; fine while single-user. - -export async function nextRoutineId(db: AppDatabase): Promise { - const [{ maxId }] = await db - .select({ maxId: sql`COALESCE(MAX(${routines.id}), 0)` }) - .from(routines); - return (maxId ?? 0) + 1; -} - -export async function add( - projectId: number, - name: string, - frequency: string, - db = defaultDb, -): Promise { - const id = await nextRoutineId(db); - const [inserted] = await db - .insert(routines) - .values({ - id, - version: 1, - projectId, - name, - frequency, - createdAt: Temporal.Now.instant().toString({ - smallestUnit: 'millisecond', - }), - }) - .returning(); - return inserted; -} - -/** Edit = insert a version+1 snapshot, copying unchanged fields forward. */ -export async function edit( - id: number, - fields: Partial>, - db = defaultDb, -): Promise { - 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: fields.name ?? current.name, - frequency: fields.frequency ?? current.frequency, - description: fields.description ?? current.description, - archived: current.archived, - archivedAt: current.archivedAt, - createdAt: current.createdAt, - }) - .returning(); - return inserted; -} - -export async function archive(id: number, db = defaultDb): Promise { - 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, - archivedAt: Temporal.Now.instant().toString({ - smallestUnit: 'millisecond', - }), - }); -} - export interface ArchivedRoutine { id: number; name: string; @@ -101,168 +18,234 @@ export interface ArchivedRoutine { archivedAt: string; } -/** Archived routines of a project, most recently archived first. */ -export async function loadArchived( - projectId: number, - db = defaultDb, -): Promise { - const versions = await db - .select() - .from(routines) - .where(eq(routines.projectId, projectId)); - - // Max-version row per routine id; archived = max snapshot archived. - const current = new Map(); - for (const r of versions) { - const existing = current.get(r.id); - if (!existing || r.version > existing.version) current.set(r.id, r); - } - return ( - [...current.values()] - .filter((r) => r.archived) - // Rows archived before archived_at existed fall back to creation date. - .sort((a, b) => ((a.archivedAt ?? '') < (b.archivedAt ?? '') ? 1 : -1)) - .map((r) => ({ - id: r.id, - name: r.name, - frequency: r.frequency, - createdAt: r.createdAt, - archivedAt: r.archivedAt ?? r.createdAt, - })) - ); +export interface ProjectDashboard { + routines: RoutineState[]; + healthScore: number; + healthSeries: HealthPoint[]; } -export async function loadAllVersions( - routineId: number, - db = defaultDb, -): Promise { - return db - .select() - .from(routines) - .where(eq(routines.id, routineId)) - .orderBy(routines.version); +export interface ProjectsOverview { + projects: { project: { id: number; name: string }; healthScore: number }[]; + globalScore: number; } -/** Current (max-version) snapshot of a routine, or undefined when unknown. */ -export async function loadCurrent( - routineId: number, - db = defaultDb, -): Promise { - const [row] = await db - .select() - .from(routines) - .where(eq(routines.id, routineId)) - .orderBy(sql`${routines.version} DESC`) - .limit(1); - return row; -} +export class RoutinesService { + constructor(private readonly db: AppDatabase) {} -export async function loadCompletions( - routineId: number, - db = defaultDb, -): Promise { - return db - .select() - .from(routineCompletions) - .where(eq(routineCompletions.routineId, routineId)) - .orderBy(desc(routineCompletions.doneAt)); -} + // ponytail: next routine id via MAX(id)+1; fine while single-user. + private async nextRoutineId(): Promise { + const [{ maxId }] = await this.db + .select({ maxId: sql`COALESCE(MAX(${routines.id}), 0)` }) + .from(routines); + return (maxId ?? 0) + 1; + } -export async function complete( - routineId: number, - note: string | null, - doneAt: string, - db = defaultDb, -): Promise { - 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'); + async add( + projectId: number, + name: string, + frequency: string, + ): Promise { + const id = await this.nextRoutineId(); + const [inserted] = await this.db + .insert(routines) + .values({ + id, + version: 1, + projectId, + name, + frequency, + createdAt: Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }), + }) + .returning(); + return inserted; + } - const [inserted] = await db - .insert(routineCompletions) - .values({ - routineId: current.id, - routineVersion: current.version, - doneAt, - note, - }) - .returning(); - return inserted; -} + /** Edit = insert a version+1 snapshot, copying unchanged fields forward. */ + async edit( + id: number, + fields: Partial>, + ): Promise { + const [current] = await this.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 this.db + .insert(routines) + .values({ + id: current.id, + version: current.version + 1, + projectId: current.projectId, + name: fields.name ?? current.name, + frequency: fields.frequency ?? current.frequency, + description: fields.description ?? current.description, + archived: current.archived, + archivedAt: current.archivedAt, + createdAt: current.createdAt, + }) + .returning(); + return inserted; + } -export interface ProjectDashboard { - routines: RoutineState[]; - healthScore: number; - healthSeries: HealthPoint[]; -} + async archive(id: number): Promise { + const [current] = await this.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 this.db.insert(routines).values({ + ...current, + version: current.version + 1, + archived: true, + archivedAt: Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }), + }); + } -export async function loadProjectDashboard( - projectId: number, - now: Temporal.Instant, - db = defaultDb, -): Promise { - const versions = await db - .select() - .from(routines) - .where(eq(routines.projectId, projectId)); + /** Archived routines of a project, most recently archived first. */ + async loadArchived(projectId: number): Promise { + const versions = await this.db + .select() + .from(routines) + .where(eq(routines.projectId, projectId)); + + // Max-version row per routine id; archived = max snapshot archived. + const current = new Map(); + for (const r of versions) { + const existing = current.get(r.id); + if (!existing || r.version > existing.version) current.set(r.id, r); + } + return ( + [...current.values()] + .filter((r) => r.archived) + // Rows archived before archived_at existed fall back to creation date. + .sort((a, b) => ((a.archivedAt ?? '') < (b.archivedAt ?? '') ? 1 : -1)) + .map((r) => ({ + id: r.id, + name: r.name, + frequency: r.frequency, + createdAt: r.createdAt, + archivedAt: r.archivedAt ?? r.createdAt, + })) + ); + } - // Max-version row per routine id, then drop archived ones. - const current = new Map(); - for (const r of versions) { - const existing = current.get(r.id); - if (!existing || r.version > existing.version) current.set(r.id, r); + async loadAllVersions(routineId: number): Promise { + return this.db + .select() + .from(routines) + .where(eq(routines.id, routineId)) + .orderBy(routines.version); } - const routines_ = [...current.values()].filter((r) => !r.archived); - if (routines_.length === 0) - return { routines: [], healthScore: 100, healthSeries: [] }; - const completions = await db - .select() - .from(routineCompletions) - .where( - inArray( - routineCompletions.routineId, - routines_.map((r) => r.id), - ), - ); + /** Current (max-version) snapshot of a routine, or undefined when unknown. */ + async loadCurrent(routineId: number): Promise { + const [row] = await this.db + .select() + .from(routines) + .where(eq(routines.id, routineId)) + .orderBy(sql`${routines.version} DESC`) + .limit(1); + return row; + } - const states = routines_.map((r) => computeRoutineState(r, completions, now)); - return { - routines: states, - healthScore: computeHealthScore(states), - healthSeries: computeProjectHealthSeries(routines_, completions, now), - }; -} + async loadCompletions(routineId: number): Promise { + return this.db + .select() + .from(routineCompletions) + .where(eq(routineCompletions.routineId, routineId)) + .orderBy(desc(routineCompletions.doneAt)); + } -export interface ProjectsOverview { - projects: { project: { id: number; name: string }; healthScore: number }[]; - globalScore: number; -} + async complete( + routineId: number, + note: string | null, + doneAt: string, + ): Promise { + const [current] = await this.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 this.db + .insert(routineCompletions) + .values({ + routineId: current.id, + routineVersion: current.version, + doneAt, + note, + }) + .returning(); + return inserted; + } -export async function loadOverview( - now: Temporal.Instant, - db = defaultDb, -): Promise { - const activeProjects = await db - .select({ id: projects.id, name: projects.name }) - .from(projects) - .where(eq(projects.archived, false)); + async loadProjectDashboard( + projectId: number, + now: Temporal.Instant, + ): Promise { + const versions = await this.db + .select() + .from(routines) + .where(eq(routines.projectId, projectId)); + + // Max-version row per routine id, then drop archived ones. + const current = new Map(); + for (const r of versions) { + const existing = current.get(r.id); + if (!existing || r.version > existing.version) current.set(r.id, r); + } + const live = [...current.values()].filter((r) => !r.archived); + if (live.length === 0) + return { routines: [], healthScore: 100, healthSeries: [] }; + + const completions = await this.db + .select() + .from(routineCompletions) + .where( + inArray( + routineCompletions.routineId, + live.map((r) => r.id), + ), + ); + + const states = live.map((r) => computeRoutineState(r, completions, now)); + return { + routines: states, + healthScore: computeHealthScore(states), + healthSeries: computeProjectHealthSeries(live, completions, now), + }; + } - const entries = await Promise.all( - activeProjects.map(async (p) => ({ - project: p, - healthScore: (await loadProjectDashboard(p.id, now, db)).healthScore, - })), - ); + async loadOverview(now: Temporal.Instant): Promise { + const activeProjects = await this.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 this.loadProjectDashboard(p.id, now)).healthScore, + })), + ); - return { - projects: entries, - globalScore: computeHealthScore( - entries.map((e) => ({ health: e.healthScore })), - ), - }; + return { + projects: entries, + globalScore: computeHealthScore( + entries.map((e) => ({ health: e.healthScore })), + ), + }; + } } -- cgit v1.3