groceries stats: fix array/json params for the app client (live store filter 500), CPI axis, header spacing
All checks were successful
Deploy production / deploy (push) Successful in 1m11s

This commit is contained in:
2026-10-25 20:48:00 +00:00
parent 8e35a58095
commit 6dcbcfee25
2 changed files with 14 additions and 7 deletions

View File

@@ -41,6 +41,8 @@ type Tx = TransactionSql;
const ALL = 'all';
// Leaves under food tops that are not edible (the taxonomy flags them food via their top).
const NON_FOOD_LEAVES = ['baby-kind--luiers', 'baby-kind--verzorging', 'baby-kind--accessoires'];
// Passed as an array literal: the app's postgres client doesn't serialise JS arrays here.
const NON_FOOD_ARR = `{${NON_FOOD_LEAVES.join(',')}}`;
// Own (private-label) brands per store, as a regex on lower(brand). Superunie labels are shared
// by its members (Dirk, Hoogvliet, Vomar, PLUS). `unbranded`: items without a brand are the
@@ -327,10 +329,10 @@ async function prepare(tx: Tx, scope: StatsScope, stores: number[] | null) {
left join groceries.groups g on g.id = i.group_id
left join groceries.categories c on c.id = i.category_id
left join groceries.categories tc on tc.id = coalesce(c.parent_id, c.id)
left join json_to_recordset(${tx.json(OWN_BRANDS as never)}::json)
left join json_to_recordset(${JSON.stringify(OWN_BRANDS)}::text::json)
as ob(slug text, rx text, unbranded boolean) on ob.slug = s.slug
where i.available and i.price_cents > 0 and i.unit_price_cents > 0 and i.unit is not null
and (${scope} = 'all' or (i.is_food and coalesce(c.slug, '') <> all(${NON_FOOD_LEAVES})
and (${scope} = 'all' or (i.is_food and coalesce(c.slug, '') <> all(${NON_FOOD_ARR}::text[])
and i.name !~* 'e-?gift|cadeaukaart|giftcard|gift card'))
and (${storeArr}::int[] is null or i.store_id = any(${storeArr}::int[]))
and i.unit_price_cents >= case i.unit when 'piece' then 1 else 10 end
@@ -717,7 +719,7 @@ async function history(tx: Tx, scope: StatsScope, stores: number[] | null) {
join groceries.stores s on s.id = i.store_id and s.archived_at is null
left join groceries.categories c on c.id = i.category_id
left join groceries.categories tc on tc.id = coalesce(c.parent_id, c.id)
where (${scope} = 'all' or (i.is_food and coalesce(c.slug, '') <> all(${NON_FOOD_LEAVES})
where (${scope} = 'all' or (i.is_food and coalesce(c.slug, '') <> all(${NON_FOOD_ARR}::text[])
and i.name !~* 'e-?gift|cadeaukaart|giftcard|gift card'))
and (${storeArr}::int[] is null or i.store_id = any(${storeArr}::int[]))`;
await tx`create unique index on it (id)`;
@@ -904,7 +906,7 @@ async function history(tx: Tx, scope: StatsScope, stores: number[] | null) {
async function basket(tx: Tx) {
const rows = await tx`
with st as (
select * from json_to_recordset(${tx.json(STAPLES as never)}::json)
select * from json_to_recordset(${JSON.stringify(STAPLES)}::text::json)
as x(key text, rx text, nrx text, units text, qty float8, cats text, min float8, max float8,
"pieceKg" float8)
),

View File

@@ -394,7 +394,12 @@
tooltip: { trigger: 'axis', valueFormatter: (v: number) => (v == null ? '–' : v.toFixed(2)) },
legend: legend(t),
xAxis: { type: 'category', data: (s?.days ?? []).map(fmtDay), axisLabel: { color: t.muted } },
yAxis: axisVal(t),
yAxis: {
...axisVal(t),
scale: true,
minInterval: 0.1,
axisLabel: { color: t.muted, formatter: (v: number) => v.toFixed(1) }
},
series: ids.map((k) => ({
name: sname(Number(k)),
type: 'line',
@@ -588,8 +593,8 @@
<p class="mt-2 flex flex-wrap items-center gap-1.5 text-sm text-[var(--site-muted)]">
<Lock size={14} aria-hidden="true" />
{#if ready && meta}
Snapshot of {fmtDay(ready.day)} · {historyDays} day{historyDays === 1 ? '' : 's'} of price history{#if ready.durationMs}
· computed in {(ready.durationMs / 1000).toFixed(1)} s{/if}
Snapshot of {fmtDay(ready.day)} · {historyDays} day{historyDays === 1 ? '' : 's'} of price history
{#if ready.durationMs}· computed in {(ready.durationMs / 1000).toFixed(1)} s{/if}
{:else}Who's cheapest, how often, and by how much.{/if}
</p>
</div>