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

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;