feat: add real-time collaborative shopping list at /cospend/list
All checks were successful
CI / update (push) Successful in 1m18s

Real-time shopping list with SSE sync between multiple clients, automatic
item categorization using embedding-based classification + Bring icon
matching, and card-based UI with category grouping.

- SSE broadcast for live sync (add/check/remove items across tabs)
- Hybrid categorizer: direct catalog lookup → category-scoped embedding
  search → per-category default icons, with DB caching
- 388 Bring catalog icons matched via multilingual-e5-base embeddings
- 170+ English→German icon aliases for reliable cross-language matching
- Move cospend dashboard to /cospend/dash, /cospend redirects to list
- Shopping icon on homepage links to /cospend/list
This commit is contained in:
2026-04-07 23:50:50 +02:00
parent d9f2a27700
commit 738875e89f
28 changed files with 2281 additions and 49 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.6.1", "version": "1.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,74 @@
/**
* Pre-assign each Bring catalog icon to a shopping category using embeddings.
* This enables category-scoped icon search at runtime.
*
* Run: pnpm exec vite-node scripts/assign-icon-categories.ts
*/
import { pipeline } from '@huggingface/transformers';
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
const MODEL_NAME = 'Xenova/multilingual-e5-base';
const CATEGORY_EMBEDDINGS_PATH = resolve('src/lib/data/shoppingCategoryEmbeddings.json');
const CATALOG_PATH = resolve('static/shopping-icons/catalog.json');
const OUTPUT_PATH = resolve('src/lib/data/shoppingIconCategories.json');
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
async function main() {
const catData = JSON.parse(readFileSync(CATEGORY_EMBEDDINGS_PATH, 'utf-8'));
const catalog: Record<string, string> = JSON.parse(readFileSync(CATALOG_PATH, 'utf-8'));
console.log(`Loading model ${MODEL_NAME}...`);
const embedder = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'q8' });
const iconNames = Object.keys(catalog);
console.log(`Assigning ${iconNames.length} icons to categories...`);
const assignments: Record<string, string> = {};
for (let i = 0; i < iconNames.length; i++) {
const name = iconNames[i];
const result = await embedder(`query: ${name.toLowerCase()}`, { pooling: 'mean', normalize: true });
const qv = Array.from(result.data as Float32Array);
let bestCategory = 'Sonstiges';
let bestScore = -1;
for (const entry of catData.entries) {
const score = cosineSimilarity(qv, entry.vector);
if (score > bestScore) {
bestScore = score;
bestCategory = entry.category;
}
}
assignments[name] = bestCategory;
if ((i + 1) % 50 === 0) {
console.log(` ${i + 1}/${iconNames.length}`);
}
}
writeFileSync(OUTPUT_PATH, JSON.stringify(assignments, null, 2), 'utf-8');
console.log(`Written ${OUTPUT_PATH} (${iconNames.length} entries)`);
// Print summary
const counts: Record<string, number> = {};
for (const cat of Object.values(assignments)) {
counts[cat] = (counts[cat] || 0) + 1;
}
console.log('\nCategory distribution:');
for (const [cat, count] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
console.log(` ${cat}: ${count}`);
}
}
main().catch(console.error);

View File

@@ -0,0 +1,107 @@
/**
* Downloads all Bring! shopping list item icons locally.
* Icons are stored at static/shopping-icons/{key}.png
*
* Run: pnpm exec vite-node scripts/download-bring-icons.ts
*/
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { resolve } from 'path';
const CATALOG_URL = 'https://web.getbring.com/locale/articles.de-DE.json';
const ICON_BASE = 'https://web.getbring.com/assets/images/items/';
const OUTPUT_DIR = resolve('static/shopping-icons');
/** Normalize key to icon filename (matches Bring's normalizeStringPath) */
function normalizeKey(key: string): string {
return key
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/é/g, 'e')
.replace(/è/g, 'e')
.replace(/ê/g, 'e')
.replace(/à/g, 'a')
.replace(/!/g, '')
.replace(/[\s\-]+/g, '_');
}
async function main() {
console.log('Fetching catalog...');
const res = await fetch(CATALOG_URL);
const catalog: Record<string, string> = await res.json();
// Filter out category headers and meta entries
const SKIP = [
'Früchte & Gemüse', 'Fleisch & Fisch', 'Milch & Käse', 'Brot & Gebäck',
'Getreideprodukte', 'Snacks & Süsswaren', 'Getränke & Tabak', 'Getränke',
'Haushalt & Gesundheit', 'Fertig- & Tiefkühlprodukte', 'Zutaten & Gewürze',
'Baumarkt & Garten', 'Tierbedarf', 'Eigene Artikel', 'Zuletzt verwendet',
'Bring!', 'Vielen Dank', 'Früchte', 'Fleisch', 'Gemüse',
];
const items = Object.keys(catalog).filter(k => !SKIP.includes(k));
console.log(`Found ${items.length} items to download`);
mkdirSync(OUTPUT_DIR, { recursive: true });
// Also download letter fallbacks a-z
const allKeys = [
...items.map(k => ({ original: k, normalized: normalizeKey(k) })),
...'abcdefghijklmnopqrstuvwxyz'.split('').map(l => ({ original: l, normalized: l })),
];
let downloaded = 0;
let skipped = 0;
let failed = 0;
for (const { original, normalized } of allKeys) {
const outPath = resolve(OUTPUT_DIR, `${normalized}.png`);
if (existsSync(outPath)) {
skipped++;
continue;
}
const url = `${ICON_BASE}${normalized}.png`;
try {
const res = await fetch(url);
if (res.ok) {
const buffer = Buffer.from(await res.arrayBuffer());
writeFileSync(outPath, buffer);
downloaded++;
} else {
console.warn(`${original} (${normalized}.png) → ${res.status}`);
failed++;
}
} catch (err) {
console.warn(`${original} (${normalized}.png) → ${err}`);
failed++;
}
// Rate limiting
if ((downloaded + skipped + failed) % 50 === 0) {
console.log(` ${downloaded + skipped + failed}/${allKeys.length} (${downloaded} new, ${skipped} cached, ${failed} failed)`);
}
}
// Save the catalog mapping (key → normalized filename) for runtime lookup
const mapping: Record<string, string> = {};
for (const item of items) {
mapping[item.toLowerCase()] = normalizeKey(item);
}
// Also add the display names as lookups
for (const [key, displayName] of Object.entries(catalog)) {
if (!SKIP.includes(key)) {
mapping[displayName.toLowerCase()] = normalizeKey(key);
}
}
const mappingPath = resolve(OUTPUT_DIR, 'catalog.json');
writeFileSync(mappingPath, JSON.stringify(mapping, null, 2));
console.log(`\nDone: ${downloaded} downloaded, ${skipped} cached, ${failed} failed`);
console.log(`Catalog: ${Object.keys(mapping).length} entries → ${mappingPath}`);
}
main().catch(console.error);

View File

@@ -7,6 +7,7 @@ import { pipeline } from '@huggingface/transformers';
const MODELS = [ const MODELS = [
'Xenova/all-MiniLM-L6-v2', 'Xenova/all-MiniLM-L6-v2',
'Xenova/multilingual-e5-small', 'Xenova/multilingual-e5-small',
'Xenova/multilingual-e5-base',
]; ];
for (const name of MODELS) { for (const name of MODELS) {

View File

@@ -0,0 +1,55 @@
/**
* Pre-compute sentence embeddings for shopping category representative items.
* Uses multilingual-e5-base for good DE/EN understanding.
*
* Run: pnpm exec vite-node scripts/embed-shopping-categories.ts
*/
import { pipeline } from '@huggingface/transformers';
import { writeFileSync } from 'fs';
import { resolve } from 'path';
const { CATEGORY_ITEMS } = await import('../src/lib/data/shoppingCategoryItems');
const MODEL_NAME = 'Xenova/multilingual-e5-base';
const OUTPUT_FILE = resolve('src/lib/data/shoppingCategoryEmbeddings.json');
async function main() {
console.log(`Loading model ${MODEL_NAME}...`);
const embedder = await pipeline('feature-extraction', MODEL_NAME, {
dtype: 'q8',
});
console.log(`Embedding ${CATEGORY_ITEMS.length} category items...`);
const entries: { name: string; category: string; vector: number[] }[] = [];
for (let i = 0; i < CATEGORY_ITEMS.length; i++) {
const item = CATEGORY_ITEMS[i];
// e5 models require "passage: " prefix for documents
const result = await embedder(`passage: ${item.name}`, { pooling: 'mean', normalize: true });
const vector = Array.from(result.data as Float32Array).map(v => Math.round(v * 10000) / 10000);
entries.push({
name: item.name,
category: item.category,
vector,
});
if ((i + 1) % 50 === 0) {
console.log(` ${i + 1}/${CATEGORY_ITEMS.length}`);
}
}
const output = {
model: MODEL_NAME,
dimensions: entries[0]?.vector.length || 768,
count: entries.length,
entries,
};
const json = JSON.stringify(output);
writeFileSync(OUTPUT_FILE, json, 'utf-8');
console.log(`Written ${OUTPUT_FILE} (${(json.length / 1024).toFixed(1)}KB, ${entries.length} entries)`);
}
main().catch(console.error);

View File

@@ -0,0 +1,55 @@
/**
* Pre-compute embeddings for Bring! catalog items to enable icon matching.
* Maps item names to their icon filenames via semantic similarity.
*
* Run: pnpm exec vite-node scripts/embed-shopping-icons.ts
*/
import { pipeline } from '@huggingface/transformers';
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
const MODEL_NAME = 'Xenova/multilingual-e5-base';
const CATALOG_PATH = resolve('static/shopping-icons/catalog.json');
const OUTPUT_FILE = resolve('src/lib/data/shoppingIconEmbeddings.json');
async function main() {
const catalog: Record<string, string> = JSON.parse(readFileSync(CATALOG_PATH, 'utf-8'));
// Deduplicate: multiple display names can map to the same icon
// We want one embedding per unique display name
const uniqueItems = new Map<string, string>();
for (const [name, iconFile] of Object.entries(catalog)) {
uniqueItems.set(name, iconFile);
}
const items = [...uniqueItems.entries()];
console.log(`Loading model ${MODEL_NAME}...`);
const embedder = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'q8' });
console.log(`Embedding ${items.length} catalog items...`);
const entries: { name: string; icon: string; vector: number[] }[] = [];
for (let i = 0; i < items.length; i++) {
const [name, icon] = items[i];
const result = await embedder(`passage: ${name}`, { pooling: 'mean', normalize: true });
const vector = Array.from(result.data as Float32Array).map(v => Math.round(v * 10000) / 10000);
entries.push({ name, icon, vector });
if ((i + 1) % 50 === 0) {
console.log(` ${i + 1}/${items.length}`);
}
}
const output = {
model: MODEL_NAME,
dimensions: entries[0]?.vector.length || 768,
count: entries.length,
entries,
};
const json = JSON.stringify(output);
writeFileSync(OUTPUT_FILE, json, 'utf-8');
console.log(`Written ${OUTPUT_FILE} (${(json.length / 1024).toFixed(1)}KB, ${entries.length} entries)`);
}
main().catch(console.error);

View File

