Groceries: private tracker at /trackers/groceries (stores, products, price history per store, favorites, cheapest highlighted); seeded AH, Jumbo, Lidl, Plus
All checks were successful
Deploy production / deploy (push) Successful in 2m40s

This commit is contained in:
2026-10-24 13:25:49 +00:00
parent b77c3f8cf5
commit 5f67ad36e7
16 changed files with 17010 additions and 2 deletions

View File

@@ -22,7 +22,8 @@ export default defineConfig({
'meta',
'health',
'books',
'tasks'
'tasks',
'groceries'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,

View File

@@ -0,0 +1,112 @@
CREATE SCHEMA "groceries";
--> statement-breakpoint
CREATE TABLE "groceries"."prices" (
"id" serial PRIMARY KEY,
"product_id" integer NOT NULL,
"store_id" integer NOT NULL,
"price_cents" integer NOT NULL,
"unit_price_cents" integer,
"promo" boolean DEFAULT false NOT NULL,
"observed_at" timestamp with time zone DEFAULT now() NOT NULL,
"source" text DEFAULT 'manual' NOT NULL,
"store_product_id" text,
"url" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "prices_price_check" CHECK ("price_cents" >= 0),
CONSTRAINT "prices_unit_price_check" CHECK ("unit_price_cents" IS NULL OR "unit_price_cents" >= 0)
);
--> statement-breakpoint
CREATE TABLE "groceries"."products" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"brand" text,
"category" text,
"size" text,
"unit_amount" numeric(10,3),
"unit" text,
"favorite" boolean DEFAULT false NOT NULL,
"notes" text,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "products_name_check" CHECK (length(btrim("name")) > 0),
CONSTRAINT "products_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
CONSTRAINT "products_unit_amount_check" CHECK ("unit_amount" IS NULL OR "unit_amount" > 0)
);
--> statement-breakpoint
CREATE TABLE "groceries"."stores" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"website" text,
"color" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "stores_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
CONSTRAINT "stores_name_check" CHECK (length(btrim("name")) > 0)
);
--> statement-breakpoint
CREATE INDEX "prices_product_store_idx" ON "groceries"."prices" ("product_id","store_id","observed_at");--> statement-breakpoint
CREATE INDEX "prices_store_idx" ON "groceries"."prices" ("store_id");--> statement-breakpoint
CREATE UNIQUE INDEX "products_name_brand_key" ON "groceries"."products" (lower("name"),lower(coalesce("brand", '')));--> statement-breakpoint
CREATE INDEX "products_category_idx" ON "groceries"."products" ("category");--> statement-breakpoint
CREATE UNIQUE INDEX "stores_slug_key" ON "groceries"."stores" ("slug");--> statement-breakpoint
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_product_id_products_id_fkey" FOREIGN KEY ("product_id") REFERENCES "groceries"."products"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- updated_at triggers, same as every other core table.
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['stores','products','prices'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Private like tasks/health: app role only, no personal_ro. Grants only run on prod,
-- where migrations run as `personal`.
REVOKE ALL ON SCHEMA groceries FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA groceries TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;
--> statement-breakpoint
INSERT INTO groceries.stores (slug, name, website, color, sort_order) VALUES
('ah', 'Albert Heijn', 'https://www.ah.nl', '#00a0e2', 10),
('jumbo', 'Jumbo', 'https://www.jumbo.com', '#eeb717', 20),
('lidl', 'Lidl', 'https://www.lidl.nl', '#0050aa', 30),
('plus', 'Plus', 'https://www.plus.nl', '#e2001a', 40)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
-- Registered in meta.tables so the /lists database browser shows them (kind 'core').
DO $$
BEGIN
IF to_regclass('meta.tables') IS NOT NULL THEN
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
VALUES ('groceries','products','groceries-products','Grocery products','core',true,false,1100),
('groceries','prices','groceries-prices','Grocery prices','core',true,false,1100),
('groceries','stores','groceries-stores','Grocery stores','core',true,false,1100)
ON CONFLICT DO NOTHING;
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
PERFORM meta.sync();
END IF;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -151,6 +151,28 @@ try {
tasks.public_usage
)
throw new Error(`tasks schema grants are wrong: ${JSON.stringify(tasks)}`);
// Private grocery tracker: same rules (app role read/write, personal_ro none).
const [groceries] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'groceries', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'groceries' and c.relkind = 'r'
`;
if (
Number(groceries.tables) !== 3 ||
Number(groceries.app_rw) !== 3 ||
Number(groceries.ro_read) !== 0 ||
groceries.public_usage
)
throw new Error(`groceries schema grants are wrong: ${JSON.stringify(groceries)}`);
// Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -160,7 +182,7 @@ try {
if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select

24
src/lib/groceries.ts Normal file
View File

@@ -0,0 +1,24 @@
// Shared (client + server) grocery helpers: price comparison and formatting.
export type LatestPrice = {
storeId: number;
priceCents: number;
unitPriceCents: number | null;
promo: boolean;
observedAt: Date;
};
/** Store ids with the lowest comparable price: unit price when every store has one, else pack price. */
export function cheapestStores(prices: LatestPrice[]): Set<number> {
if (prices.length < 2) return new Set();
const byUnit = prices.every((p) => p.unitPriceCents !== null);
const val = (p: LatestPrice) => (byUnit ? p.unitPriceCents! : p.priceCents);
const min = Math.min(...prices.map(val));
return new Set(prices.filter((p) => val(p) === min).map((p) => p.storeId));
}
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
/** 179 → "€ 1,79". */
export const formatCents = (cents: number) => eur.format(cents / 100);
export const UNIT_LABEL: Record<string, string> = { kg: 'kg', l: 'L', piece: 'st' };

View File

@@ -65,6 +65,13 @@ const allTrackers = [
external: false,
private: true
},
{
name: 'groceries',
subtitle: 'private: supermarket products, favorites and prices per store',
href: '/trackers/groceries',
external: false,
private: true
},
{
name: 'energy',
subtitle: 'Dutch electricity mix, utilization and gas storage',

View File

@@ -0,0 +1,97 @@
import { sql } from 'drizzle-orm';
import { boolean, check, index, integer, numeric, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { groceriesSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Private grocery tracker (owner only, see requireOwner): the products Stan buys, which
// ones are favourites, and what they cost at each supermarket over time.
// Prices are append-only observations in cents, so price history and "cheapest now" both
// come from the same table. The latest observation per (product, store) is the current price.
export const GROCERY_UNITS = ['piece', 'kg', 'l'] as const;
export type GroceryUnit = (typeof GROCERY_UNITS)[number];
/** Supermarkets (Albert Heijn, Jumbo, Lidl, Plus, …). `slug` is the stable key for imports. */
export const groceryStores = groceriesSchema.table(
'stores',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
website: text('website'),
color: text('color'),
sortOrder: integer('sort_order').notNull().default(0),
archivedAt: timestamptz('archived_at'),
...timestamps
},
(t) => [
uniqueIndex('stores_slug_key').on(t.slug),
check('stores_slug_check', sql`${t.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
check('stores_name_check', sql`length(btrim(${t.name})) > 0`)
]
);
/**
* A product as Stan thinks of it ("Halfvolle melk 1 L"), not one store's SKU: the same
* product can be priced at several stores. `unit_amount` + `unit` (e.g. 1.5 + 'l') make
* unit prices comparable across pack sizes.
*/
export const groceryProducts = groceriesSchema.table(
'products',
{
id: id(),
name: text('name').notNull(),
brand: text('brand'),
category: text('category'),
size: text('size'), // as printed, e.g. "6 x 1 L"
unitAmount: numeric('unit_amount', { precision: 10, scale: 3, mode: 'number' }),
unit: text('unit').$type<GroceryUnit>(),
favorite: boolean('favorite').notNull().default(false),
notes: text('notes'),
archivedAt: timestamptz('archived_at'),
...timestamps
},
(t) => [
uniqueIndex('products_name_brand_key').on(
sql`lower(${t.name})`,
sql`lower(coalesce(${t.brand}, ''))`
),
index('products_category_idx').on(t.category),
check('products_name_check', sql`length(btrim(${t.name})) > 0`),
check('products_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`),
check('products_unit_amount_check', sql`${t.unitAmount} IS NULL OR ${t.unitAmount} > 0`)
]
);
/** One observed price of a product at a store (manual entry or a later import). */
export const groceryPrices = groceriesSchema.table(
'prices',
{
id: id(),
productId: integer('product_id')
.notNull()
.references(() => groceryProducts.id, { onDelete: 'cascade' }),
storeId: integer('store_id')
.notNull()
.references(() => groceryStores.id, { onDelete: 'cascade' }),
priceCents: integer('price_cents').notNull(),
unitPriceCents: integer('unit_price_cents'), // per product.unit, if known
promo: boolean('promo').notNull().default(false), // bonus / aanbieding price
observedAt: timestamptz('observed_at').notNull().defaultNow(),
source: text('source').notNull().default('manual'), // 'manual', 'ah-api', 'jumbo-api', …
storeProductId: text('store_product_id'), // e.g. AH webshopId, Jumbo SKU
url: text('url'),
notes: text('notes'),
...timestamps
},
(t) => [
index('prices_product_store_idx').on(t.productId, t.storeId, t.observedAt),
index('prices_store_idx').on(t.storeId),
check('prices_price_check', sql`${t.priceCents} >= 0`),
check('prices_unit_price_check', sql`${t.unitPriceCents} IS NULL OR ${t.unitPriceCents} >= 0`)
]
);
export type GroceryStore = typeof groceryStores.$inferSelect;
export type GroceryProduct = typeof groceryProducts.$inferSelect;
export type GroceryPrice = typeof groceryPrices.$inferSelect;

View File

@@ -15,3 +15,4 @@ export * from './house-tracker';
export * from './health';
export * from './books';
export * from './tasks';
export * from './groceries';

View File

@@ -15,3 +15,4 @@ export const metaSchema = pgSchema('meta');
export const healthSchema = pgSchema('health');
export const booksSchema = pgSchema('books');
export const tasksSchema = pgSchema('tasks');
export const groceriesSchema = pgSchema('groceries');

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { parseCents, parseSize, priceInput, unitPriceCents } from './groceries';
import { cheapestStores, formatCents } from '../groceries';
describe('groceries helpers', () => {
it('parses prices into cents', () => {
expect(parseCents('1,79')).toBe(179);
expect(parseCents('€ 2.5')).toBe(250);
expect(parseCents('3')).toBe(300);
expect(parseCents('abc')).toBeNull();
expect(parseCents('1,234')).toBeNull();
});
it('parses pack sizes', () => {
expect(parseSize('1 L')).toEqual({ amount: 1, unit: 'l' });
expect(parseSize('6 x 1 L')).toEqual({ amount: 6, unit: 'l' });
expect(parseSize('500 g')).toEqual({ amount: 0.5, unit: 'kg' });
expect(parseSize('750ml')).toEqual({ amount: 0.75, unit: 'l' });
expect(parseSize('4 stuks')).toEqual({ amount: 4, unit: 'piece' });
expect(parseSize('1,5 liter')).toEqual({ amount: 1.5, unit: 'l' });
expect(parseSize('a bunch')).toBeNull();
});
it('computes unit prices', () => {
expect(unitPriceCents(479, 6)).toBe(80);
expect(unitPriceCents(100, null)).toBeNull();
});
it('validates price input', () => {
expect(priceInput.safeParse({ price: '1,29' }).data?.price).toBe(129);
expect(priceInput.safeParse({ price: 'x' }).success).toBe(false);
expect(priceInput.safeParse({ price: '1', url: '' }).success).toBe(true);
});
it('finds the cheapest store', () => {
const p = (storeId: number, priceCents: number, unitPriceCents: number | null) => ({
storeId,
priceCents,
unitPriceCents,
promo: false,
observedAt: new Date()
});
expect([...cheapestStores([p(1, 179, 179), p(2, 129, 129)])]).toEqual([2]);
expect([...cheapestStores([p(1, 479, 80), p(2, 129, 129)])]).toEqual([1]);
expect([...cheapestStores([p(1, 479, null), p(2, 129, 129)])]).toEqual([2]);
expect(cheapestStores([p(1, 100, null)]).size).toBe(0);
expect(formatCents(179).replace(/\s/g, ' ')).toBe('€ 1,79');
});
});

View File

@@ -0,0 +1,77 @@
import * as z from 'zod';
import { GROCERY_UNITS } from './db/schema/groceries';
/** "1,79" / "1.79" / "€ 1,79" → 179 cents; null when not a price. */
export function parseCents(input: unknown): number | null {
if (typeof input !== 'string') return null;
const s = input.replace(/[€\s]/g, '').replace(',', '.');
if (!/^\d+(\.\d{1,2})?$/.test(s)) return null;
return Math.round(Number(s) * 100);
}
/** Price per unit (kg / l / piece) in cents, rounded; null without a usable amount. */
export function unitPriceCents(priceCents: number, unitAmount: number | null | undefined) {
if (!unitAmount || !(unitAmount > 0)) return null;
return Math.round(priceCents / unitAmount);
}
/**
* Best-effort pack size parse, e.g. "1 L" → 1 l, "6 x 1 L" → 6 l, "500 g" → 0.5 kg,
* "750ml" → 0.75 l, "4 stuks" → 4 piece. Returns null when unclear.
*/
export function parseSize(
size: string | null | undefined
): { amount: number; unit: (typeof GROCERY_UNITS)[number] } | null {
if (!size) return null;
const s = size.toLowerCase().replace(',', '.').trim();
const m = s.match(
/^(?:(\d+)\s*x\s*)?(\d+(?:\.\d+)?)\s*(kg|kilo|g|gr|gram|l|liter|litre|ml|cl|st|stuk|stuks|pcs|pieces?)$/
);
if (!m) return null;
const n = (m[1] ? Number(m[1]) : 1) * Number(m[2]);
const u = m[3];
if (['kg', 'kilo'].includes(u)) return { amount: n, unit: 'kg' };
if (['g', 'gr', 'gram'].includes(u)) return { amount: n / 1000, unit: 'kg' };
if (['l', 'liter', 'litre'].includes(u)) return { amount: n, unit: 'l' };
if (u === 'ml') return { amount: n / 1000, unit: 'l' };
if (u === 'cl') return { amount: n / 100, unit: 'l' };
return { amount: n, unit: 'piece' };
}
const optText = (max: number) =>
z
.string()
.trim()
.max(max)
.transform((v) => (v === '' ? null : v))
.nullable()
.optional();
export const productInput = z.object({
name: z.string().trim().min(1, 'Name is required').max(200),
brand: optText(100),
category: optText(100),
size: optText(60),
notes: optText(2000)
});
export const priceInput = z.object({
price: z.string().transform((v, ctx) => {
const c = parseCents(v);
if (c === null) {
ctx.addIssue({ code: 'custom', message: 'Enter a price like 1,79' });
return z.NEVER;
}
return c;
}),
url: optText(2000).refine(
(v) => v == null || /^https?:\/\/\S+$/.test(v),
'Link must start with http(s)://'
),
observedOn: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.or(z.literal('').transform(() => undefined)),
notes: optText(500)
});

View File

@@ -19,6 +19,8 @@ describe('owner gate', () => {
expect(isOwnerOnlyPath('/trackers/tasks')).toBe(true);
expect(isOwnerOnlyPath('/trackers/Tasks/')).toBe(true);
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
expect(isOwnerOnlyPath('/trackers/groceries')).toBe(true);
expect(isOwnerWritePath('/trackers/groceries')).toBe(true);
});
it('gates the book reader, files and APIs but not the catalogue', () => {

View File

@@ -22,6 +22,7 @@ export function requireOwner(event: OwnerEvent) {
export const ownerOnlyPrefixes = [
'/trackers/health',
'/trackers/tasks',
'/trackers/groceries',
'/trackers/books/files',
'/lists/db'
] as const;
@@ -39,6 +40,7 @@ export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes
export const ownerWritePrefixes = [
'/trackers/health',
'/trackers/tasks',
'/trackers/groceries',
'/trackers/books'
] as const;

View File

@@ -0,0 +1,10 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only, like tasks and health: everyone else gets a plain 404. Form actions skip
// this load, so +page.server.ts checks again and hooks.server.ts blocks the prefix too.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};

View File

@@ -0,0 +1,178 @@
import { db } from '#lib/server/db/index.js';
import { groceryPrices, groceryProducts, groceryStores } from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { parseSize, priceInput, productInput, unitPriceCents } from '#lib/server/groceries.js';
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const [stores, products, prices] = await Promise.all([
db
.select()
.from(groceryStores)
.where(isNull(groceryStores.archivedAt))
.orderBy(asc(groceryStores.sortOrder), asc(groceryStores.name)),
db
.select()
.from(groceryProducts)
.where(isNull(groceryProducts.archivedAt))
.orderBy(desc(groceryProducts.favorite), asc(sql`lower(${groceryProducts.name})`)),
db
.select({
id: groceryPrices.id,
productId: groceryPrices.productId,
storeId: groceryPrices.storeId,
priceCents: groceryPrices.priceCents,
unitPriceCents: groceryPrices.unitPriceCents,
promo: groceryPrices.promo,
observedAt: groceryPrices.observedAt,
source: groceryPrices.source,
url: groceryPrices.url,
notes: groceryPrices.notes
})
.from(groceryPrices)
.orderBy(desc(groceryPrices.observedAt), desc(groceryPrices.id))
]);
return {
stores,
products,
prices,
seo: { title: 'Groceries', description: 'Private grocery and price tracker.', index: false }
};
};
const id = (v: FormDataEntryValue | null) => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
const formObject = (form: FormData) => {
const o: Record<string, unknown> = {};
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
return o;
};
const firstIssue = (e: { issues: { path: PropertyKey[]; message: string }[] }) => {
const i = e.issues[0];
return i ? `${i.path.join('.') || 'input'}: ${i.message}` : 'Invalid input';
};
function dbError(e: unknown): string {
const cause = (e as { cause?: { message?: string; constraint_name?: string } })?.cause;
if (cause?.constraint_name === 'products_name_brand_key')
return 'That product (name + brand) already exists';
if (cause?.constraint_name) return `Not allowed (${cause.constraint_name})`;
return cause?.message || 'Could not save';
}
export const actions: Actions = {
/** Create or edit a product. */
saveProduct: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('id'));
const parsed = productInput.safeParse(formObject(form));
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
const p = parsed.data;
const size = parseSize(p.size);
const values = {
name: p.name,
brand: p.brand ?? null,
category: p.category ?? null,
size: p.size ?? null,
unitAmount: size?.amount ?? null,
unit: size?.unit ?? null,
notes: p.notes ?? null,
...(form.has('favorite') ? { favorite: form.get('favorite') === 'on' } : {})
};
try {
if (productId) {
await db.transaction(async (tx) => {
await tx.update(groceryProducts).set(values).where(eq(groceryProducts.id, productId));
// Pack size changed: recompute unit prices of this product's history.
await tx
.update(groceryPrices)
.set({
unitPriceCents: values.unitAmount
? sql`round(${groceryPrices.priceCents} / ${values.unitAmount}::numeric)::int`
: null
})
.where(eq(groceryPrices.productId, productId));
});
return { saved: productId };
}
const [row] = await db
.insert(groceryProducts)
.values(values)
.returning({ id: groceryProducts.id });
return { saved: row.id };
} catch (e) {
return fail(400, { message: dbError(e) });
}
},
favorite: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('id'));
if (!productId) return fail(400, { message: 'Invalid product' });
await db
.update(groceryProducts)
.set({ favorite: form.get('favorite') === 'true' })
.where(eq(groceryProducts.id, productId));
return { updated: productId };
},
deleteProduct: async (event) => {
requireOwner(event);
const productId = id((await event.request.formData()).get('id'));
if (!productId) return fail(400, { message: 'Invalid product' });
await db.delete(groceryProducts).where(eq(groceryProducts.id, productId));
return { deleted: productId };
},
/** Record a price observation for a product at a store. */
addPrice: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('productId'));
const storeId = id(form.get('storeId'));
if (!productId || !storeId) return fail(400, { message: 'Pick a product and a store' });
const parsed = priceInput.safeParse(formObject(form));
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
const [product] = await db
.select({ unitAmount: groceryProducts.unitAmount })
.from(groceryProducts)
.where(eq(groceryProducts.id, productId));
if (!product) return fail(404, { message: 'Product not found' });
const p = parsed.data;
try {
await db.insert(groceryPrices).values({
productId,
storeId,
priceCents: p.price,
unitPriceCents: unitPriceCents(p.price, product.unitAmount),
promo: form.get('promo') === 'on',
// A plain date is stored as noon UTC; the exact time does not matter for prices.
observedAt: p.observedOn ? new Date(`${p.observedOn}T12:00:00Z`) : new Date(),
source: 'manual',
url: p.url ?? null,
notes: p.notes ?? null
});
} catch (e) {
return fail(400, { message: dbError(e) });
}
return { priced: productId };
},
deletePrice: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const priceId = id(form.get('id'));
const productId = id(form.get('productId'));
if (!priceId || !productId) return fail(400, { message: 'Invalid price' });
await db
.delete(groceryPrices)
.where(and(eq(groceryPrices.id, priceId), eq(groceryPrices.productId, productId)));
return { deleted: priceId };
}
};

