Skip to content

Commit 836c086

Browse files
JiroMusikclaude
andcommitted
Fix all spice/unit bugs: exact deduction, split categories, alias matching
Spice deduction (calcSpiceDeduction helper): - Exact: 3g from 35g jar = 8.6% deducted (uses package_size) - Vague: Prise/TL = 0.5%, EL/Bund = 1% - Fallback: <5g = 0.5%, else 1% Applied to all 3 cook endpoints (calendar, recipes, check-opened) Other fixes: - Scan prompts: "Gewürze & Saucen" → separate "Gewürze", "Saucen" - POST /api/inventory: apply mapCategory() on insert (new scans get correct category) - /api/recipes/missing-ingredients: spice early-return (skip if in stock) - /api/ha/inventory/update: replaced SQL LIKE with isIngredientInInventory - Removed unused amountsMatch import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8ffa197 commit 836c086

2 files changed

Lines changed: 61 additions & 12 deletions

File tree

server.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createServer as createViteServer } from 'vite';
66
import { db } from './server/db/database';
77
import { runMigrations } from './server/db/migrations';
88
import { mapCategory } from './server/utils/categories';
9-
import { normalizeUnit, convertToSmallestUnit, amountsMatch } from './server/utils/units';
9+
import { normalizeUnit, convertToSmallestUnit } from './server/utils/units';
1010
import { getAIResponse, checkRateLimit } from './server/services/ai.service';
1111
import { syncToBring } from './server/services/bring.service';
1212
import { SCAN_PROMPT, SCAN_SCHEMA, MHD_PROMPT, MHD_SCHEMA } from './server/services/prompts';
@@ -348,10 +348,14 @@ app.post('/api/recipes/missing-ingredients', (req, res) => {
348348
required[key].amount += smallest.amount;
349349
});
350350

351-
const inventory = db.prepare('SELECT name, generic_name, quantity, unit FROM items WHERE quantity > 0').all() as any[];
351+
const inventory = db.prepare('SELECT name, generic_name, quantity, unit, category FROM items WHERE quantity > 0').all() as any[];
352352
const missingIngredients: any[] = [];
353353