@@ -76,7 +76,7 @@
function closeModal() { function closeModal() {
// Use shallow routing to go back to dashboard without full navigation // Use shallow routing to go back to dashboard without full navigation
goto('/cospend', { replaceState: true, noScroll: true, keepFocus: true }); goto('/cospend/dash', { replaceState: true, noScroll: true, keepFocus: true });
onclose?.(); onclose?.();
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,329 @@
/**
* Representative items for each shopping category.
* Used to pre-compute embeddings for semantic category matching.
* Includes both German and English names for multilingual support.
*/
export const SHOPPING_CATEGORIES = [
'Obst & Gemüse',
'Fleisch & Fisch',
'Milchprodukte',
'Brot & Backwaren',
'Pasta, Reis & Getreide',
'Gewürze & Saucen',
'Getränke',
'Süßes & Snacks',
'Tiefkühl',
'Haushalt',
'Hygiene & Körperpflege',
'Sonstiges',
] as const;
export type ShoppingCategory = (typeof SHOPPING_CATEGORIES)[number];
export interface CategoryItem {
name: string;
category: ShoppingCategory;
}
export const CATEGORY_ITEMS: CategoryItem[] = [
// ── Obst & Gemüse ──
{ name: 'Äpfel', category: 'Obst & Gemüse' },
{ name: 'Bananen', category: 'Obst & Gemüse' },
{ name: 'Orangen', category: 'Obst & Gemüse' },
{ name: 'Zitronen', category: 'Obst & Gemüse' },
{ name: 'Erdbeeren', category: 'Obst & Gemüse' },
{ name: 'Trauben', category: 'Obst & Gemüse' },
{ name: 'Birnen', category: 'Obst & Gemüse' },
{ name: 'Wassermelone', category: 'Obst & Gemüse' },
{ name: 'Ananas', category: 'Obst & Gemüse' },
{ name: 'Mango', category: 'Obst & Gemüse' },
{ name: 'Kiwi', category: 'Obst & Gemüse' },
{ name: 'Blaubeeren', category: 'Obst & Gemüse' },
{ name: 'Himbeeren', category: 'Obst & Gemüse' },
{ name: 'Tomaten', category: 'Obst & Gemüse' },
{ name: 'Gurke', category: 'Obst & Gemüse' },
{ name: 'Paprika', category: 'Obst & Gemüse' },
{ name: 'Kartoffeln', category: 'Obst & Gemüse' },
{ name: 'Zwiebeln', category: 'Obst & Gemüse' },
{ name: 'Knoblauch', category: 'Obst & Gemüse' },
{ name: 'Karotten', category: 'Obst & Gemüse' },
{ name: 'Brokkoli', category: 'Obst & Gemüse' },
{ name: 'Blumenkohl', category: 'Obst & Gemüse' },
{ name: 'Zucchini', category: 'Obst & Gemüse' },
{ name: 'Spinat', category: 'Obst & Gemüse' },
{ name: 'Salat', category: 'Obst & Gemüse' },
{ name: 'Pilze', category: 'Obst & Gemüse' },
{ name: 'Champignons', category: 'Obst & Gemüse' },
{ name: 'Avocado', category: 'Obst & Gemüse' },
{ name: 'Lauch', category: 'Obst & Gemüse' },
{ name: 'Sellerie', category: 'Obst & Gemüse' },
{ name: 'Radieschen', category: 'Obst & Gemüse' },
{ name: 'Aubergine', category: 'Obst & Gemüse' },
{ name: 'apples', category: 'Obst & Gemüse' },
{ name: 'bananas', category: 'Obst & Gemüse' },
{ name: 'tomatoes', category: 'Obst & Gemüse' },
{ name: 'potatoes', category: 'Obst & Gemüse' },
{ name: 'lettuce', category: 'Obst & Gemüse' },
{ name: 'carrots', category: 'Obst & Gemüse' },
{ name: 'onions', category: 'Obst & Gemüse' },
{ name: 'broccoli', category: 'Obst & Gemüse' },
// ── Fleisch & Fisch ──
{ name: 'Hähnchenbrust', category: 'Fleisch & Fisch' },
{ name: 'Hackfleisch', category: 'Fleisch & Fisch' },
{ name: 'Rindfleisch', category: 'Fleisch & Fisch' },
{ name: 'Schweinefleisch', category: 'Fleisch & Fisch' },
{ name: 'Lachs', category: 'Fleisch & Fisch' },
{ name: 'Thunfisch', category: 'Fleisch & Fisch' },
{ name: 'Garnelen', category: 'Fleisch & Fisch' },
{ name: 'Schinken', category: 'Fleisch & Fisch' },
{ name: 'Salami', category: 'Fleisch & Fisch' },
{ name: 'Würstchen', category: 'Fleisch & Fisch' },
{ name: 'Bratwurst', category: 'Fleisch & Fisch' },
{ name: 'Putenbrust', category: 'Fleisch & Fisch' },
{ name: 'Speck', category: 'Fleisch & Fisch' },
{ name: 'Forelle', category: 'Fleisch & Fisch' },
{ name: 'Kabeljau', category: 'Fleisch & Fisch' },
{ name: 'chicken breast', category: 'Fleisch & Fisch' },
{ name: 'ground beef', category: 'Fleisch & Fisch' },
{ name: 'salmon', category: 'Fleisch & Fisch' },
{ name: 'bacon', category: 'Fleisch & Fisch' },
{ name: 'sausages', category: 'Fleisch & Fisch' },
// ── Milchprodukte ──
{ name: 'Milch', category: 'Milchprodukte' },
{ name: 'Butter', category: 'Milchprodukte' },
{ name: 'Käse', category: 'Milchprodukte' },
{ name: 'Joghurt', category: 'Milchprodukte' },
{ name: 'Sahne', category: 'Milchprodukte' },
{ name: 'Quark', category: 'Milchprodukte' },
{ name: 'Frischkäse', category: 'Milchprodukte' },
{ name: 'Gouda', category: 'Milchprodukte' },
{ name: 'Mozzarella', category: 'Milchprodukte' },
{ name: 'Parmesan', category: 'Milchprodukte' },
{ name: 'Emmentaler', category: 'Milchprodukte' },
{ name: 'Gruyère', category: 'Milchprodukte' },
{ name: 'Gruyere', category: 'Milchprodukte' },
{ name: 'Appenzeller', category: 'Milchprodukte' },
{ name: 'Tilsiter', category: 'Milchprodukte' },
{ name: 'Edamer', category: 'Milchprodukte' },
{ name: 'Brie', category: 'Milchprodukte' },
{ name: 'Camembert', category: 'Milchprodukte' },
{ name: 'Ricotta', category: 'Milchprodukte' },
{ name: 'Mascarpone', category: 'Milchprodukte' },
{ name: 'Hüttenkäse', category: 'Milchprodukte' },
{ name: 'Raclettekäse', category: 'Milchprodukte' },
{ name: 'Reibkäse', category: 'Milchprodukte' },
{ name: 'Eier', category: 'Milchprodukte' },
{ name: 'Schmand', category: 'Milchprodukte' },
{ name: 'Skyr', category: 'Milchprodukte' },
{ name: 'Crème fraîche', category: 'Milchprodukte' },
{ name: 'Schlagsahne', category: 'Milchprodukte' },
{ name: 'milk', category: 'Milchprodukte' },
{ name: 'cheese', category: 'Milchprodukte' },
{ name: 'yogurt', category: 'Milchprodukte' },
{ name: 'eggs', category: 'Milchprodukte' },
{ name: 'cream', category: 'Milchprodukte' },
// ── Brot & Backwaren ──
{ name: 'Brot', category: 'Brot & Backwaren' },
{ name: 'Brötchen', category: 'Brot & Backwaren' },
{ name: 'Toast', category: 'Brot & Backwaren' },
{ name: 'Vollkornbrot', category: 'Brot & Backwaren' },
{ name: 'Baguette', category: 'Brot & Backwaren' },
{ name: 'Croissant', category: 'Brot & Backwaren' },
{ name: 'Mehl', category: 'Brot & Backwaren' },
{ name: 'Weissmehl', category: 'Brot & Backwaren' },
{ name: 'Weißmehl', category: 'Brot & Backwaren' },
{ name: 'Vollkornmehl', category: 'Brot & Backwaren' },
{ name: 'Dinkelmehl', category: 'Brot & Backwaren' },
{ name: 'Roggenmehl', category: 'Brot & Backwaren' },
{ name: 'Stärke', category: 'Brot & Backwaren' },
{ name: 'Speisestärke', category: 'Brot & Backwaren' },
{ name: 'Maismehl', category: 'Brot & Backwaren' },
{ name: 'Hefe', category: 'Brot & Backwaren' },
{ name: 'Backpulver', category: 'Brot & Backwaren' },
{ name: 'Zucker', category: 'Brot & Backwaren' },
{ name: 'Vanillezucker', category: 'Brot & Backwaren' },
{ name: 'Puderzucker', category: 'Brot & Backwaren' },
{ name: 'bread', category: 'Brot & Backwaren' },
{ name: 'flour', category: 'Brot & Backwaren' },
{ name: 'sugar', category: 'Brot & Backwaren' },
{ name: 'baking powder', category: 'Brot & Backwaren' },
{ name: 'yeast', category: 'Brot & Backwaren' },
// ── Pasta, Reis & Getreide ──
{ name: 'Spaghetti', category: 'Pasta, Reis & Getreide' },
{ name: 'Penne', category: 'Pasta, Reis & Getreide' },
{ name: 'Rigatoni', category: 'Pasta, Reis & Getreide' },
{ name: 'Fusilli', category: 'Pasta, Reis & Getreide' },
{ name: 'Farfalle', category: 'Pasta, Reis & Getreide' },
{ name: 'Tagliatelle', category: 'Pasta, Reis & Getreide' },
{ name: 'Linguine', category: 'Pasta, Reis & Getreide' },
{ name: 'Lasagneblätter', category: 'Pasta, Reis & Getreide' },
{ name: 'Gnocchi', category: 'Pasta, Reis & Getreide' },
{ name: 'Nudeln', category: 'Pasta, Reis & Getreide' },
{ name: 'Reis', category: 'Pasta, Reis & Getreide' },
{ name: 'Basmati', category: 'Pasta, Reis & Getreide' },
{ name: 'Couscous', category: 'Pasta, Reis & Getreide' },
{ name: 'Haferflocken', category: 'Pasta, Reis & Getreide' },
{ name: 'Müsli', category: 'Pasta, Reis & Getreide' },
{ name: 'Cornflakes', category: 'Pasta, Reis & Getreide' },
{ name: 'Linsen', category: 'Pasta, Reis & Getreide' },
{ name: 'Kichererbsen', category: 'Pasta, Reis & Getreide' },
{ name: 'Bohnen', category: 'Pasta, Reis & Getreide' },
{ name: 'Tortellini', category: 'Pasta, Reis & Getreide' },
{ name: 'Quinoa', category: 'Pasta, Reis & Getreide' },
{ name: 'pasta', category: 'Pasta, Reis & Getreide' },
{ name: 'rice', category: 'Pasta, Reis & Getreide' },
{ name: 'oats', category: 'Pasta, Reis & Getreide' },
{ name: 'lentils', category: 'Pasta, Reis & Getreide' },
// ── Gewürze & Saucen ──
{ name: 'Salz', category: 'Gewürze & Saucen' },
{ name: 'Pfeffer', category: 'Gewürze & Saucen' },
{ name: 'Olivenöl', category: 'Gewürze & Saucen' },
{ name: 'Sonnenblumenöl', category: 'Gewürze & Saucen' },
{ name: 'Essig', category: 'Gewürze & Saucen' },
{ name: 'Ketchup', category: 'Gewürze & Saucen' },
{ name: 'Senf', category: 'Gewürze & Saucen' },
{ name: 'Sojasauce', category: 'Gewürze & Saucen' },
{ name: 'Tomatenmark', category: 'Gewürze & Saucen' },
{ name: 'Passierte Tomaten', category: 'Gewürze & Saucen' },
{ name: 'Mayonnaise', category: 'Gewürze & Saucen' },
{ name: 'Paprikapulver', category: 'Gewürze & Saucen' },
{ name: 'Zimt', category: 'Gewürze & Saucen' },
{ name: 'Oregano', category: 'Gewürze & Saucen' },
{ name: 'Basilikum', category: 'Gewürze & Saucen' },
{ name: 'Currypulver', category: 'Gewürze & Saucen' },
{ name: 'Honig', category: 'Gewürze & Saucen' },
{ name: 'olive oil', category: 'Gewürze & Saucen' },
{ name: 'salt', category: 'Gewürze & Saucen' },
{ name: 'pepper', category: 'Gewürze & Saucen' },
{ name: 'ketchup', category: 'Gewürze & Saucen' },
{ name: 'mustard', category: 'Gewürze & Saucen' },
{ name: 'soy sauce', category: 'Gewürze & Saucen' },
// ── Getränke ──
{ name: 'Wasser', category: 'Getränke' },
{ name: 'Mineralwasser', category: 'Getränke' },
{ name: 'Sprudel', category: 'Getränke' },
{ name: 'Apfelsaft', category: 'Getränke' },
{ name: 'Orangensaft', category: 'Getränke' },
{ name: 'Cola', category: 'Getränke' },
{ name: 'Bier', category: 'Getränke' },
{ name: 'Wein', category: 'Getränke' },
{ name: 'Kaffee', category: 'Getränke' },
{ name: 'Tee', category: 'Getränke' },
{ name: 'Limonade', category: 'Getränke' },
{ name: 'Milch (Hafer)', category: 'Getränke' },
{ name: 'Hafermilch', category: 'Getränke' },
{ name: 'Saft', category: 'Getränke' },
{ name: 'water', category: 'Getränke' },
{ name: 'juice', category: 'Getränke' },
{ name: 'coffee', category: 'Getränke' },
{ name: 'tea', category: 'Getränke' },
{ name: 'beer', category: 'Getränke' },
{ name: 'wine', category: 'Getränke' },
// ── Süßes & Snacks ──
{ name: 'Schokolade', category: 'Süßes & Snacks' },
{ name: 'Chips', category: 'Süßes & Snacks' },
{ name: 'Gummibärchen', category: 'Süßes & Snacks' },
{ name: 'Kekse', category: 'Süßes & Snacks' },
{ name: 'Eis', category: 'Süßes & Snacks' },
{ name: 'Nüsse', category: 'Süßes & Snacks' },
{ name: 'Erdnüsse', category: 'Süßes & Snacks' },
{ name: 'Mandeln', category: 'Süßes & Snacks' },
{ name: 'Kuchen', category: 'Süßes & Snacks' },
{ name: 'Bonbons', category: 'Süßes & Snacks' },
{ name: 'Müsliriegel', category: 'Süßes & Snacks' },
{ name: 'Popcorn', category: 'Süßes & Snacks' },
{ name: 'Salzstangen', category: 'Süßes & Snacks' },
{ name: 'Snickers', category: 'Süßes & Snacks' },
{ name: 'Mars', category: 'Süßes & Snacks' },
{ name: 'Twix', category: 'Süßes & Snacks' },
{ name: 'Haribo', category: 'Süßes & Snacks' },
{ name: 'Milka', category: 'Süßes & Snacks' },
{ name: 'Oreo', category: 'Süßes & Snacks' },
{ name: 'chocolate', category: 'Süßes & Snacks' },
{ name: 'chips', category: 'Süßes & Snacks' },
{ name: 'cookies', category: 'Süßes & Snacks' },
{ name: 'ice cream', category: 'Süßes & Snacks' },
{ name: 'nuts', category: 'Süßes & Snacks' },
// ── Tiefkühl ──
{ name: 'Tiefkühlpizza', category: 'Tiefkühl' },
{ name: 'Tiefkühlgemüse', category: 'Tiefkühl' },
{ name: 'Fischstäbchen', category: 'Tiefkühl' },
{ name: 'Pommes', category: 'Tiefkühl' },
{ name: 'Tiefkühlbeeren', category: 'Tiefkühl' },
{ name: 'Tiefkühltorte', category: 'Tiefkühl' },
{ name: 'Tiefkühlspinat', category: 'Tiefkühl' },
{ name: 'frozen pizza', category: 'Tiefkühl' },
{ name: 'frozen vegetables', category: 'Tiefkühl' },
{ name: 'fish sticks', category: 'Tiefkühl' },
{ name: 'french fries', category: 'Tiefkühl' },
// ── Haushalt ──
{ name: 'Spülmittel', category: 'Haushalt' },
{ name: 'Waschmittel', category: 'Haushalt' },
{ name: 'Müllbeutel', category: 'Haushalt' },
{ name: 'Küchenrolle', category: 'Haushalt' },
{ name: 'Toilettenpapier', category: 'Haushalt' },
{ name: 'Schwamm', category: 'Haushalt' },
{ name: 'Alufolie', category: 'Haushalt' },
{ name: 'Frischhaltefolie', category: 'Haushalt' },
{ name: 'Spülmaschinentabs', category: 'Haushalt' },
{ name: 'Allzweckreiniger', category: 'Haushalt' },
{ name: 'Kerzen', category: 'Haushalt' },
{ name: 'Batterien', category: 'Haushalt' },
{ name: 'Glühbirne', category: 'Haushalt' },
{ name: 'Backpapier', category: 'Haushalt' },
{ name: 'Zürisäcke', category: 'Haushalt' },
{ name: 'Züribags', category: 'Haushalt' },
{ name: 'Kehrichtsäcke', category: 'Haushalt' },
{ name: 'dish soap', category: 'Haushalt' },
{ name: 'detergent', category: 'Haushalt' },
{ name: 'trash bags', category: 'Haushalt' },
{ name: 'paper towels', category: 'Haushalt' },
{ name: 'toilet paper', category: 'Haushalt' },
{ name: 'aluminum foil', category: 'Haushalt' },
{ name: 'batteries', category: 'Haushalt' },
// ── Hygiene & Körperpflege ──
{ name: 'Zahnpasta', category: 'Hygiene & Körperpflege' },
{ name: 'Zahnbürste', category: 'Hygiene & Körperpflege' },
{ name: 'Duschgel', category: 'Hygiene & Körperpflege' },
{ name: 'Shampoo', category: 'Hygiene & Körperpflege' },
{ name: 'Deodorant', category: 'Hygiene & Körperpflege' },
{ name: 'Rasierer', category: 'Hygiene & Körperpflege' },
{ name: 'Sonnencreme', category: 'Hygiene & Körperpflege' },
{ name: 'Handcreme', category: 'Hygiene & Körperpflege' },
{ name: 'Seife', category: 'Hygiene & Körperpflege' },
{ name: 'Taschentücher', category: 'Hygiene & Körperpflege' },
{ name: 'Pflaster', category: 'Hygiene & Körperpflege' },
{ name: 'Wattepads', category: 'Hygiene & Körperpflege' },
{ name: 'Binden', category: 'Hygiene & Körperpflege' },
{ name: 'Tampons', category: 'Hygiene & Körperpflege' },
{ name: 'Slipeinlagen', category: 'Hygiene & Körperpflege' },
{ name: 'Pads', category: 'Hygiene & Körperpflege' },
{ name: 'toothpaste', category: 'Hygiene & Körperpflege' },
{ name: 'shampoo', category: 'Hygiene & Körperpflege' },
{ name: 'soap', category: 'Hygiene & Körperpflege' },
{ name: 'deodorant', category: 'Hygiene & Körperpflege' },
{ name: 'sunscreen', category: 'Hygiene & Körperpflege' },
// ── Sonstiges ──
{ name: 'Blumen', category: 'Sonstiges' },
{ name: 'Zeitung', category: 'Sonstiges' },
{ name: 'Briefmarken', category: 'Sonstiges' },
{ name: 'Geschenkpapier', category: 'Sonstiges' },
{ name: 'Klebeband', category: 'Sonstiges' },
{ name: 'Tiernahrung', category: 'Sonstiges' },
{ name: 'Katzenfutter', category: 'Sonstiges' },
{ name: 'Hundefutter', category: 'Sonstiges' },
{ name: 'flowers', category: 'Sonstiges' },
{ name: 'pet food', category: 'Sonstiges' },
];

View File

@@ -0,0 +1,390 @@
{
"passionsfrucht": "Fleisch & Fisch",
"getreideriegel": "Haushalt",
"glasreiniger": "Haushalt",
"gartenwerkzeug": "Obst & Gemüse",
"müesli": "Pasta, Reis & Getreide",
"pinienkerne": "Obst & Gemüse",
"creme fraiche": "Milchprodukte",
"hackfleisch": "Fleisch & Fisch",
"kekse": "Süßes & Snacks",
"salami": "Fleisch & Fisch",
"lippenpomade": "Getränke",
"putzmittel": "Haushalt",
"samen": "Hygiene & Körperpflege",
"wassermelone": "Obst & Gemüse",
"schokolade": "Süßes & Snacks",
"käse": "Milchprodukte",
"giesskanne": "Milchprodukte",
"bratwurst": "Fleisch & Fisch",
"garnelen": "Fleisch & Fisch",
"fenchel": "Brot & Backwaren",
"fruchtsaft": "Getränke",
"raclette": "Milchprodukte",
"brokkoli": "Obst & Gemüse",
"eistee": "Süßes & Snacks",
"haarspray": "Haushalt",
"pflaumen": "Hygiene & Körperpflege",
"pommes chips": "Tiefkühl",
"schweinefleisch": "Fleisch & Fisch",
"backpapier": "Haushalt",
"brot": "Brot & Backwaren",
"orangensaft": "Getränke",
"geschirrsalz": "Gewürze & Saucen",
"gipfeli": "Gewürze & Saucen",
"strohhalme": "Haushalt",
"birnen": "Obst & Gemüse",
"italian to go": "Pasta, Reis & Getreide",
"eier": "Milchprodukte",
"makeup entferner": "Milchprodukte",
"kartoffeln": "Obst & Gemüse",
"rasierklingen": "Hygiene & Körperpflege",
"kaffee": "Getränke",
"kohlrabi": "Obst & Gemüse",
"frischkäse": "Milchprodukte",
"essiggurken": "Gewürze & Saucen",
"öl": "Gewürze & Saucen",
"gelee": "Süßes & Snacks",
"trauben": "Obst & Gemüse",
"salz": "Gewürze & Saucen",
"glühbirne": "Haushalt",
"balsamico": "Gewürze & Saucen",
"fisch": "Fleisch & Fisch",
"geschenk": "Sonstiges",
"blumen": "Obst & Gemüse",
"kartoffelstock": "Obst & Gemüse",
"limonade": "Getränke",
"schwamm": "Haushalt",
"ahornsirup": "Gewürze & Saucen",
"limette": "Getränke",
"aubergine": "Obst & Gemüse",
"mettigel": "Süßes & Snacks",
"nüsse": "Süßes & Snacks",
"schinken": "Fleisch & Fisch",
"dip": "Haushalt",
"zucchetti": "Obst & Gemüse",
"suppe": "Hygiene & Körperpflege",
"rum": "Milchprodukte",
"frühlingszwiebeln": "Obst & Gemüse",
"spargel": "Getränke",
"sonnencreme": "Hygiene & Körperpflege",
"gnocchi": "Pasta, Reis & Getreide",
"handcreme": "Hygiene & Körperpflege",
"schnittlauch": "Obst & Gemüse",
"rote bete": "Brot & Backwaren",
"pelati": "Pasta, Reis & Getreide",
"fischstäbli": "Tiefkühl",
"margarine": "Milchprodukte",
"bbq sauce": "Gewürze & Saucen",
"zigaretten": "Gewürze & Saucen",
"muscheln": "Süßes & Snacks",
"oregano": "Gewürze & Saucen",
"basmatireis": "Pasta, Reis & Getreide",
"zahnseide": "Süßes & Snacks",
"tofu": "Pasta, Reis & Getreide",
"energy drink": "Süßes & Snacks",
"peperoni": "Gewürze & Saucen",
"sirup": "Haushalt",
"feigen": "Gewürze & Saucen",
"haselnüsse": "Süßes & Snacks",
"mehl": "Brot & Backwaren",
"haferflocken": "Pasta, Reis & Getreide",
"kokosmilch": "Getränke",
"apfelmus": "Getränke",
"reis": "Pasta, Reis & Getreide",
"mascarpone": "Milchprodukte",
"rasenmäher": "Hygiene & Körperpflege",
"schnitzel": "Fleisch & Fisch",
"chinese to go": "Hygiene & Körperpflege",
"grill": "Fleisch & Fisch",
"ketchup": "Gewürze & Saucen",
"lachs": "Fleisch & Fisch",
"zwiebeln": "Obst & Gemüse",
"beeren": "Obst & Gemüse",
"pflaster": "Hygiene & Körperpflege",
"fischfutter": "Tiefkühl",
"kerzen": "Haushalt",
"waffeln": "Obst & Gemüse",
"vanille sauce": "Brot & Backwaren",
"kalbfleisch": "Fleisch & Fisch",
"smoothie": "Getränke",
"rasierschaum": "Hygiene & Körperpflege",
"ingwer": "Milchprodukte",
"hüttenkäse": "Milchprodukte",
"pfirsich": "Gewürze & Saucen",
"sauerrahm": "Haushalt",
"lasagne": "Pasta, Reis & Getreide",
"pinsel": "Pasta, Reis & Getreide",
"hefe": "Brot & Backwaren",
"kuchen": "Süßes & Snacks",
"prosecco": "Milchprodukte",
"tampons": "Hygiene & Körperpflege",
"thunfisch": "Fleisch & Fisch",
"zucker": "Brot & Backwaren",
"chicken wings": "Fleisch & Fisch",
"pouletbrüstli": "Fleisch & Fisch",
"blumenkohl": "Obst & Gemüse",
"speisestärke": "Hygiene & Körperpflege",
"salat": "Obst & Gemüse",
"brezeln": "Pasta, Reis & Getreide",
"corn flakes": "Pasta, Reis & Getreide",
"muffins": "Tiefkühl",
"knoblauch": "Obst & Gemüse",
"karotten": "Obst & Gemüse",
"toast": "Brot & Backwaren",
"waschmittel": "Haushalt",
"salatsauce": "Gewürze & Saucen",
"hundefutter": "Sonstiges",
"soya milch": "Milchprodukte",
"vanillezucker": "Brot & Backwaren",
"mundspülung": "Haushalt",
"babynahrung": "Sonstiges",
"windeln": "Süßes & Snacks",
"kondome": "Hygiene & Körperpflege",
"couscous": "Pasta, Reis & Getreide",
"geschirrglanz": "Hygiene & Körperpflege",
"aprikosen": "Tiefkühl",
"himbeeren": "Obst & Gemüse",
"indian to go": "Hygiene & Körperpflege",
"oliven": "Gewürze & Saucen",
"lebkuchen": "Tiefkühl",
"kürbis": "Milchprodukte",
"sportgetränk": "Tiefkühl",
"tonic water": "Getränke",
"nektarine": "Obst & Gemüse",
"penne": "Pasta, Reis & Getreide",
"shampoo": "Hygiene & Körperpflege",
"whisky": "Getränke",
"datteln": "Pasta, Reis & Getreide",
"fondue": "Tiefkühl",
"kakao": "Süßes & Snacks",
"olivenöl": "Gewürze & Saucen",
"bohnen": "Pasta, Reis & Getreide",
"pizza": "Tiefkühl",
"kiwi": "Obst & Gemüse",
"poulet": "Fleisch & Fisch",
"wasser": "Getränke",
"milch": "Milchprodukte",
"kirschen": "Haushalt",
"mandeln": "Süßes & Snacks",
"kichererbsen": "Pasta, Reis & Getreide",
"kosmetiktücher": "Hygiene & Körperpflege",
"kaugummi": "Fleisch & Fisch",
"gesichtscreme": "Hygiene & Körperpflege",
"süsskartoffeln": "Obst & Gemüse",
"getrocknete tomaten": "Obst & Gemüse",
"koriander": "Obst & Gemüse",
"knäckebrot": "Brot & Backwaren",
"champignons": "Obst & Gemüse",
"gemüse gefroren": "Tiefkühl",
"cola light": "Getränke",
"orange": "Obst & Gemüse",
"dessert": "Süßes & Snacks",
"alufolie": "Haushalt",
"tortilla chips": "Süßes & Snacks",
"melone": "Obst & Gemüse",
"bananen": "Obst & Gemüse",
"preiselbeer sauce": "Tiefkühl",
"zahnbürsten": "Fleisch & Fisch",
"zimt": "Gewürze & Saucen",
"äpfel": "Obst & Gemüse",
"cola": "Getränke",
"bouillon": "Brot & Backwaren",
"knödel": "Pasta, Reis & Getreide",
"salbei": "Fleisch & Fisch",
"radieschen": "Obst & Gemüse",
"soyasauce": "Gewürze & Saucen",
"rohschinken": "Fleisch & Fisch",
"reibkäse": "Milchprodukte",
"aufschnitt": "Hygiene & Körperpflege",
"geschirrtabs": "Haushalt",
"sonnenschirm": "Hygiene & Körperpflege",
"mineralwasser": "Getränke",
"taschentücher": "Hygiene & Körperpflege",
"feuchttücher": "Hygiene & Körperpflege",
"erbsen": "Pasta, Reis & Getreide",
"parmesan": "Milchprodukte",
"nougatcreme": "Hygiene & Körperpflege",
"speck": "Fleisch & Fisch",
"avocado": "Obst & Gemüse",
"quark": "Milchprodukte",
"paprikapulver": "Gewürze & Saucen",
"torte": "Getränke",
"abfallsäcke": "Getränke",
"essig": "Gewürze & Saucen",
"dünger": "Brot & Backwaren",
"pilze": "Obst & Gemüse",
"batterien": "Haushalt",
"tomatensauce": "Gewürze & Saucen",
"rucola": "Süßes & Snacks",
"bier": "Getränke",
"blumenerde": "Obst & Gemüse",
"rhabarber": "Hygiene & Körperpflege",
"artischocken": "Obst & Gemüse",
"rosmarin": "Getränke",
"salzstangen": "Süßes & Snacks",
"linsenmittel": "Pasta, Reis & Getreide",
"nagellackentferner": "Milchprodukte",
"bodylotion": "Hygiene & Körperpflege",
"apfelsaft": "Getränke",
"pudding": "Brot & Backwaren",
"vitamine": "Gewürze & Saucen",
"thai to go": "Gewürze & Saucen",
"guetzli": "Pasta, Reis & Getreide",
"binden": "Hygiene & Körperpflege",
"tomatenmark": "Gewürze & Saucen",
"gurke": "Obst & Gemüse",
"holzkohle": "Obst & Gemüse",
"basilikum": "Gewürze & Saucen",
"joghurt": "Milchprodukte",
"pop corn": "Süßes & Snacks",
"weichspüler": "Haushalt",
"butter": "Milchprodukte",
"dörrobst": "Fleisch & Fisch",
"rotwein": "Getränke",
"frankfurter": "Tiefkühl",
"schnaps": "Fleisch & Fisch",
"tomaten": "Obst & Gemüse",
"ricotta": "Milchprodukte",
"watterondellen": "Hygiene & Körperpflege",
"erdbeeren": "Obst & Gemüse",
"vogelfutter": "Sonstiges",
"thymian": "Obst & Gemüse",
"katzensnack": "Sonstiges",
"puderzucker": "Brot & Backwaren",
"kräuterbutter": "Brot & Backwaren",
"kaki": "Süßes & Snacks",
"insektenschutzmittel": "Sonstiges",
"erdnüsse": "Süßes & Snacks",
"pfefferkörner": "Gewürze & Saucen",
"schrauben": "Obst & Gemüse",
"sardellen": "Süßes & Snacks",
"rindfleisch": "Fleisch & Fisch",
"conditioner": "Haushalt",
"pizzateig": "Tiefkühl",
"blauschimmelkäse": "Milchprodukte",
"zitrone": "Obst & Gemüse",
"nägel": "Hygiene & Körperpflege",
"peperoncini": "Pasta, Reis & Getreide",
"senf": "Gewürze & Saucen",
"brötchen": "Brot & Backwaren",
"baumnüsse": "Süßes & Snacks",
"nudeln": "Pasta, Reis & Getreide",
"wurst": "Fleisch & Fisch",
"griess": "Milchprodukte",
"mandarinen": "Obst & Gemüse",
"weisswein": "Getränke",
"blätterteig": "Pasta, Reis & Getreide",
"zahnstocher": "Brot & Backwaren",
"cherrytomaten": "Obst & Gemüse",
"pfefferminze": "Gewürze & Saucen",
"katzenstreu": "Sonstiges",
"kohl": "Obst & Gemüse",
"brombeeren": "Obst & Gemüse",
"feta": "Gewürze & Saucen",
"gin": "Getränke",
"vodka": "Getränke",
"honig": "Gewürze & Saucen",
"wc-papier": "Haushalt",
"paniermehl": "Brot & Backwaren",
"rahm": "Haushalt",
"mayonnaise": "Gewürze & Saucen",
"spülmittel": "Haushalt",
"sellerie": "Obst & Gemüse",
"lauch": "Obst & Gemüse",
"light limonade": "Getränke",
"rindsgeschnetzeltes": "Tiefkühl",
"wc-reiniger": "Haushalt",
"baguette": "Brot & Backwaren",
"konfitüre": "Sonstiges",
"schmerzmittel": "Hygiene & Körperpflege",
"badreiniger": "Haushalt",
"heidelbeeren": "Tiefkühl",
"mango": "Obst & Gemüse",
"mozzarella": "Milchprodukte",
"ananas": "Obst & Gemüse",
"propangas": "Hygiene & Körperpflege",
"streichhölzer": "Tiefkühl",
"pasta sauce": "Pasta, Reis & Getreide",
"bratensauce": "Fleisch & Fisch",
"lamm": "Fleisch & Fisch",
"frischhaltefolie": "Haushalt",
"zahnpasta": "Hygiene & Körperpflege",
"spaghetti": "Pasta, Reis & Getreide",
"haargel": "Hygiene & Körperpflege",
"snacks": "Sonstiges",
"petersilie": "Gewürze & Saucen",
"grapefruit": "Obst & Gemüse",
"servietten": "Obst & Gemüse",
"töpfe": "Haushalt",
"linsen": "Pasta, Reis & Getreide",
"lattich": "Fleisch & Fisch",
"duschmittel": "Haushalt",
"gorgonzola": "Milchprodukte",
"spinat": "Obst & Gemüse",
"steak": "Fleisch & Fisch",
"hundesnack": "Sonstiges",
"backpulver": "Brot & Backwaren",
"risottoreis": "Pasta, Reis & Getreide",
"rasierer": "Hygiene & Körperpflege",
"pommes frites": "Tiefkühl",
"deo": "Hygiene & Körperpflege",
"pflanzen": "Haushalt",
"katzenfutter": "Sonstiges",
"geschenkpapier": "Sonstiges",
"champagner": "Milchprodukte",
"nagellack": "Hygiene & Körperpflege",
"tee": "Getränke",
"wattestäbchen": "Tiefkühl",
"kräuter": "Süßes & Snacks",
"seife": "Hygiene & Körperpflege",
"glacé": "Milchprodukte",
"mais": "Brot & Backwaren",
"haushaltspapier": "Haushalt",
"süssigkeiten": "Fleisch & Fisch",
"burrata": "Milchprodukte",
"mandelmus": "Süßes & Snacks",
"silberzwiebeln": "Obst & Gemüse",
"müsli": "Pasta, Reis & Getreide",
"lippenpflege": "Hygiene & Körperpflege",
"sämereien": "Obst & Gemüse",
"raclettekäse": "Milchprodukte",
"chips": "Süßes & Snacks",
"croissant": "Brot & Backwaren",
"italienisches essen": "Milchprodukte",
"kartoffelpüree": "Obst & Gemüse",
"mett für igel": "Brot & Backwaren",
"zucchini": "Obst & Gemüse",
"dosentomaten": "Gewürze & Saucen",
"fischstäbchen": "Tiefkühl",
"paprika": "Obst & Gemüse",
"chinesisches essen": "Sonstiges",
"sekt": "Getränke",
"hähnchenbrust": "Fleisch & Fisch",
"sojamilch": "Gewürze & Saucen",
"klarspüler": "Haushalt",
"indisches essen": "Süßes & Snacks",
"fonduekäse": "Milchprodukte",
"hähnchen": "Fleisch & Fisch",
"süßkartoffeln": "Obst & Gemüse",
"brühe": "Brot & Backwaren",
"sojasauce": "Gewürze & Saucen",
"müllsäcke": "Haushalt",
"thai essen": "Süßes & Snacks",
"plätzchen": "Brot & Backwaren",
"wattepads": "Hygiene & Körperpflege",
"haarspülung": "Hygiene & Körperpflege",
"chili": "Gewürze & Saucen",
"walnüsse": "Süßes & Snacks",
"grieß": "Fleisch & Fisch",
"wodka": "Getränke",
"toilettenpapier": "Haushalt",
"semmelbrösel": "Brot & Backwaren",
"sahne": "Milchprodukte",
"rindergeschnetzeltes": "Fleisch & Fisch",
"toilettenreiniger": "Haushalt",
"marmelade": "Milchprodukte",
"duschgel": "Hygiene & Körperpflege",
"eis": "Süßes & Snacks",
"küchenrolle": "Haushalt"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,221 @@
/**
* Shopping list sync layer — real-time collaborative shopping list via SSE.
*
* Usage: call `getShoppingSync()` to get the shared singleton.
* Manages SSE connection, debounced pushes, and reactive item state.
*/
type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict';
export interface ShoppingItem {
id: string;
name: string;
category: string;
icon: string | null;
checked: boolean;
addedBy: string;
checkedBy?: string;
addedAt: string;
}
interface ServerList {
version: number;
items: ShoppingItem[];
}
function generateId(): string {
return Math.random().toString(36).substring(2, 10) + Date.now().toString(36);
}
export function createShoppingSync() {
let items: ShoppingItem[] = $state([]);
let status: SyncStatus = $state('idle');
let version = $state(0);
let eventSource: EventSource | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = 1000;
let _applying = false;
async function pushToServer() {
if (_applying) return;
status = 'syncing';
try {
const res = await fetch('/api/cospend/list', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items,
expectedVersion: version || undefined
})
});
if (res.ok) {
const doc = await res.json();
version = doc.version;
status = 'synced';
reconnectDelay = 1000;
} else if (res.status === 409) {
const { list } = await res.json();
applyServerState(list);
await pushToServer();
} else if (res.status === 401) {
status = 'offline';
} else {
status = 'offline';
}
} catch {
status = 'offline';
}
}
function debouncedPush() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => pushToServer(), 200);
}
function applyServerState(doc: ServerList) {
if (!doc) return;
_applying = true;
try {
version = doc.version;
items = doc.items;
status = 'synced';
} finally {
_applying = false;
}
}
function connectSSE() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
try {
eventSource = new EventSource('/api/cospend/list/stream');
eventSource.addEventListener('update', (e) => {
try {
const doc = JSON.parse(e.data);
if (doc.version > version) {
applyServerState(doc);
}
} catch { /* ignore */ }
});
eventSource.onerror = () => {
status = 'offline';
eventSource?.close();
eventSource = null;
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
connectSSE();
}, reconnectDelay);
};
eventSource.onopen = () => {
status = 'synced';
reconnectDelay = 1000;
};
} catch {
status = 'offline';
}
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
status = 'idle';
}
async function init() {
try {
const res = await fetch('/api/cospend/list');
if (!res.ok) {
status = 'offline';
return;
}
const doc = await res.json();
version = doc.version;
items = doc.items || [];
status = 'synced';
connectSSE();
} catch {
status = 'offline';
}
}
function addItem(name: string, user: string, category = 'Sonstiges') {
items = [...items, {
id: generateId(),
name: name.trim(),
category,
icon: null,
checked: false,
addedBy: user,
addedAt: new Date().toISOString()
}];
debouncedPush();
}
function toggleItem(id: string, user: string) {
items = items.map(item =>
item.id === id
? { ...item, checked: !item.checked, checkedBy: !item.checked ? user : undefined }
: item
);
debouncedPush();
}
function removeItem(id: string) {
items = items.filter(item => item.id !== id);
debouncedPush();
}
function clearChecked() {
items = items.filter(item => !item.checked);
debouncedPush();
}
function updateItemCategory(id: string, category: string, icon?: string | null) {
items = items.map(item =>
item.id === id ? { ...item, category, ...(icon !== undefined ? { icon } : {}) } : item
);
debouncedPush();
}
return {
get items() { return items; },
get status() { return status; },
get version() { return version; },
init,
addItem,
toggleItem,
removeItem,
clearChecked,
updateItemCategory,
disconnect
};
}
let _instance: ReturnType<typeof createShoppingSync> | null = null;
export function getShoppingSync() {
if (!_instance) {
_instance = createShoppingSync();
}
return _instance;
}