View File

@@ -0,0 +1,568 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Lock from 'lucide-svelte/icons/lock';
import Star from 'lucide-svelte/icons/star';
import Trash from 'lucide-svelte/icons/trash-2';
import ExternalLink from 'lucide-svelte/icons/external-link';
import { cheapestStores, formatCents, UNIT_LABEL, type LatestPrice } from '#lib/groceries.js';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Product = (typeof data.products)[number];
type Price = (typeof data.prices)[number];
let query = $state('');
let category = $state('');
let favoritesOnly = $state(false);
let quickError = $state<string | null>(null);
let dialog = $state(false);
let editingId = $state<number | null>(null);
let confirmDelete = $state(false);
let error = $state<string | null>(null);
let priceError = $state<string | null>(null);
/** Price history per product, newest first (load orders by observed_at desc). */
const history = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
const m = new Map<number, Price[]>();
for (const p of data.prices) {
const list = m.get(p.productId);
if (list) list.push(p);
else m.set(p.productId, [p]);
}
return m;
});
/** Latest price per product per store. */
const latest = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
const m = new Map<number, Map<number, LatestPrice>>();
for (const [productId, list] of history) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
const byStore = new Map<number, LatestPrice>();
for (const p of list) if (!byStore.has(p.storeId)) byStore.set(p.storeId, p);
m.set(productId, byStore);
}
return m;
});
const cheapest = $derived(
new Map([...latest].map(([id, byStore]) => [id, cheapestStores([...byStore.values()])]))
);
const categories = $derived(
[...new Set(data.products.map((p) => p.category).filter((c): c is string => !!c))].sort()
);
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
return data.products.filter(
(p) =>
(!favoritesOnly || p.favorite) &&
(!category || p.category === category) &&
(!q || [p.name, p.brand, p.category, p.notes].some((v) => v?.toLowerCase().includes(q)))
);
});
const counts = $derived({
products: data.products.length,
favorites: data.products.filter((p) => p.favorite).length,
priced: latest.size,
prices: data.prices.length
});
const editing = $derived(data.products.find((p) => p.id === editingId) ?? null);
const storeName = $derived(new Map(data.stores.map((s) => [s.id, s.name])));
const today = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
const shortDate = (d: Date | string) =>
new Date(d).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const unitPrice = (p: LatestPrice, product: Product) =>
p.unitPriceCents !== null && product.unit
? `${formatCents(p.unitPriceCents)}/${UNIT_LABEL[product.unit] ?? product.unit}`
: null;
function openProduct(p: Product | null) {
editingId = p?.id ?? null;
confirmDelete = false;
error = priceError = null;
dialog = true;
}
const submitQuick: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
quickError =
result.type === 'failure' ? String(result.data?.message ?? 'Could not add') : null;
};
const submitDialog: SubmitFunction =
() =>
async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') {
if (!editingId && typeof result.data?.saved === 'number') editingId = result.data.saved;
else dialog = false;
error = null;
} else
error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
};
const submitDelete: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') dialog = false;
};
const submitPrice: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
priceError =
result.type === 'failure' ? String(result.data?.message ?? 'Could not save') : null;
};
const submitInline: SubmitFunction =
() =>
async ({ update }) => {
await update({ reset: false });
};
</script>
{#snippet favoriteButton(p: Product)}
<form method="POST" action="?/favorite" use:enhance={submitInline} class="contents">
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="favorite" value={String(!p.favorite)} />
<button
type="submit"
class={['icon', p.favorite && 'fav']}
aria-pressed={p.favorite}
aria-label={p.favorite ? `Unfavorite ${p.name}` : `Favorite ${p.name}`}
title={p.favorite ? 'Favorite' : 'Mark as favorite'}
>
<Star size={16} fill={p.favorite ? 'currentColor' : 'none'} />
</button>
</form>
{/snippet}
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groceries</h1>
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
<Lock size={14} aria-hidden="true" /> Private. Products I buy and their prices at
{data.stores.map((s) => s.name).join(', ')}.
</p>
</div>
<dl class="stats">
<div>
<dt>products</dt>
<dd>{counts.products}</dd>
</div>
<div>
<dt>favorites</dt>
<dd>{counts.favorites}</dd>
</div>
<div>
<dt>priced</dt>
<dd>{counts.priced}</dd>
</div>
<div>
<dt>price points</dt>
<dd>{counts.prices}</dd>
</div>
</dl>
</div>
<form
method="POST"
action="?/saveProduct"
use:enhance={submitQuick}
class="panel mb-4 flex flex-wrap items-center gap-2"
>
<Input
name="name"
required
maxlength={200}
placeholder="Add a product… (e.g. Halfvolle melk)"
class="min-w-[12rem] flex-1"
aria-label="Product name"
/>
<Input name="brand" maxlength={100} placeholder="Brand" class="w-32" aria-label="Brand" />
<Input
name="size"
maxlength={60}
placeholder="Size (1 L, 500 g)"
class="w-36"
aria-label="Size"
/>
<Input
name="category"
maxlength={100}
placeholder="Category"
class="w-36"
list="grocery-categories"
aria-label="Category"
/>
<Button type="submit"><Plus size={16} /> Add</Button>
{#if quickError}<p class="w-full text-sm text-red-500" role="alert">{quickError}</p>{/if}
</form>
<datalist id="grocery-categories">
{#each categories as c (c)}<option value={c}></option>{/each}
</datalist>
<div class="mb-4 flex flex-wrap items-center gap-2">
<Input
type="search"
bind:value={query}
placeholder="Search products…"
class="max-w-xs"
aria-label="Search products"
/>
<NativeSelect bind:value={category} aria-label="Category filter">
<option value="">All categories</option>
{#each categories as c (c)}<option value={c}>{c}</option>{/each}
</NativeSelect>
<div class="segmented" role="group" aria-label="Show">
<button type="button" aria-pressed={!favoritesOnly} onclick={() => (favoritesOnly = false)}
>All</button
>
<button type="button" aria-pressed={favoritesOnly} onclick={() => (favoritesOnly = true)}
>Favorites</button
>
</div>
<span class="ml-auto text-sm text-[var(--site-muted)]">{filtered.length} shown</span>
</div>
{#if data.products.length === 0}
<div class="panel text-sm text-[var(--site-muted)]">
No products yet. Add the things you buy above, then open a product to log what it costs at
each store. The cheapest store is highlighted per product.
</div>
{:else}
<div class="panel overflow-x-auto p-0">
<table class="w-full text-sm">
<thead>
<tr>
<th class="w-8"><span class="sr-only">Favorite</span></th>
<th class="text-left">Product</th>
{#each data.stores as s (s.id)}<th class="text-right">{s.name}</th>{/each}
<th class="w-8"><span class="sr-only">Edit</span></th>
</tr>
</thead>
<tbody>
{#each filtered as p (p.id)}
{@const byStore = latest.get(p.id)}
{@const best = cheapest.get(p.id)}
<tr>
<td>{@render favoriteButton(p)}</td>
<td>
<button type="button" class="title" onclick={() => openProduct(p)}>{p.name}</button>
<div class="meta">
{[p.brand, p.size, p.category].filter(Boolean).join(' · ')}
</div>
</td>
{#each data.stores as s (s.id)}
{@const lp = byStore?.get(s.id)}
<td class={['text-right price', best?.has(s.id) && 'best']}>
{#if lp}
{@const up = unitPrice(lp, p)}
<span title={`Seen ${shortDate(lp.observedAt)}`}
>{formatCents(lp.priceCents)}{#if lp.promo}<span class="promo">promo</span
>{/if}</span
>
{#if up}<div class="meta">{up}</div>{/if}
{:else}
<span class="meta">–</span>
{/if}
</td>
{/each}
<td>
<button
class="icon"
type="button"
aria-label={`Edit ${p.name}`}
onclick={() => openProduct(p)}><Pencil size={15} /></button
>
</td>
</tr>
{:else}
<tr><td colspan={data.stores.length + 3} class="meta">No products match.</td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<Dialog.Root bind:open={dialog}>
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Header class="mb-4 pr-8">
<Dialog.Title
>{confirmDelete ? 'Delete product' : editing ? editing.name : 'New product'}</Dialog.Title
>
<Dialog.Description class="sr-only">Edit a grocery product and its prices.</Dialog.Description
>
</Dialog.Header>
{#if confirmDelete && editing}
<form method="POST" action="?/deleteProduct" use:enhance={submitDelete} class="space-y-4">
<input type="hidden" name="id" value={editing.id} />
<p class="text-sm">
Delete <strong>{editing.name}</strong> and its {history.get(editing.id)?.length ?? 0} price
points?
</p>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (confirmDelete = false)}
>Cancel</Button
>
<Button type="submit" variant="destructive">Delete</Button>
</div>
</form>
{:else}
{#key editingId}
<form method="POST" action="?/saveProduct" use:enhance={submitDialog} class="space-y-3">
{#if editing}<input type="hidden" name="id" value={editing.id} />{/if}
<label class="field"
>Name <Input name="name" required maxlength={200} value={editing?.name ?? ''} /></label
>
<div class="grid grid-cols-2 gap-3">
<label class="field"
>Brand <Input name="brand" maxlength={100} value={editing?.brand ?? ''} /></label
>
<label class="field"
>Size <Input
name="size"
maxlength={60}
placeholder="1 L, 500 g, 6 x 1 L"
value={editing?.size ?? ''}
/></label
>
</div>
<label class="field"
>Category <Input
name="category"
maxlength={100}
list="grocery-categories"
value={editing?.category ?? ''}
/></label
>
<label class="field"
>Notes <Textarea
name="notes"
maxlength={2000}
rows={2}
value={editing?.notes ?? ''}
/></label
>
<label class="flex items-center gap-2 text-sm"
><input type="checkbox" name="favorite" checked={editing?.favorite ?? false} /> Favorite (I
buy this regularly)</label
>
{#if editing?.unitAmount && editing.unit}
<p class="meta">
Unit prices per {UNIT_LABEL[editing.unit] ?? editing.unit} (pack = {editing.unitAmount}
{UNIT_LABEL[editing.unit] ?? editing.unit}).
</p>
{:else if editing?.size}
<p class="meta">Size not recognised, so no unit prices. Try "500 g" or "6 x 1 L".</p>
{/if}
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
<div class="flex justify-between gap-2">
{#if editing}
<Button type="button" variant="ghost" onclick={() => (confirmDelete = true)}
><Trash size={15} /> Delete</Button
>
{:else}<span></span>{/if}
<Button type="submit">{editing ? 'Save' : 'Create'}</Button>
</div>
</form>
{/key}
{#if editing}
{@const list = history.get(editing.id) ?? []}
<hr class="my-5 border-[var(--site-border)]" />
<h3 class="mb-2 font-medium">Prices</h3>
<form
method="POST"
action="?/addPrice"
use:enhance={submitPrice}
class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-4"
>
<input type="hidden" name="productId" value={editing.id} />
<NativeSelect name="storeId" required aria-label="Store">
{#each data.stores as s (s.id)}<option value={String(s.id)}>{s.name}</option>{/each}
</NativeSelect>
<Input
name="price"
required
inputmode="decimal"
placeholder="1,79"
aria-label="Price (€)"
/>
<Input name="observedOn" type="date" value={today()} aria-label="Date seen" />
<label class="flex items-center gap-1.5 text-sm"
><input type="checkbox" name="promo" /> Promo</label
>
<Input
name="url"
type="url"
placeholder="Product link (optional)"
class="col-span-2 sm:col-span-3"
aria-label="Product link"
/>
<Button type="submit"><Plus size={15} /> Add price</Button>
{#if priceError}<p class="col-span-full text-sm text-red-500" role="alert">
{priceError}
</p>{/if}
</form>
{#if list.length === 0}
<p class="meta">No prices yet.</p>
{:else}
<ul class="space-y-1 text-sm">
{#each list as pr (pr.id)}
<li class="flex items-center gap-2">
<span class="w-24 shrink-0">{storeName.get(pr.storeId)}</span>
<span class="tabular-nums">{formatCents(pr.priceCents)}</span>
{#if pr.promo}<span class="promo">promo</span>{/if}
<span class="meta">{shortDate(pr.observedAt)}</span>
{#if pr.url}<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
class="icon"
aria-label="Open product page"><ExternalLink size={13} /></a
>{/if}
<form
method="POST"
action="?/deletePrice"
use:enhance={submitInline}
class="ml-auto"
>
<input type="hidden" name="id" value={pr.id} />
<input type="hidden" name="productId" value={editing.id} />
<button type="submit" class="icon" aria-label="Delete price"
><Trash size={13} /></button
>
</form>
</li>
{/each}
</ul>
{/if}
{/if}
{/if}
</Dialog.Content>
</Dialog.Root>
<style>
.panel {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1rem 1.25rem;
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
}
.stats dt {
font-size: 0.75rem;
color: var(--site-muted);
}
.stats dd {
font-size: 1.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
table th {
font-size: 0.75rem;
font-weight: 500;
color: var(--site-muted);
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--site-border) 60%, transparent);
vertical-align: top;
}
tbody tr:last-child td {
border-bottom: none;
}
.title {
text-align: left;
font-weight: 500;
}
.title:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.meta {
font-size: 0.75rem;
color: var(--site-muted);
}
.price {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.price.best {
color: rgb(22 163 74);
font-weight: 600;
background: rgb(34 197 94 / 0.1);
}
.promo {
margin-left: 0.3rem;
font-size: 0.65rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: rgb(245 158 11 / 0.18);
color: rgb(217 119 6);
vertical-align: middle;
}
.field {
display: grid;
gap: 0.25rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
padding: 0.3rem;
color: var(--site-muted);
}
.icon:hover {
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
color: var(--site-text);
}
.icon.fav {
color: rgb(234 179 8);
}
.segmented {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
overflow: hidden;
}
.segmented button {
font-size: 0.8rem;
padding: 0.35rem 0.7rem;
color: var(--site-muted);
}
.segmented button[aria-pressed='true'] {
background: color-mix(in srgb, var(--site-text) 12%, transparent);
color: var(--site-text);
font-weight: 500;
}
</style>