diff options
Diffstat (limited to 'src/lib/server/services/routines.ts')
| -rw-r--r-- | src/lib/server/services/routines.ts | 441 |
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 })), + ), + }; + } } |