View File

@@ -0,0 +1,333 @@
/**
* Shopping item categorizer — hybrid approach:
* 1. Direct/substring catalog lookup for icon
* 2. Embedding-based category classification (267 representative items)
* 3. Category-scoped embedding search for icon (only icons in matched category)
* 4. Per-category default icon as final fallback
*
* DB cache ensures each unique item is only categorized once.
*/
import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
import { read } from '$app/server';
import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems';
import { ShoppingItemCategory } from '$models/ShoppingItemCategory';
import { dbConnect } from '$utils/db';
import catalog from '../../../static/shopping-icons/catalog.json';
import iconCategoriesData from '$lib/data/shoppingIconCategories.json';
import categoryEmbeddingsUrl from '$lib/data/shoppingCategoryEmbeddings.json?url';
import iconEmbeddingsUrl from '$lib/data/shoppingIconEmbeddings.json?url';
const MODEL_NAME = 'Xenova/multilingual-e5-base';
const CATEGORY_THRESHOLD = 0.5;
const ICON_THRESHOLD = 0.83;
/** Fallback icons per category when no specific icon matches */
const CATEGORY_DEFAULT_ICONS: Record<string, string> = {
'Obst & Gemüse': 'doerrobst',
'Fleisch & Fisch': 'hackfleisch',
'Milchprodukte': 'kaese',
'Brot & Backwaren': 'brot',
'Pasta, Reis & Getreide': 'nudeln',
'Gewürze & Saucen': 'kraeuter',
'Getränke': 'wasser',
'Süßes & Snacks': 'schokolade',
'Tiefkühl': 'gemuese_gefroren',
'Haushalt': 'spuelmittel',
'Hygiene & Körperpflege': 'seife',
};
/**
* Aliases: maps common English/alternate names to their German catalog equivalent.
* Checked before embeddings so these are always correct and instant.
* Add entries here when an item consistently matches the wrong icon.
*/
const ICON_ALIASES: Record<string, string> = {
// English → German catalog name
// Fruits & vegetables
'apples': 'äpfel', 'apple': 'äpfel', 'bananas': 'bananen', 'banana': 'bananen',
'oranges': 'orange', 'lemons': 'zitrone', 'lemon': 'zitrone', 'grapes': 'trauben',
'strawberries': 'erdbeeren', 'blueberries': 'heidelbeeren', 'raspberries': 'himbeeren',
'tomatoes': 'tomaten', 'tomato': 'tomaten', 'potatoes': 'kartoffeln', 'potato': 'kartoffeln',
'cucumber': 'gurke', 'onions': 'zwiebeln', 'onion': 'zwiebeln', 'garlic': 'knoblauch',
'carrots': 'karotten', 'carrot': 'karotten', 'broccoli': 'brokkoli',
'spinach': 'spinat', 'lettuce': 'salat', 'mushrooms': 'pilze', 'mushroom': 'champignons',
'avocado': 'avocado', 'peas': 'erbsen', 'beans': 'bohnen', 'corn': 'mais',
'peppers': 'paprika', 'bell pepper': 'peperoni', 'celery': 'sellerie',
'pumpkin': 'kürbis', 'watermelon': 'wassermelone', 'pineapple': 'ananas',
'mango': 'mango', 'peach': 'pfirsich', 'pear': 'birnen', 'cherries': 'kirschen',
'asparagus': 'spargel', 'eggplant': 'aubergine', 'ginger': 'ingwer',
// Meat & fish
'chicken': 'poulet', 'chicken breast': 'hähnchenbrust',
'beef': 'rindfleisch', 'pork': 'schweinefleisch', 'lamb': 'lamm',
'ham': 'schinken', 'bacon': 'speck', 'sausage': 'wurst', 'sausages': 'bratwurst',
'salmon': 'lachs', 'tuna': 'thunfisch', 'shrimp': 'garnelen', 'prawns': 'garnelen',
'fish': 'fisch', 'steak': 'steak', 'ground beef': 'hackfleisch',
'salami': 'salami', 'meatballs': 'hackfleisch',
// Dairy
'milk': 'milch', 'butter': 'butter', 'cheese': 'käse', 'eggs': 'eier', 'egg': 'eier',
'yogurt': 'joghurt', 'yoghurt': 'joghurt', 'cream': 'rahm', 'sour cream': 'sauerrahm',
'cream cheese': 'frischkäse', 'cottage cheese': 'hüttenkäse',
'mozzarella': 'mozzarella', 'parmesan': 'parmesan', 'feta': 'feta',
'ricotta': 'ricotta', 'mascarpone': 'mascarpone',
// Bread & bakery
'bread': 'brot', 'rolls': 'brötchen', 'baguette': 'baguette', 'toast': 'toast',
'croissant': 'croissant', 'flour': 'mehl', 'yeast': 'hefe',
'baking powder': 'backpulver', 'sugar': 'zucker', 'powdered sugar': 'puderzucker',
'vanilla sugar': 'vanillezucker', 'cornstarch': 'speisestärke',
// Pasta, rice & grains
'pasta': 'nudeln', 'noodles': 'nudeln', 'spaghetti': 'spaghetti', 'penne': 'penne',
'rice': 'reis', 'basmati': 'basmatireis', 'couscous': 'couscous',
'oats': 'haferflocken', 'oatmeal': 'haferflocken', 'cereal': 'corn flakes',
'muesli': 'müsli', 'lentils': 'linsen', 'chickpeas': 'kichererbsen',
'gnocchi': 'gnocchi', 'lasagna': 'lasagne', 'tortellini': 'nudeln',
// Spices & sauces
'salt': 'salz', 'pepper': 'pfefferkörner', 'oil': 'öl', 'olive oil': 'olivenöl',
'vinegar': 'essig', 'balsamic': 'balsamico', 'ketchup': 'ketchup',
'mustard': 'senf', 'mayonnaise': 'mayonnaise', 'mayo': 'mayonnaise',
'soy sauce': 'sojasauce', 'tomato paste': 'tomatenmark', 'tomato sauce': 'tomatensauce',
'honey': 'honig', 'cinnamon': 'zimt', 'oregano': 'oregano', 'basil': 'basilikum',
'parsley': 'petersilie', 'rosemary': 'rosmarin', 'thyme': 'thymian',
'paprika': 'paprikapulver', 'curry': 'paprikapulver', 'chili': 'chili',
'herbs': 'kräuter', 'bbq sauce': 'bbq sauce', 'pesto': 'pasta sauce',
'jam': 'konfitüre', 'marmalade': 'marmelade',
// Drinks
'water': 'wasser', 'sparkling water': 'mineralwasser', 'juice': 'fruchtsaft',
'orange juice': 'orangensaft', 'apple juice': 'apfelsaft',
'coffee': 'kaffee', 'tea': 'tee', 'beer': 'bier', 'wine': 'rotwein',
'white wine': 'weisswein', 'red wine': 'rotwein',
'cola': 'cola', 'lemonade': 'limonade', 'soda': 'limonade',
'energy drink': 'energy drink', 'smoothie': 'smoothie',
'whiskey': 'whisky', 'whisky': 'whisky', 'gin': 'gin', 'vodka': 'vodka', 'rum': 'rum',
'cocoa': 'kakao', 'hot chocolate': 'kakao', 'iced tea': 'eistee',
'oat milk': 'soya milch', 'soy milk': 'soya milch', 'almond milk': 'soya milch',
'coconut milk': 'kokosmilch', 'tonic': 'tonic water',
// Sweets & snacks
'chocolate': 'schokolade', 'cookies': 'kekse', 'cookie': 'kekse', 'biscuits': 'kekse',
'chips': 'chips', 'crisps': 'chips', 'nuts': 'nüsse', 'peanuts': 'erdnüsse',
'almonds': 'mandeln', 'walnuts': 'baumnüsse', 'hazelnuts': 'haselnüsse',
'ice cream': 'eis', 'cake': 'kuchen', 'candy': 'süssigkeiten', 'sweets': 'süssigkeiten',
'gummy bears': 'süssigkeiten', 'popcorn': 'pop corn', 'pretzels': 'brezeln',
'granola bar': 'getreideriegel', 'muffins': 'muffins', 'waffles': 'waffeln',
'pudding': 'pudding', 'nutella': 'nougatcreme',
// Frozen
'frozen pizza': 'pizza', 'frozen vegetables': 'gemüse gefroren',
'fish sticks': 'fischstäbchen', 'french fries': 'pommes frites', 'fries': 'pommes frites',
// Household
'dish soap': 'spülmittel', 'detergent': 'waschmittel', 'laundry detergent': 'waschmittel',
'trash bags': 'abfallsäcke', 'garbage bags': 'abfallsäcke',
'paper towels': 'haushaltspapier', 'kitchen roll': 'küchenrolle',
'toilet paper': 'toilettenpapier', 'aluminum foil': 'alufolie', 'tin foil': 'alufolie',
'plastic wrap': 'frischhaltefolie', 'cling film': 'frischhaltefolie',
'sponge': 'schwamm', 'batteries': 'batterien', 'light bulb': 'glühbirne',
'candles': 'kerzen', 'matches': 'streichhölzer', 'baking paper': 'backpapier',
'dishwasher tabs': 'geschirrtabs', 'fabric softener': 'weichspüler',
'cleaning spray': 'putzmittel', 'glass cleaner': 'glasreiniger',
'napkins': 'servietten', 'straws': 'strohhalme',
// Hygiene & personal care
'toothpaste': 'zahnpasta', 'toothbrush': 'zahnbürsten', 'dental floss': 'zahnseide',
'shampoo': 'shampoo', 'conditioner': 'conditioner', 'shower gel': 'duschgel',
'body wash': 'duschmittel', 'soap': 'seife', 'deodorant': 'deo',
'sunscreen': 'sonnencreme', 'sunblock': 'sonnencreme',
'hand cream': 'handcreme', 'body lotion': 'bodylotion', 'face cream': 'gesichtscreme',
'razor': 'rasierer', 'razor blades': 'rasierklingen', 'shaving cream': 'rasierschaum',
'tissues': 'taschentücher', 'wet wipes': 'feuchttücher',
'cotton pads': 'wattepads', 'cotton swabs': 'wattestäbchen',
'band-aids': 'pflaster', 'plasters': 'pflaster',
'mouthwash': 'mundspülung', 'nail polish': 'nagellack',
'hair gel': 'haargel', 'hairspray': 'haarspray', 'diapers': 'windeln',
'condoms': 'kondome', 'vitamins': 'vitamine', 'painkillers': 'schmerzmittel',
// Misc
'flowers': 'blumen', 'gift': 'geschenk', 'wrapping paper': 'geschenkpapier',
'cat food': 'katzenfutter', 'dog food': 'hundefutter', 'pet food': 'katzenfutter',
'bird food': 'vogelfutter', 'cat litter': 'katzenstreu',
'zürisäcke': 'abfallsäcke', 'zürisack': 'abfallsäcke', 'züribags': 'abfallsäcke',
'züribag': 'abfallsäcke', 'kehrichtsäcke': 'abfallsäcke', 'kehrichtsack': 'abfallsäcke',
'tofu': 'tofu', 'olives': 'oliven', 'pickles': 'essiggurken',
'soup': 'suppe', 'broth': 'brühe', 'bouillon': 'bouillon',
'pizza': 'pizza', 'pizza dough': 'pizzateig',
};
// --- Catalog lookup maps (built once) ---
const catalogMap = new Map<string, string>();
for (const [displayName, iconFile] of Object.entries(catalog as Record<string, string>)) {
catalogMap.set(displayName.toLowerCase(), iconFile);
}
/** Pre-computed mapping: icon display name → category */
const iconCategories = iconCategoriesData as Record<string, string>;
/** Icons grouped by category for scoped search */
const iconsByCategory = new Map<string, string[]>();
for (const [iconName, category] of Object.entries(iconCategories)) {
if (!iconsByCategory.has(category)) iconsByCategory.set(category, []);
iconsByCategory.get(category)!.push(iconName);
}
// --- Embedding state (lazy) ---
let embedder: FeatureExtractionPipeline | null = null;
let categoryIndex: { name: string; category: string; vector: number[] }[] | null = null;
let iconIndex: { name: string; icon: string; vector: number[] }[] | null = null;
async function getEmbedder(): Promise<FeatureExtractionPipeline> {
if (!embedder) {
embedder = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'q8' });
}
return embedder;
}
async function getCategoryIndex() {
if (!categoryIndex) {
const raw = await read(categoryEmbeddingsUrl).json();
categoryIndex = raw.entries;
}
return categoryIndex!;
}
async function getIconIndex() {
if (!iconIndex) {
const raw = await read(iconEmbeddingsUrl).json();
iconIndex = raw.entries;
}
return iconIndex!;
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
// --- Icon lookup (direct + substring) ---
function directIconLookup(query: string): string | null {
// Exact match
const exact = catalogMap.get(query);
if (exact) return exact;
// Alias lookup: resolve English/alternate name to German catalog name
const alias = ICON_ALIASES[query];
if (alias) {
const aliasIcon = catalogMap.get(alias);
if (aliasIcon) return aliasIcon;
}
// Substring match: query contains catalog name or vice versa
for (const [catalogName, iconFile] of catalogMap) {
if (query.includes(catalogName) || catalogName.includes(query)) {
return iconFile;
}
}
return null;
}
// --- Main function ---
export async function categorizeItem(name: string): Promise<{
category: string;
confidence: number;
icon: string | null;
}> {
const query = name.toLowerCase().trim();
if (!query) return { category: 'Sonstiges', confidence: 0, icon: null };
// Step 0: DB cache
try {
await dbConnect();
const cached = await ShoppingItemCategory.findOne({ normalizedName: query }).lean();
if (cached) {
console.log(`[categorizer] Cache hit for "${name}": ${cached.category} / ${cached.icon}`);
return { category: cached.category, confidence: 1.0, icon: cached.icon ?? null };
}
} catch { /* continue without cache */ }
// Step 1: Direct icon lookup (exact + substring against catalog)
const directIcon = directIconLookup(query);
if (directIcon) {
console.log(`[categorizer] Direct icon match for "${name}": ${directIcon}`);
}
// Step 2: Embedding-based category classification
const emb = await getEmbedder();
const catIdx = await getCategoryIndex();
const result = await emb(`query: ${query}`, { pooling: 'mean', normalize: true });
const queryVector = Array.from(result.data as Float32Array);
let bestCatScore = -1;
let bestCatIdx = 0;
for (let i = 0; i < catIdx.length; i++) {
const score = cosineSimilarity(queryVector, catIdx[i].vector);
if (score > bestCatScore) {
bestCatScore = score;
bestCatIdx = i;
}
}
const category = bestCatScore >= CATEGORY_THRESHOLD
? catIdx[bestCatIdx].category
: 'Sonstiges';
const confidence = bestCatScore;
console.log(`[categorizer] Category for "${name}": ${category} (${bestCatScore.toFixed(3)}, matched: ${catIdx[bestCatIdx].name})`);
// Step 3: Icon resolution
let icon: string | null = directIcon;
if (!icon) {
// Category-scoped embedding search: only compare against icons in the matched category
const icoIdx = await getIconIndex();
const scopedIconNames = new Set(iconsByCategory.get(category) || []);
let bestIcoScore = -1;
let bestIcoIdx = -1;
for (let i = 0; i < icoIdx.length; i++) {
// Only consider icons in the matched category
if (!scopedIconNames.has(icoIdx[i].name.toLowerCase()) && !scopedIconNames.has(icoIdx[i].name)) {
continue;
}
const score = cosineSimilarity(queryVector, icoIdx[i].vector);
if (score > bestIcoScore) {
bestIcoScore = score;
bestIcoIdx = i;
}
}
if (bestIcoIdx >= 0 && bestIcoScore >= ICON_THRESHOLD) {
icon = icoIdx[bestIcoIdx].icon;
console.log(`[categorizer] Scoped icon match for "${name}": ${icon} (${bestIcoScore.toFixed(3)}, matched: ${icoIdx[bestIcoIdx].name})`);
} else {
// Fall back to category default
icon = CATEGORY_DEFAULT_ICONS[category] ?? null;
console.log(`[categorizer] Using default icon for "${name}": ${icon} (category: ${category})`);
}
}
// Step 4: Cache result
try {
await ShoppingItemCategory.updateOne(
{ normalizedName: query },
{ $setOnInsert: { normalizedName: query, originalName: name, category, icon } },
{ upsert: true }
);
} catch { /* cache write failure is non-fatal */ }
return { category, confidence, icon };
}

View File

@@ -0,0 +1,35 @@
/**
* SSE connection manager for the shared shopping list.
* Unlike sseManager.ts (per-user), this broadcasts to ALL connected clients.
*/
type SSEController = ReadableStreamDefaultController<Uint8Array>;
const connections = new Set<SSEController>();
const encoder = new TextEncoder();
export function addConnection(controller: SSEController) {
connections.add(controller);
}
export function removeConnection(controller: SSEController) {
connections.delete(controller);
}
export function broadcast(event: string, data: unknown, excludeController?: SSEController) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
const bytes = encoder.encode(payload);
for (const controller of connections) {
if (controller === excludeController) continue;
try {
controller.enqueue(bytes);
} catch {
connections.delete(controller);
}
}
}
export function getConnectionCount(): number {
return connections.size;
}

