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:
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,
|
||||
private: true
|
||||
},
|
||||
{
|
||||
name: 'groceries',
|
||||
subtitle: 'private: supermarket products, favorites and prices per store',
|
||||
href: '/trackers/groceries',
|
||||
external: false,
|
||||
private: true
|
||||
},
|
||||
{
|
||||
name: 'energy',
|
||||
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 './books';
|
||||
export * from './tasks';
|
||||
export * from './groceries';
|
||||
|
||||
@@ -15,3 +15,4 @@ export const metaSchema = pgSchema('meta');
|
||||
export const healthSchema = pgSchema('health');
|
||||
export const booksSchema = pgSchema('books');
|
||||
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(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', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function requireOwner(event: OwnerEvent) {
|
||||
export const ownerOnlyPrefixes = [
|
||||
'/trackers/health',
|
||||
'/trackers/tasks',
|
||||
'/trackers/groceries',
|
||||
'/trackers/books/files',
|
||||
'/lists/db'
|
||||
] as const;
|
||||
@@ -39,6 +40,7 @@ export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes
|
||||
export const ownerWritePrefixes = [
|
||||
'/trackers/health',
|
||||
'/trackers/tasks',
|
||||
'/trackers/groceries',
|
||||
'/trackers/books'
|
||||
] 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