feat: add real-time collaborative shopping list at /cospend/list
All checks were successful
CI / update (push) Successful in 1m18s
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:
@@ -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": {
|
||||||
|
|||||||
74
scripts/assign-icon-categories.ts
Normal file
74
scripts/assign-icon-categories.ts
Normal 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);
|
||||||
107
scripts/download-bring-icons.ts
Normal file
107
scripts/download-bring-icons.ts
Normal 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);
|
||||||
@@ -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) {
|
||||||
|
|||||||
55
scripts/embed-shopping-categories.ts
Normal file
55
scripts/embed-shopping-categories.ts
Normal 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);
|
||||||
55
scripts/embed-shopping-icons.ts
Normal file
55
scripts/embed-shopping-icons.ts
Normal 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);
|
||||||
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
src/lib/data/shoppingCategoryEmbeddings.json
Normal file
1
src/lib/data/shoppingCategoryEmbeddings.json
Normal file
File diff suppressed because one or more lines are too long
329
src/lib/data/shoppingCategoryItems.ts
Normal file
329
src/lib/data/shoppingCategoryItems.ts
Normal 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' },
|
||||||
|
];
|
||||||
390
src/lib/data/shoppingIconCategories.json
Normal file
390
src/lib/data/shoppingIconCategories.json
Normal 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"
|
||||||
|
}
|
||||||
1
src/lib/data/shoppingIconEmbeddings.json
Normal file
1
src/lib/data/shoppingIconEmbeddings.json
Normal file
File diff suppressed because one or more lines are too long
221
src/lib/js/shoppingSync.svelte.ts
Normal file
221
src/lib/js/shoppingSync.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
333
src/lib/server/shoppingCategorizer.ts
Normal file
333
src/lib/server/shoppingCategorizer.ts
Normal 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 };
|
||||||
|
}
|
||||||
35
src/lib/server/shoppingSSE.ts
Normal file
35
src/lib/server/shoppingSSE.ts
Normal 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;
|
||||||
|
}
|
||||||
22
src/models/ShoppingItemCategory.ts
Normal file
22
src/models/ShoppingItemCategory.ts
Normal 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);
|
||||||
40
src/models/ShoppingList.ts
Normal file
40
src/models/ShoppingList.ts
Normal 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);
|
||||||
@@ -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>
|
||||||
|
|||||||
79
src/routes/api/cospend/list/+server.ts
Normal file
79
src/routes/api/cospend/list/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
17
src/routes/api/cospend/list/categorize/+server.ts
Normal file
17
src/routes/api/cospend/list/categorize/+server.ts
Normal 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);
|
||||||
|
};
|
||||||
46
src/routes/api/cospend/list/stream/+server.ts
Normal file
46
src/routes/api/cospend/list/stream/+server.ts
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
38
src/routes/cospend/dash/+page.server.ts
Normal file
38
src/routes/cospend/dash/+page.server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
8
src/routes/cospend/list/+page.server.ts
Normal file
8
src/routes/cospend/list/+page.server.ts
Normal 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 };
|
||||||
|
};
|
||||||
412
src/routes/cospend/list/+page.svelte
Normal file
412
src/routes/cospend/list/+page.svelte
Normal 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>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user