diff --git a/CHANGELOG.md b/CHANGELOG.md index b951d57..e2dc4de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. +## [1.7.20] - 2026-05-20 + +### Added +- **Startup health check** — During the splash screen, the app now runs a comprehensive server-side diagnostic before loading: PHP version, required extensions (pdo_sqlite, curl, mbstring, json), `data/` directory writability, SQLite database connection and table integrity, `.env` file presence, Gemini AI key and Bring! token. Results are displayed as an animated checklist (✅ / ⚠️ / ❌). Critical failures (DB, extensions, data dir) block the app with a clear error message and a "Retry" button — the app never starts silently broken. Non-critical warnings (missing Gemini key, Bring! token) are shown as amber items but do not block startup. +- New `?action=health_check` PHP endpoint (early-exit, no rate-limit, no auth). +- New translation keys `startup.*` in all 5 languages (IT, EN, DE, FR, ES). + ## [1.7.19] - 2026-05-19 ### Added diff --git a/api/index.php b/api/index.php index c71b758..ebb8a86 100644 --- a/api/index.php +++ b/api/index.php @@ -106,6 +106,70 @@ if (($_GET['action'] ?? '') === 'ping') { exit; } +// ── Health check — startup diagnostic (no rate-limit, no auth required) ────── +if (($_GET['action'] ?? '') === 'health_check') { + $checks = []; + + // 1. PHP version + $phpOk = version_compare(PHP_VERSION, '8.0.0', '>='); + $checks['php'] = ['ok' => $phpOk, 'value' => PHP_VERSION]; + + // 2. Required PHP extensions + $requiredExts = ['pdo_sqlite', 'curl', 'mbstring', 'json']; + $missingExts = array_filter($requiredExts, fn($e) => !extension_loaded($e)); + $checks['php_extensions'] = ['ok' => empty($missingExts), 'missing' => array_values($missingExts)]; + + // 3. data/ directory writable + $dataDir = __DIR__ . '/../data'; + $dataWritable = is_dir($dataDir) && is_writable($dataDir); + if (!$dataWritable && !is_dir($dataDir)) { + @mkdir($dataDir, 0775, true); + $dataWritable = is_dir($dataDir) && is_writable($dataDir); + } + $checks['data_dir'] = ['ok' => $dataWritable, 'path' => realpath($dataDir) ?: $dataDir]; + + // 4. SQLite DB accessible + $dbOk = false; $dbError = ''; + try { + $dbPath = $dataDir . '/dispensa.db'; + $pdo = new PDO('sqlite:' . $dbPath, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $pdo->query('SELECT 1'); + // Check at least inventory table exists + $tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN); + $dbOk = in_array('inventory', $tables); + if (!$dbOk) $dbError = 'Missing tables (fresh install?)'; + } catch (\Throwable $e) { + $dbError = $e->getMessage(); + } + $checks['database'] = ['ok' => $dbOk, 'error' => $dbError ?: null]; + + // 5. .env loaded + Gemini key present + $envPath = __DIR__ . '/../.env'; + $envLoaded = file_exists($envPath); + $geminiKey = env('GEMINI_API_KEY'); + $checks['env_file'] = ['ok' => $envLoaded]; + $checks['gemini_key'] = ['ok' => !empty($geminiKey)]; + + // 6. Bring! token (optional — warning only) + $bringToken = env('BRING_ACCESS_TOKEN'); + $checks['bring_token'] = ['ok' => !empty($bringToken), 'optional' => true]; + + // 7. cURL available + internet reachable (light check, no actual call) + $curlOk = function_exists('curl_init'); + $checks['curl'] = ['ok' => $curlOk]; + + // Overall: critical = php, php_extensions, data_dir, database + $critical = ['php', 'php_extensions', 'data_dir', 'database']; + $allOk = array_reduce($critical, fn($c, $k) => $c && ($checks[$k]['ok'] ?? false), true); + + header('Content-Type: application/json'); + echo json_encode(['ok' => $allOk, 'checks' => $checks], JSON_UNESCAPED_UNICODE); + exit; +} + // ===== RATE LIMITING ===== /** * Simple file-based rate limiter. diff --git a/assets/css/style.css b/assets/css/style.css index 8f6585b..830dcf0 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -116,6 +116,55 @@ body { letter-spacing: 0.5px; margin-top: -8px; } +/* ── Startup health check list ─────────────────────────────────────── */ +.preloader-checks { + display: flex; + flex-direction: column; + gap: 6px; + width: 240px; + max-width: 90vw; + animation: zwFadeIn 0.25s ease; +} +.preloader-check-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + color: rgba(255,255,255,0.80); + background: rgba(255,255,255,0.07); + border-radius: 8px; + padding: 5px 10px; + transition: background 0.2s; +} +.preloader-check-row[data-state="ok"] { background: rgba(74,222,128,0.10); color: rgba(255,255,255,0.92); } +.preloader-check-row[data-state="warn"] { background: rgba(251,191,36,0.12); color: rgba(255,255,255,0.92); } +.preloader-check-row[data-state="error"] { background: rgba(239,68,68,0.15); color: #fca5a5; } +.pck-icon { font-size: 1rem; line-height: 1; flex-shrink: 0; } +.pck-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.preloader-error-msg { + color: #fca5a5; + background: rgba(239,68,68,0.18); + border: 1px solid rgba(239,68,68,0.4); + border-radius: 10px; + padding: 10px 16px; + font-size: 0.88rem; + text-align: center; + max-width: 280px; + line-height: 1.4; + animation: zwFadeIn 0.3s ease; +} +.preloader-retry-btn { + background: #ef4444; + color: #fff; + border: none; + border-radius: 8px; + padding: 9px 22px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + animation: zwFadeIn 0.3s ease; +} +.preloader-retry-btn:active { opacity: 0.8; } .header-logo-icon { height: 28px; width: auto; diff --git a/assets/js/app.js b/assets/js/app.js index 48c0001..27c4008 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -14873,12 +14873,117 @@ function _heartbeatRetry() { _runHeartbeat(); } +// ── Startup / Splash health check ──────────────────────────────────────────── +/** + * Run a comprehensive server-side diagnostic during the splash screen. + * Returns true if the app can proceed, false if a critical check failed. + */ +async function _runStartupCheck() { + const checksEl = document.getElementById('preloader-checks'); + const errorEl = document.getElementById('preloader-error-msg'); + const retryBtn = document.getElementById('preloader-retry-btn'); + const spinnerEl = document.getElementById('preloader-spinner'); + + if (!checksEl) return true; // preloader already removed + + // Label map (populated again after translations are available) + const label = (key, fallback) => { + try { return t('startup.' + key); } catch(e) { return fallback; } + }; + + // Show check list container + checksEl.innerHTML = ''; + checksEl.style.display = ''; + + // Helper: add / update a row + const addRow = (id, text, state) => { + const icon = state === 'ok' ? '✅' : state === 'warn' ? '⚠️' : state === 'loading' ? '⏳' : '❌'; + let row = document.getElementById('startup-row-' + id); + if (!row) { + row = document.createElement('div'); + row.id = 'startup-row-' + id; + row.className = 'preloader-check-row'; + checksEl.appendChild(row); + } + row.innerHTML = `${text}`; + row.dataset.state = state; + }; + + // Show loading rows immediately so the splash looks active + const checkDefs = [ + { id: 'php', key: 'check_php', fallback: 'PHP' }, + { id: 'exts', key: 'check_exts', fallback: 'Estensioni PHP' }, + { id: 'data', key: 'check_data_dir', fallback: 'Cartella dati' }, + { id: 'db', key: 'check_db', fallback: 'Database' }, + { id: 'env', key: 'check_env', fallback: 'Configurazione' }, + { id: 'gemini', key: 'check_gemini', fallback: 'Chiave Gemini AI' }, + { id: 'bring', key: 'check_bring', fallback: 'Bring! token' }, + ]; + checkDefs.forEach(c => addRow(c.id, label(c.key, c.fallback), 'loading')); + + // Do the actual request + let result = null; + try { + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), 8000); + const resp = await fetch('api/index.php?action=health_check', { signal: ctrl.signal }); + clearTimeout(tid); + result = await resp.json(); + } catch(e) { + // Network or timeout error — cannot reach server at all + if (spinnerEl) spinnerEl.style.display = 'none'; + checksEl.style.display = 'none'; + const msg = label('error_network', 'Impossibile contattare il server. Controlla la connessione.'); + errorEl.textContent = msg; + errorEl.style.display = ''; + retryBtn.style.display = ''; + return false; + } + + const c = result.checks || {}; + + // Update each row + addRow('php', label('check_php', 'PHP') + (c.php?.value ? ` ${c.php.value}` : ''), c.php?.ok ? 'ok' : 'error'); + addRow('exts', label('check_exts', 'Estensioni PHP') + (c.php_extensions?.missing?.length ? ` (mancanti: ${c.php_extensions.missing.join(', ')})` : ''), c.php_extensions?.ok ? 'ok' : 'error'); + addRow('data', label('check_data_dir', 'Cartella dati'), c.data_dir?.ok ? 'ok' : 'error'); + addRow('db', label('check_db', 'Database') + (c.database?.error ? ` (${c.database.error})` : ''), c.database?.ok ? 'ok' : 'error'); + addRow('env', label('check_env', 'Configurazione'), c.env_file?.ok ? 'ok' : 'warn'); + addRow('gemini', label('check_gemini', 'Chiave Gemini AI'), c.gemini_key?.ok ? 'ok' : 'warn'); + addRow('bring', label('check_bring', 'Bring! token'), c.bring_token?.ok ? 'ok' : 'warn'); + + const allOk = result.ok === true; + + if (allOk) { + // Brief pause so the user sees the green checkmarks, then hide checks + await new Promise(r => setTimeout(r, 1200)); + checksEl.style.display = 'none'; + return true; + } else { + // Critical failure — keep preloader visible, hide spinner, show error + if (spinnerEl) spinnerEl.style.display = 'none'; + const errMsg = label('critical_error', 'Errore critico: l\'app non può avviarsi. Controlla i log del server.'); + errorEl.textContent = errMsg; + errorEl.style.display = ''; + retryBtn.style.display = ''; + return false; + } +} + +/** Retry button handler in the startup error screen. */ +function _startupRetry() { + location.reload(); +} + /** Start the heartbeat loop (called once from _initApp). */ function startHeartbeat() { _runHeartbeat(); // immediate first probe } async function _initApp() { + // ── Startup health check (runs during splash, blocks app if critical) ────── + const _startupOk = await _runStartupCheck(); + if (!_startupOk) return; // preloader stays visible with error; app does not start + // Check for setup wizard resume (after language change) const resumeStep = localStorage.getItem('evershelf_setup_step'); const resumeData = localStorage.getItem('evershelf_setup_data'); diff --git a/index.html b/index.html index 4fbacb9..30d5f01 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@