Skip to content

Commit 8ffa197

Browse files
JiroMusikclaude
andcommitted
Fix spice handling in shopping list and Bring sync
Shopping list + Bring sync: - Spices (category='Gewürze') with quantity > 0: always 'in stock' regardless of unit mismatch (recipe says 3g, inventory says 100% = still available) - Spices not in inventory or empty: added as '1 Packung' (not raw grams) - Min-stock for spices: shows as '1 Packung' when depleted - Bring service: same logic, also fixed old crude includes() matching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0a885c3 commit 8ffa197

2 files changed

Lines changed: 72 additions & 23 deletions

File tree

server.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -407,10 +407,10 @@ app.get('/api/shopping-list', (req, res) => {
407407
});
408408
});
409409

410-
const inventory = db.prepare('SELECT name, generic_name, quantity, unit, min_stock FROM items WHERE quantity > 0').all() as any[];
410+
const inventory = db.prepare('SELECT name, generic_name, quantity, unit, min_stock, category FROM items WHERE quantity > 0').all() as any[];
411411

412412
// Add items that are below minimum stock
413-
const allKnownItems = db.prepare('SELECT name, generic_name, quantity, unit, min_stock FROM items').all() as any[];
413+
const allKnownItems = db.prepare('SELECT name, generic_name, quantity, unit, min_stock, category FROM items').all() as any[];
414414
const lowStockItems: Record<string, { amount: number, unit: string, name: string }> = {};
415415

416416
// Group by name and unit for min_stock check
@@ -424,21 +424,41 @@ app.get('/api/shopping-list', (req, res) => {
424424
stockLevels[key].total += smallest.amount;
425425
});
426426

