aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/server/db/errors.test.ts45
-rw-r--r--src/lib/server/db/errors.ts13
-rw-r--r--src/lib/server/db/types.ts5
-rw-r--r--src/lib/server/services/categories.test.ts11
-rw-r--r--src/lib/server/services/categories.ts63
-rw-r--r--src/lib/server/services/groceries.test.ts30
-rw-r--r--src/lib/server/services/groceries.ts286
-rw-r--r--src/lib/server/services/grocery-actions.ts21
-rw-r--r--src/lib/server/services/index.ts16
-rw-r--r--src/lib/server/services/projects.ts55
-rw-r--r--src/lib/server/services/references.test.ts20
-rw-r--r--src/lib/server/services/references.ts100
-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.ts443
-rw-r--r--src/routes/+page.server.ts15
-rw-r--r--src/routes/groceries/+page.server.ts6
-rw-r--r--src/routes/groceries/catalog/+page.server.ts9
-rw-r--r--src/routes/groceries/categories/+page.server.ts13
-rw-r--r--src/routes/grocery/[id]/+page.server.ts44
-rw-r--r--src/routes/projects/[id]/+page.server.ts23
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts9
22 files changed, 707 insertions, 640 deletions
diff --git a/src/lib/server/db/errors.test.ts b/src/lib/server/db/errors.test.ts
new file mode 100644
index 0000000..d19aec2
--- /dev/null
+++ b/src/lib/server/db/errors.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { groceries, categories } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+import { createTestDb, type TestDb } from '$lib/server/db/test-helper';
+import { isUniqueViolation } from './errors';
+
+describe('isUniqueViolation', () => {
+ let db: TestDb['db'];
+
+ beforeEach(async () => {
+ db = (await createTestDb()).db;
+ await db.insert(categories).values({ name: 'Épices' });
+ await db
+ .insert(groceries)
+ .values({ name: 'olives', createdAt: '2026-01-01T00:00:00.000Z' });
+ });
+
+ it('detects a duplicate insert on a unique column', async () => {
+ const error = await db
+ .insert(categories)
+ .values({ name: 'Épices' })
+ .catch((e: unknown) => e);
+
+ expect(isUniqueViolation(error)).toBe(true);
+ });
+
+ it('detects a duplicate name on update', async () => {
+ await db
+ .insert(groceries)
+ .values({ name: 'pain', createdAt: '2026-01-02T00:00:00.000Z' });
+
+ const error = await db
+ .update(groceries)
+ .set({ name: 'olives' })
+ .where(eq(groceries.name, 'pain'))
+ .catch((e: unknown) => e);
+
+ expect(isUniqueViolation(error)).toBe(true);
+ });
+
+ it('leaves other failures alone', () => {
+ expect(isUniqueViolation(new Error('network is unreachable'))).toBe(false);
+ expect(isUniqueViolation(undefined)).toBe(false);
+ });
+});
diff --git a/src/lib/server/db/errors.ts b/src/lib/server/db/errors.ts
new file mode 100644
index 0000000..4b101f8
--- /dev/null
+++ b/src/lib/server/db/errors.ts
@@ -0,0 +1,13 @@
+/**
+ * True when the failure is SQLite's UNIQUE constraint. Drizzle wraps driver
+ * errors, so the libsql error sits at some depth of the `cause` chain.
+ */
+export function isUniqueViolation(error: unknown): boolean {
+ let current: unknown = error;
+ while (current instanceof Error) {
+ if ('code' in current && current.code === 'SQLITE_CONSTRAINT_UNIQUE')
+ return true;
+ current = current.cause;
+ }
+ return false;
+}
diff --git a/src/lib/server/db/types.ts b/src/lib/server/db/types.ts
new file mode 100644
index 0000000..d842677
--- /dev/null
+++ b/src/lib/server/db/types.ts
@@ -0,0 +1,5 @@
+import type { LibSQLDatabase } from 'drizzle-orm/libsql';
+import type * as schema from './schema';
+
+/** The Drizzle handle a service is built with. */
+export type AppDatabase = LibSQLDatabase<typeof schema>;
diff --git a/src/lib/server/services/categories.test.ts b/src/lib/server/services/categories.test.ts
index dbb21fb..164d798 100644
--- a/src/lib/server/services/categories.test.ts
+++ b/src/lib/server/services/categories.test.ts
@@ -1,25 +1,26 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { groceries, categories } from '$lib/server/db/schema';
-import { eq } from 'drizzle-orm';
import { createTestDb, type TestDb } from '$lib/server/db/test-helper';
-import { loadAll, add, deleteCategory } from './categories';
+import { CategoriesService } from './categories';
describe('categories service', () => {
let db: TestDb['db'];
+ let service: CategoriesService;
beforeEach(async () => {
db = (await createTestDb()).db;
+ service = new CategoriesService(db);
});
it('loadAll returns categories sorted by name', async () => {
await db.insert(categories).values([{ name: 'Zebra' }, { name: 'Apple' }]);
- const result = await loadAll(db);
+ const result = await service.loadAll();
expect(result.map((c) => c.name)).toEqual(['Apple', 'Zebra']);
});
it('add creates a category', async () => {
- const cat = await add('Beverages', db);
+ const cat = await service.add('Beverages');
expect(cat.name).toBe('Beverages');
const rows = await db.select().from(categories);
@@ -41,7 +42,7 @@ describe('categories service', () => {
categoryId: cat.id,
});
- await deleteCategory(cat.id, db);
+ await service.deleteCategory(cat.id);
const catRows = await db.select().from(categories);
expect(catRows).toHaveLength(0);
diff --git a/src/lib/server/services/categories.ts b/src/lib/server/services/categories.ts
index d6fa2c5..2ee6d2b 100644
--- a/src/lib/server/services/categories.ts
+++ b/src/lib/server/services/categories.ts
@@ -1,42 +1,39 @@
-import { db as defaultDb } from '$lib/server/db';
import { categories } from '$lib/server/db/schema';
import { eq, asc, sql } from 'drizzle-orm';
-import type { AppDatabase } from './groceries';
+import type { AppDatabase } from '$lib/server/db/types';
import type { Category } from './types';
-export async function loadAll(db = defaultDb): Promise<Category[]> {
- return db
- .select()
- .from(categories)
- .orderBy(asc(categories.order), asc(categories.name));
-}
+export class CategoriesService {
+ constructor(private readonly db: AppDatabase) {}
-export async function add(name: string, db = defaultDb): Promise<Category> {
- const [{ maxOrder }] = await db
- .select({ maxOrder: sql<number>`COALESCE(MAX(${categories.order}), 0)` })
- .from(categories);
- const [inserted] = await db
- .insert(categories)
- .values({ name, order: (maxOrder ?? 0) + 1 })
- .returning();
- return inserted;
-}
+ async loadAll(): Promise<Category[]> {
+ return this.db
+ .select()
+ .from(categories)
+ .orderBy(asc(categories.order), asc(categories.name));
+ }
-export async function reorder(
- orderedIds: number[],
- db = defaultDb,
-): Promise<void> {
- for (let i = 0; i < orderedIds.length; i++) {
- await db
- .update(categories)
- .set({ order: i + 1 })
- .where(eq(categories.id, orderedIds[i]));
+ async add(name: string): Promise<Category> {
+ const [{ maxOrder }] = await this.db
+ .select({ maxOrder: sql<number>`COALESCE(MAX(${categories.order}), 0)` })
+ .from(categories);
+ const [inserted] = await this.db
+ .insert(categories)
+ .values({ name, order: (maxOrder ?? 0) + 1 })
+ .returning();
+ return inserted;
}
-}
-export async function deleteCategory(
- id: number,
- db = defaultDb,
-): Promise<void> {
- await db.delete(categories).where(eq(categories.id, id));
+ async reorder(orderedIds: number[]): Promise<void> {
+ for (let i = 0; i < orderedIds.length; i++) {
+ await this.db
+ .update(categories)
+ .set({ order: i + 1 })
+ .where(eq(categories.id, orderedIds[i]));
+ }
+ }
+
+ async deleteCategory(id: number): Promise<void> {
+ await this.db.delete(categories).where(eq(categories.id, id));
+ }
}
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 c56599c..9f4722b 100644
--- a/src/lib/server/services/groceries.ts
+++ b/src/lib/server/services/groceries.ts
@@ -1,171 +1,175 @@
-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 { LibSQLDatabase } from 'drizzle-orm/libsql';
-import type * as schema from '$lib/server/db/schema';
+import type { AppDatabase } from '$lib/server/db/types';
import type { Category, Grocery, Reference } from './types';
-export type AppDatabase = LibSQLDatabase<typeof schema>;
-
export interface EnrichedGrocery extends Grocery {
categoryName: string | null;
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');
+
+ await this.db
+ .update(groceries)
+ .set({ checked: !item.checked })
+ .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);
- return { item: inserted, created: true };
-}
+ 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 updateGrocery(
- id: number,
- name: string,
- categoryId: number | null,
- db = defaultDb,
-): Promise<void> {
- await db
- .update(groceries)
- .set({ name, categoryId })
- .where(eq(groceries.id, id));
-}
+ const [inserted] = await this.db
+ .insert(groceries)
+ .values({
+ name,
+ categoryId,
+ createdAt: Temporal.Now.instant().toString({
+ smallestUnit: 'millisecond',
+ }),
+ })
+ .returning();
-export async function updateGroceryCategory(
- id: number,
- categoryId: number | null,
- db = defaultDb,
-): Promise<void> {
- await db.update(groceries).set({ categoryId }).where(eq(groceries.id, id));
-}
+ return { item: inserted, created: true };
+ }
-export async function bulkUpdateGroceryCategory(
- ids: number[],
- categoryId: number | null,
- db = defaultDb,
-): Promise<void> {
- await db
- .update(groceries)
- .set({ categoryId })
- .where(inArray(groceries.id, ids));
-}
+ async updateGrocery(
+ id: number,
+ name: string,
+ categoryId: number | null,
+ ): Promise<void> {
+ await this.db
+ .update(groceries)
+ .set({ name, categoryId })
+ .where(eq(groceries.id, id));
+ }
-export async function deleteGrocery(id: number, db = defaultDb): Promise<void> {
- await db.delete(groceries).where(eq(groceries.id, id));
-}
+ async updateGroceryCategory(
+ id: number,
+ categoryId: number | null,
+ ): Promise<void> {
+ await this.db
+ .update(groceries)
+ .set({ categoryId })
+ .where(eq(groceries.id, id));
+ }
-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 bulkUpdateGroceryCategory(
+ ids: number[],
+ categoryId: number | null,
+ ): Promise<void> {
+ await this.db
+ .update(groceries)
+ .set({ categoryId })
+ .where(inArray(groceries.id, ids));
}
- return rawItems.map((i) => ({
- ...i,
- categoryName: i.categoryId ? (catMap.get(i.categoryId) ?? null) : null,
- bestRef: bestRef.get(i.id) ?? null,
- }));
+ 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 1cfb05d..c2b8852 100644
--- a/src/lib/server/services/grocery-actions.ts
+++ b/src/lib/server/services/grocery-actions.ts
@@ -1,13 +1,13 @@
import { fail, redirect, type ActionFailure } from '@sveltejs/kit';
-import { addOrUncheck, loadUnchecked, toggle } from './groceries';
-import { add as addCategory } from './categories';
+import { categoriesService, groceriesService } from '$lib/server/services';
import { numericId, parseFormString, parseOptionalNumber } from './validation';
+import { isUniqueViolation } from '$lib/server/db/errors';
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' });
}
@@ -50,7 +50,14 @@ export async function addCategoryAction(request: Request) {
return name;
}
- await addCategory(name);
+ try {
+ await categoriesService.add(name);
+ } catch (error) {
+ if (isUniqueViolation(error)) {
+ return fail(400, { error: 'Cette catégorie existe déjà' });
+ }
+ throw error;
+ }
return { success: true };
}
@@ -62,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 };
}
@@ -75,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
new file mode 100644
index 0000000..372e0d3
--- /dev/null
+++ b/src/lib/server/services/index.ts
@@ -0,0 +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 b58c2f1..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 type { AppDatabase } from './groceries';
+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 d48fb9b..60dcc01 100644
--- a/src/lib/server/services/references.ts
+++ b/src/lib/server/services/references.ts
@@ -1,7 +1,6 @@
-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 './groceries';
+import type { AppDatabase } from '$lib/server/db/types';
import type { Reference } from './types';
/** Primary refs first, then best score, then insertion order. */
@@ -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 b1e210b..0114296 100644
--- a/src/lib/server/services/routines.ts
+++ b/src/lib/server/services/routines.ts
@@ -1,7 +1,6 @@
-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 './groceries';
+import type { AppDatabase } from '$lib/server/db/types';
import type { Routine, RoutineCompletion } from './types';
import {
computeRoutineState,
@@ -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 fe9b4e3..ac00aeb 100644
--- a/src/routes/+page.server.ts
+++ b/src/routes/+page.server.ts
@@ -1,11 +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 = {
@@ -14,7 +14,14 @@ export const actions: Actions = {
if (!name) {
return fail(400, { error: 'Le nom est requis' });
}
- await addProject(name);
+ try {
+ await projectsService.add(name);
+ } catch (error) {
+ if (isUniqueViolation(error)) {
+ return fail(400, { error: 'Ce projet existe déjà' });
+ }
+ throw error;
+ }
return { success: true };
},
};
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/groceries/categories/+page.server.ts b/src/routes/groceries/categories/+page.server.ts
index 6e0e3b7..e8794fa 100644
--- a/src/routes/groceries/categories/+page.server.ts
+++ b/src/routes/groceries/categories/+page.server.ts
@@ -1,9 +1,5 @@
import { fail } from '@sveltejs/kit';
-import {
- loadAll,
- deleteCategory,
- reorder,
-} from '$lib/server/services/categories';
+import { categoriesService } from '$lib/server/services';
import {
addCategoryAction,
idAction,
@@ -17,13 +13,14 @@ export interface CategoriesOutput {
}
export const load: PageServerLoad = async () => {
- return { categories: await loadAll() };
+ return { categories: await categoriesService.loadAll() };
};
export const actions: Actions = {
add: ({ request }) => addCategoryAction(request),
- delete: ({ request }) => idAction(request, deleteCategory),
+ delete: ({ request }) =>
+ idAction(request, (id) => categoriesService.deleteCategory(id)),
reorder: async ({ request }) => {
const data = await request.formData();
@@ -31,7 +28,7 @@ export const actions: Actions = {
if (ids.length === 0) {
return fail(400, { error: 'Aucune catégorie' });
}
- await reorder(ids);
+ await categoriesService.reorder(ids);
return { success: true };
},
};
diff --git a/src/routes/grocery/[id]/+page.server.ts b/src/routes/grocery/[id]/+page.server.ts
index a4ad377..dbfecff 100644
--- a/src/routes/grocery/[id]/+page.server.ts
+++ b/src/routes/grocery/[id]/+page.server.ts
@@ -2,20 +2,18 @@ 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 { loadAll as loadCategories } from '$lib/server/services/categories';
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,
parseFormString,
parseOptionalNumber,
} from '$lib/server/services/validation';
+import { isUniqueViolation } from '$lib/server/db/errors';
import type { Category, Grocery, Reference } from '$lib/server/services/types';
import type { Actions, PageServerLoad } from './$types';
@@ -38,8 +36,8 @@ export const load: PageServerLoad = async ({ params, url }) => {
.where(eq(categories.id, item.categoryId))
: [null];
- const refs = await loadForGrocery(id);
- const cats = await loadCategories();
+ const refs = await referencesService.loadForGrocery(id);
+ const cats = await categoriesService.loadAll();
const from = url.searchParams.get('from') || '/';
return {
@@ -66,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();
@@ -86,13 +95,20 @@ export const actions: Actions = {
return fail(400, { error: 'Le nom est requis' });
}
- await updateGrocery(groceryId, name, categoryId);
+ try {
+ await groceriesService.updateGrocery(groceryId, name, categoryId);
+ } catch (error) {
+ if (isUniqueViolation(error)) {
+ return fail(400, { error: 'Un article porte déjà ce nom' });
+ }
+ throw error;
+ }
return { success: true, type: 'update' as const };
},
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)