View File

@@ -0,0 +1,22 @@
import mongoose from 'mongoose';
export interface IShoppingItemCategory {
normalizedName: string;
originalName: string;
category: string;
icon: string | null;
}
const ShoppingItemCategorySchema = new mongoose.Schema(
{
normalizedName: { type: String, required: true, unique: true, index: true },
originalName: { type: String, required: true },
category: { type: String, required: true },
icon: { type: String, default: null },
},
{ timestamps: true }
);
export const ShoppingItemCategory =
mongoose.models.ShoppingItemCategory ||
mongoose.model<IShoppingItemCategory>('ShoppingItemCategory', ShoppingItemCategorySchema);

View File

@@ -0,0 +1,40 @@
import mongoose from 'mongoose';
export interface IShoppingItem {
id: string;
name: string;
category: string;
icon: string | null;
checked: boolean;
addedBy: string;
checkedBy?: string;
addedAt: Date;
}
export interface IShoppingList {
_id?: string;
version: number;
items: IShoppingItem[];
updatedAt?: Date;
}
const ShoppingItemSchema = new mongoose.Schema({
id: { type: String, required: true },
name: { type: String, required: true, trim: true },
category: { type: String, default: 'Sonstiges' },
icon: { type: String, default: null },
checked: { type: Boolean, default: false },
addedBy: { type: String, required: true, trim: true },
checkedBy: { type: String, default: null },
addedAt: { type: Date, default: () => new Date() }
}, { _id: false });
const ShoppingListSchema = new mongoose.Schema(
{
version: { type: Number, required: true, default: 0 },
items: { type: [ShoppingItemSchema], default: [] }
},
{ timestamps: true }
);
export const ShoppingList = mongoose.model<IShoppingList>('ShoppingList', ShoppingListSchema);