427+
// Min-stock: for spices show as "1 Packung", for others show deficit amount
427428
Object.entries(stockLevels).forEach(([key, level]) => {
428429
if (level.min > 0 && level.total < level.min) {
429430
const name = key.split('_')[0];
430-
const missing = level.min - level.total;
431-
if (!required[key]) {
432-
required[key] = { amount: 0, unit: level.unit, name: name.charAt(0).toUpperCase() + name.slice(1) };
431+
const item = allKnownItems.find(i => i.name.toLowerCase() === name);
432+
if (item?.category === 'Gewürze') {
433+
// Spices: add as "1 Packung"
434+
const spiceKey = `${name}_Stück`;
435+
if (!required[spiceKey]) {
436+
required[spiceKey] = { amount: 0, unit: 'Stück', name: name.charAt(0).toUpperCase() + name.slice(1) };
437+
}
438+
required[spiceKey].amount = 1;
439+
} else {
440+
const missing = level.min - level.total;
441+
if (!required[key]) {
442+
required[key] = { amount: 0, unit: level.unit, name: name.charAt(0).toUpperCase() + name.slice(1) };
443+
}
444+
required[key].amount += missing;
433445
}
434-
required[key].amount += missing;
435446
}
436447
});
437448

438449
const missingIngredients: any[] = [];
439450

440451
Object.values(required).forEach((reqIng: any) => {
441-
// Use canonical alias matching (same as recipe ingredient checking)
452+
// Spices: if any matching item exists with quantity > 0, it's available (regardless of unit mismatch g vs %)
453+
const anyMatchInInventory = inventory.some(inv => isIngredientInInventory(reqIng.name, [inv]));
454+
if (anyMatchInInventory) {
455+
const matchedItem = inventory.find(inv => isIngredientInInventory(reqIng.name, [inv]));
456+
if (matchedItem?.category === 'Gewürze' && matchedItem.quantity > 0) {
457+
return; // Spice is in stock, skip
458+
}
459+
}
460+
461+
// Standard matching with unit awareness
442462
const matchingItems = inventory.filter(inv => {
443463
if (!isIngredientInInventory(reqIng.name, [inv])) return false;
444464
const invSmallest = convertToSmallestUnit(inv.quantity, inv.unit);

server/services/bring.service.ts

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import Bring from 'bring-shopping';
33
import { db } from '../db/database';
44
import { convertToSmallestUnit, normalizeUnit } from '../utils/units';
55

6+
// Import isIngredientInInventory from server.ts is circular — inline a simple version
7+
const normalizeIngredient = (s: string) => s.toLowerCase().replace(/\(.*?\)/g, '').replace(/[,\.]/g, '').replace(/\s+/g, ' ').trim();
8+
69
const getBringClient = () => {
710
const emailRow = db.prepare('SELECT value FROM settings WHERE key = ?').get('bring_email') as any;
811
const passRow = db.prepare('SELECT value FROM settings WHERE key = ?').get('bring_password') as any;
@@ -18,13 +21,13 @@ export const syncToBring = async () => {
1821
await bring.login();
1922
const lists = await bring.loadLists();
2023
if (!lists || lists.lists.length === 0) return;
21-
24+
2225
const listId = lists.lists[0].listUuid;
2326
const bringItems = await bring.getItems(listId);
24-
27+
2528
const today = new Date().toISOString().split('T')[0];
2629
const upcomingRecipes = db.prepare('SELECT * FROM planned_recipes WHERE date >= ? AND cooked = 0').all(today);
27-
30+
2831
const required: Record<string, { amount: number, unit: string, name: string }> = {};
2932
upcomingRecipes.forEach((recipe: any) => {
3033
const ingredients = JSON.parse(recipe.ingredients);
@@ -39,36 +42,61 @@ export const syncToBring = async () => {
3942
});
4043
});
4144

42-
const inventory = db.prepare('SELECT name, quantity, unit, min_stock FROM items WHERE quantity > 0').all() as any[];
43-
const allKnownItems = db.prepare('SELECT name, quantity, unit, min_stock FROM items').all() as any[];
44-
45-
const stockLevels: Record<string, { total: number, min: number, unit: string }> = {};
45+
const inventory = db.prepare('SELECT name, generic_name, quantity, unit, min_stock, category FROM items WHERE quantity > 0').all() as any[];
46+
const allKnownItems = db.prepare('SELECT name, generic_name, quantity, unit, min_stock, category FROM items').all() as any[];
47+
48+
// Min-stock requirements
49+
const stockLevels: Record<string, { total: number, min: number, unit: string, category: string, name: string }> = {};
4650
allKnownItems.forEach(item => {
4751
const key = `${item.name.toLowerCase()}_${normalizeUnit(item.unit)}`;
4852
if (!stockLevels[key]) {
49-
stockLevels[key] = { total: 0, min: item.min_stock || 0, unit: normalizeUnit(item.unit) };
53+
stockLevels[key] = { total: 0, min: item.min_stock || 0, unit: normalizeUnit(item.unit), category: item.category, name: item.name };
5054
}
5155
const smallest = convertToSmallestUnit(item.quantity, item.unit);
5256
stockLevels[key].total += smallest.amount;
5357
});
5458

5559
Object.entries(stockLevels).forEach(([key, level]) => {
5660
if (level.min > 0 && level.total < level.min) {
57-
const name = key.split('_')[0];
58-
const missing = level.min - level.total;
59-
if (!required[key]) {
60-
required[key] = { amount: 0, unit: level.unit, name: name.charAt(0).toUpperCase() + name.slice(1) };
61+
if (level.category === 'Gewürze') {
62+
// Spices: "1 Packung"
63+
const spiceKey = `${level.name.toLowerCase()}_Stück`;
64+
if (!required[spiceKey]) {
65+
required[spiceKey] = { amount: 1, unit: 'Stück', name: level.name };
66+
}
67+
} else {
68+
const missing = level.min - level.total;
69+
if (!required[key]) {
70+
required[key] = { amount: 0, unit: level.unit, name: level.name };
71+
}
72+
required[key].amount += missing;
6173
}
62-
required[key].amount += missing;
6374
}
6475
});
6576

6677
const missingNames = new Set<string>();
6778
Object.values(required).forEach((reqIng: any) => {
79+
// Spices: if in inventory with quantity > 0, always consider available
80+
const matchedInv = inventory.find(inv => {
81+
const invNorm = normalizeIngredient(inv.name);
82+
const genNorm = inv.generic_name ? normalizeIngredient(inv.generic_name) : '';
83+
const reqNorm = normalizeIngredient(reqIng.name);
84+
return invNorm.includes(reqNorm) || reqNorm.includes(invNorm) ||
85+
genNorm.includes(reqNorm) || reqNorm.includes(genNorm);
86+
});
87+
88+
if (matchedInv?.category === 'Gewürze' && matchedInv.quantity > 0) {
89+
return; // Spice in stock, skip
90+
}
91+
92+
// Standard unit-aware matching
6893
const matchingItems = inventory.filter(inv => {
69-
const isNameMatch = inv.name.toLowerCase().includes(reqIng.name.toLowerCase()) ||
70-
reqIng.name.toLowerCase().includes(inv.name.toLowerCase());
71-
if (!isNameMatch) return false;
94+
const invNorm = normalizeIngredient(inv.name);
95+
const genNorm = inv.generic_name ? normalizeIngredient(inv.generic_name) : '';
96+
const reqNorm = normalizeIngredient(reqIng.name);
97+
const nameMatch = invNorm.includes(reqNorm) || reqNorm.includes(invNorm) ||
98+
genNorm.includes(reqNorm) || reqNorm.includes(genNorm);
99+
if (!nameMatch) return false;
72100
const invSmallest = convertToSmallestUnit(inv.quantity, inv.unit);
73101
return invSmallest.unit === reqIng.unit;
74102
});
@@ -83,6 +111,7 @@ export const syncToBring = async () => {
83111
}
84112
});
85113

114+
// ONE-WAY SYNC: add missing, remove no-longer-missing known items
86115
const bringCurrentNames = new Set(bringItems.purchase.map((i: any) => i.name.toLowerCase()));
87116
for (const missingName of missingNames) {
88117
if (!bringCurrentNames.has(missingName)) {

0 commit comments

Comments
 (0)