feat: v1.1.0 - Docker, i18n, setup wizard, rate limiting, OpenAPI

New features:
- Docker support (Dockerfile + docker-compose.yml)
- GitHub Actions CI pipeline (PHP lint, JS lint, Docker build, i18n validation)
- Internationalization system with 3 languages (it, en, de) and 347 translation keys
- First-run setup wizard (4-step configuration)
- File-based API rate limiting (120/15/5 req/min tiers)
- OpenAPI 3.1.0 specification for all 43 API endpoints
- CONTRIBUTING.md with translation and development guide
- Screenshots directory placeholder

Modified:
- README.md: Docker badges, install instructions, translations section
- api/index.php: rate limiting middleware
- assets/js/app.js: i18n system, setup wizard, t() function
- assets/css/style.css: setup wizard styles
- index.html: data-i18n attributes, setup wizard overlay, language settings
- .gitignore: rate_limits exclusion
This commit is contained in:
dadaloop82
2026-04-10 06:03:11 +00:00
parent e0956c6043
commit d13f744aea
16 changed files with 2993 additions and 102 deletions
+110
View File
@@ -5072,3 +5072,113 @@ body {
background: rgba(255,255,255,0.25);
transform: scale(0.92);
}
/* ===== SETUP WIZARD ===== */
.setup-wizard-content {
max-width: 480px;
width: 95%;
margin: auto;
border-radius: 20px;
overflow: hidden;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.setup-header {
background: var(--primary);
color: #fff;
padding: 24px 20px 16px;
text-align: center;
}
.setup-header h2 {
margin: 0 0 12px;
font-size: 1.5rem;
}
.setup-progress {
display: flex;
gap: 8px;
justify-content: center;
}
.setup-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255,255,255,0.3);
transition: background 0.3s, transform 0.3s;
}
.setup-dot.active {
background: #fff;
transform: scale(1.3);
}
.setup-dot.done {
background: var(--success-light);
}
.setup-body {
padding: 24px 20px;
overflow-y: auto;
flex: 1;
}
.setup-body h3 {
margin: 0 0 8px;
font-size: 1.2rem;
}
.setup-body p {
color: #666;
margin: 0 0 16px;
font-size: 0.9rem;
line-height: 1.5;
}
.setup-body .form-group {
margin-bottom: 16px;
}
.setup-body .form-input {
width: 100%;
box-sizing: border-box;
}
.setup-lang-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
margin: 12px 0;
}
.setup-lang-btn {
padding: 14px 12px;
border: 2px solid #e2e8f0;
border-radius: 12px;
background: #fff;
cursor: pointer;
text-align: center;
font-size: 1rem;
transition: border-color 0.2s, background 0.2s;
}
.setup-lang-btn.selected {
border-color: var(--primary);
background: rgba(45,80,22,0.08);
}
.setup-lang-btn:hover {
border-color: var(--primary-light);
}
.setup-footer {
padding: 16px 20px;
display: flex;
justify-content: space-between;
border-top: 1px solid #e2e8f0;
gap: 12px;
}
.setup-footer .btn {
flex: 1;
padding: 12px;
font-size: 1rem;
}
.setup-skip-link {
display: block;
text-align: center;
color: #999;
font-size: 0.8rem;
margin-top: 12px;
cursor: pointer;
text-decoration: underline;
}
.setup-skip-link:hover {
color: #666;
}
+31
View File
@@ -0,0 +1,31 @@
# Screenshots
Add screenshots here to showcase the app in the README.
## Recommended screenshots
Take screenshots of these pages for the README `Screenshots` section:
1. **dashboard.png** — Main dashboard with stats cards and expiry alerts
2. **inventory.png** — Inventory list with product cards
3. **scan.png** — Barcode scanning page
4. **recipe.png** — Generated recipe with cooking mode
5. **shopping.png** — Shopping list with smart predictions
6. **chat.png** — Gemini Chef AI conversation
7. **settings.png** — Settings page
8. **setup.png** — First-run setup wizard
## How to add
1. Take screenshots on a mobile device or using Chrome DevTools device emulation
2. Recommended size: 375×812 (iPhone X viewport)
3. Save as PNG with descriptive names
4. Update the README.md `## Screenshots` section to reference them:
```markdown
## Screenshots
| Dashboard | Inventory | Scan |
|:-:|:-:|:-:|
| ![Dashboard](assets/img/screenshots/dashboard.png) | ![Inventory](assets/img/screenshots/inventory.png) | ![Scan](assets/img/screenshots/scan.png) |
```
+343 -34
View File
@@ -57,6 +57,123 @@ window.addEventListener('unhandledrejection', function(e) {
// ===== CONFIGURATION =====
const API_BASE = 'api/index.php';
// ===== i18n TRANSLATION SYSTEM =====
let _i18nStrings = null; // current language translations (flat)
let _i18nFallback = null; // Italian fallback (flat)
let _currentLang = localStorage.getItem('dispensa_lang') || navigator.language?.slice(0, 2) || 'it';
const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch' };
if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'it';
// Flatten nested JSON: { a: { b: "x" } } → { "a.b": "x" }
function _flattenI18n(obj, prefix = '') {
const result = {};
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
Object.assign(result, _flattenI18n(v, key));
} else {
result[key] = v;
}
}
return result;
}
// Translation function: t('toast.thrown_away', {name: 'Latte'})
function t(key, params) {
let str = (_i18nStrings && _i18nStrings[key]) || (_i18nFallback && _i18nFallback[key]) || key;
if (params) {
for (const [k, v] of Object.entries(params)) {
str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
}
}
return str;
}
// Load translations from JSON files
async function loadTranslations(lang) {
lang = lang || _currentLang;
try {
// Always load Italian as fallback
if (!_i18nFallback) {
const fbRes = await fetch(`translations/it.json?v=${Date.now()}`);
if (fbRes.ok) _i18nFallback = _flattenI18n(await fbRes.json());
}
if (lang === 'it') {
_i18nStrings = _i18nFallback;
} else {
const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${Date.now()}`);
if (res.ok) _i18nStrings = _flattenI18n(await res.json());
else _i18nStrings = _i18nFallback;
}
_currentLang = lang;
localStorage.setItem('dispensa_lang', lang);
_applyI18nToLabels();
translatePage();
} catch (e) {
console.warn('i18n: Failed to load translations for', lang, e);
_i18nStrings = _i18nFallback;
}
}
// Update LOCATIONS / SHOPPING_SECTIONS labels from translations
function _applyI18nToLabels() {
if (!_i18nStrings) return;
for (const key of Object.keys(LOCATIONS)) {
const tKey = `locations.${key}`;
if (_i18nStrings[tKey]) LOCATIONS[key].label = _i18nStrings[tKey];
}
for (const sec of SHOPPING_SECTIONS) {
const tKey = `shopping_sections.${sec.key}`;
if (_i18nStrings[tKey]) sec.label = _i18nStrings[tKey];
}
}
// Translate all elements with data-i18n attributes
function translatePage() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (key) el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-html]').forEach(el => {
const key = el.getAttribute('data-i18n-html');
if (key) el.innerHTML = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (key) el.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (key) el.title = t(key);
});
// Update HTML lang attribute
document.documentElement.lang = _currentLang;
// Populate language selector if present
_populateLanguageSelector();
}
// Populate the language selector dropdown
function _populateLanguageSelector() {
const sel = document.getElementById('setting-language');
if (!sel) return;
sel.innerHTML = '';
for (const [code, name] of Object.entries(_SUPPORTED_LANGS)) {
const opt = document.createElement('option');
opt.value = code;
opt.textContent = name;
if (code === _currentLang) opt.selected = true;
sel.appendChild(opt);
}
}
// Change language and reload the page
function changeLanguage(lang) {
if (lang === _currentLang) return;
localStorage.setItem('dispensa_lang', lang);
location.reload();
}
const LOCATIONS = {
'dispensa': { icon: '🗄️', label: 'Dispensa' },
'frigo': { icon: '🧊', label: 'Frigo' },
@@ -822,21 +939,21 @@ function addAppliance() {
const s = getSettings();
if (!s.appliances) s.appliances = [];
if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) {
showToast('Elettrodomestico già presente', 'error');
showToast(t('error.appliance_exists'), 'error');
return;
}
s.appliances.push(name);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
input.value = '';
showToast('Elettrodomestico aggiunto', 'success');
showToast(t('toast.appliance_added'), 'success');
}
function addApplianceQuick(name) {
const s = getSettings();
if (!s.appliances) s.appliances = [];
if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) {
showToast('Già presente', 'error');
showToast(t('error.already_exists'), 'error');
return;
}
s.appliances.push(name);
@@ -1364,7 +1481,7 @@ function confirmReviewItem(inventoryId) {
}
}, 300);
}
showToast('✓ Quantità confermata', 'success');
showToast(t('toast.quantity_confirmed'), 'success');
}
function editReviewItem(inventoryId, productId) {
@@ -1747,7 +1864,7 @@ async function quickUse(productId, location) {
} catch (err) {
showLoading(false);
console.error('quickUse error:', err);
showToast('Errore nel caricamento del prodotto', 'error');
showToast(t('error.loading'), 'error');
}
}
@@ -1755,7 +1872,7 @@ async function deleteInventoryItem(id) {
if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast('Prodotto rimosso', 'success');
showToast(t('toast.product_removed'), 'success');
refreshCurrentPage();
}
}
@@ -1779,7 +1896,7 @@ function editInventoryItem(id) {
const item = currentInventory.find(i => i.id === id);
if (!item) {
closeModal();
showToast('Prodotto non trovato', 'error');
showToast(t('error.not_found'), 'error');
return;
}
@@ -2359,7 +2476,7 @@ function submitManualBarcode() {
const input = document.getElementById('manual-barcode-input');
const barcode = (input.value || '').trim();
if (!barcode) {
showToast('Inserisci un codice a barre', 'error');
showToast(t('error.barcode_empty'), 'error');
input.focus();
return;
}
@@ -2377,7 +2494,7 @@ async function submitQuickName() {
const input = document.getElementById('quick-product-name');
const name = (input.value || '').trim();
if (!name || name.length < 2) {
showToast('Scrivi almeno 2 caratteri', 'error');
showToast(t('error.min_chars'), 'error');
input.focus();
return;
}
@@ -2402,7 +2519,7 @@ async function submitQuickName() {
} catch (err) {
showLoading(false);
console.error('Quick name search error:', err);
showToast('Errore nella ricerca', 'error');
showToast(t('error.search_short'), 'error');
}
}
@@ -2506,7 +2623,7 @@ async function createQuickProduct(name) {
} catch (err) {
showLoading(false);
console.error('Quick product creation error:', err);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -2794,7 +2911,7 @@ async function submitProduct(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3270,7 +3387,7 @@ async function deleteActionInventoryItem(id) {
if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast('Prodotto rimosso', 'success');
showToast(t('toast.product_removed'), 'success');
showProductAction(); // Refresh the action page
}
}
@@ -3371,7 +3488,7 @@ async function throwAll() {
}
} catch(e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3396,7 +3513,7 @@ async function throwPartial() {
}
} catch(e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3412,7 +3529,7 @@ function toggleActionEdit() {
async function saveEditedProductInfo() {
const name = (document.getElementById('edit-action-name')?.value || '').trim();
if (!name) {
showToast('Inserisci il nome del prodotto', 'error');
showToast(t('product.name_required'), 'error');
document.getElementById('edit-action-name')?.focus();
return;
}
@@ -3446,7 +3563,7 @@ async function saveEditedProductInfo() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -3915,7 +4032,7 @@ async function submitAdd(e) {
}
showToast(`${currentProduct.name} aggiunto!${qtyInfo}`, 'success');
if (result.removed_from_bring) {
setTimeout(() => showToast('🛒 Rimosso dalla lista della spesa', 'info'), 1500);
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
} else if (shoppingItems.length > 0 && shoppingListUUID) {
// PHP matching may have missed the item (custom name / no catalog match) —
// try a client-side fuzzy remove using the already-loaded shoppingItems
@@ -3928,7 +4045,7 @@ async function submitAdd(e) {
}).then(r => {
if (r && r.success) {
shoppingItems = shoppingItems.filter(i => i !== match);
setTimeout(() => showToast('🛒 Rimosso dalla lista della spesa', 'info'), 1500);
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
}
}).catch(() => {});
}
@@ -3961,7 +4078,7 @@ async function submitAdd(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4384,7 +4501,7 @@ async function addLowStockToBring(productName) {
if (data.success && data.added > 0) {
showToast('🛒 Aggiunto alla lista della spesa!', 'success');
} else if (data.success && data.skipped > 0) {
showToast('️ Già nella lista della spesa', 'info');
showToast(t('shopping.already_in_list_short'), 'info');
}
} catch (e) {
showToast('Errore nell\'aggiunta a Bring!', 'error');
@@ -4512,7 +4629,7 @@ async function submitUseAll() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4556,7 +4673,7 @@ async function submitUse(e) {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4770,12 +4887,12 @@ async function selectAIMatch(idx) {
showProductAction();
} else {
showLoading(false);
showToast('Errore nel salvataggio', 'error');
showToast(t('error.save'), 'error');
}
} catch (err) {
showLoading(false);
console.error('AI match select error:', err);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -4804,7 +4921,7 @@ async function saveAIProductDirect() {
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -5062,7 +5179,7 @@ async function selectProductForAction(productId) {
showProductAction();
} else {
showLoading(false);
showToast('Prodotto non trovato', 'error');
showToast(t('error.not_found'), 'error');
}
} catch (err) {
showLoading(false);
@@ -5663,7 +5780,7 @@ async function addSmartToBring() {
}
} catch (e) {
showLoading(false);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -6191,7 +6308,7 @@ async function removeBringItem(idx) {
if (data.success) {
shoppingItems.splice(idx, 1);
renderShoppingItems();
showToast('Rimosso dalla lista', 'success');
showToast(t('toast.removed_from_list_short'), 'success');
logOperation('bring_manual_remove', { name: item.name });
// Update dashboard shopping count
loadShoppingCount();
@@ -6242,7 +6359,7 @@ async function generateSuggestions() {
btn.disabled = false;
btn.innerHTML = '🤖 Suggerisci cosa comprare';
console.error('Suggestion error:', err);
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -6328,7 +6445,7 @@ async function addSelectedSuggestions() {
showToast(data.error || 'Errore', 'error');
}
} catch (err) {
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
btn.disabled = false;
@@ -7186,7 +7303,7 @@ async function submitRecipeUse(useAll) {
console.error('Recipe use error:', err);
btn.disabled = false;
btn.textContent = '📦 Usa';
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
_recipeUseContext = null;
}
@@ -7921,7 +8038,7 @@ async function generateRecipe() {
console.error('Recipe error:', err);
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
showToast('Errore di connessione', 'error');
showToast(t('error.connection'), 'error');
}
}
@@ -8694,6 +8811,198 @@ function initInactivityWatcher() {
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
// Load translations first, then initialize the app
loadTranslations(_currentLang).then(() => {
_initApp();
}).catch(() => {
_initApp(); // fallback: initialize even if translations fail
});
});
// ===== SETUP WIZARD =====
let _setupStep = 0;
const _setupData = { lang: _currentLang, gemini_key: '', bring_email: '', bring_password: '' };
function _isFirstRun() {
return !localStorage.getItem('dispensa_setup_done');
}
function _setupSteps() {
return [
{
title: '🌐 ' + t('settings.language.label'),
desc: t('settings.language.hint'),
render: () => {
let html = '<div class="setup-lang-grid">';
for (const [code, name] of Object.entries(_SUPPORTED_LANGS)) {
const sel = code === _setupData.lang ? ' selected' : '';
html += `<button class="setup-lang-btn${sel}" onclick="_setupSelectLang('${code}')">${name}</button>`;
}
html += '</div>';
return html;
}
},
{
title: '🤖 Google Gemini AI',
desc: t('settings.gemini.hint'),
render: () => `
<div class="form-group">
<label>${t('settings.gemini.key_label')}</label>
<input type="text" id="setup-gemini-key" class="form-input" placeholder="AIza..." value="${_setupData.gemini_key}">
<p style="color:#999;font-size:0.8rem;margin-top:8px">
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener"> Get a free API key from Google AI Studio</a>
</p>
</div>
<span class="setup-skip-link" onclick="_setupSkipStep()">${t('btn.cancel')} ${_currentLang === 'it' ? 'configura dopo' : 'configure later'}</span>
`
},
{
title: '🛒 Bring! Shopping List',
desc: t('settings.bring.hint'),
render: () => `
<div class="form-group">
<label>${t('settings.bring.email_label')}</label>
<input type="email" id="setup-bring-email" class="form-input" placeholder="email@example.com" value="${_setupData.bring_email}">
</div>
<div class="form-group">
<label>${t('settings.bring.password_label')}</label>
<input type="password" id="setup-bring-password" class="form-input" placeholder="Password" value="${_setupData.bring_password}">
</div>
<span class="setup-skip-link" onclick="_setupSkipStep()">${t('btn.cancel')} ${_currentLang === 'it' ? 'configura dopo' : 'configure later'}</span>
`
},
{
title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : 'All set!'),
desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.'
: _currentLang === 'de' ? 'Die Konfiguration ist abgeschlossen. Du kannst diese Einstellungen jederzeit ändern.'
: 'Setup is complete. You can always change these settings from the Settings page.',
render: () => {
let summary = '<div style="text-align:center;font-size:2.5rem;margin:12px 0">🎉</div>';
return summary;
}
}
];
}
function showSetupWizard() {
_setupStep = 0;
document.getElementById('setup-wizard').style.display = '';
_renderSetupStep();
}
function _renderSetupStep() {
const steps = _setupSteps();
const step = steps[_setupStep];
// Progress dots
const dotsHtml = steps.map((_, i) => {
let cls = 'setup-dot';
if (i < _setupStep) cls += ' done';
if (i === _setupStep) cls += ' active';
return `<div class="${cls}"></div>`;
}).join('');
document.getElementById('setup-progress').innerHTML = dotsHtml;
// Body
document.getElementById('setup-body').innerHTML = `<h3>${step.title}</h3><p>${step.desc}</p>${step.render()}`;
// Buttons
const prevBtn = document.getElementById('setup-prev');
const nextBtn = document.getElementById('setup-next');
prevBtn.style.display = _setupStep > 0 ? '' : 'none';
prevBtn.textContent = t('btn.back');
if (_setupStep === steps.length - 1) {
nextBtn.textContent = _currentLang === 'it' ? '🚀 Inizia!' : _currentLang === 'de' ? '🚀 Los geht\'s!' : '🚀 Start!';
} else {
nextBtn.textContent = _currentLang === 'it' ? 'Avanti →' : _currentLang === 'de' ? 'Weiter →' : 'Next →';
}
}
function _setupSelectLang(lang) {
_setupData.lang = lang;
document.querySelectorAll('.setup-lang-btn').forEach(b => b.classList.remove('selected'));
event.target.classList.add('selected');
}
function _setupSkipStep() {
_setupStep++;
_renderSetupStep();
}
function _setupCollectCurrent() {
if (_setupStep === 1) {
const el = document.getElementById('setup-gemini-key');
if (el) _setupData.gemini_key = el.value.trim();
} else if (_setupStep === 2) {
const email = document.getElementById('setup-bring-email');
const pass = document.getElementById('setup-bring-password');
if (email) _setupData.bring_email = email.value.trim();
if (pass) _setupData.bring_password = pass.value.trim();
}
}
function setupWizardNav(dir) {
_setupCollectCurrent();
const steps = _setupSteps();
if (dir === 1 && _setupStep === steps.length - 1) {
// Finish wizard
_finishSetup();
return;
}
// If language changed, apply it
if (_setupStep === 0 && dir === 1 && _setupData.lang !== _currentLang) {
localStorage.setItem('dispensa_lang', _setupData.lang);
localStorage.setItem('dispensa_setup_step', '1');
localStorage.setItem('dispensa_setup_data', JSON.stringify(_setupData));
location.reload();
return;
}
_setupStep = Math.max(0, Math.min(steps.length - 1, _setupStep + dir));
_renderSetupStep();
}
async function _finishSetup() {
// Save settings
const s = getSettings();
if (_setupData.gemini_key) s.gemini_key = _setupData.gemini_key;
if (_setupData.bring_email) s.bring_email = _setupData.bring_email;
if (_setupData.bring_password) s.bring_password = _setupData.bring_password;
saveSettingsToStorage(s);
// Save server-side settings (.env)
try {
await api('save_settings', {}, 'POST', {
gemini_key: _setupData.gemini_key,
bring_email: _setupData.bring_email,
bring_password: _setupData.bring_password
});
} catch(e) { /* will work locally */ }
localStorage.setItem('dispensa_setup_done', '1');
localStorage.removeItem('dispensa_setup_step');
localStorage.removeItem('dispensa_setup_data');
document.getElementById('setup-wizard').style.display = 'none';
}
function _initApp() {
// Check for setup wizard resume (after language change)
const resumeStep = localStorage.getItem('dispensa_setup_step');
const resumeData = localStorage.getItem('dispensa_setup_data');
if (resumeStep) {
try { Object.assign(_setupData, JSON.parse(resumeData)); } catch(e) {}
_setupStep = parseInt(resumeStep) || 0;
localStorage.removeItem('dispensa_setup_step');
localStorage.removeItem('dispensa_setup_data');
document.getElementById('setup-wizard').style.display = '';
_renderSetupStep();
} else if (_isFirstRun()) {
showSetupWizard();
}
// Migrate old session-based flags to time-based
if (sessionStorage.getItem('_autoAddedCritical')) {
sessionStorage.removeItem('_autoAddedCritical');
@@ -8738,7 +9047,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Silent background sync: update urgency specs on Bring and add missing critical items
// Runs once at startup (time-gated: max every 10 min) without affecting the UI
_backgroundBringSync();
});
}
/**
* Background sync at startup: