diff options
Diffstat (limited to 'src/lib/server/services')
| -rw-r--r-- | src/lib/server/services/groceries.test.ts | 30 | ||||
| -rw-r--r-- | src/lib/server/services/groceries.ts | 282 | ||||
| -rw-r--r-- | src/lib/server/services/grocery-actions.ts | 11 | ||||
| -rw-r--r-- | src/lib/server/services/index.ts | 8 | ||||
| -rw-r--r-- | src/lib/server/services/projects.ts | 53 | ||||
| -rw-r--r-- | src/lib/server/services/references.test.ts | 20 | ||||
| -rw-r--r-- | src/lib/server/services/references.ts | 98 | ||||
| -rw-r--r-- | src/lib/server/services/routine-actions.ts | 16 | ||||
| -rw-r--r-- | src/lib/server/services/routines.test.ts | 104 | ||||
| -rw-r--r-- | src/lib/server/services/routines.ts | 441 |
10 files changed, 523 insertions, 540 deletions
diff --git a/src/lib/server/services/groceries.test.ts b/src/lib/server/services/groceries.test.ts index dbf46b7..a37e549 100644 --- a/src/lib/server/services/groceries.test.ts +++ b/src/lib/server/services/groceries.test.ts @@ -2,19 +2,15 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { groceries, categories, references } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; import { createTestDb, type TestDb } from '$lib/server/db/test-helper'; -import { - loadUnchecked, - loadAll, - toggle, - addOrUncheck, - deleteGrocery, -} from './groceries'; +import { GroceriesService } from './groceries'; describe('groceries service', () => { let db: TestDb['db']; + let service: GroceriesService; beforeEach(async () => { db = (await createTestDb()).db; + service = new GroceriesService(db); }); it('loadUnchecked returns only unchecked items', async () => { @@ -26,7 +22,7 @@ describe('groceries service', () => { { name: 'bread', checked: true, createdAt: now }, ]); - const result = await loadUnchecked(db); + const result = await service.loadUnchecked(); expect(result.items).toHaveLength(1); expect(result.items[0].name).toBe('milk'); }); @@ -40,7 +36,7 @@ describe('groceries service', () => { { name: 'bread', checked: true, createdAt: now }, ]); - const result = await loadAll(db); + const result = await service.loadAll(); expect(result.items).toHaveLength(2); }); @@ -53,10 +49,10 @@ describe('groceries service', () => { { name: 'bread', checked: false, createdAt: now }, ]); - const unchecked = await loadUnchecked(db); + const unchecked = await service.loadUnchecked(); expect(unchecked.items.map((i) => i.name)).toEqual(['bread', 'milk']); - const all = await loadAll(db); + const all = await service.loadAll(); expect(all.items.map((i) => i.name)).toEqual(['bread', 'milk']); }); @@ -73,7 +69,7 @@ describe('groceries service', () => { { groceryId: g.id, reference: 'Brand B', qualityScore: 80 }, ]); - const result = await loadAll(db); + const result = await service.loadAll(); expect(result.items[0].bestRef?.reference).toBe('Brand A'); }); @@ -97,7 +93,7 @@ describe('groceries service', () => { .set({ isPrimary: true }) .where(eq(references.id, b.id)); - const result = await loadAll(db); + const result = await service.loadAll(); expect(result.items[0].bestRef?.reference).toBe('Brand B'); }); @@ -110,7 +106,7 @@ describe('groceries service', () => { .values({ name: 'milk', checked: false, createdAt: now }); const [item] = await db.select().from(groceries); - await toggle(item.id, db); + await service.toggle(item.id); const [updated] = await db .select() @@ -120,7 +116,7 @@ describe('groceries service', () => { }); it('addOrUncheck creates a new grocery', async () => { - const { item, created } = await addOrUncheck('eggs', null, db); + const { item, created } = await service.addOrUncheck('eggs', null); expect(created).toBe(true); expect(item.name).toBe('eggs'); @@ -137,7 +133,7 @@ describe('groceries service', () => { .insert(groceries) .values({ name: 'milk', checked: true, createdAt: now }); - const { item, created } = await addOrUncheck('milk', null, db); + const { item, created } = await service.addOrUncheck('milk', null); expect(created).toBe(false); expect(item.checked).toBe(false); @@ -156,7 +152,7 @@ describe('groceries service', () => { .values({ name: 'milk', checked: false, createdAt: now }); const [item] = await db.select().from(groceries); - await deleteGrocery(item.id, db); + await service.deleteGrocery(item.id); const rows = await db.select().from(groceries); expect(rows).toHaveLength(0); diff --git a/src/lib/server/services/groceries.ts b/src/lib/server/services/groceries.ts index 092aba0..9f4722b 100644 --- a/src/lib/server/services/groceries.ts +++ b/src/lib/server/services/groceries.ts @@ -1,7 +1,7 @@ -import { db as defaultDb } from '$lib/server/db'; import { groceries, categories, references } from '$lib/server/db/schema'; import { eq, inArray, asc, desc } from 'drizzle-orm'; import { referenceOrder } from './references'; +import type { AppDatabase } from '$lib/server/db/types'; import type { Category, Grocery, Reference } from './types'; export interface EnrichedGrocery extends Grocery { @@ -9,159 +9,167 @@ export interface EnrichedGrocery extends Grocery { bestRef: Reference | null; } -export async function loadUnchecked( - db = defaultDb, -): Promise<{ items: EnrichedGrocery[]; categories: Category[] }> { - const [items, cats, refs] = await Promise.all([ - db - .select() - .from(groceries) - .where(eq(groceries.checked, false)) - .orderBy(desc(groceries.createdAt), desc(groceries.id)), - db - .select() - .from(categories) - .orderBy(asc(categories.order), asc(categories.name)), - db - .select() - .from(references) - .orderBy(...referenceOrder), - ]); +function enrichItems( + rawItems: Grocery[], + rawCategories: Category[], + rawReferences: Reference[], +): EnrichedGrocery[] { + const catMap = new Map(rawCategories.map((c) => [c.id, c.name])); + const bestRef = new Map<number, Reference>(); + for (const ref of rawReferences) { + if (!bestRef.has(ref.groceryId)) { + bestRef.set(ref.groceryId, ref); + } + } - return { - items: enrichItems(items, cats, refs), - categories: cats, - }; + return rawItems.map((i) => ({ + ...i, + categoryName: i.categoryId ? (catMap.get(i.categoryId) ?? null) : null, + bestRef: bestRef.get(i.id) ?? null, + })); } -export async function loadAll( - db = defaultDb, -): Promise<{ items: EnrichedGrocery[]; categories: Category[] }> { - const [items, cats, refs] = await Promise.all([ - db - .select() - .from(groceries) - .orderBy(desc(groceries.createdAt), desc(groceries.id)), - db - .select() - .from(categories) - .orderBy(asc(categories.order), asc(categories.name)), - db - .select() - .from(references) - .orderBy(...referenceOrder), - ]); - - return { - items: enrichItems(items, cats, refs), - categories: cats, - }; -} +export class GroceriesService { + constructor(private readonly db: AppDatabase) {} -export async function toggle(id: number, db = defaultDb): Promise<void> { - const [item] = await db.select().from(groceries).where(eq(groceries.id, id)); - if (!item) throw new Error('Grocery not found'); + async loadUnchecked(): Promise<{ + items: EnrichedGrocery[]; + categories: Category[]; + }> { + const [items, cats, refs] = await Promise.all([ + this.db + .select() + .from(groceries) + .where(eq(groceries.checked, false)) + .orderBy(desc(groceries.createdAt), desc(groceries.id)), + this.db + .select() + .from(categories) + .orderBy(asc(categories.order), asc(categories.name)), + this.db + .select() + .from(references) + .orderBy(...referenceOrder), + ]); - await db - .update(groceries) - .set({ checked: !item.checked }) - .where(eq(groceries.id, id)); -} + return { + items: enrichItems(items, cats, refs), + categories: cats, + }; + } -export async function addOrUncheck( - name: string, - categoryId: number | null, - db = defaultDb, -): Promise<{ item: Grocery; created: boolean }> { - const [existing] = await db - .select() - .from(groceries) - .where(eq(groceries.name, name)) - .limit(1); + async loadAll(): Promise<{ + items: EnrichedGrocery[]; + categories: Category[]; + }> { + const [items, cats, refs] = await Promise.all([ + this.db + .select() + .from(groceries) + .orderBy(desc(groceries.createdAt), desc(groceries.id)), + this.db + .select() + .from(categories) + .orderBy(asc(categories.order), asc(categories.name)), + this.db + .select() + .from(references) + .orderBy(...referenceOrder), + ]); - if (existing) { - await db - .update(groceries) - .set({ - checked: false, - categoryId: categoryId ?? existing.categoryId, - }) - .where(eq(groceries.id, existing.id)); return { - item: { - ...existing, - checked: false, - categoryId: categoryId ?? existing.categoryId, - }, - created: false, + items: enrichItems(items, cats, refs), + categories: cats, }; } - const [inserted] = await db - .insert(groceries) - .values({ - name, - categoryId, - createdAt: Temporal.Now.instant().toString({ - smallestUnit: 'millisecond', - }), - }) - .returning(); + async toggle(id: number): Promise<void> { + const [item] = await this.db + .select() + .from(groceries) + .where(eq(groceries.id, id)); + if (!item) throw new Error('Grocery not found'); - return { item: inserted, created: true }; -} + await this.db + .update(groceries) + .set({ checked: !item.checked }) + .where(eq(groceries.id, id)); + } -export async function updateGrocery( - id: number, - name: string, - categoryId: number | null, - db = defaultDb, -): Promise<void> { - await db - .update(groceries) - .set({ name, categoryId }) - .where(eq(groceries.id, id)); -} + async addOrUncheck( + name: string, + categoryId: number | null, + ): Promise<{ item: Grocery; created: boolean }> { + const [existing] = await this.db + .select() + .from(groceries) + .where(eq(groceries.name, name)) + .limit(1); -export async function updateGroceryCategory( - id: number, - categoryId: number | null, - db = defaultDb, -): Promise<void> { - await db.update(groceries).set({ categoryId }).where(eq(groceries.id, id)); -} + if (existing) { + await this.db + .update(groceries) + .set({ + checked: false, + categoryId: categoryId ?? existing.categoryId, + }) + .where(eq(groceries.id, existing.id)); + return { + item: { + ...existing, + checked: false, + categoryId: categoryId ?? existing.categoryId, + }, + created: false, + }; + } -export async function bulkUpdateGroceryCategory( - ids: number[], - categoryId: number | null, - db = defaultDb, -): Promise<void> { - await db - .update(groceries) - .set({ categoryId }) - .where(inArray(groceries.id, ids)); -} + const [inserted] = await this.db + .insert(groceries) + .values({ + name, + categoryId, + createdAt: Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }), + }) + .returning(); -export async function deleteGrocery(id: number, db = defaultDb): Promise<void> { - await db.delete(groceries).where(eq(groceries.id, id)); -} + return { item: inserted, created: true }; + } -function enrichItems( - rawItems: Grocery[], - rawCategories: Category[], - rawReferences: Reference[], -): EnrichedGrocery[] { - const catMap = new Map(rawCategories.map((c) => [c.id, c.name])); - const bestRef = new Map<number, Reference>(); - for (const ref of rawReferences) { - if (!bestRef.has(ref.groceryId)) { - bestRef.set(ref.groceryId, ref); - } + async updateGrocery( + id: number, + name: string, + categoryId: number | null, + ): Promise<void> { + await this.db + .update(groceries) + .set({ name, categoryId }) + .where(eq(groceries.id, id)); } - return rawItems.map((i) => ({ - ...i, - categoryName: i.categoryId ? (catMap.get(i.categoryId) ?? null) : null, - bestRef: bestRef.get(i.id) ?? null, - })); + async updateGroceryCategory( + id: number, + categoryId: number | null, + ): Promise<void> { + await this.db + .update(groceries) + .set({ categoryId }) + .where(eq(groceries.id, id)); + } + + async bulkUpdateGroceryCategory( + ids: number[], + categoryId: number | null, + ): Promise<void> { + await this.db + .update(groceries) + .set({ categoryId }) + .where(inArray(groceries.id, ids)); + } + + async deleteGrocery(id: number): Promise<void> { + await this.db.delete(groceries).where(eq(groceries.id, id)); + } } diff --git a/src/lib/server/services/grocery-actions.ts b/src/lib/server/services/grocery-actions.ts index 2eff205..c2b8852 100644 --- a/src/lib/server/services/grocery-actions.ts +++ b/src/lib/server/services/grocery-actions.ts @@ -1,6 +1,5 @@ import { fail, redirect, type ActionFailure } from '@sveltejs/kit'; -import { addOrUncheck, loadUnchecked, toggle } from './groceries'; -import { categoriesService } from '$lib/server/services'; +import { categoriesService, groceriesService } from '$lib/server/services'; import { numericId, parseFormString, parseOptionalNumber } from './validation'; import { isUniqueViolation } from '$lib/server/db/errors'; @@ -8,7 +7,7 @@ export async function toggleGroceryAction(request: Request) { const data = await request.formData(); try { const id = numericId(data.get('id')); - await toggle(id); + await groceriesService.toggle(id); } catch { return fail(400, { error: 'ID invalide' }); } @@ -70,8 +69,8 @@ export async function addGroceryAction(request: Request) { } const categoryId = parseOptionalNumber(data.get('category_id')); - await addOrUncheck(name, categoryId); - const { items } = await loadUnchecked(); + await groceriesService.addOrUncheck(name, categoryId); + const { items } = await groceriesService.loadUnchecked(); return { success: true, items }; } @@ -83,6 +82,6 @@ export async function addGroceryAndEditAction(request: Request) { } const categoryId = parseOptionalNumber(data.get('category_id')); - const { item } = await addOrUncheck(name, categoryId); + const { item } = await groceriesService.addOrUncheck(name, categoryId); throw redirect(303, `/grocery/${item.id}`); } diff --git a/src/lib/server/services/index.ts b/src/lib/server/services/index.ts index 25a05be..372e0d3 100644 --- a/src/lib/server/services/index.ts +++ b/src/lib/server/services/index.ts @@ -1,8 +1,16 @@ import { db } from '$lib/server/db'; import { CategoriesService } from './categories'; +import { GroceriesService } from './groceries'; +import { ProjectsService } from './projects'; +import { ReferencesService } from './references'; +import { RoutinesService } from './routines'; /** * Services wired once for the running server. Tests build their own instances * with a test database instead of importing from here. */ export const categoriesService = new CategoriesService(db); +export const groceriesService = new GroceriesService(db); +export const projectsService = new ProjectsService(db); +export const referencesService = new ReferencesService(db); +export const routinesService = new RoutinesService(db); diff --git a/src/lib/server/services/projects.ts b/src/lib/server/services/projects.ts index 443297b..d389aa5 100644 --- a/src/lib/server/services/projects.ts +++ b/src/lib/server/services/projects.ts @@ -1,38 +1,31 @@ -import { db as defaultDb } from '$lib/server/db'; import { projects } from '$lib/server/db/schema'; -import { asc, eq } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import type { AppDatabase } from '$lib/server/db/types'; 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 class ProjectsService { + constructor(private readonly db: AppDatabase) {} -export async function add(name: string, db = defaultDb): Promise<Project> { - const [inserted] = await db.insert(projects).values({ name }).returning(); - return inserted; -} + async add(name: string): Promise<Project> { + const [inserted] = await this.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)); -} + async archive(id: number): Promise<void> { + await this.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; + async getById(id: number): Promise<Project | undefined> { + const [project] = await this.db + .select() + .from(projects) + .where(eq(projects.id, id)); + return project; + } } diff --git a/src/lib/server/services/references.test.ts b/src/lib/server/services/references.test.ts index fa3fc97..1f5d172 100644 --- a/src/lib/server/services/references.test.ts +++ b/src/lib/server/services/references.test.ts @@ -2,13 +2,15 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { groceries, references } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; import { createTestDb, type TestDb } from '$lib/server/db/test-helper'; -import { loadForGrocery, add, promote, deleteReference } from './references'; +import { ReferencesService } from './references'; describe('references service', () => { let db: TestDb['db']; + let service: ReferencesService; beforeEach(async () => { db = (await createTestDb()).db; + service = new ReferencesService(db); }); it('loadForGrocery returns refs for the given grocery', async () => { @@ -29,7 +31,7 @@ describe('references service', () => { { groceryId: g2.id, reference: 'Brand B' }, ]); - const refs = await loadForGrocery(g1.id, db); + const refs = await service.loadForGrocery(g1.id); expect(refs).toHaveLength(1); expect(refs[0].reference).toBe('Brand A'); }); @@ -49,7 +51,7 @@ describe('references service', () => { { groceryId: g.id, reference: 'D', qualityScore: 50 }, ]); - const refs = await loadForGrocery(g.id, db); + const refs = await service.loadForGrocery(g.id); expect(refs.map((r) => r.reference)).toEqual(['B', 'D', 'A', 'C']); }); @@ -62,7 +64,7 @@ describe('references service', () => { .values({ name: 'pasta', checked: false, createdAt: now }) .returning(); - const ref = await add(g.id, 'Barilla', 2.5, '€/kg', 85, db); + const ref = await service.add(g.id, 'Barilla', 2.5, '€/kg', 85); expect(ref.reference).toBe('Barilla'); expect(ref.price).toBe(2.5); @@ -90,12 +92,12 @@ describe('references service', () => { .values({ groceryId: g.id, reference: 'B' }) .returning(); - await promote(b.id, db); - let refs = await loadForGrocery(g.id, db); + await service.promote(b.id); + let refs = await service.loadForGrocery(g.id); expect(refs.map((r) => r.reference)).toEqual(['B', 'A']); - await promote(a.id, db); - refs = await loadForGrocery(g.id, db); + await service.promote(a.id); + refs = await service.loadForGrocery(g.id); expect(refs.map((r) => r.reference)).toEqual(['A', 'B']); const rows = await db @@ -119,7 +121,7 @@ describe('references service', () => { .values({ groceryId: g.id, reference: 'Brand X' }) .returning(); - await deleteReference(ref.id, db); + await service.deleteReference(ref.id); const rows = await db.select().from(references); expect(rows).toHaveLength(0); diff --git a/src/lib/server/services/references.ts b/src/lib/server/services/references.ts index accf1ba..60dcc01 100644 --- a/src/lib/server/services/references.ts +++ b/src/lib/server/services/references.ts @@ -1,4 +1,3 @@ -import { db as defaultDb } from '$lib/server/db'; import { references } from '$lib/server/db/schema'; import { eq, ne, and, desc, asc } from 'drizzle-orm'; import type { AppDatabase } from '$lib/server/db/types'; @@ -11,58 +10,55 @@ export const referenceOrder = [ asc(references.id), ]; -export async function loadForGrocery( - groceryId: number, - db = defaultDb, -): Promise<Reference[]> { - return db - .select() - .from(references) - .where(eq(references.groceryId, groceryId)) - .orderBy(...referenceOrder); -} +export class ReferencesService { + constructor(private readonly db: AppDatabase) {} -/** - * Marks `id` as the primary ref of its grocery, clearing any sibling primary - * so at most one ref per grocery is promoted. - */ -export async function promote(id: number, db = defaultDb): Promise<void> { - const [target] = await db - .select() - .from(references) - .where(eq(references.id, id)); - if (!target) throw new Error('Reference not found'); + async loadForGrocery(groceryId: number): Promise<Reference[]> { + return this.db + .select() + .from(references) + .where(eq(references.groceryId, groceryId)) + .orderBy(...referenceOrder); + } - await db - .update(references) - .set({ isPrimary: true }) - .where(eq(references.id, id)); - await db - .update(references) - .set({ isPrimary: false }) - .where( - and(eq(references.groceryId, target.groceryId), ne(references.id, id)), - ); -} + /** + * Marks `id` as the primary ref of its grocery, clearing any sibling primary + * so at most one ref per grocery is promoted. + */ + async promote(id: number): Promise<void> { + const [target] = await this.db + .select() + .from(references) + .where(eq(references.id, id)); + if (!target) throw new Error('Reference not found'); -export async function add( - groceryId: number, - reference: string, - price: number | null, - unit: string | null, - qualityScore: number | null, - db = defaultDb, -): Promise<Reference> { - const [inserted] = await db - .insert(references) - .values({ groceryId, reference, price, unit, qualityScore }) - .returning(); - return inserted; -} + await this.db + .update(references) + .set({ isPrimary: true }) + .where(eq(references.id, id)); + await this.db + .update(references) + .set({ isPrimary: false }) + .where( + and(eq(references.groceryId, target.groceryId), ne(references.id, id)), + ); + } + + async add( + groceryId: number, + reference: string, + price: number | null, + unit: string | null, + qualityScore: number | null, + ): Promise<Reference> { + const [inserted] = await this.db + .insert(references) + .values({ groceryId, reference, price, unit, qualityScore }) + .returning(); + return inserted; + } -export async function deleteReference( - id: number, - db = defaultDb, -): Promise<void> { - await db.delete(references).where(eq(references.id, id)); + async deleteReference(id: number): Promise<void> { + await this.db.delete(references).where(eq(references.id, id)); + } } diff --git a/src/lib/server/services/routine-actions.ts b/src/lib/server/services/routine-actions.ts index 1d7f8b5..ea4e91e 100644 --- a/src/lib/server/services/routine-actions.ts +++ b/src/lib/server/services/routine-actions.ts @@ -1,5 +1,5 @@ import { fail, type ActionFailure } from '@sveltejs/kit'; -import { archive, complete, edit, loadCurrent } from './routines'; +import { routinesService } from '$lib/server/services'; import { parseFormString } from './validation'; import { instantFromDay } from '$lib/shared/temporal'; import { Frequency } from '$lib/shared/frequency'; @@ -14,7 +14,7 @@ export async function editRoutineAction( routineId: number, extra?: { type: 'edit' }, ): Promise<EditRoutineResult> { - const current = await loadCurrent(routineId); + const current = await routinesService.loadCurrent(routineId); if (!current) { return fail(400, { error: 'Routine introuvable' }); } @@ -47,7 +47,7 @@ export async function editRoutineAction( } try { - await edit(routineId, fields); + await routinesService.edit(routineId, fields); } catch { return fail(400, { error: 'Routine introuvable' }); } @@ -55,7 +55,7 @@ export async function editRoutineAction( } export async function completeRoutineAction(data: FormData, routineId: number) { - const current = await loadCurrent(routineId); + const current = await routinesService.loadCurrent(routineId); if (!current) { return fail(400, { error: 'Routine introuvable' }); } @@ -73,7 +73,11 @@ export async function completeRoutineAction(data: FormData, routineId: number) { } try { - await complete(routineId, parseFormString(data.get('note')), doneAt); + await routinesService.complete( + routineId, + parseFormString(data.get('note')), + doneAt, + ); } catch { return fail(400, { error: 'Routine introuvable' }); } @@ -82,7 +86,7 @@ export async function completeRoutineAction(data: FormData, routineId: number) { export async function archiveRoutineAction(routineId: number) { try { - await archive(routineId); + await routinesService.archive(routineId); } catch { return fail(400, { error: 'Routine introuvable' }); } diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts index 2ad409a..b92db25 100644 --- a/src/lib/server/services/routines.test.ts +++ b/src/lib/server/services/routines.test.ts @@ -1,108 +1,103 @@ 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, - loadArchived, - loadProjectDashboard, - loadOverview, -} from './routines'; +import { createTestDb, type TestDb } from '$lib/server/db/test-helper'; +import { ProjectsService } from './projects'; +import { RoutinesService } from './routines'; describe('routines service', () => { - let db: Awaited<ReturnType<typeof createTestDb>>['db']; + let db: TestDb['db']; + let service: RoutinesService; let projectId: number; beforeEach(async () => { const testDb = await createTestDb(); db = testDb.db; - const project = await addProject('Household', db); + service = new RoutinesService(db); + const project = await new ProjectsService(db).add('Household'); projectId = project.id; }); it('creates a routine with version 1', async () => { - const r = await add(projectId, 'clean kitchen', '7d', db); + const r = await service.add(projectId, 'clean kitchen', '7d'); expect(r.id).toBe(1); expect(r.version).toBe(1); expect(r.frequency).toBe('7d'); }); it('edit inserts a new snapshot version and keeps history', async () => { - const r = await add(projectId, 'clean kitchen', '7d', db); - const updated = await edit( - r.id, - { name: 'clean kitchen deeply', frequency: '3d' }, - db, - ); + const r = await service.add(projectId, 'clean kitchen', '7d'); + const updated = await service.edit(r.id, { + name: 'clean kitchen deeply', + frequency: '3d', + }); expect(updated.version).toBe(2); expect(updated.frequency).toBe('3d'); - const versions = await loadAllVersions(r.id, db); + const versions = await service.loadAllVersions(r.id); expect(versions).toHaveLength(2); expect(versions[0].frequency).toBe('7d'); expect(versions[1].frequency).toBe('3d'); }); it('new routines start with an empty description', async () => { - const r = await add(projectId, 'clean kitchen', '7d', db); + const r = await service.add(projectId, 'clean kitchen', '7d'); expect(r.description).toBe(''); }); it('edit is partial: name/frequency edits keep the description and vice-versa', async () => { - const r = await add(projectId, 'clean kitchen', '7d', db); - const described = await edit(r.id, { description: 'wax on, wax off' }, db); + const r = await service.add(projectId, 'clean kitchen', '7d'); + const described = await service.edit(r.id, { + description: 'wax on, wax off', + }); expect(described.name).toBe('clean kitchen'); expect(described.frequency).toBe('7d'); expect(described.description).toBe('wax on, wax off'); - const renamed = await edit(r.id, { name: 'clean the whole kitchen' }, db); + const renamed = await service.edit(r.id, { + name: 'clean the whole kitchen', + }); expect(renamed.version).toBe(3); expect(renamed.description).toBe('wax on, wax off'); expect(renamed.frequency).toBe('7d'); - const refreq = await edit(r.id, { frequency: '14d' }, db); + const refreq = await service.edit(r.id, { frequency: '14d' }); expect(refreq.version).toBe(4); expect(refreq.name).toBe('clean the whole kitchen'); expect(refreq.description).toBe('wax on, wax off'); - const cleared = await edit(r.id, { description: '' }, db); + const cleared = await service.edit(r.id, { description: '' }); expect(cleared.version).toBe(5); expect(cleared.description).toBe(''); expect(cleared.name).toBe('clean the whole kitchen'); }); it('archive inserts an archived snapshot without removing history', async () => { - const r = await add(projectId, 'clean kitchen', '7d', db); - await archive(r.id, db); - const versions = await loadAllVersions(r.id, db); + const r = await service.add(projectId, 'clean kitchen', '7d'); + await service.archive(r.id); + const versions = await service.loadAllVersions(r.id); expect(versions).toHaveLength(2); expect(versions[0].archived).toBe(false); expect(versions[1].archived).toBe(true); - const dashboard = await loadProjectDashboard( + const dashboard = await service.loadProjectDashboard( projectId, Temporal.Now.instant(), - db, ); expect(dashboard.routines).toHaveLength(0); expect(dashboard.healthScore).toBe(100); }); it('loadArchived lists archived routines; edits keep the archive date', async () => { - const a = await add(projectId, 'clean kitchen', '7d', db); - await add(projectId, 'water plants', '3d', db); - await archive(a.id, db); - const stamped = (await loadAllVersions(a.id, db))[1].archivedAt; + const a = await service.add(projectId, 'clean kitchen', '7d'); + await service.add(projectId, 'water plants', '3d'); + await service.archive(a.id); + const stamped = (await service.loadAllVersions(a.id))[1].archivedAt; expect(stamped).toBeTruthy(); - const edited = await edit(a.id, { name: 'clean kitchen deeply' }, db); + const edited = await service.edit(a.id, { name: 'clean kitchen deeply' }); expect(edited.archived).toBe(true); expect(edited.archivedAt).toBe(stamped); - const archived = await loadArchived(projectId, db); + const archived = await service.loadArchived(projectId); expect(archived.map((r) => r.id)).toEqual([a.id]); expect(archived[0].name).toBe('clean kitchen deeply'); expect(archived[0].frequency).toBe('7d'); @@ -110,12 +105,12 @@ describe('routines service', () => { }); it('loadArchived sorts most recently archived first', async () => { - const a = await add(projectId, 'first', '1d', db); - const b = await add(projectId, 'second', '1d', db); - await archive(a.id, db); - await archive(b.id, db); + const a = await service.add(projectId, 'first', '1d'); + const b = await service.add(projectId, 'second', '1d'); + await service.archive(a.id); + await service.archive(b.id); - const archived = await loadArchived(projectId, db); + const archived = await service.loadArchived(projectId); expect(archived).toHaveLength(2); // Descending archive date; equal timestamps keep insertion order. expect( @@ -126,28 +121,27 @@ describe('routines service', () => { }); it('complete records the snapshot version it was made against', async () => { - const r = await add(projectId, 'clean kitchen', '7d', db); - await edit(r.id, { frequency: '5d' }, db); + const r = await service.add(projectId, 'clean kitchen', '7d'); + await service.edit(r.id, { frequency: '5d' }); const doneAt = Temporal.Now.instant().toString({ smallestUnit: 'millisecond', }); - const completion = await complete(r.id, 'with vinegar', doneAt, db); + const completion = await service.complete(r.id, 'with vinegar', doneAt); 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', '7d', db); + const r = await service.add(projectId, 'clean kitchen', '7d'); const created = Temporal.Instant.from(r.createdAt); - await complete( + await service.complete( r.id, null, created.toString({ smallestUnit: 'millisecond' }), - db, ); // 22 days after completion with a 7-day routine => two periods overdue const now = created.toZonedDateTimeISO('UTC').add({ days: 22 }).toInstant(); - const dashboard = await loadProjectDashboard(projectId, now, db); + const dashboard = await service.loadProjectDashboard(projectId, now); expect(dashboard.routines).toHaveLength(1); expect(dashboard.routines[0].status).toBe('veryLate'); expect(dashboard.healthScore).toBe(0); @@ -157,12 +151,12 @@ describe('routines service', () => { }); it('overview aggregates per-project health and a global score', async () => { - await add(projectId, 'clean kitchen', '7d', db); - const growth = await addProject('Personal growth', db); - await add(growth.id, 'read 30 minutes', '1d', db); + await service.add(projectId, 'clean kitchen', '7d'); + const growth = await new ProjectsService(db).add('Personal growth'); + await service.add(growth.id, 'read 30 minutes', '1d'); const now = Temporal.Now.instant(); - const overview = await loadOverview(now, db); + const overview = await service.loadOverview(now); 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 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 })), + ), + }; + } } |
