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;
}
});
});