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:
@@ -1,7 +1,10 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { refreshGroupStats } from '#lib/server/groceries/import.js';
|
||||
import { CATEGORIES, displayName, fold } from '#lib/server/groceries/normalize.js';
|
||||
import { parseEuros } from '#lib/groceries.js';
|
||||
import { displayName, fold, parseSize } from '#lib/server/groceries/normalize.js';
|
||||
import { HOMEWARE_TOPS } from '#lib/server/groceries/taxonomy.js';
|
||||
import { deleteObject, groceryImageKey, uploadImage } from '#lib/server/r2.js';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Sql } from 'postgres';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -22,6 +25,34 @@ export type GroupRow = {
|
||||
minUnitPriceCents: number | null;
|
||||
imageUrl: string | null;
|
||||
familyKey: string | null;
|
||||
minPriceCents: number | null;
|
||||
promoCount: number;
|
||||
categoryId: number | null;
|
||||
};
|
||||
export type ManualRow = {
|
||||
id: number;
|
||||
groupId: number;
|
||||
itemId: number | null;
|
||||
storeId: number;
|
||||
priceCents: number;
|
||||
promo: boolean;
|
||||
sizeText: string | null;
|
||||
unit: string | null;
|
||||
unitAmount: number | null;
|
||||
unitPriceCents: number | null;
|
||||
observedOn: string;
|
||||
photoUrl: string | null;
|
||||
note: string | null;
|
||||
};
|
||||
export type CategoryRow = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
isFood: boolean;
|
||||
count: number;
|
||||
};
|
||||
export type ItemRow = {
|
||||
id: number;
|
||||
@@ -60,7 +91,21 @@ const ITEM_COLS = (s: Sql) => s`
|
||||
const GROUP_COLS = (s: Sql) => s`
|
||||
g.id, g.name, g.brand, g.category, g.unit, g.favorite, g.manual, g.item_count as "itemCount",
|
||||
g.store_count as "storeCount", g.min_unit_price_cents as "minUnitPriceCents",
|
||||
g.image_url as "imageUrl", g.family_key as "familyKey"`;
|
||||
g.image_url as "imageUrl", g.family_key as "familyKey", g.min_price_cents as "minPriceCents",
|
||||
g.promo_count as "promoCount", g.category_id as "categoryId"`;
|
||||
/** Manual shelf prices with the group their item is in now (items can move between groups). */
|
||||
const MANUAL_SELECT = (s: Sql) => s`
|
||||
select m.id, coalesce(i.group_id, g0.merged_into_id, m.group_id) as "groupId", m.item_id as "itemId",
|
||||
m.store_id as "storeId", m.price_cents as "priceCents", m.promo, m.size_text as "sizeText", m.unit,
|
||||
m.unit_amount::float8 as "unitAmount", m.unit_price_cents as "unitPriceCents",
|
||||
m.observed_on::text as "observedOn", m.photo_url as "photoUrl", m.note
|
||||
from groceries.manual_prices m
|
||||
left join groceries.items i on i.id = m.item_id
|
||||
left join groceries.groups g0 on g0.id = m.group_id`;
|
||||
/** Manual prices older than this stop counting for "cheapest" in the list (still in the detail). */
|
||||
const MANUAL_FRESH_DAYS = 90;
|
||||
const SORTS = ['', 'unit', 'price', 'promo', 'stores'] as const;
|
||||
type Sort = (typeof SORTS)[number];
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
@@ -69,7 +114,9 @@ export const load: PageServerLoad = async (event) => {
|
||||
const q = fold(p.get('q')?.trim() ?? '').slice(0, 100);
|
||||
const words = q.split(/\s+/).filter(Boolean).slice(0, 6);
|
||||
const storeSlug = p.get('store') ?? '';
|
||||
const category = p.get('cat') ?? '';
|
||||
const catSlug = p.get('cat') ?? '';
|
||||
const sortParam = p.get('sort') ?? '';
|
||||
const sort: Sort = (SORTS as readonly string[]).includes(sortParam) ? (sortParam as Sort) : '';
|
||||
const favorites = p.get('fav') === '1';
|
||||
const promo = p.get('promo') === '1';
|
||||
const multi = p.get('multi') === '1';
|
||||
@@ -85,27 +132,61 @@ export const load: PageServerLoad = async (event) => {
|
||||
(select count(*)::int from groceries.items i where i.store_id = st.id and i.available) as items
|
||||
from groceries.stores st where st.archived_at is null order by st.sort_order, st.name`;
|
||||
const store = stores.find((x) => x.slug === storeSlug) ?? null;
|
||||
const cats = await s<Omit<CategoryRow, 'count'>[]>`
|
||||
select id, slug, name, parent_id as "parentId", icon, color, is_food as "isFood"
|
||||
from groceries.categories order by sort_order, name`;
|
||||
const cat = cats.find((c) => c.slug === catSlug) ?? null;
|
||||
const catIds = cat
|
||||
? cats.filter((c) => c.id === cat.id || c.parentId === cat.id).map((c) => c.id)
|
||||
: [];
|
||||
const topOf = (c: (typeof cats)[number]) =>
|
||||
c.parentId ? cats.find((x) => x.id === c.parentId)! : c;
|
||||
const homewareIds = cats
|
||||
.filter((c) => (HOMEWARE_TOPS as readonly string[]).includes(topOf(c).slug))
|
||||
.map((c) => c.id);
|
||||
// Choosing a homeware category shows it even with the non-food toggle off.
|
||||
const showNonfood = nonfood || (cat ? homewareIds.includes(cat.id) : false);
|
||||
|
||||
const conds = [s`g.merged_into_id is null`, s`(g.item_count > 0 or g.favorite)`];
|
||||
for (const w of words)
|
||||
conds.push(s`g.search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
|
||||
if (store) conds.push(s`g.store_ids @> array[${store.id}]::int[]`);
|
||||
if (category) conds.push(s`g.category = ${category}`);
|
||||
if (store)
|
||||
conds.push(s`(g.store_ids @> array[${store.id}]::int[] or exists (
|
||||
select 1 from groceries.manual_prices m where m.group_id = g.id and m.store_id = ${store.id}))`);
|
||||
if (favorites) conds.push(s`g.favorite`);
|
||||
if (promo) conds.push(s`g.promo_count > 0`);
|
||||
if (multi) conds.push(s`g.store_count >= 2`);
|
||||
if (!nonfood) conds.push(s`(g.is_food or g.category is distinct from 'Overig')`);
|
||||
const where = conds.reduce((a, c) => s`${a} and ${c}`);
|
||||
const order = q
|
||||
? s`g.favorite desc, (lower(g.name) = ${q}) desc, public.word_similarity(${q}::text, lower(g.name)) desc, g.store_count desc, g.item_count desc, g.name`
|
||||
: s`g.favorite desc, g.store_count desc, g.item_count desc, g.name`;
|
||||
if (!showNonfood && homewareIds.length)
|
||||
conds.push(
|
||||
s`(g.is_food or g.category_id is null or not (g.category_id = any(${homewareIds}::int[])))`
|
||||
);
|
||||
const and = (list: typeof conds) => list.reduce((a, c) => s`${a} and ${c}`);
|
||||
// Chip counts ignore the category filter itself (facets).
|
||||
const facetWhere = and(conds);
|
||||
const where = cat ? and([...conds, s`g.category_id = any(${catIds}::int[])`]) : facetWhere;
|
||||
const relevance = q
|
||||
? s`(lower(g.name) = ${q}) desc, public.word_similarity(${q}::text, lower(g.name)) desc,`
|
||||
: s``;
|
||||
const order =
|
||||
sort === 'unit'
|
||||
? s`g.min_unit_price_cents asc nulls last, g.store_count desc`
|
||||
: sort === 'price'
|
||||
? s`g.min_price_cents asc nulls last, g.store_count desc`
|
||||
: sort === 'promo'
|
||||
? s`(g.promo_count > 0) desc, g.favorite desc, ${relevance} g.store_count desc, g.item_count desc`
|
||||
: sort === 'stores'
|
||||
? s`g.store_count desc, g.item_count desc`
|
||||
: s`g.favorite desc, ${relevance} g.store_count desc, g.item_count desc`;
|
||||
|
||||
const [groups, [{ total }]] = await Promise.all([
|
||||
s<GroupRow[]>`
|
||||
select ${GROUP_COLS(s)} from groceries.groups g where ${where}
|
||||
order by ${order}, g.id limit ${PAGE_SIZE} offset ${(page - 1) * PAGE_SIZE}`,
|
||||
order by ${order}, g.name, g.id limit ${PAGE_SIZE} offset ${(page - 1) * PAGE_SIZE}`,
|
||||
s<{ total: number }[]>`select count(*)::int as total from groceries.groups g where ${where}`
|
||||
]);
|
||||
const facetRows = await s<{ id: number | null; n: number }[]>`
|
||||
select g.category_id as id, count(*)::int as n from groceries.groups g where ${facetWhere}
|
||||
group by 1`;
|
||||
const ids = groups.map((g) => g.id);
|
||||
const items = ids.length
|
||||
? await s<ItemRow[]>`
|
||||
@@ -113,6 +194,13 @@ export const load: PageServerLoad = async (event) => {
|
||||
where i.group_id = any(${ids}::int[]) and i.available
|
||||
order by coalesce(i.promo_unit_price_cents, i.unit_price_cents) nulls last, i.id`
|
||||
: [];
|
||||
const manual = ids.length
|
||||
? await s<ManualRow[]>`
|
||||
${MANUAL_SELECT(s)}
|
||||
where coalesce(i.group_id, g0.merged_into_id, m.group_id) = any(${ids}::int[])
|
||||
and m.observed_on >= current_date - ${MANUAL_FRESH_DAYS}::int
|
||||
order by m.observed_on desc, m.id desc`
|
||||
: [];
|
||||
|
||||
const [stats] = await s<
|
||||
{
|
||||
@@ -138,21 +226,22 @@ export const load: PageServerLoad = async (event) => {
|
||||
>`
|
||||
select distinct on (store_id) store_id as "storeId", finished_at as "finishedAt", status, fetched
|
||||
from groceries.import_runs order by store_id, started_at desc`;
|
||||
const categories = await s<{ category: string }[]>`
|
||||
select distinct category from groceries.groups where category is not null and item_count > 0`;
|
||||
const catList = [
|
||||
...CATEGORIES.filter((c) => categories.some((x) => x.category === c)),
|
||||
...categories
|
||||
.map((x) => x.category)
|
||||
.filter((c) => !(CATEGORIES as readonly string[]).includes(c))
|
||||
];
|
||||
const facet = new Map(facetRows.map((r) => [r.id, r.n]));
|
||||
const categories: CategoryRow[] = cats.map((c) => ({
|
||||
...c,
|
||||
count:
|
||||
(facet.get(c.id) ?? 0) +
|
||||
(c.parentId
|
||||
? 0
|
||||
: cats.filter((x) => x.parentId === c.id).reduce((a, x) => a + (facet.get(x.id) ?? 0), 0))
|
||||
}));
|
||||
|
||||
let detail = null;
|
||||
if (detailId) {
|
||||
const [group] = await s<(GroupRow & { notes: string | null })[]>`
|
||||
select ${GROUP_COLS(s)}, g.notes from groceries.groups g where g.id = ${detailId}`;
|
||||
if (group) {
|
||||
const [dItems, similar] = await Promise.all([
|
||||
const [dItems, similar, dManual] = await Promise.all([
|
||||
s<ItemRow[]>`
|
||||
select ${ITEM_COLS(s)} from groceries.items i where i.group_id = ${detailId}
|
||||
order by i.available desc, coalesce(i.promo_unit_price_cents, i.unit_price_cents) nulls last, i.id`,
|
||||
@@ -162,7 +251,11 @@ export const load: PageServerLoad = async (event) => {
|
||||
where g.family_key = ${group.familyKey} and g.id <> ${detailId}
|
||||
and g.merged_into_id is null and g.item_count > 0
|
||||
order by g.min_unit_price_cents nulls last limit 12`
|
||||
: Promise.resolve([] as GroupRow[])
|
||||
: Promise.resolve([] as GroupRow[]),
|
||||
s<ManualRow[]>`
|
||||
${MANUAL_SELECT(s)}
|
||||
where coalesce(i.group_id, g0.merged_into_id, m.group_id) = ${detailId}
|
||||
order by m.observed_on desc, m.id desc limit 100`
|
||||
]);
|
||||
const history = dItems.length
|
||||
? await s<
|
||||
@@ -179,7 +272,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
from groceries.item_prices where item_id = any(${dItems.map((i) => i.id)}::int[])
|
||||
order by observed_on desc limit 400`
|
||||
: [];
|
||||
detail = { group, items: dItems, similar, history };
|
||||
detail = { group, items: dItems, similar, history, manual: dManual };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,19 +280,22 @@ export const load: PageServerLoad = async (event) => {
|
||||
stores,
|
||||
groups,
|
||||
items,
|
||||
manual,
|
||||
total,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
filters: {
|
||||
q: p.get('q') ?? '',
|
||||
store: store?.slug ?? '',
|
||||
category,
|
||||
category: cat?.slug ?? '',
|
||||
sort,
|
||||
favorites,
|
||||
promo,
|
||||
multi,
|
||||
nonfood
|
||||
},
|
||||
categories: catList,
|
||||
categories,
|
||||
manualFreshDays: MANUAL_FRESH_DAYS,
|
||||
stats,
|
||||
runs,
|
||||
detail,
|
||||
@@ -212,7 +308,100 @@ const id = (v: FormDataEntryValue | null) => {
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
export const actions: Actions = {
|
||||
/**
|
||||
* Record a shelf price seen in a store (owner only): for a store product or a whole group,
|
||||
* with an optional pack size (for the unit price), date, note and photo.
|
||||
*/
|
||||
addManualPrice: async (event) => {
|
||||
requireOwner(event);
|
||||
const s = sql();
|
||||
const form = await event.request.formData();
|
||||
const storeId = id(form.get('storeId'));
|
||||
let groupId = id(form.get('groupId'));
|
||||
const itemId = id(form.get('itemId'));
|
||||
const priceCents = parseEuros(String(form.get('price') ?? ''));
|
||||
if (!storeId) return fail(400, { message: 'Pick a store' });
|
||||
if (!priceCents) return fail(400, { message: 'Enter a price like 1,29' });
|
||||
const [st] =
|
||||
await s`select id from groceries.stores where id = ${storeId} and archived_at is null`;
|
||||
if (!st) return fail(404, { message: 'Store not found' });
|
||||
type ItemSize = { group_id: number | null; unit: string | null; unit_amount: number | null };
|
||||
let item: ItemSize | undefined;
|
||||
if (itemId) {
|
||||
[item] = await s<ItemSize[]>`
|
||||
select group_id, unit, unit_amount::float8 as unit_amount from groceries.items where id = ${itemId}`;
|
||||
if (!item) return fail(404, { message: 'Product not found' });
|
||||
groupId = item.group_id ?? groupId;
|
||||
}
|
||||
if (!groupId) return fail(400, { message: 'Pick a product' });
|
||||
const [grp] = await s<{ id: number; unit: string | null }[]>`
|
||||
select coalesce(merged_into_id, id) as id, unit from groceries.groups where id = ${groupId}`;
|
||||
if (!grp) return fail(404, { message: 'Product not found' });
|
||||
groupId = grp.id;
|
||||
|
||||
const sizeText =
|
||||
String(form.get('size') ?? '')
|
||||
.trim()
|
||||
.slice(0, 60) || null;
|
||||
let size = parseSize(sizeText);
|
||||
if (!size && item?.unit && item.unit_amount)
|
||||
size = { unit: item.unit as 'kg', amount: item.unit_amount };
|
||||
if (!size && !sizeText) {
|
||||
// Same pack as the group's usual one (when its products agree on a size).
|
||||
const [usual] = await s<{ unit: string; amount: number }[]>`
|
||||
select unit, unit_amount::float8 as amount from groceries.items
|
||||
where group_id = ${groupId} and available and unit is not null and unit_amount > 0
|
||||
group by 1, 2 order by count(*) desc limit 1`;
|
||||
if (usual) size = { unit: usual.unit as 'kg', amount: usual.amount };
|
||||
}
|
||||
const unitPriceCents = size && size.amount > 0 ? Math.round(priceCents / size.amount) : null;
|
||||
|
||||
const dateRaw = String(form.get('observedOn') ?? '');
|
||||
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
|
||||
const observedOn =
|
||||
ISO_DATE.test(dateRaw) && dateRaw >= '2020-01-01' && dateRaw <= today ? dateRaw : today;
|
||||
const note =
|
||||
String(form.get('note') ?? '')
|
||||
.trim()
|
||||
.slice(0, 500) || null;
|
||||
|
||||
let photoUrl: string | null = null;
|
||||
const photo = form.get('photo');
|
||||
if (photo instanceof File && photo.size > 0) {
|
||||
if (!PHOTO_TYPES.includes(photo.type) || photo.size > 15 * 1024 * 1024)
|
||||
return fail(400, { message: 'Photo: JPEG, PNG or WebP up to 15 MB' });
|
||||
try {
|
||||
photoUrl = (await uploadImage(photo, 'groceries')).publicUrl;
|
||||
} catch (e) {
|
||||
console.error('groceries photo upload failed', e);
|
||||
return fail(502, { message: 'Photo upload failed; try again without the photo' });
|
||||
}
|
||||
}
|
||||
const [row] = await s<{ id: number }[]>`
|
||||
insert into groceries.manual_prices (store_id, group_id, item_id, price_cents, promo, size_text, unit,
|
||||
unit_amount, unit_price_cents, observed_on, photo_url, note)
|
||||
values (${storeId}, ${groupId}, ${itemId}, ${priceCents}, ${form.get('promo') === 'on' || form.get('promo') === '1'},
|
||||
${sizeText}, ${size?.unit ?? null}, ${size?.amount ?? null}, ${unitPriceCents}, ${observedOn},
|
||||
${photoUrl}, ${note})
|
||||
returning id`;
|
||||
return { manualAdded: row.id, groupId };
|
||||
},
|
||||
|
||||
deleteManualPrice: async (event) => {
|
||||
requireOwner(event);
|
||||
const mid = id((await event.request.formData()).get('id'));
|
||||
if (!mid) return fail(400, { message: 'Invalid price' });
|
||||
const [row] = await sql()<{ photo_url: string | null }[]>`
|
||||
delete from groceries.manual_prices where id = ${mid} returning photo_url`;
|
||||
const key = row?.photo_url ? groceryImageKey(row.photo_url) : null;
|
||||
if (key)
|
||||
await deleteObject(key).catch((e) => console.error('groceries photo delete failed', e));
|
||||
return { manualDeleted: mid };
|
||||
},
|
||||
favorite: async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { Sql } from 'postgres';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/** Group picker for merge/move: top matches for ?q= (owner only). */
|
||||
/** Group picker for merge/move and shelf prices: top matches for ?q= (owner only). */
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const q = fold(event.url.searchParams.get('q')?.trim() ?? '').slice(0, 100);
|
||||
@@ -14,7 +14,8 @@ export const GET: RequestHandler = async (event) => {
|
||||
const s = db.$client as unknown as Sql;
|
||||
const conds = words.map((w) => s`search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
|
||||
const rows = await s`
|
||||
select id, name, brand, unit, store_count as "storeCount", item_count as "itemCount"
|
||||
select id, name, brand, unit, store_count as "storeCount", item_count as "itemCount",
|
||||
image_url as "imageUrl", min_price_cents as "minPriceCents"
|
||||
from groceries.groups
|
||||
where merged_into_id is null and ${conds.reduce((a, c) => s`${a} and ${c}`)}
|
||||
order by public.word_similarity(${q}::text, lower(name)) desc, store_count desc, item_count desc, id
|
||||
|
||||
Reference in New Issue
Block a user