From 04fabf115ccea31ac504b06cc51d78423b697f60 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 28 Jul 2026 15:07:57 +0200 Subject: 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 --- README.md | 8 +- drizzle/0001_big_rachel_grey.sql | 15 ++ drizzle/meta/0001_snapshot.json | 172 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + package-lock.json | 19 ++- package.json | 7 +- scripts/migrate.js | 21 +-- src/app.css | 246 +++++++++++++++++++++++++++++ src/lib/components/Nav.svelte | 3 +- src/lib/server/db/groceries.test.ts | 21 +-- src/lib/server/db/ondelete.test.ts | 32 ++++ src/lib/server/db/schema.ts | 4 +- src/lib/server/db/test-helper.ts | 13 ++ src/lib/server/services/categories.test.ts | 51 ++++++ src/lib/server/services/categories.ts | 21 +++ src/lib/server/services/groceries.test.ts | 98 ++++++++++++ src/lib/server/services/groceries.ts | 122 ++++++++++++++ src/lib/server/services/references.test.ts | 70 ++++++++ src/lib/server/services/references.ts | 38 +++++ src/lib/server/services/types.ts | 6 + src/lib/server/services/validation.ts | 45 ++++++ src/routes/+page.server.ts | 127 ++++----------- src/routes/+page.svelte | 154 +++--------------- src/routes/catalog/+page.server.ts | 54 ++----- src/routes/catalog/+page.svelte | 129 ++------------- src/routes/categories/+page.server.ts | 32 ++-- src/routes/categories/+page.svelte | 146 +++-------------- src/routes/grocery/[id]/+page.server.ts | 58 ++++--- src/routes/grocery/[id]/+page.svelte | 170 ++++---------------- src/smoke.test.ts | 7 - 30 files changed, 1162 insertions(+), 734 deletions(-) create mode 100644 drizzle/0001_big_rachel_grey.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/lib/server/db/ondelete.test.ts create mode 100644 src/lib/server/db/test-helper.ts create mode 100644 src/lib/server/services/categories.test.ts create mode 100644 src/lib/server/services/categories.ts create mode 100644 src/lib/server/services/groceries.test.ts create mode 100644 src/lib/server/services/groceries.ts create mode 100644 src/lib/server/services/references.test.ts create mode 100644 src/lib/server/services/references.ts create mode 100644 src/lib/server/services/types.ts create mode 100644 src/lib/server/services/validation.ts delete mode 100644 src/smoke.test.ts diff --git a/README.md b/README.md index 86ce6b4..5046e06 100644 --- a/README.md +++ b/README.md @@ -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 @@