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
All checks were successful
Deploy production / deploy (push) Successful in 2m40s
This commit is contained in:
24
src/lib/groceries.ts
Normal file
24
src/lib/groceries.ts
Normal 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' };
|
||||
@@ -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',
|
||||
|
||||
97
src/lib/server/db/schema/groceries.ts
Normal file
97
src/lib/server/db/schema/groceries.ts
Normal 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;
|
||||
@@ -15,3 +15,4 @@ export * from './house-tracker';
|
||||
export * from './health';
|
||||
export * from './books';
|
||||
export * from './tasks';
|
||||
export * from './groceries';
|
||||
|
||||
@@ -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');
|
||||
|
||||
45
src/lib/server/groceries.test.ts
Normal file
45
src/lib/server/groceries.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
77
src/lib/server/groceries.ts
Normal file
77
src/lib/server/groceries.ts
Normal 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)
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user