diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-07-28 15:07:57 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-07-28 15:07:57 +0200 |
| commit | 04fabf115ccea31ac504b06cc51d78423b697f60 (patch) | |
| tree | 26c21f8822ccd24fce3a80b7361382c8a87d0c96 | |
| parent | 45cc0afb734a422ee7765dc5c60dccbcc6721367 (diff) | |
refactor: services layer, valibot validation, shared CSS utilities, aria labels, and drizzle migrate
- Extract groceries/categories/references services with db injection
- Add valibot for form validation and safe numeric id parsing
- Fix category delete FK behavior with onDelete: set null + migration
- Replace custom migrate.js with drizzle-orm/libsql/migrator
- Add shared CSS utility classes in app.css (forms, lists, buttons)
- Add aria-pressed and aria-label on interactive elements
- Remove redundant app.css imports and unused /stores import
- Add integration tests for all services using drizzle migrate helper
- Remove smoke test; add db:migrate and db:studio scripts
30 files changed, 1162 insertions, 734 deletions
@@ -56,7 +56,13 @@ efficient shopping walk. ## TODO - [ ] Edit grocery category -- [ ] Global code review +- [x] Global code review (services, validation, tests, aria) +- [ ] Keyboard focus +- [ ] Bug: list and catalog show the same items +- [ ] PWA +- [ ] Weird form stickyness that collapse the gap with the navbar +- [ ] Live updates from server +- [ ] Extract color palette ## Deploy diff --git a/drizzle/0001_big_rachel_grey.sql b/drizzle/0001_big_rachel_grey.sql new file mode 100644 index 0000000..539cad6 --- /dev/null +++ b/drizzle/0001_big_rachel_grey.sql @@ -0,0 +1,15 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_groceries` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `checked` integer DEFAULT false NOT NULL, + `created_at` text NOT NULL, + `category_id` integer, + FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +INSERT INTO `__new_groceries`("id", "name", "checked", "created_at", "category_id") SELECT "id", "name", "checked", "created_at", "category_id" FROM `groceries`;--> statement-breakpoint +DROP TABLE `groceries`;--> statement-breakpoint +ALTER TABLE `__new_groceries` RENAME TO `groceries`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `groceries_name_unique` ON `groceries` (`name`);
\ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..fc1a462 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,172 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ac842614-304d-4599-8104-88b6efe0ed33", + "prevId": "be05d3e6-4ab4-4a59-a592-6459b19b440f", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groceries": { + "name": "groceries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groceries_name_unique": { + "name": "groceries_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "groceries_category_id_categories_id_fk": { + "name": "groceries_category_id_categories_id_fk", + "tableFrom": "groceries", + "tableTo": "categories", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "references": { + "name": "references", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "grocery_id": { + "name": "grocery_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "references_grocery_id_groceries_id_fk": { + "name": "references_grocery_id_groceries_id_fk", + "tableFrom": "references", + "tableTo": "groceries", + "columnsFrom": ["grocery_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 10dce89..467ba8b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1785179210259, "tag": "0000_greedy_masked_marvel", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785236926984, + "tag": "0001_big_rachel_grey", + "breakpoints": true } ] } diff --git a/package-lock.json b/package-lock.json index 2359e46..64dcaba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.1", "dependencies": { "@libsql/client": "^0.15.0", - "drizzle-orm": "^0.44.0" + "drizzle-orm": "^0.44.0", + "valibot": "^1.4.2" }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.12", @@ -5166,7 +5167,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5223,6 +5224,20 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/package.json b/package.json index 7c4eacd..03465d0 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "test": "vitest run", "test:watch": "vitest", "db:push": "drizzle-kit push", - "db:generate": "drizzle-kit generate" + "db:generate": "drizzle-kit generate", + "db:migrate": "node scripts/migrate.js", + "db:studio": "drizzle-kit studio" }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.12", @@ -32,6 +34,7 @@ }, "dependencies": { "@libsql/client": "^0.15.0", - "drizzle-orm": "^0.44.0" + "drizzle-orm": "^0.44.0", + "valibot": "^1.4.2" } } diff --git a/scripts/migrate.js b/scripts/migrate.js index dee9411..656c752 100644 --- a/scripts/migrate.js +++ b/scripts/migrate.js @@ -1,22 +1,11 @@ import { createClient } from '@libsql/client'; -import { readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { drizzle } from 'drizzle-orm/libsql'; +import { migrate } from 'drizzle-orm/libsql/migrator'; const dbUrl = process.env.DATABASE_URL ?? 'file:./data/data.db'; const client = createClient({ url: dbUrl }); +const db = drizzle(client); -const dir = '/app/drizzle'; -const files = readdirSync(dir) - .filter((f) => f.endsWith('.sql')) - .sort(); - -for (const file of files) { - const sql = readFileSync(join(dir, file), 'utf8'); - const stmts = sql.split('--> statement-breakpoint').filter(Boolean); - for (const stmt of stmts) { - await client.execute(stmt); - } -} - -console.log(`${files.length} migration(s) applied.`); +await migrate(db, { migrationsFolder: './drizzle' }); +console.log('Migrations applied successfully'); client.close(); diff --git a/src/app.css b/src/app.css index 2aeefb0..b9ce7f3 100644 --- a/src/app.css +++ b/src/app.css @@ -54,3 +54,249 @@ button { cursor: pointer; font-family: inherit; } + +/* ===== Utility Classes ===== */ + +/* Lists */ +.item-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.item-list--wide { + gap: 0.5rem; +} + +/* Row containers */ +.item-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.item-row--card { + padding: 0.5rem 0.75rem; + background: var(--surface); + border-radius: 0.75rem; + box-shadow: 0 1px 2px var(--shadow); + transition: all 0.2s ease; +} + +.item-row--card:hover { + box-shadow: 0 2px 6px var(--shadow); + transform: translateY(-1px); +} + +.item-row--fixed { + height: 2.75rem; +} + +.item-row--ref { + padding: 0.875rem 1rem; + gap: 0.75rem; + background: var(--surface); + border-radius: 0.75rem; + box-shadow: 0 1px 2px var(--shadow); + transition: all 0.2s ease; +} + +.item-row--ref:hover { + box-shadow: 0 2px 6px var(--shadow); + transform: translateY(-1px); +} + +/* Buttons inside rows */ +.item-btn { + width: 100%; + text-align: left; + padding: 0 0.875rem; + height: 100%; + border: none; + border-radius: 0.75rem; + background: var(--surface); + font-size: 1rem; + font-weight: 500; + display: flex; + align-items: center; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); +} + +.item-btn:hover { + background: #fff8f5; + box-shadow: 0 2px 6px rgba(217, 119, 87, 0.1); + transform: translateY(-1px); +} + +.item-btn--grow { + flex-grow: 1; + width: auto; +} + +/* Text */ +.item-text { + display: flex; + flex-direction: column; + text-align: left; + line-height: 1.2; + gap: 0.0625rem; + overflow: hidden; +} + +.best-ref { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 400; +} + +/* Links */ +.ref-link { + width: 2.75rem; + flex-shrink: 0; + height: 2.75rem; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.75rem; + background: var(--surface); + color: var(--text-secondary); + text-decoration: none; + font-size: 1rem; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); +} + +.ref-link:hover { + background: #fff8f5; + color: var(--accent); + box-shadow: 0 2px 6px rgba(217, 119, 87, 0.1); + transform: translateY(-1px); +} + +/* Category heading */ +.category-name { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + margin: 1.25rem 0 0.5rem; + padding-left: 0.5rem; + border-left: 3px solid var(--accent); +} + +/* Empty state */ +.empty { + color: var(--text-secondary); + font-size: 0.9375rem; + text-align: center; + background: var(--surface); + border-radius: 0.75rem; + box-shadow: 0 1px 2px var(--shadow); +} + +/* Error */ +.error { + color: var(--danger); + font-size: 0.875rem; + padding: 0.5rem 0.75rem; + background: rgba(217, 83, 79, 0.08); + border-radius: 0.5rem; +} + +/* Forms */ +.form-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--surface); + border-radius: 0.75rem; + box-shadow: 0 1px 2px var(--shadow); +} + +.form-card--compact { + gap: 0.5rem; +} + +.form-card label { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.form-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); +} + +.form-input, +.form-select { + padding: 0.75rem 1rem; + border: 1.5px solid var(--border); + border-radius: 0.75rem; + font-size: 1rem; + font-family: inherit; + background: var(--bg); + min-width: 0; + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.15); +} + +/* Buttons */ +.btn-primary { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.75rem; + background: var(--accent); + color: #fff; + font-size: 0.9375rem; + font-weight: 500; + align-self: flex-start; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); +} + +.btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(217, 119, 87, 0.2); +} + +.btn-ghost { + flex-shrink: 0; + width: 2rem; + aspect-ratio: 1; + padding: 0; + border: none; + border-radius: 0.625rem; + background: transparent; + color: var(--text-secondary); + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-ghost:hover { + background: rgba(217, 83, 79, 0.1); + color: var(--danger); +} diff --git a/src/lib/components/Nav.svelte b/src/lib/components/Nav.svelte index 725c098..5558f6f 100644 --- a/src/lib/components/Nav.svelte +++ b/src/lib/components/Nav.svelte @@ -4,7 +4,8 @@ <nav class="navbar"> <a href="/" class:active={$page.url.pathname === '/'}>Liste</a> - <a href="/catalog" class:active={$page.url.pathname === '/catalog'}>Catalogue</a + <a href="/catalog" class:active={$page.url.pathname === '/catalog'} + >Catalogue</a > <a href="/categories" class:active={$page.url.pathname === '/categories'} >Catégories</a diff --git a/src/lib/server/db/groceries.test.ts b/src/lib/server/db/groceries.test.ts index bd14040..2f8baed 100644 --- a/src/lib/server/db/groceries.test.ts +++ b/src/lib/server/db/groceries.test.ts @@ -1,27 +1,14 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { drizzle } from 'drizzle-orm/libsql'; -import { createClient } from '@libsql/client'; import { groceries, categories } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; +import { createTestDb } from './test-helper'; describe('groceries add', () => { - const client = createClient({ url: ':memory:' }); - const db = drizzle(client, { schema: { groceries, categories } }); + let db: Awaited<ReturnType<typeof createTestDb>>['db']; beforeEach(async () => { - await db.run(`DROP TABLE IF EXISTS groceries`); - await db.run(`DROP TABLE IF EXISTS categories`); - await db.run(`CREATE TABLE categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL - )`); - await db.run(`CREATE TABLE groceries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - checked INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - category_id INTEGER REFERENCES categories(id) - )`); + const testDb = await createTestDb(); + db = testDb.db; }); async function addItem(name: string, categoryId?: number | null) { diff --git a/src/lib/server/db/ondelete.test.ts b/src/lib/server/db/ondelete.test.ts new file mode 100644 index 0000000..c2c86de --- /dev/null +++ b/src/lib/server/db/ondelete.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { groceries, categories } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createTestDb } from './test-helper'; + +describe('category delete behavior', () => { + let db: Awaited<ReturnType<typeof createTestDb>>['db']; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + }); + + it('sets category_id to null when a referenced category is deleted', async () => { + await db.insert(categories).values({ name: 'Boissons' }); + const cats = await db.select().from(categories); + const catId = cats[0].id; + + await db.insert(groceries).values({ + name: 'lait', + checked: false, + createdAt: new Date().toISOString(), + categoryId: catId, + }); + + await db.delete(categories).where(eq(categories.id, catId)); + + const items = await db.select().from(groceries); + expect(items).toHaveLength(1); + expect(items[0].categoryId).toBeNull(); + }); +}); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 9113d9b..fae002d 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -10,7 +10,9 @@ export const groceries = sqliteTable('groceries', { name: text('name').notNull().unique(), checked: integer('checked', { mode: 'boolean' }).notNull().default(false), createdAt: text('created_at').notNull(), - categoryId: integer('category_id').references(() => categories.id), + categoryId: integer('category_id').references(() => categories.id, { + onDelete: 'set null', + }), }); export const references = sqliteTable('references', { diff --git a/src/lib/server/db/test-helper.ts b/src/lib/server/db/test-helper.ts new file mode 100644 index 0000000..5bae681 --- /dev/null +++ b/src/lib/server/db/test-helper.ts @@ -0,0 +1,13 @@ +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; +import { migrate } from 'drizzle-orm/libsql/migrator'; +import * as schema from './schema'; + +export async function createTestDb() { + const client = createClient({ url: ':memory:' }); + const db = drizzle(client, { schema }); + + await migrate(db, { migrationsFolder: './drizzle' }); + + return { db, client }; +} diff --git a/src/lib/server/services/categories.test.ts b/src/lib/server/services/categories.test.ts new file mode 100644 index 0000000..cfde1da --- /dev/null +++ b/src/lib/server/services/categories.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { groceries, categories } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createTestDb } from '$lib/server/db/test-helper'; +import { loadAll, add, deleteCategory } from './categories'; + +describe('categories service', () => { + let db: Awaited<ReturnType<typeof createTestDb>>['db']; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + }); + + it('loadAll returns categories sorted by name', async () => { + await db.insert(categories).values([{ name: 'Zebra' }, { name: 'Apple' }]); + + const result = await loadAll(db); + expect(result.map((c) => c.name)).toEqual(['Apple', 'Zebra']); + }); + + it('add creates a category', async () => { + const cat = await add('Beverages', db); + expect(cat.name).toBe('Beverages'); + + const rows = await db.select().from(categories); + expect(rows).toHaveLength(1); + }); + + it('deleteCategory removes the category and nullifies grocery categoryId', async () => { + const [cat] = await db + .insert(categories) + .values({ name: 'Dairy' }) + .returning(); + const now = new Date().toISOString(); + await db.insert(groceries).values({ + name: 'milk', + checked: false, + createdAt: now, + categoryId: cat.id, + }); + + await deleteCategory(cat.id, db); + + const catRows = await db.select().from(categories); + expect(catRows).toHaveLength(0); + + const [item] = await db.select().from(groceries); + expect(item.categoryId).toBeNull(); + }); +}); diff --git a/src/lib/server/services/categories.ts b/src/lib/server/services/categories.ts new file mode 100644 index 0000000..f8b420f --- /dev/null +++ b/src/lib/server/services/categories.ts @@ -0,0 +1,21 @@ +import { db as defaultDb } from '$lib/server/db'; +import { categories } from '$lib/server/db/schema'; +import { eq, asc } from 'drizzle-orm'; +import type { AppDatabase } from './groceries'; +import type { Category } from './types'; + +export async function loadAll(db = defaultDb): Promise<Category[]> { + return db.select().from(categories).orderBy(asc(categories.name)); +} + +export async function add(name: string, db = defaultDb): Promise<Category> { + const [inserted] = await db.insert(categories).values({ name }).returning(); + return inserted; +} + +export async function deleteCategory( + id: number, + db = defaultDb, +): Promise<void> { + await 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 new file mode 100644 index 0000000..83f8d95 --- /dev/null +++ b/src/lib/server/services/groceries.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { groceries, categories, references } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createTestDb } from '$lib/server/db/test-helper'; +import { + loadUnchecked, + loadAll, + toggle, + addOrUncheck, + deleteGrocery, +} from './groceries'; + +describe('groceries service', () => { + let db: Awaited<ReturnType<typeof createTestDb>>['db']; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + }); + + it('loadUnchecked returns only unchecked items', async () => { + const now = new Date().toISOString(); + await db.insert(groceries).values([ + { name: 'milk', checked: false, createdAt: now }, + { name: 'bread', checked: true, createdAt: now }, + ]); + + const result = await loadUnchecked(db); + expect(result.items).toHaveLength(1); + expect(result.items[0].name).toBe('milk'); + }); + + it('loadAll returns all items', async () => { + const now = new Date().toISOString(); + await db.insert(groceries).values([ + { name: 'milk', checked: false, createdAt: now }, + { name: 'bread', checked: true, createdAt: now }, + ]); + + const result = await loadAll(db); + expect(result.items).toHaveLength(2); + }); + + it('toggle flips checked state', async () => { + const now = new Date().toISOString(); + await db + .insert(groceries) + .values({ name: 'milk', checked: false, createdAt: now }); + const [item] = await db.select().from(groceries); + + await toggle(item.id, db); + + const [updated] = await db + .select() + .from(groceries) + .where(eq(groceries.id, item.id)); + expect(updated.checked).toBe(true); + }); + + it('addOrUncheck creates a new grocery', async () => { + const { item, created } = await addOrUncheck('eggs', null, db); + + expect(created).toBe(true); + expect(item.name).toBe('eggs'); + + const rows = await db.select().from(groceries); + expect(rows).toHaveLength(1); + }); + + it('addOrUncheck unchecks an existing checked item', async () => { + const now = new Date().toISOString(); + await db + .insert(groceries) + .values({ name: 'milk', checked: true, createdAt: now }); + + const { item, created } = await addOrUncheck('milk', null, db); + + expect(created).toBe(false); + expect(item.checked).toBe(false); + + const rows = await db.select().from(groceries); + expect(rows).toHaveLength(1); + expect(rows[0].checked).toBe(false); + }); + + it('deleteGrocery removes the item', async () => { + const now = new Date().toISOString(); + await db + .insert(groceries) + .values({ name: 'milk', checked: false, createdAt: now }); + const [item] = await db.select().from(groceries); + + await deleteGrocery(item.id, db); + + 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 new file mode 100644 index 0000000..fb42224 --- /dev/null +++ b/src/lib/server/services/groceries.ts @@ -0,0 +1,122 @@ +import { db as defaultDb } from '$lib/server/db'; +import { groceries, categories, references } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; +import type * as schema from '$lib/server/db/schema'; +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)), + db.select().from(categories).orderBy(desc(categories.name)), + db.select().from(references).orderBy(desc(references.qualityScore)), + ]); + + return { + items: enrichItems(items, cats, refs), + categories: cats, + }; +} + +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)), + db.select().from(categories).orderBy(desc(categories.name)), + db.select().from(references).orderBy(desc(references.qualityScore)), + ]); + + return { + items: enrichItems(items, cats, refs), + categories: cats, + }; +} + +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'); + + await db + .update(groceries) + .set({ checked: !item.checked }) + .where(eq(groceries.id, id)); +} + +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); + + 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, + }; + } + + const [inserted] = await db + .insert(groceries) + .values({ + name, + categoryId, + createdAt: new Date().toISOString(), + }) + .returning(); + + return { item: inserted, created: true }; +} + +export async function deleteGrocery(id: number, db = defaultDb): Promise<void> { + await db.delete(groceries).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); + } + } + + return rawItems.map((i) => ({ + ...i, + categoryName: i.categoryId ? (catMap.get(i.categoryId) ?? null) : null, + bestRef: bestRef.get(i.id) ?? null, + })); +} diff --git a/src/lib/server/services/references.test.ts b/src/lib/server/services/references.test.ts new file mode 100644 index 0000000..c02f9e6 --- /dev/null +++ b/src/lib/server/services/references.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { groceries, references } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createTestDb } from '$lib/server/db/test-helper'; +import { loadForGrocery, add, deleteReference } from './references'; + +describe('references service', () => { + let db: Awaited<ReturnType<typeof createTestDb>>['db']; + + beforeEach(async () => { + const testDb = await createTestDb(); + db = testDb.db; + }); + + it('loadForGrocery returns refs for the given grocery', async () => { + const now = new Date().toISOString(); + const [g1] = await db + .insert(groceries) + .values({ name: 'coffee', checked: false, createdAt: now }) + .returning(); + const [g2] = await db + .insert(groceries) + .values({ name: 'tea', checked: false, createdAt: now }) + .returning(); + + await db.insert(references).values([ + { groceryId: g1.id, reference: 'Brand A' }, + { groceryId: g2.id, reference: 'Brand B' }, + ]); + + const refs = await loadForGrocery(g1.id, db); + expect(refs).toHaveLength(1); + expect(refs[0].reference).toBe('Brand A'); + }); + + it('add creates a reference', async () => { + const now = new Date().toISOString(); + const [g] = await db + .insert(groceries) + .values({ name: 'pasta', checked: false, createdAt: now }) + .returning(); + + const ref = await add(g.id, 'Barilla', 2.5, '€/kg', 85, db); + expect(ref.reference).toBe('Barilla'); + expect(ref.price).toBe(2.5); + + const rows = await db + .select() + .from(references) + .where(eq(references.groceryId, g.id)); + expect(rows).toHaveLength(1); + }); + + it('deleteReference removes the ref', async () => { + const now = new Date().toISOString(); + const [g] = await db + .insert(groceries) + .values({ name: 'rice', checked: false, createdAt: now }) + .returning(); + const [ref] = await db + .insert(references) + .values({ groceryId: g.id, reference: 'Brand X' }) + .returning(); + + await deleteReference(ref.id, db); + + 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 new file mode 100644 index 0000000..99ba487 --- /dev/null +++ b/src/lib/server/services/references.ts @@ -0,0 +1,38 @@ +import { db as defaultDb } from '$lib/server/db'; +import { references } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import type { AppDatabase } from './groceries'; +import type { Reference } from './types'; + +export async function loadForGrocery( + groceryId: number, + db = defaultDb, +): Promise<Reference[]> { + return db + .select() + .from(references) + .where(eq(references.groceryId, groceryId)) + .orderBy(desc(references.qualityScore)); +} + +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; +} + +export async function deleteReference( + id: number, + db = defaultDb, +): Promise<void> { + await db.delete(references).where(eq(references.id, id)); +} diff --git a/src/lib/server/services/types.ts b/src/lib/server/services/types.ts new file mode 100644 index 0000000..e5e3918 --- /dev/null +++ b/src/lib/server/services/types.ts @@ -0,0 +1,6 @@ +import type { InferSelectModel } from 'drizzle-orm'; +import type { categories, groceries, references } from '$lib/server/db/schema'; + +export type Category = InferSelectModel<typeof categories>; +export type Grocery = InferSelectModel<typeof groceries>; +export type Reference = InferSelectModel<typeof references>; diff --git a/src/lib/server/services/validation.ts b/src/lib/server/services/validation.ts new file mode 100644 index 0000000..a8cebd9 --- /dev/null +++ b/src/lib/server/services/validation.ts @@ -0,0 +1,45 @@ +import * as v from 'valibot'; + +export const IdSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); + +export const AddGrocerySchema = v.object({ + name: v.pipe(v.string(), v.minLength(1)), + categoryId: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(1))), +}); + +export const AddReferenceSchema = v.object({ + reference: v.pipe(v.string(), v.minLength(1)), + price: v.nullable(v.pipe(v.number(), v.minValue(0))), + unit: v.nullable(v.string()), + qualityScore: v.nullable( + v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(100)), + ), +}); + +export function numericId( + value: FormDataEntryValue | null | undefined, +): number { + const str = value?.toString().trim() ?? ''; + const n = Number(str); + if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) { + throw new Error('Invalid id'); + } + return n; +} + +export function parseFormString( + value: FormDataEntryValue | null | undefined, +): string | null { + const str = value?.toString().trim() ?? ''; + return str || null; +} + +export function parseOptionalNumber( + value: FormDataEntryValue | null | undefined, +): number | null { + const str = value?.toString().trim() ?? ''; + if (!str) return null; + const n = Number(str); + if (!Number.isFinite(n)) return null; + return n; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 0a8a3f4..9675ad8 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,127 +1,62 @@ -import { fail, redirect } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; -import { groceries, categories, references } from '$lib/server/db/schema'; -import { eq, desc } from 'drizzle-orm'; +import { fail, redirect, error } from '@sveltejs/kit'; +import { + loadUnchecked, + toggle, + addOrUncheck, +} from '$lib/server/services/groceries'; +import { + numericId, + parseFormString, + parseOptionalNumber, +} from '$lib/server/services/validation'; +import type { EnrichedGrocery } from '$lib/server/services/groceries'; +import type { Category } from '$lib/server/services/types'; import type { Actions, PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { - const [items, cats, refs] = await Promise.all([ - db - .select() - .from(groceries) - .where(eq(groceries.checked, false)) - .orderBy(desc(groceries.createdAt)), - db.select().from(categories).orderBy(desc(categories.name)), - db.select().from(references).orderBy(desc(references.qualityScore)), - ]); - - const catMap = new Map(cats.map((c) => [c.id, c.name])); - const bestRef = new Map<number, (typeof refs)[number]>(); - for (const ref of refs) { - if (!bestRef.has(ref.groceryId)) { - bestRef.set(ref.groceryId, ref); - } - } +export interface HomeOutput { + items: EnrichedGrocery[]; + categories: Category[]; +} - return { - items: items.map((i) => ({ - ...i, - categoryName: i.categoryId ? (catMap.get(i.categoryId) ?? null) : null, - bestRef: bestRef.get(i.id) ?? null, - })), - categories: cats, - }; +export const load: PageServerLoad = async () => { + return loadUnchecked(); }; export const actions: Actions = { add: async ({ request }) => { const data = await request.formData(); - const name = data.get('name')?.toString().trim(); - const categoryId = data.get('category_id')?.toString() || null; + const name = parseFormString(data.get('name')); + const categoryId = parseOptionalNumber(data.get('category_id')); if (!name) { return fail(400, { error: 'Le nom est requis' }); } - const [existing] = await db - .select() - .from(groceries) - .where(eq(groceries.name, name)) - .limit(1); - - if (existing) { - await db - .update(groceries) - .set({ - checked: false, - categoryId: categoryId ? Number(categoryId) : existing.categoryId, - }) - .where(eq(groceries.id, existing.id)); - } else { - await db.insert(groceries).values({ - name, - categoryId: categoryId ? Number(categoryId) : null, - createdAt: new Date().toISOString(), - }); - } - + await addOrUncheck(name, categoryId); return { success: true }; }, toggle: async ({ request }) => { const data = await request.formData(); - const id = Number(data.get('id')); - - const [item] = await db - .select() - .from(groceries) - .where(eq(groceries.id, id)); - if (!item) { - return fail(404, { error: 'Article introuvable' }); + try { + const id = numericId(data.get('id')); + await toggle(id); + } catch { + return fail(400, { error: 'ID invalide' }); } - - await db - .update(groceries) - .set({ checked: !item.checked }) - .where(eq(groceries.id, id)); - return { success: true }; }, addAndEdit: async ({ request }) => { const data = await request.formData(); - const name = data.get('name')?.toString().trim(); - const categoryId = data.get('category_id')?.toString() || null; + const name = parseFormString(data.get('name')); + const categoryId = parseOptionalNumber(data.get('category_id')); if (!name) { return fail(400, { error: 'Le nom est requis' }); } - const [existing] = await db - .select() - .from(groceries) - .where(eq(groceries.name, name)) - .limit(1); - - if (existing) { - await db - .update(groceries) - .set({ - checked: false, - categoryId: categoryId ? Number(categoryId) : existing.categoryId, - }) - .where(eq(groceries.id, existing.id)); - throw redirect(303, `/grocery/${existing.id}`); - } else { - const [inserted] = await db - .insert(groceries) - .values({ - name, - categoryId: categoryId ? Number(categoryId) : null, - createdAt: new Date().toISOString(), - }) - .returning(); - throw redirect(303, `/grocery/${inserted.id}`); - } + const { item, created } = await addOrUncheck(name, categoryId); + throw redirect(303, `/grocery/${item.id}`); }, }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3dcd3bc..2392790 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import '../app.css'; import { enhance } from '$app/forms'; let { data, form } = $props(); @@ -19,6 +18,7 @@ <form class="add-bar" method="POST" action="?/add" use:enhance> <input + class="form-input" type="text" name="name" bind:value={name} @@ -26,7 +26,9 @@ autocomplete="off" /> <button type="submit" aria-label="Ajouter">+</button> - <button type="submit" formaction="?/addAndEdit" aria-label="Modifier">→</button> + <button type="submit" formaction="?/addAndEdit" aria-label="Modifier" + >→</button + > </form> {#if form?.error} @@ -41,20 +43,26 @@ <h3 class="category-name">{cat}</h3> <ul class="item-list"> {#each items as item (item.id)} - <li class="catalog-item"> - <button name="id" value={item.id} type="submit"> + <li class="item-row item-row--fixed"> + <button + class="item-btn item-btn--grow" + name="id" + value={item.id} + type="submit" + aria-pressed={item.checked} + > <span class="item-text"> {item.name} {#if item.bestRef} - <span class="best-ref" - >{item.bestRef.reference} - {#if item.bestRef.price && item.bestRef.unit} - {item.bestRef.price.toFixed(2)} {item.bestRef.unit} - {/if} - {#if item.bestRef.qualityScore != null} - ({item.bestRef.qualityScore}/100) - {/if} - </span> + <span class="best-ref" + >{item.bestRef.reference} + {#if item.bestRef.price && item.bestRef.unit} + {item.bestRef.price.toFixed(2)} {item.bestRef.unit} + {/if} + {#if item.bestRef.qualityScore != null} + ({item.bestRef.qualityScore}/100) + {/if} + </span> {/if} </span> </button> @@ -77,22 +85,9 @@ z-index: 10; } - input { + .add-bar input { flex: 1; - padding: 0.75rem 1rem; - border: 1.5px solid var(--border); - border-radius: 0.75rem; - font-size: 1rem; - font-family: inherit; background: var(--surface); - min-width: 0; - transition: border-color 0.2s, box-shadow 0.2s; - } - - input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.15); } .add-bar button { @@ -136,116 +131,11 @@ } .error { - color: var(--danger); - font-size: 0.875rem; margin-bottom: 1rem; - padding: 0.5rem 0.75rem; - background: rgba(217, 83, 79, 0.08); - border-radius: 0.5rem; - } - - .item-list { - list-style: none; - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .item-list li button { - width: 100%; - text-align: left; - padding: 0 0.875rem; - height: 100%; - border: none; - border-radius: 0.75rem; - background: var(--surface); - font-size: 1rem; - font-weight: 500; - display: flex; - align-items: center; - transition: all 0.2s ease; - box-shadow: 0 1px 2px var(--shadow); - } - - .item-list li button:hover { - background: #fff8f5; - box-shadow: 0 2px 6px rgba(217, 119, 87, 0.1); - transform: translateY(-1px); - } - - .catalog-item { - display: flex; - gap: 0.5rem; - align-items: center; - height: 2.75rem; - } - - .catalog-item button:first-child { - flex-grow: 1; - width: auto; - display: flex; - align-items: center; - height: 100%; - } - - .item-text { - display: flex; - flex-direction: column; - text-align: left; - line-height: 1.2; - gap: 0.0625rem; - overflow: hidden; - } - - .best-ref { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 400; - } - - .ref-link { - width: 2.75rem; - flex-shrink: 0; - height: 2.75rem; - aspect-ratio: 1; - display: flex; - align-items: center; - justify-content: center; - border-radius: 0.75rem; - background: var(--surface); - color: var(--text-secondary); - text-decoration: none; - font-size: 1rem; - transition: all 0.2s ease; - box-shadow: 0 1px 2px var(--shadow); - } - - .ref-link:hover { - background: #fff8f5; - color: var(--accent); - box-shadow: 0 2px 6px rgba(217, 119, 87, 0.1); - transform: translateY(-1px); } .empty { - color: var(--text-secondary); - font-size: 0.9375rem; margin-top: 1rem; - text-align: center; padding: 2rem; - background: var(--surface); - border-radius: 0.75rem; - box-shadow: 0 1px 2px var(--shadow); - } - - .category-name { - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent); - margin: 1.25rem 0 0.5rem; - padding-left: 0.5rem; - border-left: 3px solid var(--accent); } </style> diff --git a/src/routes/catalog/+page.server.ts b/src/routes/catalog/+page.server.ts index a3a611e..69a775a 100644 --- a/src/routes/catalog/+page.server.ts +++ b/src/routes/catalog/+page.server.ts @@ -1,52 +1,28 @@ import { fail } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; -import { groceries, categories, references } from '$lib/server/db/schema'; -import { eq, desc } from 'drizzle-orm'; +import { loadAll, toggle } from '$lib/server/services/groceries'; +import { numericId } from '$lib/server/services/validation'; +import type { EnrichedGrocery } from '$lib/server/services/groceries'; +import type { Category } from '$lib/server/services/types'; import type { Actions, PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { - const [items, cats, refs] = await Promise.all([ - db.select().from(groceries).orderBy(desc(groceries.createdAt)), - db.select().from(categories).orderBy(desc(categories.name)), - db.select().from(references).orderBy(desc(references.qualityScore)), - ]); - - const catMap = new Map(cats.map((c) => [c.id, c.name])); - const bestRef = new Map<number, (typeof refs)[number]>(); - for (const ref of refs) { - if (!bestRef.has(ref.groceryId)) { - bestRef.set(ref.groceryId, ref); - } - } +export interface CatalogOutput { + items: EnrichedGrocery[]; + categories: Category[]; +} - return { - items: items.map((i) => ({ - ...i, - categoryName: i.categoryId ? (catMap.get(i.categoryId) ?? null) : null, - bestRef: bestRef.get(i.id) ?? null, - })), - categories: cats, - }; +export const load: PageServerLoad = async () => { + return loadAll(); }; export const actions: Actions = { toggle: async ({ request }) => { const data = await request.formData(); - const id = Number(data.get('id')); - - const [item] = await db - .select() - .from(groceries) - .where(eq(groceries.id, id)); - if (!item) { - return fail(404, { error: 'Article introuvable' }); + try { + const id = numericId(data.get('id')); + await toggle(id); + } catch { + return fail(400, { error: 'ID invalide' }); } - - await db - .update(groceries) - .set({ checked: !item.checked }) - .where(eq(groceries.id, id)); - return { success: true }; }, }; diff --git a/src/routes/catalog/+page.svelte b/src/routes/catalog/+page.svelte index 0fda271..4b43c02 100644 --- a/src/routes/catalog/+page.svelte +++ b/src/routes/catalog/+page.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - import '../../app.css'; import { enhance } from '$app/forms'; let { data, form } = $props(); @@ -24,20 +23,26 @@ <h3 class="category-name">{cat}</h3> <ul class="item-list"> {#each items as item (item.id)} - <li class="catalog-item"> - <button name="id" value={item.id} type="submit"> + <li class="item-row item-row--fixed"> + <button + class="item-btn item-btn--grow" + name="id" + value={item.id} + type="submit" + aria-pressed={item.checked} + > <span class="item-text"> <span class:checked={item.checked}>{item.name}</span> {#if item.bestRef} - <span class="best-ref" - >{item.bestRef.reference} - {#if item.bestRef.price && item.bestRef.unit} - {item.bestRef.price.toFixed(2)} {item.bestRef.unit} - {/if} - {#if item.bestRef.qualityScore != null} - ({item.bestRef.qualityScore}/100) - {/if} - </span> + <span class="best-ref" + >{item.bestRef.reference} + {#if item.bestRef.price && item.bestRef.unit} + {item.bestRef.price.toFixed(2)} {item.bestRef.unit} + {/if} + {#if item.bestRef.qualityScore != null} + ({item.bestRef.qualityScore}/100) + {/if} + </span> {/if} </span> </button> @@ -50,114 +55,14 @@ {/if} <style> - .item-list { - list-style: none; - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .item-list li button { - width: 100%; - text-align: left; - padding: 0 0.875rem; - height: 100%; - border: none; - border-radius: 0.75rem; - background: var(--surface); - font-size: 1rem; - font-weight: 500; - display: flex; - align-items: center; - transition: all 0.2s ease; - box-shadow: 0 1px 2px var(--shadow); - } - - .item-list li button:hover { - background: #fff8f5; - box-shadow: 0 2px 6px rgba(217, 119, 87, 0.1); - transform: translateY(-1px); - } - - .catalog-item { - display: flex; - gap: 0.5rem; - align-items: center; - height: 2.75rem; - } - - .catalog-item button:first-child { - flex-grow: 1; - width: auto; - display: flex; - align-items: center; - height: 100%; - } - - .item-text { - display: flex; - flex-direction: column; - text-align: left; - line-height: 1.2; - gap: 0.0625rem; - overflow: hidden; - } - .checked { text-decoration: line-through; color: var(--text-secondary); font-weight: 400; } - .best-ref { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 400; - } - - .ref-link { - width: 2.75rem; - flex-shrink: 0; - height: 2.75rem; - aspect-ratio: 1; - display: flex; - align-items: center; - justify-content: center; - border-radius: 0.75rem; - background: var(--surface); - color: var(--text-secondary); - text-decoration: none; - font-size: 1rem; - transition: all 0.2s ease; - box-shadow: 0 1px 2px var(--shadow); - } - - .ref-link:hover { - background: #fff8f5; - color: var(--accent); - box-shadow: 0 2px 6px rgba(217, 119, 87, 0.1); - transform: translateY(-1px); - } - .empty { - color: var(--text-secondary); - font-size: 0.9375rem; margin-top: 1rem; - text-align: center; padding: 2rem; - background: var(--surface); - border-radius: 0.75rem; - box-shadow: 0 1px 2px var(--shadow); - } - - .category-name { - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent); - margin: 1.25rem 0 0.5rem; - padding-left: 0.5rem; - border-left: 3px solid var(--accent); } </style> diff --git a/src/routes/categories/+page.server.ts b/src/routes/categories/+page.server.ts index 7cedd98..ac53ca8 100644 --- a/src/routes/categories/+page.server.ts +++ b/src/routes/categories/+page.server.ts @@ -1,38 +1,38 @@ import { fail } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; -import { categories } from '$lib/server/db/schema'; -import { eq, asc } from 'drizzle-orm'; +import { loadAll, add, deleteCategory } from '$lib/server/services/categories'; +import { numericId, parseFormString } from '$lib/server/services/validation'; +import type { Category } from '$lib/server/services/types'; import type { Actions, PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { - const items = await db - .select() - .from(categories) - .orderBy(asc(categories.name)); +export interface CategoriesOutput { + categories: Category[]; +} - return { categories: items }; +export const load: PageServerLoad = async () => { + return { categories: await loadAll() }; }; export const actions: Actions = { add: async ({ request }) => { const data = await request.formData(); - const name = data.get('name')?.toString().trim(); + const name = parseFormString(data.get('name')); if (!name) { return fail(400, { error: 'Le nom est requis' }); } - await db.insert(categories).values({ name }); - + await add(name); return { success: true }; }, delete: async ({ request }) => { const data = await request.formData(); - const id = Number(data.get('id')); - - await db.delete(categories).where(eq(categories.id, id)); - + try { + const id = numericId(data.get('id')); + await deleteCategory(id); + } catch { + return fail(400, { error: 'ID invalide' }); + } return { success: true }; }, }; diff --git a/src/routes/categories/+page.svelte b/src/routes/categories/+page.svelte index eab0219..41b40c5 100644 --- a/src/routes/categories/+page.svelte +++ b/src/routes/categories/+page.svelte @@ -1,15 +1,20 @@ <script lang="ts"> - import '../../app.css'; import { enhance } from '$app/forms'; let { data, form } = $props(); let name = $state(''); </script> -<form class="add-form" method="POST" action="?/add" use:enhance> +<form + class="form-card form-card--compact" + method="POST" + action="?/add" + use:enhance +> <label> - <span class="label">Nouvelle catégorie</span> + <span class="form-label">Nouvelle catégorie</span> <input + class="form-input" type="text" name="name" bind:value={name} @@ -17,7 +22,7 @@ autocomplete="off" /> </label> - <button type="submit">Ajouter</button> + <button class="btn-primary" type="submit">Ajouter</button> </form> {#if form?.error} @@ -27,9 +32,15 @@ <form method="POST" action="?/delete" use:enhance> <ul class="item-list"> {#each data?.categories ?? [] as cat (cat.id)} - <li class="catalog-item"> + <li class="item-row item-row--card"> <span class="cat-name">{cat.name}</span> - <button class="delete-btn" name="id" value={cat.id} type="submit"> + <button + class="btn-ghost" + name="id" + value={cat.id} + type="submit" + aria-label="Supprimer" + > × </button> </li> @@ -42,136 +53,19 @@ {/if} <style> - .add-form { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1.5rem; - padding: 1rem; - background: var(--surface); - border-radius: 0.75rem; - box-shadow: 0 1px 2px var(--shadow); - } - - .add-form label { - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .label { - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-secondary); - } - - input { - padding: 0.75rem 1rem; - border: 1.5px solid var(--border); - border-radius: 0.75rem; - font-size: 1rem; - font-family: inherit; - background: var(--bg); - transition: border-color 0.2s, box-shadow 0.2s; - } - - input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.15); - } - - .add-form button[type='submit'] { - padding: 0.75rem 1.5rem; - border: none; - border-radius: 0.75rem; - background: var(--accent); - color: #fff; - font-size: 0.9375rem; - font-weight: 500; - align-self: flex-start; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 1px 2px var(--shadow); - } - - .add-form button[type='submit']:hover { - background: var(--accent-hover); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(217, 119, 87, 0.2); - } - - .error { - color: var(--danger); - font-size: 0.875rem; - margin-top: -0.25rem; - margin-bottom: 0.5rem; - padding: 0.5rem 0.75rem; - background: rgba(217, 83, 79, 0.08); - border-radius: 0.5rem; - } - - .item-list { - list-style: none; - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .catalog-item { - display: flex; - gap: 0.5rem; - align-items: center; - padding: 0.5rem 0.75rem; - background: var(--surface); - border-radius: 0.75rem; - box-shadow: 0 1px 2px var(--shadow); - transition: all 0.2s ease; - } - - .catalog-item:hover { - box-shadow: 0 2px 6px var(--shadow); - transform: translateY(-1px); - } - .cat-name { flex-grow: 1; font-weight: 500; color: var(--text); } - .delete-btn { - border: none; - border-radius: 0.625rem; - background: transparent; - color: var(--text-secondary); - transition: all 0.2s ease; - width: 2rem; - aspect-ratio: 1; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; - cursor: pointer; - flex-shrink: 0; - } - - .delete-btn:hover { - background: rgba(217, 83, 79, 0.1); - color: var(--danger); + .error { + margin-top: -0.25rem; + margin-bottom: 0.5rem; } .empty { - color: var(--text-secondary); - font-size: 0.9375rem; margin-top: 1rem; - text-align: center; padding: 2rem; - background: var(--surface); - border-radius: 0.75rem; - box-shadow: 0 1px 2px var(--shadow); } </style> diff --git a/src/routes/grocery/[id]/+page.server.ts b/src/routes/grocery/[id]/+page.server.ts index b19825a..3c3967a 100644 --- a/src/routes/grocery/[id]/+page.server.ts +++ b/src/routes/grocery/[id]/+page.server.ts @@ -1,11 +1,28 @@ import { error, fail, redirect } from '@sveltejs/kit'; import { db } from '$lib/server/db'; -import { groceries, categories, references } from '$lib/server/db/schema'; +import { groceries, categories } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; +import { + loadForGrocery, + add as addRef, + deleteReference, +} from '$lib/server/services/references'; +import { deleteGrocery } from '$lib/server/services/groceries'; +import { + numericId, + parseFormString, + parseOptionalNumber, +} from '$lib/server/services/validation'; +import type { Grocery, Reference } from '$lib/server/services/types'; import type { Actions, PageServerLoad } from './$types'; +export interface GroceryDetailOutput { + item: Grocery & { categoryName: string | null }; + references: Reference[]; +} + export const load: PageServerLoad = async ({ params }) => { - const id = Number(params.id); + const id = numericId(params.id); const [item] = await db.select().from(groceries).where(eq(groceries.id, id)); if (!item) throw error(404, 'Article introuvable'); @@ -17,10 +34,7 @@ export const load: PageServerLoad = async ({ params }) => { .where(eq(categories.id, item.categoryId)) : [null]; - const refs = await db - .select() - .from(references) - .where(eq(references.groceryId, id)); + const refs = await loadForGrocery(id); return { item: { @@ -34,38 +48,34 @@ export const load: PageServerLoad = async ({ params }) => { export const actions: Actions = { add: async ({ request, params }) => { const data = await request.formData(); - const reference = data.get('reference')?.toString().trim(); - const price = data.get('price')?.toString().trim(); - const unit = data.get('unit')?.toString().trim(); - const qualityScore = data.get('quality_score')?.toString().trim(); + const groceryId = numericId(params.id); + const reference = parseFormString(data.get('reference')); + const price = parseOptionalNumber(data.get('price')); + const unit = parseFormString(data.get('unit')); + const qualityScore = parseOptionalNumber(data.get('quality_score')); if (!reference) { return fail(400, { error: 'La référence est requise' }); } - await db.insert(references).values({ - groceryId: Number(params.id), - reference, - price: price ? Number(price) : null, - unit: unit || null, - qualityScore: qualityScore ? Number(qualityScore) : null, - }); - + await addRef(groceryId, reference, price, unit, qualityScore); return { success: true }; }, delete: async ({ request }) => { const data = await request.formData(); - const id = Number(data.get('id')); - - await db.delete(references).where(eq(references.id, id)); - + try { + const id = numericId(data.get('id')); + await deleteReference(id); + } catch { + return fail(400, { error: 'ID invalide' }); + } return { success: true }; }, deleteItem: async ({ params }) => { - await db.delete(groceries).where(eq(groceries.id, Number(params.id))); - + const groceryId = numericId(params.id); + await deleteGrocery(groceryId); throw redirect(303, '/'); }, }; diff --git a/src/routes/grocery/[id]/+page.svelte b/src/routes/grocery/[id]/+page.svelte index c3c3706..a49d037 100644 --- a/src/routes/grocery/[id]/+page.svelte +++ b/src/routes/grocery/[id]/+page.svelte @@ -1,7 +1,5 @@ <script lang="ts"> - import '../../../app.css'; import { enhance } from '$app/forms'; - import { page } from '$app/stores'; let { data, form } = $props(); let reference = $state(''); @@ -20,10 +18,11 @@ <h2>Références</h2> -<form class="add-form" method="POST" action="?/add" use:enhance> +<form class="form-card" method="POST" action="?/add" use:enhance> <label> - <span class="label">Référence</span> + <span class="form-label">Référence</span> <input + class="form-input" type="text" name="reference" bind:value={reference} @@ -33,8 +32,9 @@ </label> <div class="ref-row"> <label> - <span class="label">Prix</span> + <span class="form-label">Prix</span> <input + class="form-input" type="number" name="price" bind:value={price} @@ -43,16 +43,17 @@ /> </label> <label> - <span class="label">Unité</span> - <select name="unit" bind:value={unit}> + <span class="form-label">Unité</span> + <select class="form-select" name="unit" bind:value={unit}> <option value="€/kg">€/kg</option> <option value="€/L">€/L</option> <option value="€/u">€/u</option> </select> </label> <label> - <span class="label">Note</span> + <span class="form-label">Note</span> <input + class="form-input" type="number" name="quality_score" bind:value={qualityScore} @@ -62,7 +63,7 @@ /> </label> </div> - <button type="submit">Ajouter une référence</button> + <button class="btn-primary" type="submit">Ajouter une référence</button> </form> {#if form?.error} @@ -70,9 +71,9 @@ {/if} <form method="POST" action="?/delete" use:enhance> - <ul class="item-list"> + <ul class="item-list item-list--wide"> {#each data?.references ?? [] as ref (ref.id)} - <li class="ref-item"> + <li class="item-row item-row--ref"> <div class="ref-info"> <strong>{ref.reference}</strong> {#if ref.price} @@ -84,7 +85,13 @@ <span class="ref-detail">Note : {ref.qualityScore}/100</span> {/if} </div> - <button class="delete-btn" name="id" value={ref.id} type="submit"> + <button + class="btn-ghost" + name="id" + value={ref.id} + type="submit" + aria-label="Supprimer" + > × </button> </li> @@ -110,51 +117,6 @@ margin-top: 0.25rem; } - .add-form { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin-bottom: 1.5rem; - margin-top: 0.75rem; - padding: 1rem; - background: var(--surface); - border-radius: 0.75rem; - box-shadow: 0 1px 2px var(--shadow); - } - - .add-form label { - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .label { - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-secondary); - } - - input, - select { - padding: 0.75rem 1rem; - border: 1.5px solid var(--border); - border-radius: 0.75rem; - font-size: 1rem; - font-family: inherit; - background: var(--bg); - transition: border-color 0.2s, box-shadow 0.2s; - min-width: 0; - } - - input:focus, - select:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.15); - } - .ref-row { display: flex; gap: 0.75rem; @@ -166,65 +128,6 @@ min-width: 6rem; } - .add-form button[type='submit'] { - padding: 0.75rem 1.5rem; - border: none; - border-radius: 0.75rem; - background: var(--accent); - color: #fff; - font-size: 0.9375rem; - font-weight: 500; - align-self: flex-start; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 1px 2px var(--shadow); - } - - .add-form button[type='submit']:hover { - background: var(--accent-hover); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(217, 119, 87, 0.2); - } - - .error { - color: var(--danger); - font-size: 0.875rem; - margin-top: -0.25rem; - margin-bottom: 0.5rem; - padding: 0.5rem 0.75rem; - background: rgba(217, 83, 79, 0.08); - border-radius: 0.5rem; - } - - hr { - border: none; - border-top: 1.5px solid var(--border); - margin: 1.5rem 0; - } - - .item-list { - list-style: none; - display: flex; - flex-direction: column; - gap: 0.5rem; - } - - .ref-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem 1rem; - border-radius: 0.75rem; - background: var(--surface); - box-shadow: 0 1px 2px var(--shadow); - transition: all 0.2s ease; - } - - .ref-item:hover { - box-shadow: 0 2px 6px var(--shadow); - transform: translateY(-1px); - } - .ref-info { flex-grow: 1; display: flex; @@ -242,37 +145,20 @@ border-radius: 0.375rem; } - .delete-btn { - flex-shrink: 0; - width: 2rem; - aspect-ratio: 1; - padding: 0; - border: none; - border-radius: 0.625rem; - background: transparent; - color: var(--text-secondary); - font-size: 1.25rem; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s ease; - } - - .delete-btn:hover { - background: rgba(217, 83, 79, 0.1); - color: var(--danger); + .error { + margin-top: -0.25rem; + margin-bottom: 0.5rem; } .empty { - color: var(--text-secondary); - font-size: 0.9375rem; margin-top: 0.75rem; - text-align: center; padding: 1.5rem; - background: var(--surface); - border-radius: 0.75rem; - box-shadow: 0 1px 2px var(--shadow); + } + + hr { + border: none; + border-top: 1.5px solid var(--border); + margin: 1.5rem 0; } .back-link { diff --git a/src/smoke.test.ts b/src/smoke.test.ts deleted file mode 100644 index ae2b326..0000000 --- a/src/smoke.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('smoke', () => { - it('math works', () => { - expect(1 + 1).toBe(2); - }); -}); |
