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
All checks were successful
Deploy production / deploy (push) Successful in 2m40s
This commit is contained in:
@@ -22,7 +22,8 @@ export default defineConfig({
|
|||||||
'meta',
|
'meta',
|
||||||
'health',
|
'health',
|
||||||
'books',
|
'books',
|
||||||
'tasks'
|
'tasks',
|
||||||
|
'groceries'
|
||||||
],
|
],
|
||||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
112
drizzle/20261024132343_groceries/migration.sql
Normal file
112
drizzle/20261024132343_groceries/migration.sql
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
CREATE SCHEMA "groceries";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."prices" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"product_id" integer NOT NULL,
|
||||||
|
"store_id" integer NOT NULL,
|
||||||
|
"price_cents" integer NOT NULL,
|
||||||
|
"unit_price_cents" integer,
|
||||||
|
"promo" boolean DEFAULT false NOT NULL,
|
||||||
|
"observed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"source" text DEFAULT 'manual' NOT NULL,
|
||||||
|
"store_product_id" text,
|
||||||
|
"url" text,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "prices_price_check" CHECK ("price_cents" >= 0),
|
||||||
|
CONSTRAINT "prices_unit_price_check" CHECK ("unit_price_cents" IS NULL OR "unit_price_cents" >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."products" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"brand" text,
|
||||||
|
"category" text,
|
||||||
|
"size" text,
|
||||||
|
"unit_amount" numeric(10,3),
|
||||||
|
"unit" text,
|
||||||
|
"favorite" boolean DEFAULT false NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "products_name_check" CHECK (length(btrim("name")) > 0),
|
||||||
|
CONSTRAINT "products_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l')),
|
||||||
|
CONSTRAINT "products_unit_amount_check" CHECK ("unit_amount" IS NULL OR "unit_amount" > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."stores" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"website" text,
|
||||||
|
"color" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "stores_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||||
|
CONSTRAINT "stores_name_check" CHECK (length(btrim("name")) > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "prices_product_store_idx" ON "groceries"."prices" ("product_id","store_id","observed_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "prices_store_idx" ON "groceries"."prices" ("store_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "products_name_brand_key" ON "groceries"."products" (lower("name"),lower(coalesce("brand", '')));--> statement-breakpoint
|
||||||
|
CREATE INDEX "products_category_idx" ON "groceries"."products" ("category");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "stores_slug_key" ON "groceries"."stores" ("slug");--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_product_id_products_id_fkey" FOREIGN KEY ("product_id") REFERENCES "groceries"."products"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."prices" ADD CONSTRAINT "prices_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- updated_at triggers, same as every other core table.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
FOREACH t IN ARRAY ARRAY['stores','products','prices'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON groceries.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Private like tasks/health: app role only, no personal_ro. Grants only run on prod,
|
||||||
|
-- where migrations run as `personal`.
|
||||||
|
REVOKE ALL ON SCHEMA groceries FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA groceries TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA groceries
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO groceries.stores (slug, name, website, color, sort_order) VALUES
|
||||||
|
('ah', 'Albert Heijn', 'https://www.ah.nl', '#00a0e2', 10),
|
||||||
|
('jumbo', 'Jumbo', 'https://www.jumbo.com', '#eeb717', 20),
|
||||||
|
('lidl', 'Lidl', 'https://www.lidl.nl', '#0050aa', 30),
|
||||||
|
('plus', 'Plus', 'https://www.plus.nl', '#e2001a', 40)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Registered in meta.tables so the /lists database browser shows them (kind 'core').
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||||
|
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||||
|
VALUES ('groceries','products','groceries-products','Grocery products','core',true,false,1100),
|
||||||
|
('groceries','prices','groceries-prices','Grocery prices','core',true,false,1100),
|
||||||
|
('groceries','stores','groceries-stores','Grocery stores','core',true,false,1100)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
15861
drizzle/20261024132343_groceries/snapshot.json
Normal file
15861
drizzle/20261024132343_groceries/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -151,6 +151,28 @@ try {
|
|||||||
tasks.public_usage
|
tasks.public_usage
|
||||||
)
|
)
|
||||||
throw new Error(`tasks schema grants are wrong: ${JSON.stringify(tasks)}`);
|
throw new Error(`tasks schema grants are wrong: ${JSON.stringify(tasks)}`);
|
||||||
|
// Private grocery tracker: same rules (app role read/write, personal_ro none).
|
||||||
|
const [groceries] = await client`
|
||||||
|
select
|
||||||
|
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||||
|
count(*) filter (
|
||||||
|
where exists (
|
||||||
|
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
||||||
|
where r.rolname = 'personal_ro'
|
||||||
|
)
|
||||||
|
) as ro_read,
|
||||||
|
count(*) as tables,
|
||||||
|
has_schema_privilege('public', 'groceries', 'usage') as public_usage
|
||||||
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'groceries' and c.relkind = 'r'
|
||||||
|
`;
|
||||||
|
if (
|
||||||
|
Number(groceries.tables) !== 3 ||
|
||||||
|
Number(groceries.app_rw) !== 3 ||
|
||||||
|
Number(groceries.ro_read) !== 0 ||
|
||||||
|
groceries.public_usage
|
||||||
|
)
|
||||||
|
throw new Error(`groceries schema grants are wrong: ${JSON.stringify(groceries)}`);
|
||||||
// Energy tracker reads the ned-collector tables (SELECT only).
|
// Energy tracker reads the ned-collector tables (SELECT only).
|
||||||
const [energy] = await client`
|
const [energy] = await client`
|
||||||
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
||||||
@@ -160,7 +182,7 @@ try {
|
|||||||
if (!Number(energy.hours))
|
if (!Number(energy.hours))
|
||||||
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
||||||
console.log(
|
console.log(
|
||||||
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||||
);
|
);
|
||||||
const [catalog] = await client`
|
const [catalog] = await client`
|
||||||
select
|
select
|
||||||
|
|||||||
24
src/lib/groceries.ts
Normal file
24
src/lib/groceries.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Shared (client + server) grocery helpers: price comparison and formatting.
|
||||||
|
|
||||||
|
export type LatestPrice = {
|
||||||
|
storeId: number;
|
||||||
|
priceCents: number;
|
||||||
|
unitPriceCents: number | null;
|
||||||
|
promo: boolean;
|
||||||
|
observedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Store ids with the lowest comparable price: unit price when every store has one, else pack price. */
|
||||||
|
export function cheapestStores(prices: LatestPrice[]): Set<number> {
|
||||||
|
if (prices.length < 2) return new Set();
|
||||||
|
const byUnit = prices.every((p) => p.unitPriceCents !== null);
|
||||||
|
const val = (p: LatestPrice) => (byUnit ? p.unitPriceCents! : p.priceCents);
|
||||||
|
const min = Math.min(...prices.map(val));
|
||||||
|
return new Set(prices.filter((p) => val(p) === min).map((p) => p.storeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
|
||||||
|
/** 179 → "€ 1,79". */
|
||||||
|
export const formatCents = (cents: number) => eur.format(cents / 100);
|
||||||
|
|
||||||
|
export const UNIT_LABEL: Record<string, string> = { kg: 'kg', l: 'L', piece: 'st' };
|
||||||
@@ -65,6 +65,13 @@ const allTrackers = [
|
|||||||
external: false,
|
external: false,
|
||||||
private: true
|
private: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'groceries',
|
||||||
|
subtitle: 'private: supermarket products, favorites and prices per store',
|
||||||
|
href: '/trackers/groceries',
|
||||||
|
external: false,
|
||||||
|
private: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'energy',
|
name: 'energy',
|
||||||
subtitle: 'Dutch electricity mix, utilization and gas storage',
|
subtitle: 'Dutch electricity mix, utilization and gas storage',
|
||||||
|
|||||||
97
src/lib/server/db/schema/groceries.ts
Normal file
97
src/lib/server/db/schema/groceries.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { boolean, check, index, integer, numeric, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { groceriesSchema } from './schemas';
|
||||||
|
import { id, timestamps, timestamptz } from './helpers';
|
||||||
|
|
||||||
|
// Private grocery tracker (owner only, see requireOwner): the products Stan buys, which
|
||||||
|
// ones are favourites, and what they cost at each supermarket over time.
|
||||||
|
// Prices are append-only observations in cents, so price history and "cheapest now" both
|
||||||
|
// come from the same table. The latest observation per (product, store) is the current price.
|
||||||
|
|
||||||
|
export const GROCERY_UNITS = ['piece', 'kg', 'l'] as const;
|
||||||
|
export type GroceryUnit = (typeof GROCERY_UNITS)[number];
|
||||||
|
|
||||||
|
/** Supermarkets (Albert Heijn, Jumbo, Lidl, Plus, …). `slug` is the stable key for imports. */
|
||||||
|
export const groceryStores = groceriesSchema.table(
|
||||||
|
'stores',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
website: text('website'),
|
||||||
|
color: text('color'),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
archivedAt: timestamptz('archived_at'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('stores_slug_key').on(t.slug),
|
||||||
|
check('stores_slug_check', sql`${t.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
|
||||||
|
check('stores_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A product as Stan thinks of it ("Halfvolle melk 1 L"), not one store's SKU: the same
|
||||||
|
* product can be priced at several stores. `unit_amount` + `unit` (e.g. 1.5 + 'l') make
|
||||||
|
* unit prices comparable across pack sizes.
|
||||||
|
*/
|
||||||
|
export const groceryProducts = groceriesSchema.table(
|
||||||
|
'products',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
brand: text('brand'),
|
||||||
|
category: text('category'),
|
||||||
|
size: text('size'), // as printed, e.g. "6 x 1 L"
|
||||||
|
unitAmount: numeric('unit_amount', { precision: 10, scale: 3, mode: 'number' }),
|
||||||
|
unit: text('unit').$type<GroceryUnit>(),
|
||||||
|
favorite: boolean('favorite').notNull().default(false),
|
||||||
|
notes: text('notes'),
|
||||||
|
archivedAt: timestamptz('archived_at'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('products_name_brand_key').on(
|
||||||
|
sql`lower(${t.name})`,
|
||||||
|
sql`lower(coalesce(${t.brand}, ''))`
|
||||||
|
),
|
||||||
|
index('products_category_idx').on(t.category),
|
||||||
|
check('products_name_check', sql`length(btrim(${t.name})) > 0`),
|
||||||
|
check('products_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`),
|
||||||
|
check('products_unit_amount_check', sql`${t.unitAmount} IS NULL OR ${t.unitAmount} > 0`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** One observed price of a product at a store (manual entry or a later import). */
|
||||||
|
export const groceryPrices = groceriesSchema.table(
|
||||||
|
'prices',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
productId: integer('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => groceryProducts.id, { onDelete: 'cascade' }),
|
||||||
|
storeId: integer('store_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => groceryStores.id, { onDelete: 'cascade' }),
|
||||||
|
priceCents: integer('price_cents').notNull(),
|
||||||
|
unitPriceCents: integer('unit_price_cents'), // per product.unit, if known
|
||||||
|
promo: boolean('promo').notNull().default(false), // bonus / aanbieding price
|
||||||
|
observedAt: timestamptz('observed_at').notNull().defaultNow(),
|
||||||
|
source: text('source').notNull().default('manual'), // 'manual', 'ah-api', 'jumbo-api', …
|
||||||
|
storeProductId: text('store_product_id'), // e.g. AH webshopId, Jumbo SKU
|
||||||
|
url: text('url'),
|
||||||
|
notes: text('notes'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('prices_product_store_idx').on(t.productId, t.storeId, t.observedAt),
|
||||||
|
index('prices_store_idx').on(t.storeId),
|
||||||
|
check('prices_price_check', sql`${t.priceCents} >= 0`),
|
||||||
|
check('prices_unit_price_check', sql`${t.unitPriceCents} IS NULL OR ${t.unitPriceCents} >= 0`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type GroceryStore = typeof groceryStores.$inferSelect;
|
||||||
|
export type GroceryProduct = typeof groceryProducts.$inferSelect;
|
||||||
|
export type GroceryPrice = typeof groceryPrices.$inferSelect;
|
||||||
@@ -15,3 +15,4 @@ export * from './house-tracker';
|
|||||||
export * from './health';
|
export * from './health';
|
||||||
export * from './books';
|
export * from './books';
|
||||||
export * from './tasks';
|
export * from './tasks';
|
||||||
|
export * from './groceries';
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export const metaSchema = pgSchema('meta');
|
|||||||
export const healthSchema = pgSchema('health');
|
export const healthSchema = pgSchema('health');
|
||||||
export const booksSchema = pgSchema('books');
|
export const booksSchema = pgSchema('books');
|
||||||
export const tasksSchema = pgSchema('tasks');
|
export const tasksSchema = pgSchema('tasks');
|
||||||
|
export const groceriesSchema = pgSchema('groceries');
|
||||||
|
|||||||
45
src/lib/server/groceries.test.ts
Normal file
45
src/lib/server/groceries.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseCents, parseSize, priceInput, unitPriceCents } from './groceries';
|
||||||
|
import { cheapestStores, formatCents } from '../groceries';
|
||||||
|
|
||||||
|
describe('groceries helpers', () => {
|
||||||
|
it('parses prices into cents', () => {
|
||||||
|
expect(parseCents('1,79')).toBe(179);
|
||||||
|
expect(parseCents('€ 2.5')).toBe(250);
|
||||||
|
expect(parseCents('3')).toBe(300);
|
||||||
|
expect(parseCents('abc')).toBeNull();
|
||||||
|
expect(parseCents('1,234')).toBeNull();
|
||||||
|
});
|
||||||
|
it('parses pack sizes', () => {
|
||||||
|
expect(parseSize('1 L')).toEqual({ amount: 1, unit: 'l' });
|
||||||
|
expect(parseSize('6 x 1 L')).toEqual({ amount: 6, unit: 'l' });
|
||||||
|
expect(parseSize('500 g')).toEqual({ amount: 0.5, unit: 'kg' });
|
||||||
|
expect(parseSize('750ml')).toEqual({ amount: 0.75, unit: 'l' });
|
||||||
|
expect(parseSize('4 stuks')).toEqual({ amount: 4, unit: 'piece' });
|
||||||
|
expect(parseSize('1,5 liter')).toEqual({ amount: 1.5, unit: 'l' });
|
||||||
|
expect(parseSize('a bunch')).toBeNull();
|
||||||
|
});
|
||||||
|
it('computes unit prices', () => {
|
||||||
|
expect(unitPriceCents(479, 6)).toBe(80);
|
||||||
|
expect(unitPriceCents(100, null)).toBeNull();
|
||||||
|
});
|
||||||
|
it('validates price input', () => {
|
||||||
|
expect(priceInput.safeParse({ price: '1,29' }).data?.price).toBe(129);
|
||||||
|
expect(priceInput.safeParse({ price: 'x' }).success).toBe(false);
|
||||||
|
expect(priceInput.safeParse({ price: '1', url: '' }).success).toBe(true);
|
||||||
|
});
|
||||||
|
it('finds the cheapest store', () => {
|
||||||
|
const p = (storeId: number, priceCents: number, unitPriceCents: number | null) => ({
|
||||||
|
storeId,
|
||||||
|
priceCents,
|
||||||
|
unitPriceCents,
|
||||||
|
promo: false,
|
||||||
|
observedAt: new Date()
|
||||||
|
});
|
||||||
|
expect([...cheapestStores([p(1, 179, 179), p(2, 129, 129)])]).toEqual([2]);
|
||||||
|
expect([...cheapestStores([p(1, 479, 80), p(2, 129, 129)])]).toEqual([1]);
|
||||||
|
expect([...cheapestStores([p(1, 479, null), p(2, 129, 129)])]).toEqual([2]);
|
||||||
|
expect(cheapestStores([p(1, 100, null)]).size).toBe(0);
|
||||||
|
expect(formatCents(179).replace(/\s/g, ' ')).toBe('€ 1,79');
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/lib/server/groceries.ts
Normal file
77
src/lib/server/groceries.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
import { GROCERY_UNITS } from './db/schema/groceries';
|
||||||
|
|
||||||
|
/** "1,79" / "1.79" / "€ 1,79" → 179 cents; null when not a price. */
|
||||||
|
export function parseCents(input: unknown): number | null {
|
||||||
|
if (typeof input !== 'string') return null;
|
||||||
|
const s = input.replace(/[€\s]/g, '').replace(',', '.');
|
||||||
|
if (!/^\d+(\.\d{1,2})?$/.test(s)) return null;
|
||||||
|
return Math.round(Number(s) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price per unit (kg / l / piece) in cents, rounded; null without a usable amount. */
|
||||||
|
export function unitPriceCents(priceCents: number, unitAmount: number | null | undefined) {
|
||||||
|
if (!unitAmount || !(unitAmount > 0)) return null;
|
||||||
|
return Math.round(priceCents / unitAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort pack size parse, e.g. "1 L" → 1 l, "6 x 1 L" → 6 l, "500 g" → 0.5 kg,
|
||||||
|
* "750ml" → 0.75 l, "4 stuks" → 4 piece. Returns null when unclear.
|
||||||
|
*/
|
||||||
|
export function parseSize(
|
||||||
|
size: string | null | undefined
|
||||||
|
): { amount: number; unit: (typeof GROCERY_UNITS)[number] } | null {
|
||||||
|
if (!size) return null;
|
||||||
|
const s = size.toLowerCase().replace(',', '.').trim();
|
||||||
|
const m = s.match(
|
||||||
|
/^(?:(\d+)\s*x\s*)?(\d+(?:\.\d+)?)\s*(kg|kilo|g|gr|gram|l|liter|litre|ml|cl|st|stuk|stuks|pcs|pieces?)$/
|
||||||
|
);
|
||||||
|
if (!m) return null;
|
||||||
|
const n = (m[1] ? Number(m[1]) : 1) * Number(m[2]);
|
||||||
|
const u = m[3];
|
||||||
|
if (['kg', 'kilo'].includes(u)) return { amount: n, unit: 'kg' };
|
||||||
|
if (['g', 'gr', 'gram'].includes(u)) return { amount: n / 1000, unit: 'kg' };
|
||||||
|
if (['l', 'liter', 'litre'].includes(u)) return { amount: n, unit: 'l' };
|
||||||
|
if (u === 'ml') return { amount: n / 1000, unit: 'l' };
|
||||||
|
if (u === 'cl') return { amount: n / 100, unit: 'l' };
|
||||||
|
return { amount: n, unit: 'piece' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const optText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.transform((v) => (v === '' ? null : v))
|
||||||
|
.nullable()
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export const productInput = z.object({
|
||||||
|
name: z.string().trim().min(1, 'Name is required').max(200),
|
||||||
|
brand: optText(100),
|
||||||
|
category: optText(100),
|
||||||
|
size: optText(60),
|
||||||
|
notes: optText(2000)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const priceInput = z.object({
|
||||||
|
price: z.string().transform((v, ctx) => {
|
||||||
|
const c = parseCents(v);
|
||||||
|
if (c === null) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: 'Enter a price like 1,79' });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
url: optText(2000).refine(
|
||||||
|
(v) => v == null || /^https?:\/\/\S+$/.test(v),
|
||||||
|
'Link must start with http(s)://'
|
||||||
|
),
|
||||||
|
observedOn: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('').transform(() => undefined)),
|
||||||
|
notes: optText(500)
|
||||||
|
});
|
||||||
@@ -19,6 +19,8 @@ describe('owner gate', () => {
|
|||||||
expect(isOwnerOnlyPath('/trackers/tasks')).toBe(true);
|
expect(isOwnerOnlyPath('/trackers/tasks')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/Tasks/')).toBe(true);
|
expect(isOwnerOnlyPath('/trackers/Tasks/')).toBe(true);
|
||||||
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
|
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
|
||||||
|
expect(isOwnerOnlyPath('/trackers/groceries')).toBe(true);
|
||||||
|
expect(isOwnerWritePath('/trackers/groceries')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gates the book reader, files and APIs but not the catalogue', () => {
|
it('gates the book reader, files and APIs but not the catalogue', () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function requireOwner(event: OwnerEvent) {
|
|||||||
export const ownerOnlyPrefixes = [
|
export const ownerOnlyPrefixes = [
|
||||||
'/trackers/health',
|
'/trackers/health',
|
||||||
'/trackers/tasks',
|
'/trackers/tasks',
|
||||||
|
'/trackers/groceries',
|
||||||
'/trackers/books/files',
|
'/trackers/books/files',
|
||||||
'/lists/db'
|
'/lists/db'
|
||||||
] as const;
|
] as const;
|
||||||
@@ -39,6 +40,7 @@ export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes
|
|||||||
export const ownerWritePrefixes = [
|
export const ownerWritePrefixes = [
|
||||||
'/trackers/health',
|
'/trackers/health',
|
||||||
'/trackers/tasks',
|
'/trackers/tasks',
|
||||||
|
'/trackers/groceries',
|
||||||
'/trackers/books'
|
'/trackers/books'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
10
src/routes/trackers/groceries/+layout.server.ts
Normal file
10
src/routes/trackers/groceries/+layout.server.ts
Normal 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 {};
|
||||||
|
};
|
||||||
178
src/routes/trackers/groceries/+page.server.ts
Normal file
178
src/routes/trackers/groceries/+page.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
568
src/routes/trackers/groceries/+page.svelte
Normal file
568
src/routes/trackers/groceries/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user