diff --git a/nginx/html/css/style.css b/nginx/html/css/style.css index d001b91..330abc4 100644 --- a/nginx/html/css/style.css +++ b/nginx/html/css/style.css @@ -377,3 +377,40 @@ td input[type="date"] { border-top: 1px solid var(--border); margin: 1.25rem 0; } + +/* ── Offline banner ───────────────────────────────────────────────────────── */ +#offline-banner { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 0.5rem 1.25rem; + border-radius: 99px; + font-size: 0.875rem; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +/* ── Session expiry warning ───────────────────────────────────────────────── */ +#session-warning { + background: #fff3cd; + color: #856404; + border-bottom: 1px solid #ffc107; + padding: 0.5rem 1.5rem; + font-size: 0.875rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} +#session-warning button { + background: none; + border: none; + font-size: 1rem; + cursor: pointer; + color: inherit; + padding: 0 0.25rem; + line-height: 1; +} diff --git a/nginx/html/js/api.js b/nginx/html/js/api.js index 9c09125..b753e35 100644 --- a/nginx/html/js/api.js +++ b/nginx/html/js/api.js @@ -6,10 +6,7 @@ const API = { const headers = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(url, { - headers, - ...options, - }); + const res = await fetch(url, { headers, ...options }); if (res.status === 401) { localStorage.removeItem('token'); @@ -25,12 +22,53 @@ const API = { return res.json(); }, - get: (url) => API._fetch(url), - post: (url, data) => API._fetch(url, { method: 'POST', body: JSON.stringify(data) }), - put: (url, data) => API._fetch(url, { method: 'PUT', body: JSON.stringify(data) }), - del: (url) => API._fetch(url, { method: 'DELETE' }), + // GET requests retry up to 3 times with exponential backoff on network errors or 5xx. + // Mutating methods (POST/PUT/DELETE) are not retried — they are not idempotent. + async _fetchWithRetry(url, options = {}) { + const isReadOnly = !options.method || options.method === 'GET'; + const delays = [500, 1000, 2000]; + let lastErr; + const attempts = isReadOnly ? 3 : 1; + for (let i = 0; i < attempts; i++) { + try { + return await API._fetch(url, options); + } catch (err) { + lastErr = err; + // Don't retry client errors (4xx) or if it's the last attempt + const is5xx = err.message.match(/\(5\d\d\)/); + if ((!isReadOnly || (!is5xx && !err.message.includes('Failed to fetch'))) || i === attempts - 1) throw err; + await new Promise(r => setTimeout(r, delays[i])); + } + } + throw lastErr; + }, + + get: (url) => API._fetchWithRetry(url), + post: (url, data) => API._fetchWithRetry(url, { method: 'POST', body: JSON.stringify(data) }), + put: (url, data) => API._fetchWithRetry(url, { method: 'PUT', body: JSON.stringify(data) }), + del: (url) => API._fetchWithRetry(url, { method: 'DELETE' }), }; +// ── Offline indicator ───────────────────────────────────────────────────────── + +function _setOfflineBanner(offline) { + let banner = document.getElementById('offline-banner'); + if (offline) { + if (!banner) { + banner = document.createElement('div'); + banner.id = 'offline-banner'; + banner.textContent = 'You are offline — changes cannot be saved'; + document.body.prepend(banner); + } + } else { + if (banner) banner.remove(); + } +} + +window.addEventListener('online', () => _setOfflineBanner(false)); +window.addEventListener('offline', () => _setOfflineBanner(true)); +document.addEventListener('DOMContentLoaded', () => _setOfflineBanner(!navigator.onLine)); + // Show a timed success or error message inside a .message element function showMessage(el, text, type = 'success') { el.textContent = text; diff --git a/nginx/html/js/auth.js b/nginx/html/js/auth.js index bb29330..93da8f9 100644 --- a/nginx/html/js/auth.js +++ b/nginx/html/js/auth.js @@ -87,10 +87,26 @@ function buildTimezoneOptions(selected) { // ── Nav + settings modal ────────────────────────────────────────────────────── +function _checkSessionExpiry(user) { + const msLeft = (user.exp * 1000) - Date.now(); + const hoursLeft = msLeft / (1000 * 60 * 60); + if (hoursLeft > 24) return; + + const warning = document.createElement('div'); + warning.id = 'session-warning'; + const label = hoursLeft < 1 + ? 'Your session expires in less than an hour — please log out and back in.' + : `Your session expires in ${Math.floor(hoursLeft)} hours — please log out and back in.`; + warning.innerHTML = `${label}`; + document.querySelector('.nav')?.insertAdjacentElement('afterend', warning); +} + function initNav() { const user = Auth.requireAuth(); if (!user) return; + _checkSessionExpiry(user); + const nav = document.querySelector('.nav'); if (!nav) return; @@ -201,8 +217,8 @@ async function submitPasswordChange() { msgEl.className = 'message error visible'; return; } - if (newPw.length < 6) { - msgEl.textContent = 'Password must be at least 6 characters'; + if (newPw.length < 10) { + msgEl.textContent = 'Password must be at least 10 characters'; msgEl.className = 'message error visible'; return; } diff --git a/nginx/html/js/budget.js b/nginx/html/js/budget.js index 2b05f5a..9faf21c 100644 --- a/nginx/html/js/budget.js +++ b/nginx/html/js/budget.js @@ -231,12 +231,14 @@ document.addEventListener('DOMContentLoaded', () => { feedForm.addEventListener('submit', async (e) => { e.preventDefault(); + const btn = feedForm.querySelector('[type=submit]'); const data = { date: document.getElementById('date').value, bags: parseFloat(bagsInput.value), price_per_bag: parseFloat(priceInput.value), notes: document.getElementById('notes').value.trim() || null, }; + btn.disabled = true; try { await API.post('/api/feed', data); showMessage(msg, 'Feed purchase saved!'); @@ -246,16 +248,20 @@ document.addEventListener('DOMContentLoaded', () => { loadBudget(); } catch (err) { showMessage(msg, `Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; } }); otherForm.addEventListener('submit', async (e) => { e.preventDefault(); + const btn = otherForm.querySelector('[type=submit]'); const data = { date: document.getElementById('other-date').value, total: parseFloat(document.getElementById('other-total').value), notes: document.getElementById('other-notes').value.trim() || null, }; + btn.disabled = true; try { await API.post('/api/other', data); showMessage(msg, 'Purchase saved!'); @@ -264,6 +270,8 @@ document.addEventListener('DOMContentLoaded', () => { loadBudget(); } catch (err) { showMessage(msg, `Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; } }); diff --git a/nginx/html/js/dashboard.js b/nginx/html/js/dashboard.js index 6ffd148..a4929e4 100644 --- a/nginx/html/js/dashboard.js +++ b/nginx/html/js/dashboard.js @@ -1,7 +1,7 @@ let eggChart = null; function buildChart(eggs) { - const today = new Date(); + const tz = (typeof Auth !== 'undefined' && Auth.getUser()?.timezone) || 'UTC'; const labels = []; const data = []; @@ -9,14 +9,12 @@ function buildChart(eggs) { const eggMap = {}; eggs.forEach(e => { eggMap[e.date] = e.eggs; }); - // Generate the last 30 days in chronological order + // Generate the last 30 days in the user's configured timezone for (let i = 29; i >= 0; i--) { - const d = new Date(today); + const d = new Date(); d.setDate(d.getDate() - i); - const y = d.getFullYear(); - const mo = String(d.getMonth() + 1).padStart(2, '0'); - const dy = String(d.getDate()).padStart(2, '0'); - const dateStr = `${y}-${mo}-${dy}`; + const dateStr = d.toLocaleDateString('en-CA', { timeZone: tz }); + const [, mo, dy] = dateStr.split('-'); labels.push(`${mo}/${dy}`); data.push(eggMap[dateStr] ?? 0); } diff --git a/nginx/html/js/flock.js b/nginx/html/js/flock.js index 6d7efc6..3a930fc 100644 --- a/nginx/html/js/flock.js +++ b/nginx/html/js/flock.js @@ -97,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => { form.addEventListener('submit', async (e) => { e.preventDefault(); + const btn = form.querySelector('[type=submit]'); const data = { date: document.getElementById('date').value, @@ -104,6 +105,7 @@ document.addEventListener('DOMContentLoaded', () => { notes: document.getElementById('notes').value.trim() || null, }; + btn.disabled = true; try { await API.post('/api/flock', data); showMessage(msg, 'Flock change saved!'); @@ -112,6 +114,8 @@ document.addEventListener('DOMContentLoaded', () => { loadFlock(); } catch (err) { showMessage(msg, `Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; } }); diff --git a/nginx/html/js/log.js b/nginx/html/js/log.js index 8c4dfbb..3273497 100644 --- a/nginx/html/js/log.js +++ b/nginx/html/js/log.js @@ -7,6 +7,7 @@ document.addEventListener('DOMContentLoaded', () => { form.addEventListener('submit', async (e) => { e.preventDefault(); + const btn = form.querySelector('[type=submit]'); const data = { date: document.getElementById('date').value, @@ -14,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => { notes: document.getElementById('notes').value.trim() || null, }; + btn.disabled = true; try { await API.post('/api/eggs', data); showMessage(msg, 'Entry saved!'); @@ -22,6 +24,8 @@ document.addEventListener('DOMContentLoaded', () => { loadHistory(); } catch (err) { showMessage(msg, `Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; } }); });