Files
website/src/routes/trackers/groceries/+page.svelte
Stan Runge 3f30bb883f
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
groceries: add PLUS, Xenos and Amazing Oriental stores, is_food flag, PLUS EAN backfill
2026-10-25 17:17:53 +00:00

779 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import Lock from 'lucide-svelte/icons/lock';
import Unlock from 'lucide-svelte/icons/lock-open';
import Star from 'lucide-svelte/icons/star';
import ExternalLink from 'lucide-svelte/icons/external-link';
import Split from 'lucide-svelte/icons/split';
import MoveRight from 'lucide-svelte/icons/move-right';
import Merge from 'lucide-svelte/icons/merge';
import {
cheapest,
effectivePrice,
effectiveUnitPrice,
formatAmount,
formatCents,
formatUnitPrice,
sortByPrice
} from '#lib/groceries.js';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Item = (typeof data.items)[number];
type Group = (typeof data.groups)[number];
type PickerHit = {
id: number;
name: string;
brand: string | null;
unit: string | null;
storeCount: number;
itemCount: number;
};
const MAX_ROWS = 6;
let mode = $state<'unit' | 'pack'>('unit');
let actionError = $state<string | null>(null);
let picker = $state<{ kind: 'merge' } | { kind: 'move'; itemId: number } | null>(null);
let pickerQuery = $state('');
let pickerHits = $state.raw<PickerHit[]>([]);
let pickerTimer: ReturnType<typeof setTimeout> | undefined;
const storeById = $derived(new Map(data.stores.map((s) => [s.id, s])));
const itemsByGroup = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
const m = new Map<number, Item[]>();
for (const i of data.items) {
const list = m.get(i.groupId);
if (list) list.push(i);
else m.set(i.groupId, [i]);
}
return m;
});
const pct = (n: number, d: number) => (d ? `${Math.round((n / d) * 100)}%` : '–');
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
const lastImport = $derived(
data.runs
.filter((r) => r.finishedAt)
.map((r) => new Date(r.finishedAt!).getTime())
.sort((a, b) => b - a)[0]
);
const historyByItem = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
const m = new Map<number, NonNullable<typeof data.detail>['history']>();
for (const h of data.detail?.history ?? []) {
const list = m.get(h.itemId);
if (list) list.push(h);
else m.set(h.itemId, [h]);
}
return m;
});
const when = (d: Date | string | number) =>
new Date(d).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
/** URL with some search params changed (null removes). */
function withParams(changes: Record<string, string | null>) {
const u = new URL(page.url);
for (const [k, v] of Object.entries(changes)) {
if (v === null || v === '') u.searchParams.delete(k);
else u.searchParams.set(k, v);
}
return `${u.pathname}${u.search}`;
}
const detailOpen = $derived(!!data.detail);
function closeDetail() {
picker = null;
actionError = null;
goto(withParams({ g: null }), { noScroll: true, keepFocus: true });
}
function filterChange(e: Event) {
const form = (e.currentTarget as HTMLFormElement | null) ?? null;
form?.requestSubmit();
}
function openPicker(p: NonNullable<typeof picker>) {
picker = p;
pickerQuery = data.detail?.group.name ?? '';
searchGroups();
}
function searchGroups() {
clearTimeout(pickerTimer);
pickerTimer = setTimeout(async () => {
const q = pickerQuery.trim();
if (!q) {
pickerHits = [];
return;
}
const res = await fetch(`/trackers/groceries/search?q=${encodeURIComponent(q)}`);
pickerHits = res.ok
? ((await res.json()) as PickerHit[]).filter((h) => h.id !== data.detail?.group.id)
: [];
}, 200);
}
const submitAction: SubmitFunction =
() =>
async ({ result, update }) => {
if (result.type === 'success' && typeof result.data?.merged === 'number') {
picker = null;
actionError = null;
await goto(withParams({ g: String(result.data.merged) }), {
noScroll: true,
invalidateAll: true
});
return;
}
await update({ reset: false });
if (result.type === 'success') {
picker = null;
actionError = null;
} else
actionError = String(
(result.type === 'failure' && result.data?.message) || 'Could not save'
);
};
const submitInline: SubmitFunction =
() =>
async ({ update }) => {
await update({ reset: false });
};
</script>
{#snippet favoriteButton(g: Group)}
<form method="POST" action="?/favorite" use:enhance={submitInline} class="contents">
<input type="hidden" name="id" value={g.id} />
<input type="hidden" name="favorite" value={String(!g.favorite)} />
<button
type="submit"
class={['icon', g.favorite && 'fav']}
aria-pressed={g.favorite}
aria-label={g.favorite ? `Unfavorite ${g.name}` : `Favorite ${g.name}`}
title={g.favorite ? 'Favorite' : 'Mark as favorite'}
>
<Star size={16} fill={g.favorite ? 'currentColor' : 'none'} />
</button>
</form>
{/snippet}
{#snippet storeTag(storeId: number)}
{@const st = storeById.get(storeId)}
<span class="store" style:--store={st?.color ?? 'gray'}>{st?.name ?? '?'}</span>
{/snippet}
{#snippet priceCells(i: Item, best: { overall: number | null; perStore: Set<number> })}
{@const up = effectiveUnitPrice(i)}
{@const pp = effectivePrice(i)}
<td
class={[
'num',
mode === 'pack' && 'primary',
mode === 'pack' && best.overall === i.id && 'best',
mode === 'pack' && best.perStore.has(i.id) && 'store-best'
]}
>
{#if pp != null}
{formatCents(pp)}
{#if i.promoPriceCents != null && i.priceCents != null}<s class="meta"
>{formatCents(i.priceCents)}</s
>{/if}
{:else}–{/if}
</td>
<td
class={[
'num',
mode === 'unit' && 'primary',
mode === 'unit' && best.overall === i.id && 'best',
mode === 'unit' && best.perStore.has(i.id) && 'store-best'
]}
>
{formatUnitPrice(up, i.unit) ?? '–'}
</td>
{/snippet}
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groceries</h1>
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
<Lock size={14} aria-hidden="true" /> Private. Supermarket prices compared per kg, litre or piece.
{#if lastImport}Updated {when(lastImport)}.{/if}
</p>
</div>
<dl class="stats">
{#each data.stores.filter((s) => s.items > 0) as s (s.id)}
<div>
<dt>{s.name}</dt>
<dd>{s.items.toLocaleString('nl-NL')}</dd>
</div>
{/each}
<div>
<dt>groups</dt>
<dd>{data.stats.groups.toLocaleString('nl-NL')}</dd>
</div>
<div>
<dt>in 2+ stores</dt>
<dd title="Share of products whose group has products from at least two stores">
{pct(data.stats.grouped, data.stats.items)}
</dd>
</div>
<div>
<dt>unit priced</dt>
<dd>{pct(data.stats.unitPriced, data.stats.items)}</dd>
</div>
</dl>
</div>
<form
method="GET"
class="mb-4 flex flex-wrap items-center gap-2"
data-sveltekit-keepfocus
onchange={filterChange}
>
<Input
type="search"
name="q"
value={data.filters.q}
placeholder="Search (pindakaas, halfvolle melk, Calvé…)"
class="min-w-[14rem] flex-1"
aria-label="Search products"
/>
<NativeSelect name="store" value={data.filters.store} aria-label="Store">
<option value="">All stores</option>
{#each data.stores.filter((s) => s.items > 0) as s (s.id)}<option value={s.slug}
>{s.name}</option
>{/each}
</NativeSelect>
<NativeSelect name="cat" value={data.filters.category} aria-label="Category">
<option value="">All categories</option>
{#each data.categories as c (c)}<option value={c}>{c}</option>{/each}
</NativeSelect>
<label class="check"
><input type="checkbox" name="fav" value="1" checked={data.filters.favorites} /> Favorites</label
>
<label class="check"
><input type="checkbox" name="promo" value="1" checked={data.filters.promo} /> Promo</label
>
<label class="check"
><input type="checkbox" name="multi" value="1" checked={data.filters.multi} /> In 2+ stores</label
>
<label class="check" title="Also show homeware and other non-food (mostly Xenos)"
><input type="checkbox" name="nonfood" value="1" checked={data.filters.nonfood} /> Non-food</label
>
<Button type="submit" variant="outline">Search</Button>
<div class="segmented ml-auto" role="group" aria-label="Compare by">
<button type="button" aria-pressed={mode === 'unit'} onclick={() => (mode = 'unit')}
>Per kg / L / st</button
>
<button type="button" aria-pressed={mode === 'pack'} onclick={() => (mode = 'pack')}
>Pack price</button
>
</div>
</form>
<p class="mb-3 text-sm text-[var(--site-muted)]">
{data.total.toLocaleString('nl-NL')} groups{#if data.total > data.pageSize}, page {data.page} of {pages}{/if}.
<span class="legend best">cheapest</span>
<span class="legend store-best">cheapest at that store</span>
</p>
{#if data.stats.items === 0}
<div class="panel text-sm text-[var(--site-muted)]">
No store products yet. The nightly import (scripts/groceries-import.ts) fills this in.
</div>
{/if}
<div class="grid gap-3">
{#each data.groups as g (g.id)}
{@const all = sortByPrice(itemsByGroup.get(g.id) ?? [], mode)}
{@const best = cheapest(all, mode)}
<section class="panel group">
<header class="mb-2 flex items-start gap-3">
{#if g.imageUrl}
<img
src={g.imageUrl}
alt=""
loading="lazy"
referrerpolicy="no-referrer"
class="thumb"
width="48"
height="48"
/>
{/if}
<div class="min-w-0 flex-1">
<a href={withParams({ g: String(g.id) })} data-sveltekit-noscroll class="title"
>{g.name}</a
>
<div class="meta">
{[
g.brand,
g.category,
`${g.storeCount} store${g.storeCount === 1 ? '' : 's'}`,
`${g.itemCount} product${g.itemCount === 1 ? '' : 's'}`
]
.filter(Boolean)
.join(' · ')}
</div>
</div>
{@render favoriteButton(g)}
</header>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="text-left">Store</th>
<th class="text-left">Product</th>
<th class="text-right">Size</th>
<th class="text-right">Price</th>
<th class="text-right">Per unit</th>
</tr>
</thead>
<tbody>
{#each all.slice(0, MAX_ROWS) as i (i.id)}
<tr>
<td>{@render storeTag(i.storeId)}</td>
<td class="name">
{#if i.url}<a href={i.url} target="_blank" rel="noopener noreferrer">{i.name}</a
>{:else}{i.name}{/if}
{#if i.promoLabel}<span class="promo">{i.promoLabel}</span>{/if}
</td>
<td class="num meta">{i.sizeText ?? formatAmount(i.unitAmount, i.unit) ?? ''}</td>
{@render priceCells(i, best)}
</tr>
{/each}
</tbody>
</table>
</div>
{#if all.length > MAX_ROWS}
<a href={withParams({ g: String(g.id) })} data-sveltekit-noscroll class="more"
>Show all {all.length}</a
>
{/if}
</section>
{:else}
{#if data.stats.items > 0}<div class="panel text-sm text-[var(--site-muted)]">
Nothing matches.
</div>{/if}
{/each}
</div>
{#if pages > 1}
<nav class="mt-6 flex items-center justify-center gap-3 text-sm" aria-label="Pages">
{#if data.page > 1}<a
class="pager"
href={withParams({ page: String(data.page - 1), g: null })}>← Previous</a
>{/if}
<span class="meta">{data.page} / {pages}</span>
{#if data.page < pages}<a
class="pager"
href={withParams({ page: String(data.page + 1), g: null })}>Next →</a
>{/if}
</nav>
{/if}
</div>
<Dialog.Root open={detailOpen} onOpenChange={(o) => !o && closeDetail()}>
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
{#if data.detail}
{@const d = data.detail}
{@const sorted = sortByPrice(d.items, mode)}
{@const best = cheapest(d.items, mode)}
<Dialog.Header class="mb-3 pr-8">
<Dialog.Title>{d.group.name}</Dialog.Title>
<Dialog.Description>
{[d.group.brand, d.group.category, d.group.manual ? 'edited by hand' : 'auto-grouped']
.filter(Boolean)
.join(' · ')}
</Dialog.Description>
</Dialog.Header>
<div class="mb-3 flex flex-wrap items-center gap-2">
<div class="segmented" role="group" aria-label="Compare by">
<button type="button" aria-pressed={mode === 'unit'} onclick={() => (mode = 'unit')}
>Per unit</button
>
<button type="button" aria-pressed={mode === 'pack'} onclick={() => (mode = 'pack')}
>Pack</button
>
</div>
<Button size="sm" variant="outline" onclick={() => openPicker({ kind: 'merge' })}
><Merge size={14} /> Merge into…</Button
>
</div>
{#if picker}
<div class="panel mb-3">
<p class="mb-2 text-sm font-medium">
{picker.kind === 'merge' ? `Merge “${d.group.name}” into:` : 'Move this product to:'}
</p>
<Input
bind:value={pickerQuery}
oninput={searchGroups}
placeholder="Search groups…"
aria-label="Search groups"
/>
<ul class="mt-2 grid gap-1">
{#each pickerHits as h (h.id)}
<li>
<form
method="POST"
action={picker.kind === 'merge' ? '?/merge' : '?/moveItem'}
use:enhance={submitAction}
>
{#if picker.kind === 'merge'}
<input type="hidden" name="id" value={d.group.id} />
{:else}
<input type="hidden" name="itemId" value={picker.itemId} />
{/if}
<input type="hidden" name="into" value={h.id} />
<button type="submit" class="hit">
{h.name}
<span class="meta"
>{[h.brand, `${h.storeCount} stores`, `${h.itemCount} products`]
.filter(Boolean)
.join(' · ')}</span
>
</button>
</form>
</li>
{:else}
<li class="meta">No groups found.</li>
{/each}
</ul>
<Button size="sm" variant="ghost" class="mt-2" onclick={() => (picker = null)}
>Cancel</Button
>
</div>
{/if}
{#if actionError}<p class="mb-2 text-sm text-red-500" role="alert">{actionError}</p>{/if}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="text-left">Store</th>
<th class="text-left">Product</th>
<th class="text-right">Price</th>
<th class="text-right">Per unit</th>
<th class="text-left">Match</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
{#each sorted as i (i.id)}
{@const hist = historyByItem.get(i.id) ?? []}
<tr class={[!i.available && 'gone']}>
<td>{@render storeTag(i.storeId)}</td>
<td class="name">
{#if i.url}<a href={i.url} target="_blank" rel="noopener noreferrer"
>{i.name} <ExternalLink size={11} class="inline" /></a
>{:else}{i.name}{/if}
<div class="meta">
{[
i.sizeText,
i.unitSource === 'store'
? `${formatAmount(i.unitAmount, i.unit)} (from store unit price)`
: null,
i.brand,
i.gtin ? `EAN ${i.gtin}` : null,
!i.available ? 'not available' : null
]
.filter(Boolean)
.join(' · ')}
</div>
{#if i.promoLabel}<span class="promo">{i.promoLabel}</span>{/if}
{#if hist.length > 1}
<div class="meta">
History: {hist
.slice(0, 6)
.map(
(h) =>
`${h.observedOn.slice(5)} ${h.priceCents != null ? formatCents(h.promoPriceCents ?? h.priceCents) : '–'}`
)
.join(', ')}
</div>
{/if}
</td>
{@render priceCells(i, best)}
<td class="meta">
{i.matchMethod ?? '–'}{#if i.matchConfidence != null}&nbsp;{Math.round(
i.matchConfidence * 100
)}%{/if}
{#if i.matchLocked}<Lock size={11} class="inline" aria-label="locked" />{/if}
</td>
<td class="whitespace-nowrap text-right">
<button
type="button"
class="icon"
title="Move to another group"
aria-label="Move to another group"
onclick={() => openPicker({ kind: 'move', itemId: i.id })}
><MoveRight size={14} /></button
>
<form
method="POST"
action="?/moveItem"
use:enhance={submitAction}
class="contents"
>
<input type="hidden" name="itemId" value={i.id} />
<input type="hidden" name="into" value="new" />
<button
type="submit"
class="icon"
title="Split into its own group"
aria-label="Split into its own group"><Split size={14} /></button
>
</form>
{#if i.matchLocked}
<form
method="POST"
action="?/unlockItem"
use:enhance={submitAction}
class="contents"
>
<input type="hidden" name="itemId" value={i.id} />
<button
type="submit"
class="icon"
title="Let the nightly matcher place it again"
aria-label="Unlock"><Unlock size={14} /></button
>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if d.similar.length}
<h3 class="mt-5 mb-2 text-sm font-medium">Similar (other brands)</h3>
<ul class="grid gap-1 text-sm">
{#each d.similar as s (s.id)}
<li class="flex justify-between gap-3">
<a href={withParams({ g: String(s.id) })} data-sveltekit-noscroll class="title"
>{s.brand ? `${s.brand} · ` : ''}{s.name}</a
>
<span class="num meta"
>{formatUnitPrice(s.minUnitPriceCents, s.unit) ?? ''} · {s.storeCount} stores</span
>
</li>
{/each}
</ul>
{/if}
<form
method="POST"
action="?/rename"
use:enhance={submitAction}
class="mt-5 grid gap-2 border-t pt-4"
>
<input type="hidden" name="id" value={d.group.id} />
<label class="field"
>Group name <Input name="name" value={d.group.name} maxlength={200} required /></label
>
<label class="field"
>Notes <Textarea
name="notes"
value={d.group.notes ?? ''}
rows={2}
maxlength={2000}
/></label
>
<div class="flex justify-end"><Button type="submit" size="sm">Save</Button></div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
<style>
.panel {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1rem 1.25rem;
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
}
.stats dt {
font-size: 0.75rem;
color: var(--site-muted);
}
.stats dd {
font-size: 1.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.thumb {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 0.375rem;
background: white;
}
table th {
font-size: 0.7rem;
font-weight: 500;
color: var(--site-muted);
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
table td {
padding: 0.35rem 0.5rem;
border-bottom: 1px solid color-mix(in srgb, var(--site-border) 50%, transparent);
vertical-align: top;
}
tbody tr:last-child td {
border-bottom: none;
}
tr.gone {
opacity: 0.5;
}
.name a:hover,
.title:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.title {
font-weight: 500;
}
.meta {
font-size: 0.75rem;
color: var(--site-muted);
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.num s {
margin-left: 0.25rem;
}
.primary {
font-weight: 500;
}
.best,
.legend.best {
color: rgb(22 163 74);
font-weight: 600;
background: rgb(34 197 94 / 0.12);
}
.store-best,
.legend.store-best {
box-shadow: inset 2px 0 0 rgb(34 197 94 / 0.6);
}
.legend {
font-size: 0.7rem;
padding: 0.05rem 0.4rem;
border-radius: 0.25rem;
margin-left: 0.4rem;
}
.store {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
padding: 0.05rem 0.4rem;
border-radius: 0.25rem;
white-space: nowrap;
color: var(--store);
background: color-mix(in srgb, var(--store) 14%, transparent);
}
.promo {
margin-left: 0.3rem;
font-size: 0.65rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: rgb(245 158 11 / 0.18);
color: rgb(217 119 6);
white-space: nowrap;
}
.more,
.pager {
display: inline-block;
margin-top: 0.4rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.more:hover,
.pager:hover {
color: var(--site-text);
}
.check {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
}
.field {
display: grid;
gap: 0.25rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.hit {
display: flex;
width: 100%;
justify-content: space-between;
gap: 0.75rem;
text-align: left;
padding: 0.3rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.85rem;
}
.hit:hover {
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
padding: 0.3rem;
color: var(--site-muted);
}
.icon:hover {
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
color: var(--site-text);
}
.icon.fav {
color: rgb(234 179 8);
}
.segmented {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
overflow: hidden;
}
.segmented button {
font-size: 0.8rem;
padding: 0.35rem 0.7rem;
color: var(--site-muted);
}
.segmented button[aria-pressed='true'] {
background: color-mix(in srgb, var(--site-text) 12%, transparent);
color: var(--site-text);
font-weight: 500;
}
</style>