Groceries: private tracker at /trackers/groceries (stores, products, price history per store, favorites, cheapest highlighted); seeded AH, Jumbo, Lidl, Plus
All checks were successful
Deploy production / deploy (push) Successful in 2m40s

This commit is contained in:
2026-10-24 13:25:49 +00:00
parent b77c3f8cf5
commit 5f67ad36e7
16 changed files with 17010 additions and 2 deletions

View File

@@ -0,0 +1,10 @@
import { requireOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// Owner only, like tasks and health: everyone else gets a plain 404. Form actions skip
// this load, so +page.server.ts checks again and hooks.server.ts blocks the prefix too.
export const load: LayoutServerLoad = (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {};
};

View File

@@ -0,0 +1,178 @@
import { db } from '#lib/server/db/index.js';
import { groceryPrices, groceryProducts, groceryStores } from '#lib/server/db/schema/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { parseSize, priceInput, productInput, unitPriceCents } from '#lib/server/groceries.js';
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const [stores, products, prices] = await Promise.all([
db
.select()
.from(groceryStores)
.where(isNull(groceryStores.archivedAt))
.orderBy(asc(groceryStores.sortOrder), asc(groceryStores.name)),
db
.select()
.from(groceryProducts)
.where(isNull(groceryProducts.archivedAt))
.orderBy(desc(groceryProducts.favorite), asc(sql`lower(${groceryProducts.name})`)),
db
.select({
id: groceryPrices.id,
productId: groceryPrices.productId,
storeId: groceryPrices.storeId,
priceCents: groceryPrices.priceCents,
unitPriceCents: groceryPrices.unitPriceCents,
promo: groceryPrices.promo,
observedAt: groceryPrices.observedAt,
source: groceryPrices.source,
url: groceryPrices.url,
notes: groceryPrices.notes
})
.from(groceryPrices)
.orderBy(desc(groceryPrices.observedAt), desc(groceryPrices.id))
]);
return {
stores,
products,
prices,
seo: { title: 'Groceries', description: 'Private grocery and price tracker.', index: false }
};
};
const id = (v: FormDataEntryValue | null) => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
const formObject = (form: FormData) => {
const o: Record<string, unknown> = {};
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
return o;
};
const firstIssue = (e: { issues: { path: PropertyKey[]; message: string }[] }) => {
const i = e.issues[0];
return i ? `${i.path.join('.') || 'input'}: ${i.message}` : 'Invalid input';
};
function dbError(e: unknown): string {
const cause = (e as { cause?: { message?: string; constraint_name?: string } })?.cause;
if (cause?.constraint_name === 'products_name_brand_key')
return 'That product (name + brand) already exists';
if (cause?.constraint_name) return `Not allowed (${cause.constraint_name})`;
return cause?.message || 'Could not save';
}
export const actions: Actions = {
/** Create or edit a product. */
saveProduct: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('id'));
const parsed = productInput.safeParse(formObject(form));
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
const p = parsed.data;
const size = parseSize(p.size);
const values = {
name: p.name,
brand: p.brand ?? null,
category: p.category ?? null,
size: p.size ?? null,
unitAmount: size?.amount ?? null,
unit: size?.unit ?? null,
notes: p.notes ?? null,
...(form.has('favorite') ? { favorite: form.get('favorite') === 'on' } : {})
};
try {
if (productId) {
await db.transaction(async (tx) => {
await tx.update(groceryProducts).set(values).where(eq(groceryProducts.id, productId));
// Pack size changed: recompute unit prices of this product's history.
await tx
.update(groceryPrices)
.set({
unitPriceCents: values.unitAmount
? sql`round(${groceryPrices.priceCents} / ${values.unitAmount}::numeric)::int`
: null
})
.where(eq(groceryPrices.productId, productId));
});
return { saved: productId };
}
const [row] = await db
.insert(groceryProducts)
.values(values)
.returning({ id: groceryProducts.id });
return { saved: row.id };
} catch (e) {
return fail(400, { message: dbError(e) });
}
},
favorite: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('id'));
if (!productId) return fail(400, { message: 'Invalid product' });
await db
.update(groceryProducts)
.set({ favorite: form.get('favorite') === 'true' })
.where(eq(groceryProducts.id, productId));
return { updated: productId };
},
deleteProduct: async (event) => {
requireOwner(event);
const productId = id((await event.request.formData()).get('id'));
if (!productId) return fail(400, { message: 'Invalid product' });
await db.delete(groceryProducts).where(eq(groceryProducts.id, productId));
return { deleted: productId };
},
/** Record a price observation for a product at a store. */
addPrice: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const productId = id(form.get('productId'));
const storeId = id(form.get('storeId'));
if (!productId || !storeId) return fail(400, { message: 'Pick a product and a store' });
const parsed = priceInput.safeParse(formObject(form));
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
const [product] = await db
.select({ unitAmount: groceryProducts.unitAmount })
.from(groceryProducts)
.where(eq(groceryProducts.id, productId));
if (!product) return fail(404, { message: 'Product not found' });
const p = parsed.data;
try {
await db.insert(groceryPrices).values({
productId,
storeId,
priceCents: p.price,
unitPriceCents: unitPriceCents(p.price, product.unitAmount),
promo: form.get('promo') === 'on',
// A plain date is stored as noon UTC; the exact time does not matter for prices.
observedAt: p.observedOn ? new Date(`${p.observedOn}T12:00:00Z`) : new Date(),
source: 'manual',
url: p.url ?? null,
notes: p.notes ?? null
});
} catch (e) {
return fail(400, { message: dbError(e) });
}
return { priced: productId };
},
deletePrice: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const priceId = id(form.get('id'));
const productId = id(form.get('productId'));
if (!priceId || !productId) return fail(400, { message: 'Invalid price' });
await db
.delete(groceryPrices)
.where(and(eq(groceryPrices.id, priceId), eq(groceryPrices.productId, productId)));
return { deleted: priceId };
}
};

