feat: bring urgency sync, background auto-sync, recipe mealplan chip, screensaver fix

This commit is contained in:
dadaloop82
2026-04-04 14:32:25 +00:00
parent 6e3e451a39
commit 63db7cc114
6 changed files with 1907 additions and 31 deletions
+144 -12
View File
@@ -1350,6 +1350,7 @@ function generateRecipe(PDO $db): void {
$appliances = $input['appliances'] ?? [];
$dietaryRestrictions = $input['dietary_restrictions'] ?? '';
$todayRecipes = $input['today_recipes'] ?? [];
$mealPlanType = $input['meal_plan_type'] ?? ''; // e.g. 'pasta', 'pesce', 'legumi', ...
// Fetch all inventory items with expiry info
$stmt = $db->query("
@@ -1503,6 +1504,79 @@ function generateRecipe(PDO $db): void {
$dietaryText = "\n\nRESTRIZIONI ALIMENTARI:\n{$dietaryRestrictions}\nRispetta SEMPRE queste restrizioni.";
}
// Weekly meal plan type hint
$mealPlanTypeLabels = [
'pasta' => 'Pasta (primo piatto a base di pasta)',
'riso' => 'Riso (risotto, insalata di riso, riso saltato, ecc.)',
'carne' => 'Carne (secondo piatto a base di carne)',
'pesce' => 'Pesce (secondo piatto a base di pesce o frutti di mare)',
'legumi' => 'Legumi (zuppa, insalata, hummus, pasta e fagioli, ecc.)',
'uova' => 'Uova (frittata, uova strapazzate, quiche, ecc.)',
'formaggio' => 'Formaggio (fonduta, gnocchi al formaggio, torta salata, ecc.)',
'pizza' => 'Pizza o focaccia (impastata in casa o usi ingredienti simili)',
'affettati' => 'Affettati (tagliere misto, piadina, panino, ecc.)',
'verdure' => 'Verdure (piatto principale a base di verdure, contorno abbondante)',
'zuppa' => 'Zuppa o minestra (zuppe, vellutate, minestrone)',
'insalata' => 'Insalata (insalata mista, insalata di riso o pasta, poke)',
'pane' => 'Pane / Sandwich (toast, tramezzino, bruschette)',
'dolce' => 'Dolce o dessert',
'libero' => '',
];
// Keywords to match inventory names against each meal plan type
$typeKeywords = [
'pesce' => ['tonno', 'salmone', 'merluzzo', 'branzino', 'orata', 'sardine', 'acciughe', 'alici', 'gamberi', 'cozze', 'vongole', 'polpo', 'calamari', 'seppia', 'sgombro', 'trota', 'baccalà', 'dentice', 'spigola', 'pesce'],
'carne' => ['pollo', 'manzo', 'maiale', 'vitello', 'agnello', 'tacchino', 'salsiccia', 'hamburger', 'bistecca', 'cotoletta', 'pancetta', 'speck', 'carne', 'arrosto', 'filetto', 'lonza', 'braciola'],
'pasta' => ['pasta', 'spaghetti', 'penne', 'rigatoni', 'fusilli', 'tagliatelle', 'lasagne', 'farfalle', 'orecchiette', 'bucatini', 'linguine', 'maccheroni', 'gnocchi', 'pennette', 'bavette'],
'riso' => ['riso', 'basmati', 'arborio', 'carnaroli', 'parboiled', 'riso integrale'],
'legumi' => ['fagioli', 'ceci', 'lenticchie', 'piselli', 'fave', 'lupini', 'soia', 'legumi', 'borlotti', 'cannellini', 'azuki'],
'uova' => ['uova', 'uovo'],
'formaggio' => ['formaggio', 'parmigiano', 'mozzarella', 'ricotta', 'pecorino', 'grana', 'gorgonzola', 'scamorza', 'fontina', 'emmental', 'asiago', 'provola', 'provolone', 'taleggio', 'stracchino'],
'pizza' => ['farina', 'lievito', 'pizza', 'focaccia'],
'affettati' => ['prosciutto', 'salame', 'bresaola', 'mortadella', 'speck', 'coppa', 'affettati', 'wurstel', 'würstel', 'piadina', 'pancetta cotta'],
'verdure' => ['zucchine', 'zucchina', 'melanzane', 'peperoni', 'spinaci', 'cavolfiore', 'broccoli', 'carote', 'zucca', 'bietole', 'cavolo', 'carciofi', 'asparagi', 'lattuga', 'rucola', 'radicchio', 'cicoria', 'finocchio', 'cipolla', 'porri', 'verdure'],
'zuppa' => ['brodo', 'zuppa', 'minestra', 'minestrone', 'vellutata', 'orzo', 'farro', 'fagioli', 'ceci', 'lenticchie'],
'insalata' => ['insalata', 'lattuga', 'rucola', 'spinaci', 'radicchio', 'misticanza', 'valeriana', 'songino'],
'pane' => ['pane', 'pancarrè', 'baguette', 'toast', 'tramezzino', 'crackers', 'grissini', 'ciabatta', 'rosetta'],
'dolce' => ['cioccolato', 'cacao', 'zucchero', 'miele', 'marmellata', 'nutella', 'creme caramel', 'savoiardi', 'biscotti', 'pan di spagna', 'panna'],
];
$mealPlanText = '';
$mealPlanRule = '';
if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') {
$hint = $mealPlanTypeLabels[$mealPlanType];
// Scan inventory for ingredients matching this meal plan type
$matchingItems = [];
if (isset($typeKeywords[$mealPlanType])) {
foreach ($items as $item) {
$nameLower = mb_strtolower($item['name'] . ' ' . ($item['brand'] ?? ''));
foreach ($typeKeywords[$mealPlanType] as $kw) {
if (mb_strpos($nameLower, $kw) !== false) {
$entry = "{$item['name']}" . ($item['brand'] ? " ({$item['brand']})" : '') . ": {$item['quantity']} {$item['unit']}";
if (!empty($item['expiry_date'])) {
$dl = intval($item['days_left']);
$entry .= $dl < 0 ? " [SCADUTO]" : " [scade tra $dl giorni]";
}
$matchingItems[] = $entry;
break;
}
}
}
$matchingItems = array_unique($matchingItems);
}
if (!empty($matchingItems)) {
$matchingList = implode("\n", $matchingItems);
$matchingBlock = "Ingredienti disponibili in dispensa compatibili con questa tipologia (usa almeno uno di questi come BASE della ricetta):\n{$matchingList}";
} else {
$matchingBlock = "Nessun ingrediente perfettamente corrispondente trovato — usa la cosa più affine disponibile e segnalalo in nutrition_note.";
}
$mealPlanText = "\n\n🎯 TIPOLOGIA PASTO PIANIFICATA — OBBLIGATORIA:\nOggi questo pasto DEVE essere: {$hint}\nQuesta è una regola del piano alimentare personale dell'utente, NON un suggerimento.\n{$matchingBlock}";
$mealPlanRule = "0. TIPOLOGIA PASTO OBBLIGATORIA: la ricetta DEVE rispettare il tipo pianificato ({$hint}). Usa gli ingredienti compatibili evidenziati sopra come base principale del piatto. Non ignorare questa regola.\n ";
}
// Today's previous recipes from DB - avoid repetition
$todayText = '';
$today = date('Y-m-d');
@@ -1542,10 +1616,10 @@ function generateRecipe(PDO $db): void {
$prompt = <<<PROMPT
Sei un nutrizionista e chef italiano esperto. Genera UNA ricetta per $mealLabel per $persons persona/e usando PRINCIPALMENTE gli ingredienti disponibili nella dispensa dell'utente.
{$extraRulesText}{$appliancesText}{$dietaryText}{$varietyText}{$mustUseText}
{$extraRulesText}{$appliancesText}{$dietaryText}{$mealPlanText}{$varietyText}{$mustUseText}
REGOLE IMPORTANTI:
1. ORDINE DI PRIORITÀ INGREDIENTI (dal più urgente al meno urgente) — gli ingredienti nella lista sono già ordinati per priorità:
{$mealPlanRule}1. ORDINE DI PRIORITÀ INGREDIENTI (dal più urgente al meno urgente) — gli ingredienti nella lista sono già ordinati per priorità:
a) PRODOTTI SCADUTI: se ancora commestibili, usali SUBITO — hanno la priorità massima assoluta
b) PRODOTTI IN SCADENZA IMMINENTE (≤3 giorni): usa questi per primi dopo gli scaduti
c) PRODOTTI IN SCADENZA RAVVICINATA (≤7 giorni): poi questi
@@ -2165,15 +2239,16 @@ function bringAddItems(): void {
}
$added = 0;
$updated = 0;
$skipped = 0;
$errors = [];
// Fetch current list to check for duplicates
$existingNames = [];
// Fetch current list to check for duplicates and existing specs
$existingItems = []; // strtolower(name) => specification
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
if ($listData && isset($listData['purchase'])) {
foreach ($listData['purchase'] as $existingItem) {
$existingNames[] = strtolower($existingItem['name'] ?? '');
$existingItems[strtolower($existingItem['name'] ?? '')] = $existingItem['specification'] ?? '';
}
}
@@ -2183,14 +2258,26 @@ function bringAddItems(): void {
// Map Italian name to Bring! catalog key (German) for proper recognition
$bringName = italianToBring($name);
$bringKey = strtolower($bringName);
$spec = $item['specification'] ?? '';
$update_spec = $item['update_spec'] ?? false; // explicit flag to force spec update
// Skip if already on the list
if (in_array(strtolower($bringName), $existingNames)) {
$skipped++;
if (array_key_exists($bringKey, $existingItems)) {
// Item already on the list — only update if specification changed and update_spec requested
if ($update_spec && $existingItems[$bringKey] !== $spec) {
$body = http_build_query([
'uuid' => $listUUID,
'purchase' => $bringName,
'specification' => $spec,
]);
$result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
if ($result !== null) $updated++;
} else {
$skipped++;
}
continue;
}
$spec = $item['specification'] ?? '';
$body = http_build_query([
'uuid' => $listUUID,
'purchase' => $bringName,
@@ -2205,7 +2292,11 @@ function bringAddItems(): void {
}
}
echo json_encode(['success' => true, 'added' => $added, 'skipped' => $skipped, 'errors' => $errors]);
if ($added > 0 || $updated > 0) {
// Invalidate cache so next smart_shopping request reflects the updated Bring! list
@unlink(__DIR__ . '/../data/smart_shopping_cache.json');
}
echo json_encode(['success' => true, 'added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'errors' => $errors]);
}
function bringRemoveItem(): void {
@@ -2234,6 +2325,10 @@ function bringRemoveItem(): void {
]);
$result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
if ($result !== null) {
// Invalidate cache so next smart_shopping request reflects the updated Bring! list
@unlink(__DIR__ . '/../data/smart_shopping_cache.json');
}
echo json_encode(['success' => $result !== null]);
}
@@ -2305,6 +2400,43 @@ function smartShoppingCached(PDO $db): void {
* Smart Shopping List: analyzes usage frequency, stock levels, expiry to produce
* intelligent urgency-ranked shopping recommendations.
*/
/**
* Token-based fuzzy match: returns true if the product name shares at least one
* significant word (> 2 chars, not a stopword) with any key in $bringItems.
* Mirrors the JS _findSimilarItem / _nameTokens logic.
*/
/**
* Strict matching: returns true only when a Bring item's name "covers" the product name,
* i.e. the FIRST significant token of the product matches the FIRST significant token of
* a Bring item name. This prevents false positives like "Früchte/Frutta" matching the
* product "Muesli Frutta Secca" (which has "frutta" as a secondary token, not the first).
* Mirrors JS _matchBringToSmart / _syncOnBringFlags logic.
*/
function _productOnBring(string $productName, array $bringItems): bool {
// Exact key match (both German raw and Italian translated keys are stored)
if (isset($bringItems[mb_strtolower($productName)])) return true;
static $stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per','su',
'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo'];
$tokenize = function(string $s) use ($stop): array {
$clean = mb_strtolower(preg_replace('/[^\p{L}\s]/u', ' ', $s));
return array_values(array_filter(
preg_split('/\s+/', trim($clean)),
fn($t) => mb_strlen($t) > 2 && !in_array($t, $stop)
));
};
$pTokens = $tokenize($productName);
if (empty($pTokens)) return false;
$pFirst = $pTokens[0];
foreach (array_keys($bringItems) as $bKey) {
$bTokens = $tokenize($bKey);
if (empty($bTokens)) continue;
// First token of product must equal first token of Bring item
if ($bTokens[0] === $pFirst) return true;
}
return false;
}
function smartShopping(PDO $db): void {
$now = time();
$today = date('Y-m-d');
@@ -2513,8 +2645,8 @@ function smartShopping(PDO $db): void {
if ($useCount >= 8) $score += 15;
elseif ($useCount >= 5) $score += 10;
// Is already on Bring?
$onBring = isset($bringItems[mb_strtolower($p['name'])]);
// Is already on Bring? (fuzzy token match — mirrors JS _findSimilarItem logic)
$onBring = _productOnBring($p['name'], $bringItems);
$items[] = [
'product_id' => $pid,