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

@@ -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');