Groceries: private tracker at /trackers/groceries (stores, products, price history per store, favorites, cheapest highlighted); seeded AH, Jumbo, Lidl, Plus
Deploy production / deploy (push) Successful in 2m40s
Deploy production / deploy (push) Successful in 2m40s
This commit is contained in:
1 parent
b77c3f8cf5
commit
5f67ad36e7
16 files changed
+17010
-2
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user