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:
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');
|
||||
|
||||
Reference in New Issue
Block a user