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
Some checks failed
Deploy production / deploy (push) Failing after 1m1s
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user