View File

@@ -0,0 +1,568 @@
<script lang="ts">
import { enhance } from '$app/forms';
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 Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Lock from 'lucide-svelte/icons/lock';
import Star from 'lucide-svelte/icons/star';
import Trash from 'lucide-svelte/icons/trash-2';
import ExternalLink from 'lucide-svelte/icons/external-link';
import { cheapestStores, formatCents, UNIT_LABEL, type LatestPrice } from '#lib/groceries.js';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Product = (typeof data.products)[number];
type Price = (typeof data.prices)[number];
let query = $state('');
let category = $state('');
let favoritesOnly = $state(false);
let quickError = $state<string | null>(null);
let dialog = $state(false);
let editingId = $state<number | null>(null);
let confirmDelete = $state(false);
let error = $state<string | null>(null);
let priceError = $state<string | null>(null);
/** Price history per product, newest first (load orders by observed_at desc). */
const history = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
const m = new Map<number, Price[]>();
for (const p of data.prices) {
const list = m.get(p.productId);
if (list) list.push(p);
else m.set(p.productId, [p]);
}
return m;
});
/** Latest price per product per store. */
const latest = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
const m = new Map<number, Map<number, LatestPrice>>();
for (const [productId, list] of history) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated later
const byStore = new Map<number, LatestPrice>();
for (const p of list) if (!byStore.has(p.storeId)) byStore.set(p.storeId, p);
m.set(productId, byStore);
}
return m;
});
const cheapest = $derived(
new Map([...latest].map(([id, byStore]) => [id, cheapestStores([...byStore.values()])]))
);
const categories = $derived(
[...new Set(data.products.map((p) => p.category).filter((c): c is string => !!c))].sort()
);
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
return data.products.filter(
(p) =>
(!favoritesOnly || p.favorite) &&
(!category || p.category === category) &&
(!q || [p.name, p.brand, p.category, p.notes].some((v) => v?.toLowerCase().includes(q)))
);
});
const counts = $derived({
products: data.products.length,
favorites: data.products.filter((p) => p.favorite).length,
priced: latest.size,
prices: data.prices.length
});
const editing = $derived(data.products.find((p) => p.id === editingId) ?? null);
const storeName = $derived(new Map(data.stores.map((s) => [s.id, s.name])));
const today = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
const shortDate = (d: Date | string) =>
new Date(d).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const unitPrice = (p: LatestPrice, product: Product) =>
p.unitPriceCents !== null && product.unit
? `${formatCents(p.unitPriceCents)}/${UNIT_LABEL[product.unit] ?? product.unit}`
: null;
function openProduct(p: Product | null) {
editingId = p?.id ?? null;
confirmDelete = false;
error = priceError = null;
dialog = true;
}
const submitQuick: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
quickError =
result.type === 'failure' ? String(result.data?.message ?? 'Could not add') : null;
};
const submitDialog: SubmitFunction =
() =>
async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') {
if (!editingId && typeof result.data?.saved === 'number') editingId = result.data.saved;
else dialog = false;
error = null;
} else
error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
};
const submitDelete: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') dialog = false;
};
const submitPrice: SubmitFunction =
() =>
async ({ result, update }) => {
await update();
priceError =
result.type === 'failure' ? String(result.data?.message ?? 'Could not save') : null;
};
const submitInline: SubmitFunction =
() =>
async ({ update }) => {
await update({ reset: false });
};
</script>
{#snippet favoriteButton(p: Product)}
<form method="POST" action="?/favorite" use:enhance={submitInline} class="contents">
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="favorite" value={String(!p.favorite)} />
<button
type="submit"
class={['icon', p.favorite && 'fav']}
aria-pressed={p.favorite}
aria-label={p.favorite ? `Unfavorite ${p.name}` : `Favorite ${p.name}`}
title={p.favorite ? 'Favorite' : 'Mark as favorite'}
>
<Star size={16} fill={p.favorite ? 'currentColor' : 'none'} />
</button>
</form>
{/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. Products I buy and their prices at
{data.stores.map((s) => s.name).join(', ')}.
</p>
</div>
<dl class="stats">
<div>
<dt>products</dt>
<dd>{counts.products}</dd>
</div>
<div>
<dt>favorites</dt>
<dd>{counts.favorites}</dd>
</div>
<div>
<dt>priced</dt>
<dd>{counts.priced}</dd>
</div>
<div>
<dt>price points</dt>
<dd>{counts.prices}</dd>
</div>
</dl>
</div>
<form
method="POST"
action="?/saveProduct"
use:enhance={submitQuick}
class="panel mb-4 flex flex-wrap items-center gap-2"
>
<Input
name="name"
required
maxlength={200}
placeholder="Add a product… (e.g. Halfvolle melk)"
class="min-w-[12rem] flex-1"
aria-label="Product name"
/>
<Input name="brand" maxlength={100} placeholder="Brand" class="w-32" aria-label="Brand" />
<Input
name="size"
maxlength={60}
placeholder="Size (1 L, 500 g)"
class="w-36"
aria-label="Size"
/>
<Input
name="category"
maxlength={100}
placeholder="Category"
class="w-36"
list="grocery-categories"
aria-label="Category"
/>
<Button type="submit"><Plus size={16} /> Add</Button>
{#if quickError}<p class="w-full text-sm text-red-500" role="alert">{quickError}</p>{/if}
</form>
<datalist id="grocery-categories">
{#each categories as c (c)}<option value={c}></option>{/each}
</datalist>
<div class="mb-4 flex flex-wrap items-center gap-2">
<Input
type="search"
bind:value={query}
placeholder="Search products…"
class="max-w-xs"
aria-label="Search products"
/>
<NativeSelect bind:value={category} aria-label="Category filter">
<option value="">All categories</option>
{#each categories as c (c)}<option value={c}>{c}</option>{/each}
</NativeSelect>
<div class="segmented" role="group" aria-label="Show">
<button type="button" aria-pressed={!favoritesOnly} onclick={() => (favoritesOnly = false)}
>All</button
>
<button type="button" aria-pressed={favoritesOnly} onclick={() => (favoritesOnly = true)}
>Favorites</button
>
</div>
<span class="ml-auto text-sm text-[var(--site-muted)]">{filtered.length} shown</span>
</div>
{#if data.products.length === 0}
<div class="panel text-sm text-[var(--site-muted)]">
No products yet. Add the things you buy above, then open a product to log what it costs at
each store. The cheapest store is highlighted per product.
</div>
{:else}
<div class="panel overflow-x-auto p-0">
<table class="w-full text-sm">
<thead>
<tr>
<th class="w-8"><span class="sr-only">Favorite</span></th>
<th class="text-left">Product</th>
{#each data.stores as s (s.id)}<th class="text-right">{s.name}</th>{/each}
<th class="w-8"><span class="sr-only">Edit</span></th>
</tr>
</thead>
<tbody>
{#each filtered as p (p.id)}
{@const byStore = latest.get(p.id)}
{@const best = cheapest.get(p.id)}
<tr>
<td>{@render favoriteButton(p)}</td>
<td>
<button type="button" class="title" onclick={() => openProduct(p)}>{p.name}</button>
<div class="meta">
{[p.brand, p.size, p.category].filter(Boolean).join(' · ')}
</div>
</td>
{#each data.stores as s (s.id)}
{@const lp = byStore?.get(s.id)}
<td class={['text-right price', best?.has(s.id) && 'best']}>
{#if lp}
{@const up = unitPrice(lp, p)}
<span title={`Seen ${shortDate(lp.observedAt)}`}
>{formatCents(lp.priceCents)}{#if lp.promo}<span class="promo">promo</span
>{/if}</span
>
{#if up}<div class="meta">{up}</div>{/if}
{:else}
<span class="meta">–</span>
{/if}
</td>
{/each}
<td>
<button
class="icon"
type="button"
aria-label={`Edit ${p.name}`}
onclick={() => openProduct(p)}><Pencil size={15} /></button
>
</td>
</tr>
{:else}
<tr><td colspan={data.stores.length + 3} class="meta">No products match.</td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<Dialog.Root bind:open={dialog}>
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Header class="mb-4 pr-8">
<Dialog.Title
>{confirmDelete ? 'Delete product' : editing ? editing.name : 'New product'}</Dialog.Title
>
<Dialog.Description class="sr-only">Edit a grocery product and its prices.</Dialog.Description
>
</Dialog.Header>
{#if confirmDelete && editing}
<form method="POST" action="?/deleteProduct" use:enhance={submitDelete} class="space-y-4">
<input type="hidden" name="id" value={editing.id} />
<p class="text-sm">
Delete <strong>{editing.name}</strong> and its {history.get(editing.id)?.length ?? 0} price
points?
</p>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (confirmDelete = false)}
>Cancel</Button
>
<Button type="submit" variant="destructive">Delete</Button>
</div>
</form>
{:else}
{#key editingId}
<form method="POST" action="?/saveProduct" use:enhance={submitDialog} class="space-y-3">
{#if editing}<input type="hidden" name="id" value={editing.id} />{/if}
<label class="field"
>Name <Input name="name" required maxlength={200} value={editing?.name ?? ''} /></label
>
<div class="grid grid-cols-2 gap-3">
<label class="field"
>Brand <Input name="brand" maxlength={100} value={editing?.brand ?? ''} /></label
>
<label class="field"
>Size <Input
name="size"
maxlength={60}
placeholder="1 L, 500 g, 6 x 1 L"
value={editing?.size ?? ''}
/></label
>
</div>
<label class="field"
>Category <Input
name="category"
maxlength={100}
list="grocery-categories"
value={editing?.category ?? ''}
/></label
>
<label class="field"
>Notes <Textarea
name="notes"
maxlength={2000}
rows={2}
value={editing?.notes ?? ''}
/></label
>
<label class="flex items-center gap-2 text-sm"
><input type="checkbox" name="favorite" checked={editing?.favorite ?? false} /> Favorite (I
buy this regularly)</label
>
{#if editing?.unitAmount && editing.unit}
<p class="meta">
Unit prices per {UNIT_LABEL[editing.unit] ?? editing.unit} (pack = {editing.unitAmount}
{UNIT_LABEL[editing.unit] ?? editing.unit}).
</p>
{:else if editing?.size}
<p class="meta">Size not recognised, so no unit prices. Try "500 g" or "6 x 1 L".</p>
{/if}
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
<div class="flex justify-between gap-2">
{#if editing}
<Button type="button" variant="ghost" onclick={() => (confirmDelete = true)}
><Trash size={15} /> Delete</Button
>
{:else}<span></span>{/if}
<Button type="submit">{editing ? 'Save' : 'Create'}</Button>
</div>
</form>
{/key}
{#if editing}
{@const list = history.get(editing.id) ?? []}
<hr class="my-5 border-[var(--site-border)]" />
<h3 class="mb-2 font-medium">Prices</h3>
<form
method="POST"
action="?/addPrice"
use:enhance={submitPrice}
class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-4"
>
<input type="hidden" name="productId" value={editing.id} />
<NativeSelect name="storeId" required aria-label="Store">
{#each data.stores as s (s.id)}<option value={String(s.id)}>{s.name}</option>{/each}
</NativeSelect>
<Input
name="price"
required
inputmode="decimal"
placeholder="1,79"
aria-label="Price (€)"
/>
<Input name="observedOn" type="date" value={today()} aria-label="Date seen" />
<label class="flex items-center gap-1.5 text-sm"
><input type="checkbox" name="promo" /> Promo</label
>
<Input
name="url"
type="url"
placeholder="Product link (optional)"
class="col-span-2 sm:col-span-3"
aria-label="Product link"
/>
<Button type="submit"><Plus size={15} /> Add price</Button>
{#if priceError}<p class="col-span-full text-sm text-red-500" role="alert">
{priceError}
</p>{/if}
</form>
{#if list.length === 0}
<p class="meta">No prices yet.</p>
{:else}
<ul class="space-y-1 text-sm">
{#each list as pr (pr.id)}
<li class="flex items-center gap-2">
<span class="w-24 shrink-0">{storeName.get(pr.storeId)}</span>
<span class="tabular-nums">{formatCents(pr.priceCents)}</span>
{#if pr.promo}<span class="promo">promo</span>{/if}
<span class="meta">{shortDate(pr.observedAt)}</span>
{#if pr.url}<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
class="icon"
aria-label="Open product page"><ExternalLink size={13} /></a
>{/if}
<form
method="POST"
action="?/deletePrice"
use:enhance={submitInline}
class="ml-auto"
>
<input type="hidden" name="id" value={pr.id} />
<input type="hidden" name="productId" value={editing.id} />
<button type="submit" class="icon" aria-label="Delete price"
><Trash size={13} /></button
>
</form>
</li>
{/each}
</ul>
{/if}
{/if}
{/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;
}
table th {
font-size: 0.75rem;
font-weight: 500;
color: var(--site-muted);
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--site-border) 60%, transparent);
vertical-align: top;
}
tbody tr:last-child td {
border-bottom: none;
}
.title {
text-align: left;
font-weight: 500;
}
.title:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.meta {
font-size: 0.75rem;
color: var(--site-muted);
}
.price {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.price.best {
color: rgb(22 163 74);
font-weight: 600;
background: rgb(34 197 94 / 0.1);
}
.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);
vertical-align: middle;
}
.field {
display: grid;
gap: 0.25rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.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>