groceries: mobile-first tracker redesign — compact rows per product group (cheapest store badge, unit price, spread, promo), tap to compare stores; category tree (23 tops, subcategories, icons/colours) with chips, store tiles, sort and non-food toggle; owner shelf prices (manual source with date, photo, note)
Some checks failed
Deploy production / deploy (push) Failing after 1m1s

This commit is contained in:
2026-10-25 19:02:44 +00:00
parent 67d74f39b8
commit 707f07efd4
13 changed files with 33141 additions and 447 deletions

View File

@@ -46,6 +46,35 @@ export const groceryStores = groceriesSchema.table(
]
);
/**
* Shared category tree across stores (top level + subcategories), e.g. "Zuivel & eieren" >
* "Yoghurt & kwark". Rows are synced from TAXONOMY (src/lib/server/groceries/taxonomy.ts) by
* applyTaxonomy(), which also maps every item onto it from the store's own category path and
* name, so new stores land in the same tree without per-store work. `icon` is a lucide name.
*/
export const groceryCategories = groceriesSchema.table(
'categories',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
parentId: integer('parent_id').references((): AnyPgColumn => groceryCategories.id, {
onDelete: 'cascade'
}),
icon: text('icon'),
color: text('color'),
sortOrder: integer('sort_order').notNull().default(0),
isFood: boolean('is_food').notNull().default(true),
...timestamps
},
(t) => [
uniqueIndex('categories_slug_key').on(t.slug),
index('categories_parent_idx').on(t.parentId),
check('categories_slug_check', sql`${t.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
check('categories_name_check', sql`length(btrim(${t.name})) > 0`)
]
);
/**
* A comparable product ("Halfvolle melk", house brand) across stores and sizes. Stats columns
* (item_count … search_text) are denormalised by refreshGroupStats() so the list stays fast.
@@ -59,6 +88,10 @@ export const groceryGroups = groceriesSchema.table(
name: text('name').notNull(),
brand: text('brand'), // display brand; 'Huismerk' for AH/Jumbo/Plus own brands
category: text('category'),
/** Shared taxonomy leaf (or top) most of its items map to; set by applyTaxonomy(). */
categoryId: integer('category_id').references(() => groceryCategories.id, {
onDelete: 'set null'
}),
unit: text('unit').$type<GroceryUnit>(),
/** Name tokens without brand: groups sharing it are "similar" (e.g. house vs Campina melk). */
familyKey: text('family_key'),
@@ -87,6 +120,7 @@ export const groceryGroups = groceriesSchema.table(
uniqueIndex('groups_key_key').on(t.key),
index('groups_family_idx').on(t.familyKey),
index('groups_category_idx').on(t.category),
index('groups_category_id_idx').on(t.categoryId),
index('groups_store_ids_idx').using('gin', t.storeIds),
index('groups_search_trgm_idx').using('gin', sql`${t.searchText} gin_trgm_ops`),
index('groups_list_idx').on(t.storeCount, t.name),
@@ -119,6 +153,10 @@ export const groceryItems = groceriesSchema.table(
promoUnitPriceCents: integer('promo_unit_price_cents'),
category: text('category'), // canonical category (shared across stores)
categoryPath: text('category_path'), // the store's own path
/** Shared taxonomy node (subcategory, else top) from applyTaxonomy(). */
categoryId: integer('category_id').references(() => groceryCategories.id, {
onDelete: 'set null'
}),
gtin: text('gtin'),
gtinCheckedAt: timestamptz('gtin_checked_at'),
imageUrl: text('image_url'),
@@ -139,6 +177,7 @@ export const groceryItems = groceriesSchema.table(
uniqueIndex('items_store_product_key').on(t.storeId, t.storeProductId),
index('items_group_idx').on(t.groupId),
index('items_gtin_idx').on(t.gtin),
index('items_category_id_idx').on(t.categoryId),
index('items_name_trgm_idx').using('gin', sql`${t.name} gin_trgm_ops`),
check('items_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`),
check('items_price_check', sql`${t.priceCents} IS NULL OR ${t.priceCents} >= 0`)
@@ -162,6 +201,41 @@ export const groceryItemPrices = groceriesSchema.table(
(t) => [uniqueIndex('item_prices_item_day_key').on(t.itemId, t.observedOn)]
);
/**
* Shelf prices Stan enters by hand (local store visits, stores without a webshop). Linked to a
* store product (`item_id`) or just a group; shown next to the online prices with a "manual"
* badge and the date seen. `unit_price_cents` is derived from the pack size when known.
*/
export const groceryManualPrices = groceriesSchema.table(
'manual_prices',
{
id: id(),
storeId: integer('store_id')
.notNull()
.references(() => groceryStores.id, { onDelete: 'cascade' }),
groupId: integer('group_id').references(() => groceryGroups.id, { onDelete: 'cascade' }),
itemId: integer('item_id').references(() => groceryItems.id, { onDelete: 'set null' }),
priceCents: integer('price_cents').notNull(),
/** True when the shelf price was a promotion (bonus, sticker). */
promo: boolean('promo').notNull().default(false),
sizeText: text('size_text'),
unit: text('unit').$type<GroceryUnit>(),
unitAmount: numeric('unit_amount', { precision: 12, scale: 4, mode: 'number' }),
unitPriceCents: integer('unit_price_cents'),
observedOn: date('observed_on').notNull().defaultNow(),
photoUrl: text('photo_url'),
note: text('note'),
...timestamps
},
(t) => [
index('manual_prices_group_idx').on(t.groupId, t.observedOn),
index('manual_prices_item_idx').on(t.itemId),
check('manual_prices_price_check', sql`${t.priceCents} > 0 AND ${t.priceCents} < 1000000`),
check('manual_prices_target_check', sql`${t.groupId} IS NOT NULL OR ${t.itemId} IS NOT NULL`),
check('manual_prices_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`)
]
);
/** One importer run per store (counts and errors), for the UI footer and debugging. */
export const groceryImportRuns = groceriesSchema.table(
'import_runs',
@@ -185,3 +259,5 @@ export const groceryImportRuns = groceriesSchema.table(
export type GroceryStore = typeof groceryStores.$inferSelect;
export type GroceryGroup = typeof groceryGroups.$inferSelect;
export type GroceryItem = typeof groceryItems.$inferSelect;
export type GroceryCategory = typeof groceryCategories.$inferSelect;
export type GroceryManualPrice = typeof groceryManualPrices.$inferSelect;