View File

@@ -177,7 +177,7 @@ section h2{
<h3>{labels.searchEngine}</h3> <h3>{labels.searchEngine}</h3>
</a> </a>
<a href="cospend"> <a href="cospend/list">
<svg class="lock-icon"><use href="#lock-icon"/></svg> <svg class="lock-icon"><use href="#lock-icon"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M0 24C0 10.7 10.7 0 24 0H69.5c22 0 41.5 12.8 50.6 32h411c26.3 0 45.5 25 38.6 50.4l-41 152.3c-8.5 31.4-37 53.3-69.5 53.3H170.7l5.4 28.5c2.2 11.3 12.1 19.5 23.6 19.5H488c13.3 0 24 10.7 24 24s-10.7 24-24 24H199.7c-34.6 0-64.3-24.6-70.7-58.5L77.4 54.5c-.7-3.8-4-6.5-7.9-6.5H24C10.7 48 0 37.3 0 24zM128 464a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zm336-48a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M0 24C0 10.7 10.7 0 24 0H69.5c22 0 41.5 12.8 50.6 32h411c26.3 0 45.5 25 38.6 50.4l-41 152.3c-8.5 31.4-37 53.3-69.5 53.3H170.7l5.4 28.5c2.2 11.3 12.1 19.5 23.6 19.5H488c13.3 0 24 10.7 24 24s-10.7 24-24 24H199.7c-34.6 0-64.3-24.6-70.7-58.5L77.4 54.5c-.7-3.8-4-6.5-7.9-6.5H24C10.7 48 0 37.3 0 24zM128 464a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zm336-48a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"/></svg>
<h3>{labels.shopping}</h3> <h3>{labels.shopping}</h3>

View File

@@ -0,0 +1,79 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { ShoppingList } from '$models/ShoppingList';
import { broadcast } from '$lib/server/shoppingSSE';
async function getOrCreateList() {
let list = await ShoppingList.findOne().lean();
if (!list) {
list = await ShoppingList.create({ version: 0, items: [] });
list = list.toObject();
}
return list;
}
// GET /api/cospend/list — fetch current shopping list
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const list = await getOrCreateList();
return json(list);
};
// PUT /api/cospend/list — update shopping list with version conflict detection
export const PUT: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const data = await request.json();
const { items, expectedVersion } = data;
if (!Array.isArray(items)) {
throw error(400, 'items must be an array');
}
const existing = await getOrCreateList();
if (expectedVersion != null && existing.version !== expectedVersion) {
return json(
{ error: 'Version conflict', list: existing },
{ status: 409 }
);
}
const newVersion = existing.version + 1;
const doc = await ShoppingList.findOneAndUpdate(
{},
{ $set: { items, version: newVersion } },
{ upsert: true, returnDocument: 'after', lean: true }
);
broadcast('update', doc);
return json(doc);
};
// DELETE /api/cospend/list — clear all items
export const DELETE: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const existing = await getOrCreateList();
const newVersion = existing.version + 1;
const doc = await ShoppingList.findOneAndUpdate(
{},
{ $set: { items: [], version: newVersion } },
{ upsert: true, returnDocument: 'after', lean: true }
);
broadcast('update', doc);
return json({ ok: true });
};

