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:
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 };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user