aboutsummaryrefslogtreecommitdiff
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
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.
-rw-r--r--src/lib/server/services/groceries.test.ts30
-rw-r--r--src/lib/server/services/groceries.ts282
-rw-r--r--src/lib/server/services/grocery-actions.ts11
-rw-r--r--src/lib/server/services/index.ts8
-rw-r--r--src/lib/server/services/projects.ts53
-rw-r--r--src/lib/server/services/references.test.ts20
-rw-r--r--src/lib/server/services/references.ts98
-rw-r--r--src/lib/server/services/routine-actions.ts16
-rw-r--r--src/lib/server/services/routines.test.ts104
-rw-r--r--src/lib/server/services/routines.ts441
-rw-r--r--src/routes/+page.server.ts7
-rw-r--r--src/routes/groceries/+page.server.ts6
-rw-r--r--src/routes/groceries/catalog/+page.server.ts9
-rw-r--r--src/routes/grocery/[id]/+page.server.ts34
-rw-r--r--src/routes/projects/[id]/+page.server.ts23
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts9
16 files changed, 566 insertions, 585 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 })),
+ ),
+ };
+ }
}
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
index 008f7f3..ac00aeb 100644
--- a/src/routes/+page.server.ts
+++ b/src/routes/+page.server.ts
@@ -1,12 +1,11 @@
import { fail } from '@sveltejs/kit';
-import { loadOverview } from '$lib/server/services/routines';
-import { add as addProject } from '$lib/server/services/projects';
+import { projectsService, routinesService } from '$lib/server/services';
import { parseFormString } from '$lib/server/services/validation';
import { isUniqueViolation } from '$lib/server/db/errors';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
- return loadOverview(Temporal.Now.instant());
+ return routinesService.loadOverview(Temporal.Now.instant());
};
export const actions: Actions = {
@@ -16,7 +15,7 @@ export const actions: Actions = {
return fail(400, { error: 'Le nom est requis' });
}
try {
- await addProject(name);
+ await projectsService.add(name);
} catch (error) {
if (isUniqueViolation(error)) {
return fail(400, { error: 'Ce projet existe déjà' });
diff --git a/src/routes/groceries/+page.server.ts b/src/routes/groceries/+page.server.ts
index 1cae275..d33c6a5 100644
--- a/src/routes/groceries/+page.server.ts
+++ b/src/routes/groceries/+page.server.ts
@@ -1,4 +1,4 @@
-import { loadUnchecked, loadAll } from '$lib/server/services/groceries';
+import { groceriesService } from '$lib/server/services';
import {
addGroceryAction,
addGroceryAndEditAction,
@@ -15,8 +15,8 @@ export interface GroceriesOutput {
}
export const load: PageServerLoad = async () => {
- const unchecked = await loadUnchecked();
- const all = await loadAll();
+ const unchecked = await groceriesService.loadUnchecked();
+ const all = await groceriesService.loadAll();
return {
items: unchecked.items,
categories: unchecked.categories,
diff --git a/src/routes/groceries/catalog/+page.server.ts b/src/routes/groceries/catalog/+page.server.ts
index fd71e30..8d94154 100644
--- a/src/routes/groceries/catalog/+page.server.ts
+++ b/src/routes/groceries/catalog/+page.server.ts
@@ -1,8 +1,5 @@
import { fail } from '@sveltejs/kit';
-import {
- loadAll,
- bulkUpdateGroceryCategory,
-} from '$lib/server/services/groceries';
+import { groceriesService } from '$lib/server/services';
import { toggleGroceryAction } from '$lib/server/services/grocery-actions';
import {
numericId,
@@ -18,7 +15,7 @@ export interface CatalogOutput {
}
export const load: PageServerLoad = async () => {
- return loadAll();
+ return groceriesService.loadAll();
};
export const actions: Actions = {
@@ -34,7 +31,7 @@ export const actions: Actions = {
return fail(400, { error: 'Aucun article sélectionné' });
}
- await bulkUpdateGroceryCategory(ids, categoryId);
+ await groceriesService.bulkUpdateGroceryCategory(ids, categoryId);
return { success: true, type: 'bulkUpdate' as const };
},
};
diff --git a/src/routes/grocery/[id]/+page.server.ts b/src/routes/grocery/[id]/+page.server.ts
index 0f386f3..dbfecff 100644
--- a/src/routes/grocery/[id]/+page.server.ts
+++ b/src/routes/grocery/[id]/+page.server.ts
@@ -2,14 +2,11 @@ import { error, fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { groceries, categories } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
-import { categoriesService } from '$lib/server/services';
import {
- loadForGrocery,
- add as addRef,
- promote,
- deleteReference,
-} from '$lib/server/services/references';
-import { updateGrocery, deleteGrocery } from '$lib/server/services/groceries';
+ categoriesService,
+ groceriesService,
+ referencesService,
+} from '$lib/server/services';
import { idAction } from '$lib/server/services/grocery-actions';
import {
numericId,
@@ -39,7 +36,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
.where(eq(categories.id, item.categoryId))
: [null];
- const refs = await loadForGrocery(id);
+ const refs = await referencesService.loadForGrocery(id);
const cats = await categoriesService.loadAll();
const from = url.searchParams.get('from') || '/';
@@ -67,14 +64,25 @@ export const actions: Actions = {
return fail(400, { error: 'La référence est requise' });
}
- await addRef(groceryId, reference, price, unit, qualityScore);
+ await referencesService.add(
+ groceryId,
+ reference,
+ price,
+ unit,
+ qualityScore,
+ );
return { success: true, type: 'add' as const };
},
delete: ({ request }) =>
- idAction(request, deleteReference, { type: 'delete' }),
+ idAction(request, (id) => referencesService.deleteReference(id), {
+ type: 'delete',
+ }),
- promote: ({ request }) => idAction(request, promote, { type: 'promote' }),
+ promote: ({ request }) =>
+ idAction(request, (id) => referencesService.promote(id), {
+ type: 'promote',
+ }),
update: async ({ request, params }) => {
const data = await request.formData();
@@ -88,7 +96,7 @@ export const actions: Actions = {
}
try {
- await updateGrocery(groceryId, name, categoryId);
+ await groceriesService.updateGrocery(groceryId, name, categoryId);
} catch (error) {
if (isUniqueViolation(error)) {
return fail(400, { error: 'Un article porte déjà ce nom' });
@@ -100,7 +108,7 @@ export const actions: Actions = {
deleteItem: async ({ params, request }) => {
const groceryId = numericId(params.id);
- await deleteGrocery(groceryId);
+ await groceriesService.deleteGrocery(groceryId);
const data = await request.formData();
const from = parseFormString(data.get('from')) || '/';
throw redirect(303, from);
diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts
index 83d7571..1df28c2 100644
--- a/src/routes/projects/[id]/+page.server.ts
+++ b/src/routes/projects/[id]/+page.server.ts
@@ -1,13 +1,5 @@
import { error, fail, redirect } from '@sveltejs/kit';
-import {
- archive as archiveProject,
- getById,
-} from '$lib/server/services/projects';
-import {
- add,
- loadProjectDashboard,
- loadArchived,
-} from '$lib/server/services/routines';
+import { projectsService, routinesService } from '$lib/server/services';
import { completeRoutineAction } from '$lib/server/services/routine-actions';
import { numericId, parseFormString } from '$lib/server/services/validation';
import { Frequency } from '$lib/shared/frequency';
@@ -15,12 +7,15 @@ import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const id = numericId(params.id);
- const project = await getById(id);
+ const project = await projectsService.getById(id);
if (!project || project.archived) {
throw error(404, 'Projet introuvable');
}
- const dashboard = await loadProjectDashboard(id, Temporal.Now.instant());
- const archived = await loadArchived(id);
+ const dashboard = await routinesService.loadProjectDashboard(
+ id,
+ Temporal.Now.instant(),
+ );
+ const archived = await routinesService.loadArchived(id);
return {
project: { id: project.id, name: project.name },
routines: dashboard.routines,
@@ -38,7 +33,7 @@ export const actions: Actions = {
return fail(400, { error: 'Le nom est requis' });
}
try {
- await add(
+ await routinesService.add(
numericId(params.id),
name,
Frequency.parse(data.get('frequency')?.toString() ?? '').toSpec(),
@@ -55,7 +50,7 @@ export const actions: Actions = {
},
archiveProject: async ({ params }) => {
- await archiveProject(numericId(params.id));
+ await projectsService.archive(numericId(params.id));
throw redirect(303, '/');
},
};
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
index cb14e0d..4ace86f 100644
--- a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
+++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts
@@ -1,6 +1,5 @@
import { error } from '@sveltejs/kit';
-import { getById } from '$lib/server/services/projects';
-import { loadCurrent, loadCompletions } from '$lib/server/services/routines';
+import { projectsService, routinesService } from '$lib/server/services';
import {
archiveRoutineAction,
completeRoutineAction,
@@ -14,17 +13,17 @@ export const load: PageServerLoad = async ({ params }) => {
const projectId = numericId(params.projectId);
const routineId = numericId(params.routineId);
- const project = await getById(projectId);
+ const project = await projectsService.getById(projectId);
if (!project || project.archived) {
throw error(404, 'Projet introuvable');
}
- const routine = await loadCurrent(routineId);
+ const routine = await routinesService.loadCurrent(routineId);
if (!routine || routine.projectId !== projectId) {
throw error(404, 'Routine introuvable');
}
- const completions = await loadCompletions(routineId);
+ const completions = await routinesService.loadCompletions(routineId);
// Archived routines are frozen at their archive instant, not now.
const at = routine.archivedAt
? Temporal.Instant.from(routine.archivedAt)