diff --git a/docker/schema_family.sql b/docker/schema_family.sql index 2b89602..7fe0d17 100644 --- a/docker/schema_family.sql +++ b/docker/schema_family.sql @@ -355,6 +355,15 @@ CREATE TABLE IF NOT EXISTS pf_grocery_items ( updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS pf_grocery_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + label_hash CHAR(64) NOT NULL, + label_display VARCHAR(500) NOT NULL, + last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_grocery_hist_hash (label_hash), + KEY idx_hist_last (last_used_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- ─── Calendar iOS ───────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS pf_calendar_events ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/groceries.php b/groceries.php index 3927c13..c314a5e 100644 --- a/groceries.php +++ b/groceries.php @@ -30,9 +30,12 @@ require __DIR__ . '/header.php';
+
+ +
diff --git a/includes/lang/ca.php b/includes/lang/ca.php index 1c3938d..b64db3b 100644 --- a/includes/lang/ca.php +++ b/includes/lang/ca.php @@ -124,6 +124,21 @@ return [ 'groceries_section_pending' => 'Pendents', 'groceries_section_cart' => 'Al carretó', 'groceries_prompt_edit' => 'Editar el producte', + 'groceries_btn_clear_list' => 'Buidar la llista', + 'groceries_confirm_empty_list_title' => 'Buidar tota la llista?', + 'groceries_confirm_empty_list_body' => 'Es trauran tots els productes. Romandran a l’historial per tornar-los a afegir ràpidament.', + 'groceries_list_cleared' => 'Llista buidada', + 'groceries_history_title' => 'Historial — un clic per afegir', + 'groceries_history_empty' => 'L’historial es va omplint amb els productes que afegiu (fins al límit definit als paràmetres).', + 'groceries_history_meta' => 'Fins a {max} productes · ', + 'groceries_settings_link' => 'Paràmetres', + 'groceries_settings_title' => 'Compra — historial', + 'groceries_settings_intro' => 'Nombre màxim de productes recordats a l’historial (sense duplicats, sense distingir majúscules).', + 'groceries_settings_history_max' => 'Mida de l’historial', + 'groceries_settings_history_hint' => 'Entre 1 i 50. Les entrades més antigues se suprimeixen quan es supera el límit.', + 'groceries_settings_saved' => 'Preferències de la compra desades.', + 'groceries_settings_error' => 'No s’ha pogut desar:', + 'groceries_err_duplicate' => 'Aquest producte ja és a la llista.', 'cta_open' => 'Obrir', 'cta_explore' => 'Explorar', 'cta_view_lists' => 'Desembolicar', diff --git a/includes/lang/en.php b/includes/lang/en.php index 4aab305..05ae25b 100644 --- a/includes/lang/en.php +++ b/includes/lang/en.php @@ -125,6 +125,21 @@ return [ 'groceries_section_pending' => 'To pick up', 'groceries_section_cart' => 'In cart', 'groceries_prompt_edit' => 'Edit product', + 'groceries_btn_clear_list' => 'Clear list', + 'groceries_confirm_empty_list_title' => 'Clear the entire list?', + 'groceries_confirm_empty_list_body' => 'All products will be removed. They stay in history so you can add them again quickly.', + 'groceries_list_cleared' => 'List cleared', + 'groceries_history_title' => 'History — click to add', + 'groceries_history_empty' => 'History fills as you add products (up to the limit set in Settings).', + 'groceries_history_meta' => 'Up to {max} products · ', + 'groceries_settings_link' => 'Settings', + 'groceries_settings_title' => 'Groceries — history', + 'groceries_settings_intro' => 'Maximum number of products remembered in history (no duplicates, case-insensitive).', + 'groceries_settings_history_max' => 'History size', + 'groceries_settings_history_hint' => 'Between 1 and 50. Oldest entries are removed when the limit is exceeded.', + 'groceries_settings_saved' => 'Grocery preferences saved.', + 'groceries_settings_error' => 'Could not save:', + 'groceries_err_duplicate' => 'This product is already on the list.', 'cta_open' => 'Open', 'cta_explore' => 'Explore', 'cta_view_lists' => 'Unwrap', diff --git a/includes/lang/fr.php b/includes/lang/fr.php index 61aa7ca..532e1b1 100644 --- a/includes/lang/fr.php +++ b/includes/lang/fr.php @@ -127,8 +127,24 @@ return [ 'groceries_section_pending' => 'À prendre', 'groceries_section_cart' => 'Dans le caddie', 'groceries_prompt_edit' => 'Modifier le produit', + 'groceries_btn_clear_list' => 'Vider la liste', + 'groceries_confirm_empty_list_title' => 'Vider toute la liste ?', + 'groceries_confirm_empty_list_body' => 'Tous les produits seront retirés. Ils resteront disponibles dans l’historique pour les rajouter vite.', + 'groceries_list_cleared' => 'Liste vidée', + 'groceries_history_title' => 'Historique — un clic pour rajouter', + 'groceries_history_empty' => 'L’historique se remplit au fur et à mesure des produits ajoutés (jusqu’à la limite définie dans les paramètres).', + 'groceries_history_meta' => 'Jusqu’à {max} produits · ', + 'groceries_settings_link' => 'Réglages dans Paramètres', + 'groceries_settings_title' => 'Liste de courses — historique', + 'groceries_settings_intro' => 'Nombre maximum de produits mémorisés dans l’historique (sans doublon, insensible à la casse).', + 'groceries_settings_history_max' => 'Taille de l’historique', + 'groceries_settings_history_hint' => 'Entre 1 et 50. Les entrées les plus anciennes sont supprimées quand la limite est dépassée.', + 'groceries_settings_saved' => 'Préférences courses enregistrées.', + 'groceries_settings_error' => 'Impossible d’enregistrer :', + 'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.', 'cta_open' => 'Ouvrir', 'cta_explore' => 'Explorer', + 'cta_view_lists' => 'Déballer', 'cta_manage' => 'Gérer', 'cta_service' => 'Réviser', 'cta_jot' => 'Griffonner', diff --git a/modules/groceries/api.php b/modules/groceries/api.php index 10d6b4a..55a2caf 100644 --- a/modules/groceries/api.php +++ b/modules/groceries/api.php @@ -19,6 +19,7 @@ set_exception_handler(function (\Throwable $e) { }); require_once dirname(__DIR__, 2) . '/includes/db.php'; +require_once dirname(__DIR__, 2) . '/includes/i18n.php'; $pdo->exec(" CREATE TABLE IF NOT EXISTS pf_grocery_items ( @@ -30,6 +31,16 @@ CREATE TABLE IF NOT EXISTS pf_grocery_items ( updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "); +$pdo->exec(" +CREATE TABLE IF NOT EXISTS pf_grocery_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + label_hash CHAR(64) NOT NULL, + label_display VARCHAR(500) NOT NULL, + last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_grocery_hist_hash (label_hash), + KEY idx_hist_last (last_used_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +"); $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; @@ -50,6 +61,84 @@ function gBody() return json_decode(file_get_contents('php://input'), true) ?? []; } +function groceryNormalize(string $label): string +{ + $t = trim($label); + if ($t === '') { + return ''; + } + if (function_exists('mb_strtolower')) { + $t = mb_strtolower($t, 'UTF-8'); + } else { + $t = strtolower($t); + } + if (function_exists('mb_strlen') && mb_strlen($t, 'UTF-8') > 500) { + $t = mb_substr($t, 0, 500, 'UTF-8'); + } elseif (strlen($t) > 500) { + $t = substr($t, 0, 500); + } + return $t; +} + +function groceryHistoryMax(PDO $pdo): int +{ + try { + $s = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'"); + $v = $s ? $s->fetchColumn() : false; + $n = ($v !== false && $v !== null && $v !== '') ? (int) $v : 20; + } catch (\Throwable $e) { + $n = 20; + } + return max(1, min(50, $n)); +} + +function groceryTouchHistory(PDO $pdo, string $labelDisplay): void +{ + $norm = groceryNormalize($labelDisplay); + if ($norm === '') { + return; + } + $disp = trim($labelDisplay); + if (function_exists('mb_strlen') && mb_strlen($disp, 'UTF-8') > 500) { + $disp = mb_substr($disp, 0, 500, 'UTF-8'); + } elseif (strlen($disp) > 500) { + $disp = substr($disp, 0, 500); + } + $hash = hash('sha256', $norm, false); + $pdo->prepare( + 'INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?) + ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()' + )->execute([$hash, $disp]); + + $max = groceryHistoryMax($pdo); + $cnt = (int) $pdo->query('SELECT COUNT(*) FROM pf_grocery_history')->fetchColumn(); + if ($cnt <= $max) { + return; + } + $toDel = $cnt - $max; + $stmt = $pdo->prepare('SELECT id FROM pf_grocery_history ORDER BY last_used_at ASC, id ASC LIMIT ?'); + $stmt->execute([$toDel]); + $ids = $stmt->fetchAll(PDO::FETCH_COLUMN); + if (!$ids) { + return; + } + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $pdo->prepare("DELETE FROM pf_grocery_history WHERE id IN ($placeholders)")->execute($ids); +} + +function groceryCurrentNormSet(PDO $pdo): array +{ + $rows = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN); + $set = []; + foreach ($rows as $lab) { + $n = groceryNormalize((string) $lab); + if ($n !== '') { + $set[$n] = true; + } + } + return $set; +} + if ($action === 'items') { if ($method === 'GET') { $rows = $pdo->query( @@ -72,9 +161,17 @@ if ($action === 'items') { if ($label === '') { gErr('Libellé requis'); } + $norm = groceryNormalize($label); + $chk = $pdo->query('SELECT label FROM pf_grocery_items'); + while ($existing = $chk->fetchColumn()) { + if (groceryNormalize((string) $existing) === $norm) { + gErr(function_exists('tr') ? tr('groceries_err_duplicate') : 'Ce produit est déjà dans la liste.'); + } + } $max = (int) $pdo->query('SELECT COALESCE(MAX(position),0) FROM pf_grocery_items')->fetchColumn(); $pdo->prepare('INSERT INTO pf_grocery_items (label, position) VALUES (?,?)')->execute([$label, $max + 1]); $id = (int) $pdo->lastInsertId(); + groceryTouchHistory($pdo, $label); $s = $pdo->prepare('SELECT id, label, in_cart, position, created_at, updated_at FROM pf_grocery_items WHERE id=?'); $s->execute([$id]); $row = $s->fetch(PDO::FETCH_ASSOC); @@ -100,6 +197,7 @@ if ($action === 'items') { gErr('Libellé requis'); } $pdo->prepare('UPDATE pf_grocery_items SET label=?, updated_at=NOW() WHERE id=?')->execute([$label, $id]); + groceryTouchHistory($pdo, $label); gOk(['updated' => true]); } @@ -113,14 +211,50 @@ if ($action === 'items') { } } +if ($action === 'history' && $method === 'GET') { + $max = groceryHistoryMax($pdo); + $rows = $pdo->query( + 'SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT 100' + )->fetchAll(PDO::FETCH_COLUMN); + $onList = groceryCurrentNormSet($pdo); + $labels = []; + foreach ($rows as $disp) { + $disp = (string) $disp; + if (groceryNormalize($disp) === '') { + continue; + } + if (isset($onList[groceryNormalize($disp)])) { + continue; + } + $labels[] = $disp; + if (count($labels) >= $max) { + break; + } + } + gOk(['labels' => $labels, 'history_max' => $max]); +} + if ($action === 'uncheck_all' && $method === 'POST') { $pdo->exec('UPDATE pf_grocery_items SET in_cart=0'); gOk(['updated' => true]); } if ($action === 'delete_picked' && $method === 'POST') { + $labs = $pdo->query('SELECT label FROM pf_grocery_items WHERE in_cart=1')->fetchAll(PDO::FETCH_COLUMN); + foreach ($labs as $lab) { + groceryTouchHistory($pdo, (string) $lab); + } $pdo->exec('DELETE FROM pf_grocery_items WHERE in_cart=1'); gOk(['deleted' => true]); } +if ($action === 'clear_all' && $method === 'POST') { + $labs = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN); + foreach ($labs as $lab) { + groceryTouchHistory($pdo, (string) $lab); + } + $pdo->exec('DELETE FROM pf_grocery_items'); + gOk(['deleted' => true]); +} + gErr('Action inconnue', 404); diff --git a/modules/groceries/assets/groceries.css b/modules/groceries/assets/groceries.css index f3b3971..96c78ae 100644 --- a/modules/groceries/assets/groceries.css +++ b/modules/groceries/assets/groceries.css @@ -87,6 +87,16 @@ border-color: rgba(239, 68, 68, 0.5) !important; color: var(--danger) !important; } +.groceries-toolbar-btn--outline { + border: 1px dashed var(--border-strong) !important; + background: transparent !important; + color: var(--text-muted) !important; +} +.groceries-toolbar-btn--outline:hover { + background: var(--bg-page) !important; + color: var(--text-main) !important; + border-color: var(--text-muted) !important; +} .groceries-section-title { font-size: 0.72rem; @@ -249,3 +259,90 @@ [data-theme='dark'] .groceries-check.checked { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22), 0 2px 8px rgba(0, 0, 0, 0.35); } + +/* Historique produits */ +.groceries-history-card { + margin-top: 1.75rem; + padding: 1.15rem 1.2rem 1.2rem; + border-radius: 14px; + border: 1px solid var(--border-light); + background: var(--bg-panel); + box-shadow: 0 2px 12px rgba(15, 23, 42, 0.05); +} +.groceries-history-h2 { + font-size: 0.95rem; + font-weight: 700; + margin: 0 0 0.35rem; + color: var(--text-main); +} +.groceries-history-meta { + font-size: 0.78rem; + color: var(--text-muted); + margin: 0 0 0.85rem; + line-height: 1.45; +} +.groceries-history-settings-link { + color: var(--primary); + font-weight: 600; + text-decoration: none; +} +.groceries-history-settings-link:hover { + text-decoration: underline; +} +.groceries-history-chips { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} +.groceries-history-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.45rem 0.75rem 0.45rem 0.55rem; + font-size: 0.875rem; + font-weight: 500; + font-family: inherit; + color: var(--text-main); + background: var(--bg-page); + border: 1px solid var(--border-light); + border-radius: 999px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + text-align: left; + max-width: 100%; +} +.groceries-history-chip:hover { + border-color: var(--primary); + background: rgba(59, 130, 246, 0.06); + box-shadow: 0 1px 4px rgba(59, 130, 246, 0.12); +} +.groceries-history-chip-plus { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + background: var(--primary); + color: var(--text-on-primary, #fff); + font-size: 0.85rem; + font-weight: 700; + flex-shrink: 0; +} +.groceries-history-empty { + margin: 0.5rem 0 0; + font-size: 0.82rem; + color: var(--text-muted); + line-height: 1.4; +} + +[data-theme='dark'] .groceries-history-card { + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2); +} +[data-theme='dark'] .groceries-history-chip { + background: var(--bg-page); + border-color: var(--border-light); +} +[data-theme='dark'] .groceries-history-chip:hover { + background: rgba(59, 130, 246, 0.1); +} diff --git a/modules/groceries/assets/groceries.js b/modules/groceries/assets/groceries.js index 84bf2ea..d68e53c 100644 --- a/modules/groceries/assets/groceries.js +++ b/modules/groceries/assets/groceries.js @@ -54,11 +54,12 @@ async function api(action, method = 'GET', data = null, extra = '') { return j.data; } -async function loadItems() { +async function loadAll() { try { - const items = await api('items', 'GET'); + const [items, hist] = await Promise.all([api('items', 'GET'), api('history', 'GET')]); render(items); updateSubtitle(items); + renderHistory(hist); } catch (e) { toast(e.message || T('error_occured', 'Erreur'), 'error'); } @@ -79,6 +80,46 @@ function updateSubtitle(items) { .replace('{cart}', String(cart)); } +function renderHistory(hist) { + const root = document.getElementById('groceries-history-root'); + if (!root) return; + const max = hist && typeof hist.history_max === 'number' ? hist.history_max : 20; + const labels = (hist && hist.labels) || []; + const title = escHtml(T('groceries_history_title', 'Historique')); + const metaTpl = T('groceries_history_meta', 'Jusqu’à {max} produits · '); + const meta = escHtml(metaTpl.replace('{max}', String(max))); + const linkLabel = escHtml(T('groceries_settings_link', 'Paramètres')); + let chips = ''; + labels.forEach((lab) => { + const l = String(lab); + chips += + ''; + }); + const emptyHint = labels.length + ? '' + : '

' + escHtml(T('groceries_history_empty', '')) + '

'; + root.innerHTML = + '
' + + '
' + + '

' + + title + + '

' + + '

' + + meta + + '' + + linkLabel + + '

' + + '
' + + chips + + '
' + + emptyHint + + '
'; +} + function render(items) { const wrap = document.getElementById('groceries-lists'); if (!items.length) { @@ -160,7 +201,19 @@ async function addFromInput() { input.value = ''; input.focus(); toast(T('groceries_added', 'Ajouté')); - loadItems(); + loadAll(); + } catch (e) { + toast(e.message || T('error_occured', 'Erreur'), 'error'); + } +} + +async function addFromHistory(label) { + const t = String(label || '').trim(); + if (!t) return; + try { + await api('items', 'POST', { label: t }); + toast(T('groceries_added', 'Ajouté')); + loadAll(); } catch (e) { toast(e.message || T('error_occured', 'Erreur'), 'error'); } @@ -169,7 +222,7 @@ async function addFromInput() { async function toggleCart(id, next) { try { await api('items', 'PUT', { in_cart: !!parseInt(next, 10) }, '&id=' + id); - loadItems(); + loadAll(); } catch (e) { toast(e.message || T('error_occured', 'Erreur'), 'error'); } @@ -188,7 +241,7 @@ async function editItem(id) { try { await api('items', 'PUT', { label: t }, '&id=' + id); toast(T('groceries_updated', 'Mis à jour')); - loadItems(); + loadAll(); } catch (e) { toast(e.message || T('error_occured', 'Erreur'), 'error'); } @@ -203,7 +256,7 @@ async function deleteItem(id) { try { await api('items', 'DELETE', null, '&id=' + id); toast(T('groceries_deleted', 'Supprimé')); - loadItems(); + loadAll(); } catch (e) { toast(e.message || T('error_occured', 'Erreur'), 'error'); } @@ -218,7 +271,7 @@ async function uncheckAll() { try { await api('uncheck_all', 'POST', {}); toast(T('groceries_reset_next', 'Liste prête pour la prochaine sortie')); - loadItems(); + loadAll(); } catch (e) { toast(e.message || T('error_occured', 'Erreur'), 'error'); } @@ -233,7 +286,22 @@ async function deletePicked() { try { await api('delete_picked', 'POST', {}); toast(T('groceries_picked_removed', 'Articles retirés')); - loadItems(); + loadAll(); + } catch (e) { + toast(e.message || T('error_occured', 'Erreur'), 'error'); + } +} + +async function clearWholeList() { + const ok = await pachaConfirm( + T('groceries_confirm_empty_list_title', 'Vider toute la liste ?'), + T('groceries_confirm_empty_list_body', '') + ); + if (!ok) return; + try { + await api('clear_all', 'POST', {}); + toast(T('groceries_list_cleared', 'Liste vidée')); + loadAll(); } catch (e) { toast(e.message || T('error_occured', 'Erreur'), 'error'); } @@ -249,5 +317,14 @@ document.addEventListener('DOMContentLoaded', () => { } }); } - loadItems(); + const histRoot = document.getElementById('groceries-history-root'); + if (histRoot) { + histRoot.addEventListener('click', (e) => { + const btn = e.target.closest('.groceries-history-chip'); + if (!btn || !histRoot.contains(btn)) return; + const raw = btn.getAttribute('data-label'); + if (raw) addFromHistory(raw); + }); + } + loadAll(); }); diff --git a/settings.php b/settings.php index 2ff0a6c..4e90da6 100644 --- a/settings.php +++ b/settings.php @@ -133,6 +133,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $old) @unlink($old); $success = "Image d'accueil réinitialisée."; } + + if ($action === 'grocery_history_max' && $family_id) { + require_once __DIR__ . '/includes/db.php'; + if (!isset($pdo)) { + $error = "Base famille indisponible."; + } else { + $n = (int) ($_POST['history_max'] ?? 20); + $n = max(1, min(50, $n)); + try { + $pdo->prepare( + "INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('grocery_settings','history_max',?) ON DUPLICATE KEY UPDATE content=VALUES(content)" + )->execute([(string) $n]); + $success = tr('groceries_settings_saved'); + } catch (\Throwable $e) { + $error = tr('groceries_settings_error') . ' ' . $e->getMessage(); + } + } + } if ($action === 'calendar_ios_save') { $username = trim($_POST['icloud_username'] ?? ''); @@ -216,6 +234,21 @@ $calendarIntegration = $meta_pdo->prepare("SELECT username, calendar_url, status $calendarIntegration->execute([$user_id]); $calendarIntegration = $calendarIntegration->fetch(); +$groceryHistoryMaxSetting = 20; +if ($family_id) { + require_once __DIR__ . '/includes/db.php'; + if (isset($pdo)) { + try { + $gv = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'")->fetchColumn(); + if ($gv !== false && $gv !== null && $gv !== '') { + $groceryHistoryMaxSetting = max(1, min(50, (int) $gv)); + } + } catch (\Throwable $e) { + // ignore + } + } +} + $pageTitle = "Paramètres — HouseHub"; $activePage = "settings"; require __DIR__ . '/header.php'; @@ -293,6 +326,28 @@ require __DIR__ . '/header.php'; + +
+

🛒

+

+
+ +
+ + +

+
+ +
+

+ +

+
+ +

📱 Intégration Calendrier iOS (CalDAV)

Configurez ici votre calendrier iCloud pour synchroniser les événements créés dans HouseHub.