View File

@@ -0,0 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { categorizeItem } from '$lib/server/shoppingCategorizer';
// POST /api/cospend/list/categorize — categorize a shopping item by name
export const POST: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const { name } = await request.json();
if (!name || typeof name !== 'string') {
throw error(400, 'name is required');
}
const result = await categorizeItem(name);
return json(result);
};

View File

@@ -0,0 +1,46 @@
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
import { addConnection, removeConnection } from '$lib/server/shoppingSSE';
// GET /api/cospend/list/stream — SSE endpoint for live shopping list updates
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const encoder = new TextEncoder();
let controllerRef: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controllerRef = controller;
addConnection(controller);
try {
controller.enqueue(encoder.encode(': heartbeat\n\n'));
} catch {
// ignore
}
},
cancel() {
removeConnection(controllerRef);
}
});
const heartbeatInterval = setInterval(() => {
try {
controllerRef.enqueue(encoder.encode(': heartbeat\n\n'));
} catch {
clearInterval(heartbeatInterval);
removeConnection(controllerRef);
}
}, 30000);
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -7,7 +7,7 @@
import PaymentModal from '$lib/components/cospend/PaymentModal.svelte'; import PaymentModal from '$lib/components/cospend/PaymentModal.svelte';
import Header from '$lib/components/Header.svelte'; import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.svelte'; import UserHeader from '$lib/components/UserHeader.svelte';
import { LayoutDashboard, Wallet, RefreshCw } from '@lucide/svelte'; import { LayoutDashboard, Wallet, RefreshCw, ShoppingCart } from '@lucide/svelte';
let { data, children } = $props(); let { data, children } = $props();
@@ -20,7 +20,7 @@
// Check if URL contains payment view route OR if we have paymentId in state // Check if URL contains payment view route OR if we have paymentId in state
const match = $page.url.pathname.match(/\/cospend\/payments\/view\/([^\/]+)/); const match = $page.url.pathname.match(/\/cospend\/payments\/view\/([^\/]+)/);
const statePaymentId = $page.state?.paymentId; const statePaymentId = $page.state?.paymentId;
const isOnDashboard = $page.route.id === '/cospend'; const isOnDashboard = $page.route.id === '/cospend/dash';
// Only show modal if we're on the dashboard AND have a payment to show // Only show modal if we're on the dashboard AND have a payment to show
if (isOnDashboard && (match || statePaymentId)) { if (isOnDashboard && (match || statePaymentId)) {
@@ -38,7 +38,7 @@
paymentId = null; paymentId = null;
// Dispatch a custom event to trigger dashboard refresh // Dispatch a custom event to trigger dashboard refresh
if ($page.route.id === '/cospend') { if ($page.route.id === '/cospend/dash') {
window.dispatchEvent(new CustomEvent('dashboardRefresh')); window.dispatchEvent(new CustomEvent('dashboardRefresh'));
} }
} }
@@ -47,8 +47,8 @@
function isActive(path) { function isActive(path) {
const currentPath = $page.url.pathname; const currentPath = $page.url.pathname;
// Exact match for cospend root // Exact match for cospend root
if (path === '/cospend') { if (path === '/cospend/dash') {
return currentPath === '/cospend' || currentPath === '/cospend/'; return currentPath === '/cospend/dash' || currentPath === '/cospend/dash/';
} }
// For other paths, check if current path starts with the link path // For other paths, check if current path starts with the link path
return currentPath.startsWith(path); return currentPath.startsWith(path);
@@ -58,7 +58,8 @@
<Header> <Header>
{#snippet links()} {#snippet links()}
<ul class="site_header"> <ul class="site_header">
<li style="--active-fill: var(--nord9)"><a href="/cospend" class:active={isActive('/cospend')}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Dashboard</span></a></li> <li style="--active-fill: var(--nord9)"><a href="/cospend/dash" class:active={isActive('/cospend/dash')}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Dashboard</span></a></li>
<li style="--active-fill: var(--nord13)"><a href="/cospend/list" class:active={isActive('/cospend/list')}><ShoppingCart size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Liste</span></a></li>
<li style="--active-fill: var(--nord14)"><a href="/cospend/payments" class:active={isActive('/cospend/payments')}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">All Payments</span></a></li> <li style="--active-fill: var(--nord14)"><a href="/cospend/payments" class:active={isActive('/cospend/payments')}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">All Payments</span></a></li>
<li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/cospend/recurring" class:active={isActive('/cospend/recurring')}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">Recurring</span></a></li> <li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/cospend/recurring" class:active={isActive('/cospend/recurring')}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">Recurring</span></a></li>
</ul> </ul>

View File

@@ -1,38 +1,5 @@
import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit';
import { redirect, error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, fetch }) => { export function load() {
const session = await locals.auth(); redirect(302, '/cospend/list');
}
if (!session) {
throw redirect(302, '/login');
}
try {
// Fetch both balance and debt data server-side using existing APIs
const [balanceResponse, debtResponse] = await Promise.all([
fetch('/api/cospend/balance'),
fetch('/api/cospend/debts')
]);
if (!balanceResponse.ok) {
throw new Error('Failed to fetch balance');
}
if (!debtResponse.ok) {
throw new Error('Failed to fetch debt data');
}
const balance = await balanceResponse.json();
const debtData = await debtResponse.json();
return {
session,
balance,
debtData
};
} catch (e) {
console.error('Error loading dashboard data:', e);
throw error(500, 'Failed to load dashboard data');
}
};

View File

@@ -0,0 +1,38 @@
import type { PageServerLoad } from './$types';
import { redirect, error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, fetch }) => {
const session = await locals.auth();
if (!session) {
throw redirect(302, '/login');
}
try {
// Fetch both balance and debt data server-side using existing APIs
const [balanceResponse, debtResponse] = await Promise.all([
fetch('/api/cospend/balance'),
fetch('/api/cospend/debts')
]);
if (!balanceResponse.ok) {
throw new Error('Failed to fetch balance');
}
if (!debtResponse.ok) {
throw new Error('Failed to fetch debt data');
}
const balance = await balanceResponse.json();
const debtData = await debtResponse.json();
return {
session,
balance,
debtData
};
} catch (e) {
console.error('Error loading dashboard data:', e);
throw error(500, 'Failed to load dashboard data');
}
};

View File

@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
const session = await locals.auth();
if (!session) throw redirect(302, '/login');
return { session };
};

