aboutsummaryrefslogtreecommitdiff
path: root/src/lib/server/services/routines.ts
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 22:22:55 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 22:22:55 +0200
commit4efed80a2f8dc238b420eadf97d56c3eccaadb76 (patch)
tree7732a80745622c097a9e75dfb056603bceb10a23 /src/lib/server/services/routines.ts
parent0685e9ece66fbcb09c9457027cf7bb2107cc0171 (diff)
refactor: inject the database into every servicemain
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.
Diffstat (limited to 'src/lib/server/services/routines.ts')
-rw-r--r--src/lib/server/services/routines.ts441
1 files changed, 212 insertions, 229 deletions
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<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,
- frequency: string,
- db = defaultDb,
-): Promise<Routine> {
- 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<Pick<Routine, 'name' | 'frequency' | 'description'>>,
- 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: 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<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,
- 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<ArchivedRoutine[]> {
- 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<number, Routine>();
- 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<Routine[]> {
- 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<Routine | undefined> {
- 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<RoutineCompletion[]> {
- 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<number> {
+ const [{ maxId }] = await this.db
+ .select({ maxId: sql<number>`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<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');
+ async add(
+ projectId: number,
+ name: string,
+ frequency: string,
+ ): Promise<Routine> {
+ 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<Pick<Routine, 'name' | 'frequency' | 'description'>>,
+ ): Promise<Routine> {
+ 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');
-export interface ProjectDashboard {
- routines: RoutineState[];
- healthScore: number;
- healthSeries: HealthPoint[];
-}
+ 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 async function loadProjectDashboard(
- projectId: number,
- now: Temporal.Instant,
- db = defaultDb,
-): Promise<ProjectDashboard> {
- const versions = await db
- .select()
- .from(routines)
- .where(eq(routines.projectId, projectId));
+ async archive(id: number): Promise<void> {
+ 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');
- // Max-version row per routine id, then drop archived ones.
- 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);
+ await this.db.insert(routines).values({
+ ...current,
+ version: current.version + 1,
+ archived: true,
+ archivedAt: Temporal.Now.instant().toString({
+ smallestUnit: 'millisecond',
+ }),
+ });
}
- 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),
- ),
+ /** Archived routines of a project, most recently archived first. */
+ async loadArchived(projectId: number): Promise<ArchivedRoutine[]> {
+ 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<number, Routine>();
+ 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,
+ }))
);
+ }
- const states = routines_.map((r) => computeRoutineState(r, completions, now));
- return {
- routines: states,
- healthScore: computeHealthScore(states),
- healthSeries: computeProjectHealthSeries(routines_, completions, now),
- };
-}
+ async loadAllVersions(routineId: number): Promise<Routine[]> {
+ return this.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. */
+ async loadCurrent(routineId: number): Promise<Routine | undefined> {
+ const [row] = await this.db
+ .select()
+ .from(routines)
+ .where(eq(routines.id, routineId))
+ .orderBy(sql`${routines.version} DESC`)
+ .limit(1);
+ return row;
+ }
+
+ async loadCompletions(routineId: number): Promise<RoutineCompletion[]> {
+ return this.db
+ .select()
+ .from(routineCompletions)
+ .where(eq(routineCompletions.routineId, routineId))
+ .orderBy(desc(routineCompletions.doneAt));
+ }
+
+ async complete(
+ routineId: number,
+ note: string | null,
+ doneAt: string,
+ ): Promise<RoutineCompletion> {
+ 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;
+ }
+
+ async loadProjectDashboard(
+ projectId: number,
+ now: Temporal.Instant,
+ ): Promise<ProjectDashboard> {
+ const versions = await this.db
+ .select()
+ .from(routines)
+ .where(eq(routines.projectId, projectId));
-export async function loadOverview(
- now: Temporal.Instant,
- db = defaultDb,
-): Promise<ProjectsOverview> {
- const activeProjects = await db
- .select({ id: projects.id, name: projects.name })
- .from(projects)
- .where(eq(projects.archived, false));
+ // Max-version row per routine id, then drop archived ones.
+ 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 live = [...current.values()].filter((r) => !r.archived);
+ if (live.length === 0)
+ return { routines: [], healthScore: 100, healthSeries: [] };
- const entries = await Promise.all(
- activeProjects.map(async (p) => ({
- project: p,
- healthScore: (await loadProjectDashboard(p.id, now, db)).healthScore,
- })),
- );
+ const completions = await this.db
+ .select()
+ .from(routineCompletions)
+ .where(
+ inArray(
+ routineCompletions.routineId,
+ live.map((r) => r.id),
+ ),
+ );
- return {
- projects: entries,
- globalScore: computeHealthScore(
- entries.map((e) => ({ health: e.healthScore })),
- ),
- };
+ const states = live.map((r) => computeRoutineState(r, completions, now));
+ return {
+ routines: states,
+ healthScore: computeHealthScore(states),
+ healthSeries: computeProjectHealthSeries(live, completions, now),
+ };
+ }
+
+ async loadOverview(now: Temporal.Instant): Promise<ProjectsOverview> {
+ 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 })),
+ ),
+ };
+ }
}