354354
Object.values(required).forEach((reqIng: any) => {
355+
// Spices with any stock: always available regardless of unit
356+
const spiceMatch = inventory.find(inv => isIngredientInInventory(reqIng.name, [inv]));
357+
if (spiceMatch?.category === 'Gewürze' && spiceMatch.quantity > 0) return;
358+
355359
// Use canonical alias matching (same as recipe ingredient checking)
356360
const matchingItems = inventory.filter(inv => {
357361
if (!isIngredientInInventory(reqIng.name, [inv])) return false;
@@ -739,20 +743,21 @@ app.get('/api/recipes/weekly', (req, res) => {
739743

740744
app.post('/api/inventory', (req, res) => {
741745
const { name, generic_name, quantity, unit, expiry_date, category, barcode, pieces_per_pack, location, price, min_stock } = req.body;
746+
const finalCategory = mapCategory(category || '', name);
742747
try {
743748
// Save to product_lookup for future barcode scans
744749
if (barcode) {
745750
db.prepare('INSERT OR IGNORE INTO product_lookup (barcode, name, generic_name, category, default_quantity, unit, pieces_per_pack, location, price, min_stock) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
746-
.run(barcode, name, generic_name || name, category, quantity, unit, pieces_per_pack || 1, location, price, min_stock);
751+
.run(barcode, name, generic_name || name, finalCategory, quantity, unit, pieces_per_pack || 1, location, price, min_stock);
747752
}
748753

749754
// Sanitize expiry_date — AI sometimes returns "null" string
750755
const cleanExpiry = (expiry_date && expiry_date !== 'null' && expiry_date !== 'undefined') ? expiry_date : null;
751756

752757
// Always create a new item — each physical package is its own row
753758
const stmt = db.prepare('INSERT INTO items (name, generic_name, quantity, unit, expiry_date, category, barcode, pieces_per_pack, package_size, is_open, location, price, min_stock) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)');
754-
const info = stmt.run(name, generic_name || name, quantity, unit, cleanExpiry, category, barcode, pieces_per_pack || 1, quantity, location || 'Vorratsschrank', price || 0, min_stock || 0);
755-
res.json({ id: info.lastInsertRowid, name, generic_name: generic_name || name, quantity, unit, expiry_date, category, barcode, package_size: quantity, location: location || 'Vorratsschrank', price, min_stock });
759+
const info = stmt.run(name, generic_name || name, quantity, unit, cleanExpiry, finalCategory, barcode, pieces_per_pack || 1, quantity, location || 'Vorratsschrank', price || 0, min_stock || 0);
760+
res.json({ id: info.lastInsertRowid, name, generic_name: generic_name || name, quantity, unit, expiry_date, category: finalCategory, barcode, package_size: quantity, location: location || 'Vorratsschrank', price, min_stock });
756761
// Trigger async sync
757762
syncToBring().catch(console.error);
758763
} catch (error) {
@@ -888,7 +893,8 @@ app.post('/api/ha/inventory/update', (req, res) => {
888893
} else if (barcode) {
889894
item = db.prepare('SELECT * FROM items WHERE barcode = ?').get(barcode) as any;
890895
} else if (name) {
891-
item = db.prepare("SELECT * FROM items WHERE name LIKE ? ESCAPE '\\'").get(`%${escapeLike(name)}%`) as any;
896+
const allItems = db.prepare('SELECT * FROM items').all() as any[];
897+
item = allItems.find((inv: any) => isIngredientInInventory(name, [inv]));
892898
}
893899

894900
if (!item) return res.status(404).json({ error: 'Item not found' });
@@ -938,7 +944,7 @@ app.post('/api/scan', largeBody, async (req, res) => {
938944
if (!imageBase64) return res.status(400).json({ error: 'No image provided' });
939945

940946
try {
941-
const prompt = 'Analyze this image of a food product, barcode, or label. 1. Identify the product name. IMPORTANT: Remove brand names (e.g., "Iglo", "Barilla", "Gut & Günstig") but KEEP the variant/type (e.g., "Rahmspinat", "Lasagne", "Dunkler Saucenbinder", "Hähnchenschnitzel"). Example: "Iglo Rahmspinat Blubb" -> "Rahmspinat". "Knorr Saucenbinder Dunkel" -> "Dunkler Saucenbinder". 2. Determine a generic_name for grouping (e.g., "Rahmspinat (TK)"). IMPORTANT: For spices, seasonings, and spice mixes, the generic_name MUST be the specific spice/mix name (e.g., "Curry gemahlen", "Magic Dust", "Burger Gewürz"). NEVER use generic terms like "Gewürzmischung", "Spice Mix", or "Seasoning" as generic_name. 3. If a barcode is visible, try to decode it. 4. Estimate the CONTENT quantity and unit — always use the actual content unit, never "Packung". A 2L bottle = quantity:2000 unit:"ml". A 500g pack of pasta = quantity:500 unit:"g". A pack of 10 eggs = quantity:10 unit:"Stück". For spices/oils where exact weight is hard to track, use quantity:100 unit:"%". 5. Identify an expiry date if visible (YYYY-MM-DD). ONLY return a date if you are VERY sure. If unsure or not visible, return null. 6. Estimate the price in EUR if visible or common. 7. Suggest a storage location (e.g., Fridge, Freezer, Pantry). Return a JSON object with keys: name (string), generic_name (string), quantity (number), unit (string), category (string), expiry_date (string or null), price (number or null), location (string). The category MUST be one of: "Obst & Gemüse", "Kühlregal", "Tiefkühl", "Vorratsschrank", "Getränke", "Backwaren", "Fleisch & Fisch", "Snacks & Süßigkeiten", "Gewürze & Saucen", "Haushalt & Drogerie", "Sonstiges". The unit MUST be one of: "Stück", "g", "kg", "ml", "l", "%".';
947+
const prompt = 'Analyze this image of a food product, barcode, or label. 1. Identify the product name. IMPORTANT: Remove brand names (e.g., "Iglo", "Barilla", "Gut & Günstig") but KEEP the variant/type (e.g., "Rahmspinat", "Lasagne", "Dunkler Saucenbinder", "Hähnchenschnitzel"). Example: "Iglo Rahmspinat Blubb" -> "Rahmspinat". "Knorr Saucenbinder Dunkel" -> "Dunkler Saucenbinder". 2. Determine a generic_name for grouping (e.g., "Rahmspinat (TK)"). IMPORTANT: For spices, seasonings, and spice mixes, the generic_name MUST be the specific spice/mix name (e.g., "Curry gemahlen", "Magic Dust", "Burger Gewürz"). NEVER use generic terms like "Gewürzmischung", "Spice Mix", or "Seasoning" as generic_name. 3. If a barcode is visible, try to decode it. 4. Estimate the CONTENT quantity and unit — always use the actual content unit, never "Packung". A 2L bottle = quantity:2000 unit:"ml". A 500g pack of pasta = quantity:500 unit:"g". A pack of 10 eggs = quantity:10 unit:"Stück". For spices/oils where exact weight is hard to track, use quantity:100 unit:"%". 5. Identify an expiry date if visible (YYYY-MM-DD). ONLY return a date if you are VERY sure. If unsure or not visible, return null. 6. Estimate the price in EUR if visible or common. 7. Suggest a storage location (e.g., Fridge, Freezer, Pantry). Return a JSON object with keys: name (string), generic_name (string), quantity (number), unit (string), category (string), expiry_date (string or null), price (number or null), location (string). The category MUST be one of: "Obst & Gemüse", "Kühlregal", "Tiefkühl", "Vorratsschrank", "Getränke", "Backwaren", "Fleisch & Fisch", "Snacks & Süßigkeiten", "Gewürze", "Saucen", "Haushalt & Drogerie", "Sonstiges". The unit MUST be one of: "Stück", "g", "kg", "ml", "l", "%".';
942948

943949
const schema = {
944950
type: Type.OBJECT,
@@ -947,7 +953,7 @@ app.post('/api/scan', largeBody, async (req, res) => {
947953
generic_name: { type: Type.STRING },
948954
quantity: { type: Type.NUMBER },
949955
unit: { type: Type.STRING, enum: ["Stück", "g", "kg", "ml", "l", "%"] },
950-
category: { type: Type.STRING, enum: ["Obst & Gemüse", "Kühlregal", "Tiefkühl", "Vorratsschrank", "Getränke", "Backwaren", "Fleisch & Fisch", "Snacks & Süßigkeiten", "Gewürze & Saucen", "Haushalt & Drogerie", "Sonstiges"] },
956+
category: { type: Type.STRING, enum: ["Obst & Gemüse", "Kühlregal", "Tiefkühl", "Vorratsschrank", "Getränke", "Backwaren", "Fleisch & Fisch", "Snacks & Süßigkeiten", "Gewürze", "Saucen", "Haushalt & Drogerie", "Sonstiges"] },
951957
expiry_date: { type: Type.STRING, description: "YYYY-MM-DD format or null" },
952958
price: { type: Type.NUMBER },
953959
location: { type: Type.STRING }
@@ -1082,7 +1088,16 @@ app.post('/api/cook/check-opened', async (req, res) => {
10821088
// Use canonical alias matching
10831089
const allInv = db.prepare('SELECT * FROM items ORDER BY is_open DESC, expiry_date ASC').all() as any[];
10841090
const rows = allInv.filter(inv => isIngredientInInventory(ing.name, [inv]));
1085-
1091+
1092+
// Spice: deduct percentage (exact if package_size known, else 0.5%/1%)
1093+
const spiceItem = rows.find((r: any) => r.category === 'Gewürze' && r.unit === '%' && r.quantity > 0);
1094+
if (spiceItem && !rows.some(r => convertToSmallestUnit(r.quantity, r.unit).unit === convertToSmallestUnit(remainingToDeduct, ing.unit).unit)) {
1095+
const pctDeduct = calcSpiceDeduction(spiceItem, deductAmount, ing.unit);
1096+
const newQty = Math.max(0, spiceItem.quantity - pctDeduct);
1097+
db.prepare('UPDATE items SET quantity = ? WHERE id = ?').run(newQty, spiceItem.id);
1098+
remainingToDeduct = 0;
1099+
}
1100+
10861101
for (const item of rows) {
10871102
if (remainingToDeduct <= 0) break;
10881103

@@ -1408,6 +1423,18 @@ app.put('/api/calendar/:id/cook', async (req, res) => {
14081423
return invSmallest.unit === smallestReq.unit;
14091424
});
14101425

1426+
// Spice special handling: deduct percentage (exact if package_size known)
1427+
if (matchingRows.length === 0) {
1428+
const spiceItem = rows.find((r: any) => r.category === 'Gewürze' && r.unit === '%' && r.quantity > 0);
1429+
if (spiceItem) {
1430+
const pctDeduct = calcSpiceDeduction(spiceItem, reqAmt, ing.unit);
1431+
const newQty = Math.max(0, spiceItem.quantity - pctDeduct);
1432+
db.prepare('UPDATE items SET quantity = ? WHERE id = ?').run(newQty, spiceItem.id);
1433+
deducted.push({ ...spiceItem, quantity_deducted_smallest: pctDeduct });
1434+
remainingToDeduct = 0;
1435+
}
1436+
}
1437+
14111438
if (matchingRows.length > 0) {
14121439
for (const item of matchingRows) {
14131440
if (remainingToDeduct <= 0) break;
@@ -1703,6 +1730,21 @@ app.delete('/api/recipes/favorites/:id', (req, res) => {
17031730
}
17041731
});
17051732

1733+
// --- Spice Deduction Helper ---
1734+
function calcSpiceDeduction(item: any, amount: number, unit: string): number {
1735+
const vagueUnits = ['prise', 'tl', 'el', 'bund'];
1736+
const isVague = vagueUnits.includes(unit.toLowerCase());
1737+
if (isVague) {
1738+
return (unit.toLowerCase() === 'prise' || unit.toLowerCase() === 'tl') ? 0.5 : 1;
1739+
}
1740+
// Exact: calculate % based on package_size (e.g. 3g from 35g jar = 8.6%)
1741+
if (item.package_size && item.package_size > 0 && (unit === 'g' || unit === 'ml')) {
1742+
return Math.round((amount / item.package_size) * 100 * 10) / 10; // 1 decimal
1743+
}
1744+
// Fallback
1745+
return amount < 5 ? 0.5 : 1;
1746+
}
1747+
17061748
// --- Image Generation ---
17071749
async function generateRecipeImage(title: string, description?: string): Promise<string | null> {
17081750
const provider = (db.prepare('SELECT value FROM settings WHERE key = ?').get('image_provider') as any)?.value || 'openai';
@@ -1823,7 +1865,14 @@ app.post('/api/recipes/cook', (req, res) => {
18231865
// Use canonical alias matching (same as recipe ingredient checking)
18241866
const item = allItems.find(inv => isIngredientInInventory(ing.name, [inv]));
18251867
if (item) {
1826-
if (item.quantity >= ing.amount) {
1868+
// Spice special case: deduct percentage (exact if package_size known)
1869+
if (item.category === 'Gewürze' && item.unit === '%') {
1870+
const pctDeduct = calcSpiceDeduction(item, ing.amount, ing.unit || 'g');
1871+
const newQty = Math.max(0, item.quantity - pctDeduct);
1872+
db.prepare('UPDATE items SET quantity = ? WHERE id = ?').run(newQty, item.id);
1873+
item.quantity = newQty;
1874+
deducted.push({ ...item, quantity_deducted: pctDeduct });
1875+
} else if (item.quantity >= ing.amount) {
18271876
const newQty = item.quantity - ing.amount;
18281877
db.prepare('UPDATE items SET quantity = ? WHERE id = ?').run(newQty, item.id);
18291878
item.quantity = newQty; // update in-memory for subsequent matches

server/services/prompts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Type } from '@google/genai';
22

3-
export const SCAN_PROMPT = 'Analyze this image of a food product, barcode, or label. 1. Identify the product name. IMPORTANT: Remove brand names (e.g., "Iglo", "Barilla", "Gut & Günstig") but KEEP the variant/type (e.g., "Rahmspinat", "Lasagne", "Dunkler Saucenbinder", "Hähnchenschnitzel"). Example: "Iglo Rahmspinat Blubb" -> "Rahmspinat". "Knorr Saucenbinder Dunkel" -> "Dunkler Saucenbinder". 2. Determine a generic_name for grouping (e.g., "Rahmspinat (TK)"). IMPORTANT: For spices, seasonings, and spice mixes, the generic_name MUST be the specific spice/mix name (e.g., "Curry gemahlen", "Magic Dust", "Burger Gewürz"). NEVER use generic terms like "Gewürzmischung", "Spice Mix", or "Seasoning" as generic_name. 3. If a barcode is visible, try to decode it. 4. Estimate the CONTENT quantity and unit — always use the actual content unit, never "Packung". A 2L bottle = quantity:2000 unit:"ml". A 500g pack of pasta = quantity:500 unit:"g". A pack of 10 eggs = quantity:10 unit:"Stück". For spices/oils where exact weight is hard to track, use quantity:100 unit:"%". 5. Identify an expiry date if visible (YYYY-MM-DD). ONLY return a date if you are VERY sure. If unsure or not visible, return null. 6. Estimate the price in EUR if visible or common. 7. Suggest a storage location (e.g., Fridge, Freezer, Pantry). Return a JSON object with keys: name (string), generic_name (string), quantity (number), unit (string), category (string), expiry_date (string or null), price (number or null), location (string). The category MUST be one of: "Obst & Gemüse", "Kühlregal", "Tiefkühl", "Vorratsschrank", "Getränke", "Backwaren", "Fleisch & Fisch", "Snacks & Süßigkeiten", "Gewürze & Saucen", "Haushalt & Drogerie", "Sonstiges". The unit MUST be one of: "Stück", "g", "kg", "ml", "l", "%".';
3+
export const SCAN_PROMPT = 'Analyze this image of a food product, barcode, or label. 1. Identify the product name. IMPORTANT: Remove brand names (e.g., "Iglo", "Barilla", "Gut & Günstig") but KEEP the variant/type (e.g., "Rahmspinat", "Lasagne", "Dunkler Saucenbinder", "Hähnchenschnitzel"). Example: "Iglo Rahmspinat Blubb" -> "Rahmspinat". "Knorr Saucenbinder Dunkel" -> "Dunkler Saucenbinder". 2. Determine a generic_name for grouping (e.g., "Rahmspinat (TK)"). IMPORTANT: For spices, seasonings, and spice mixes, the generic_name MUST be the specific spice/mix name (e.g., "Curry gemahlen", "Magic Dust", "Burger Gewürz"). NEVER use generic terms like "Gewürzmischung", "Spice Mix", or "Seasoning" as generic_name. 3. If a barcode is visible, try to decode it. 4. Estimate the CONTENT quantity and unit — always use the actual content unit, never "Packung". A 2L bottle = quantity:2000 unit:"ml". A 500g pack of pasta = quantity:500 unit:"g". A pack of 10 eggs = quantity:10 unit:"Stück". For spices/oils where exact weight is hard to track, use quantity:100 unit:"%". 5. Identify an expiry date if visible (YYYY-MM-DD). ONLY return a date if you are VERY sure. If unsure or not visible, return null. 6. Estimate the price in EUR if visible or common. 7. Suggest a storage location (e.g., Fridge, Freezer, Pantry). Return a JSON object with keys: name (string), generic_name (string), quantity (number), unit (string), category (string), expiry_date (string or null), price (number or null), location (string). The category MUST be one of: "Obst & Gemüse", "Kühlregal", "Tiefkühl", "Vorratsschrank", "Getränke", "Backwaren", "Fleisch & Fisch", "Snacks & Süßigkeiten", "Gewürze", "Saucen", "Haushalt & Drogerie", "Sonstiges". The unit MUST be one of: "Stück", "g", "kg", "ml", "l", "%".';
44

55
export const SCAN_SCHEMA = {
66
type: Type.OBJECT,
@@ -9,7 +9,7 @@ export const SCAN_SCHEMA = {
99
generic_name: { type: Type.STRING },
1010
quantity: { type: Type.NUMBER },
1111
unit: { type: Type.STRING, enum: ["Stück", "g", "kg", "ml", "l", "%"] },
12-
category: { type: Type.STRING, enum: ["Obst & Gemüse", "Kühlregal", "Tiefkühl", "Vorratsschrank", "Getränke", "Backwaren", "Fleisch & Fisch", "Snacks & Süßigkeiten", "Gewürze & Saucen", "Haushalt & Drogerie", "Sonstiges"] },
12+
category: { type: Type.STRING, enum: ["Obst & Gemüse", "Kühlregal", "Tiefkühl", "Vorratsschrank", "Getränke", "Backwaren", "Fleisch & Fisch", "Snacks & Süßigkeiten", "Gewürze", "Saucen", "Haushalt & Drogerie", "Sonstiges"] },
1313
expiry_date: { type: Type.STRING, description: "YYYY-MM-DD format or null" },
1414
price: { type: Type.NUMBER },
1515
location: { type: Type.STRING }

0 commit comments

Comments
 (0)