View File

@@ -0,0 +1,412 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { getShoppingSync } from '$lib/js/shoppingSync.svelte';
import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems';
import { Plus, ListX } from '@lucide/svelte';
import { flip } from 'svelte/animate';
import { slide } from 'svelte/transition';
import { SvelteSet } from 'svelte/reactivity';
let { data } = $props();
let user = $derived(data.session?.user?.nickname || '');
const sync = getShoppingSync();
let newItemName = $state('');
/** @type {HTMLInputElement | null} */
let inputEl = $state(null);
let categorizing = new SvelteSet();
/** @type {Record<string, boolean>} */
let collapsed = $state({});
/** Get icon URL for an item */
function iconUrl(item) {
if (item.icon) return `https://bocken.org/static/shopping-icons/${item.icon}.png`;
// Fallback: first letter
const letter = item.name.charAt(0).toLowerCase();
if (letter >= 'a' && letter <= 'z') return `https://bocken.org/static/shopping-icons/${letter}.png`;
return null;
}
// Group items by category, unchecked first within each group
let groupedItems = $derived.by(() => {
/** @type {Map<string, import('$lib/js/shoppingSync.svelte').ShoppingItem[]>} */
const groups = new Map();
for (const item of sync.items) {
if (!groups.has(item.category)) groups.set(item.category, []);
groups.get(item.category).push(item);
}
for (const [, items] of groups) {
items.sort((a, b) => Number(a.checked) - Number(b.checked));
}
const ordered = [...SHOPPING_CATEGORIES]
.filter(cat => groups.has(cat))
.map(cat => ({ category: cat, items: groups.get(cat) }));
for (const [cat, items] of groups) {
if (!SHOPPING_CATEGORIES.includes(/** @type {any} */ (cat))) {
ordered.push({ category: cat, items });
}
}
return ordered;
});
let checkedCount = $derived(sync.items.filter(i => i.checked).length);
let totalCount = $derived(sync.items.length);
onMount(() => { sync.init(); });
onDestroy(() => { sync.disconnect(); });
async function addItem() {
const name = newItemName.trim();
if (!name) return;
sync.addItem(name, user);
newItemName = '';
inputEl?.focus();
const addedItem = sync.items[sync.items.length - 1];
if (!addedItem) return;
const itemId = addedItem.id;
categorizing.add(itemId);
try {
console.log(`[shopping] Categorizing "${name}" (item ${itemId})...`);
const res = await fetch('/api/cospend/list/categorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
console.log(`[shopping] Categorize response: ${res.status}`);
if (res.ok) {
const { category, icon } = await res.json();
console.log(`[shopping] Got category=${category}, icon=${icon}, updating item ${itemId}`);
sync.updateItemCategory(itemId, category, icon);
} else {
console.warn(`[shopping] Categorize failed: ${res.status} ${await res.text()}`);
}
} catch (err) {
console.error('[shopping] Categorize error:', err);
}
categorizing.delete(itemId);
}
/** @param {KeyboardEvent} e */
function onKeydown(e) {
if (e.key === 'Enter') { e.preventDefault(); addItem(); }
}
/** @param {string} cat */
function toggleCollapse(cat) {
collapsed = { ...collapsed, [cat]: !collapsed[cat] };
}
</script>
<div class="shopping-page">
<header class="page-header">
<h1>Einkaufsliste</h1>
{#if totalCount > 0}
<p class="subtitle">{checkedCount} / {totalCount} erledigt</p>
{/if}
</header>
<div class="add-bar">
<input
bind:this={inputEl}
bind:value={newItemName}
onkeydown={onKeydown}
type="text"
placeholder="Artikel hinzufügen..."
autocomplete="off"
/>
<button class="btn-add" onclick={addItem} disabled={!newItemName.trim()}>
<Plus size={18} />
</button>
</div>
{#if totalCount === 0}
<p class="empty-state">Die Einkaufsliste ist leer</p>
{:else}
<div class="item-list">
{#each groupedItems as group (group.category)}
<section class="category-section" transition:slide={{ duration: 200 }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="category-header" onclick={() => toggleCollapse(group.category)}>
<h2>{group.category}</h2>
<span class="category-count">{group.items.filter(i => !i.checked).length}</span>
</div>
{#if !collapsed[group.category]}
<div class="card-grid" transition:slide={{ duration: 150 }}>
{#each group.items as item (item.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="item-card"
class:checked={item.checked}
animate:flip={{ duration: 200 }}
onclick={() => sync.toggleItem(item.id, user)}
>
<div class="card-icon">
{#if iconUrl(item)}
<img src={iconUrl(item)} alt="" />
{:else}
<span class="card-letter">{item.name.charAt(0)}</span>
{/if}
</div>
<span class="card-name">{item.name}</span>
</div>
{/each}
</div>
{/if}
</section>
{/each}
</div>
{#if checkedCount > 0}
<button class="btn-clear-checked" onclick={() => sync.clearChecked()}>
<ListX size={16} />
Erledigte entfernen ({checkedCount})
</button>
{/if}
{/if}
{#if sync.status === 'offline'}
<div class="status-badge offline">Offline</div>
{:else if sync.status === 'syncing'}
<div class="status-badge syncing">Synchronisiere...</div>
{/if}
</div>
<style>
.shopping-page {
max-width: 700px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
.page-header {
text-align: center;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.85rem;
}
/* Add bar */
.add-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.add-bar input {
flex: 1;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
font-size: 0.9rem;
}
.add-bar input:focus {
outline: none;
border-color: var(--nord10);
}
.btn-add {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: none;
border-radius: 10px;
background: var(--nord10);
color: white;
cursor: pointer;
transition: background 150ms;
flex-shrink: 0;
}
.btn-add:hover { background: var(--nord9); }
.btn-add:disabled { opacity: 0.4; cursor: default; }
.empty-state {
text-align: center;
color: var(--color-text-secondary);
font-size: 0.9rem;
margin-top: 3rem;
}
/* Categories */
.item-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-section {
overflow: hidden;
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.2rem;
cursor: pointer;
user-select: none;
}
.category-header h2 {
font-size: 0.78rem;
font-weight: 700;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.category-count {
font-size: 0.68rem;
font-weight: 700;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
padding: 0.1rem 0.45rem;
border-radius: 100px;
}
/* Card grid */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 0.5rem;
padding: 0.25rem 0;
}
.item-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.6rem 0.3rem;
border-radius: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
cursor: pointer;
transition: all 150ms;
aspect-ratio: 1;
user-select: none;
}
.item-card:hover {
background: var(--color-bg-elevated);
}
.item-card:active {
transform: scale(0.95);
}
.item-card.checked {
opacity: 0.45;
background: color-mix(in srgb, var(--nord14) 8%, var(--color-surface));
}
.card-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-icon img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.item-card.checked .card-icon {
filter: grayscale(0.6);
}
.card-letter {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
}
.card-name {
font-size: 0.72rem;
font-weight: 500;
text-align: center;
line-height: 1.2;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
}
.item-card.checked .card-name {
text-decoration: line-through;
color: var(--color-text-secondary);
}
/* Clear checked */
.btn-clear-checked {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
margin: 1.5rem auto 0;
padding: 0.5rem 1.2rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.82rem;
cursor: pointer;
transition: all 150ms;
}
.btn-clear-checked:hover {
color: var(--nord11);
border-color: var(--nord11);
background: color-mix(in srgb, var(--nord11) 6%, transparent);
}
/* Status */
.status-badge {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
padding: 0.35rem 0.8rem;
border-radius: 100px;
font-size: 0.72rem;
font-weight: 600;
z-index: 50;
}
.status-badge.offline { background: var(--nord11); color: white; }
.status-badge.syncing { background: var(--nord13); color: var(--nord0); }
@media (max-width: 500px) {
.shopping-page { padding: 1rem 0.75rem; }
h1 { font-size: 1.3rem; }
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.4rem;
}
.card-icon { width: 36px; height: 36px; }
.card-name { font-size: 0.68rem; }
}
</style>

View File

@@ -286,7 +286,7 @@
} }
const result = await response.json(); const result = await response.json();
await goto('/cospend'); await goto('/cospend/dash');
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);

View File

@@ -158,7 +158,7 @@
<h2>🎉 All Settled!</h2> <h2>🎉 All Settled!</h2>
<p>No outstanding debts to settle. Everyone is even!</p> <p>No outstanding debts to settle. Everyone is even!</p>
<div class="actions"> <div class="actions">
<a href="/cospend" class="btn btn-primary">Back to Dashboard</a> <a href="/cospend/dash" class="btn btn-primary">Back to Dashboard</a>
</div> </div>
</div> </div>
{:else} {:else}
@@ -342,7 +342,7 @@
<button type="submit" class="btn btn-settlement"> <button type="submit" class="btn btn-settlement">
Record Settlement Record Settlement
</button> </button>
<a href="/cospend" class="btn btn-secondary"> <a href="/cospend/dash" class="btn btn-secondary">
Cancel Cancel
</a> </a>
</div> </div>