Implement reliability improvements across frontend
- api.js: add exponential backoff retry (3 attempts, 500/1000/2000ms) for GET requests on network errors and 5xx responses; mutating methods are not retried since they are not idempotent - api.js: add offline indicator — fixed pill banner appears at bottom of page when navigator goes offline, disappears when back online - style.css: add styles for offline banner and session expiry warning - auth.js: show amber warning banner below nav when session expires within 24 hours (with exact hours remaining); dismissible with X button - auth.js: fix password min-length client-side check from 6 to 10 to match the backend - log.js, flock.js, budget.js: disable submit button during async request and re-enable in finally block to prevent double-submits and make loading state visible - dashboard.js: fix chart date labels to use user's configured timezone instead of the browser's local timezone Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -377,3 +377,40 @@ td input[type="date"] {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
margin: 1.25rem 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ const API = {
|
|||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, { headers, ...options });
|
||||||
headers,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
@@ -25,12 +22,53 @@ const API = {
|
|||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
get: (url) => API._fetch(url),
|
// GET requests retry up to 3 times with exponential backoff on network errors or 5xx.
|
||||||
post: (url, data) => API._fetch(url, { method: 'POST', body: JSON.stringify(data) }),
|
// Mutating methods (POST/PUT/DELETE) are not retried — they are not idempotent.
|
||||||
put: (url, data) => API._fetch(url, { method: 'PUT', body: JSON.stringify(data) }),
|
async _fetchWithRetry(url, options = {}) {
|
||||||
del: (url) => API._fetch(url, { method: 'DELETE' }),
|
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
|
// Show a timed success or error message inside a .message element
|
||||||
function showMessage(el, text, type = 'success') {
|
function showMessage(el, text, type = 'success') {
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
|
|||||||
@@ -87,10 +87,26 @@ function buildTimezoneOptions(selected) {
|
|||||||
|
|
||||||
// ── Nav + settings modal ──────────────────────────────────────────────────────
|
// ── 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 = `<span>${label}</span><button title="Dismiss" onclick="this.parentElement.remove()">✕</button>`;
|
||||||
|
document.querySelector('.nav')?.insertAdjacentElement('afterend', warning);
|
||||||
|
}
|
||||||
|
|
||||||
function initNav() {
|
function initNav() {
|
||||||
const user = Auth.requireAuth();
|
const user = Auth.requireAuth();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
|
_checkSessionExpiry(user);
|
||||||
|
|
||||||
const nav = document.querySelector('.nav');
|
const nav = document.querySelector('.nav');
|
||||||
if (!nav) return;
|
if (!nav) return;
|
||||||
|
|
||||||
@@ -201,8 +217,8 @@ async function submitPasswordChange() {
|
|||||||
msgEl.className = 'message error visible';
|
msgEl.className = 'message error visible';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPw.length < 6) {
|
if (newPw.length < 10) {
|
||||||
msgEl.textContent = 'Password must be at least 6 characters';
|
msgEl.textContent = 'Password must be at least 10 characters';
|
||||||
msgEl.className = 'message error visible';
|
msgEl.className = 'message error visible';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,12 +231,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
feedForm.addEventListener('submit', async (e) => {
|
feedForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const btn = feedForm.querySelector('[type=submit]');
|
||||||
const data = {
|
const data = {
|
||||||
date: document.getElementById('date').value,
|
date: document.getElementById('date').value,
|
||||||
bags: parseFloat(bagsInput.value),
|
bags: parseFloat(bagsInput.value),
|
||||||
price_per_bag: parseFloat(priceInput.value),
|
price_per_bag: parseFloat(priceInput.value),
|
||||||
notes: document.getElementById('notes').value.trim() || null,
|
notes: document.getElementById('notes').value.trim() || null,
|
||||||
};
|
};
|
||||||
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await API.post('/api/feed', data);
|
await API.post('/api/feed', data);
|
||||||
showMessage(msg, 'Feed purchase saved!');
|
showMessage(msg, 'Feed purchase saved!');
|
||||||
@@ -246,16 +248,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadBudget();
|
loadBudget();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showMessage(msg, `Error: ${err.message}`, 'error');
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
otherForm.addEventListener('submit', async (e) => {
|
otherForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const btn = otherForm.querySelector('[type=submit]');
|
||||||
const data = {
|
const data = {
|
||||||
date: document.getElementById('other-date').value,
|
date: document.getElementById('other-date').value,
|
||||||
total: parseFloat(document.getElementById('other-total').value),
|
total: parseFloat(document.getElementById('other-total').value),
|
||||||
notes: document.getElementById('other-notes').value.trim() || null,
|
notes: document.getElementById('other-notes').value.trim() || null,
|
||||||
};
|
};
|
||||||
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await API.post('/api/other', data);
|
await API.post('/api/other', data);
|
||||||
showMessage(msg, 'Purchase saved!');
|
showMessage(msg, 'Purchase saved!');
|
||||||
@@ -264,6 +270,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadBudget();
|
loadBudget();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showMessage(msg, `Error: ${err.message}`, 'error');
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let eggChart = null;
|
let eggChart = null;
|
||||||
|
|
||||||
function buildChart(eggs) {
|
function buildChart(eggs) {
|
||||||
const today = new Date();
|
const tz = (typeof Auth !== 'undefined' && Auth.getUser()?.timezone) || 'UTC';
|
||||||
const labels = [];
|
const labels = [];
|
||||||
const data = [];
|
const data = [];
|
||||||
|
|
||||||
@@ -9,14 +9,12 @@ function buildChart(eggs) {
|
|||||||
const eggMap = {};
|
const eggMap = {};
|
||||||
eggs.forEach(e => { eggMap[e.date] = e.eggs; });
|
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--) {
|
for (let i = 29; i >= 0; i--) {
|
||||||
const d = new Date(today);
|
const d = new Date();
|
||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
const y = d.getFullYear();
|
const dateStr = d.toLocaleDateString('en-CA', { timeZone: tz });
|
||||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
const [, mo, dy] = dateStr.split('-');
|
||||||
const dy = String(d.getDate()).padStart(2, '0');
|
|
||||||
const dateStr = `${y}-${mo}-${dy}`;
|
|
||||||
labels.push(`${mo}/${dy}`);
|
labels.push(`${mo}/${dy}`);
|
||||||
data.push(eggMap[dateStr] ?? 0);
|
data.push(eggMap[dateStr] ?? 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const btn = form.querySelector('[type=submit]');
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
date: document.getElementById('date').value,
|
date: document.getElementById('date').value,
|
||||||
@@ -104,6 +105,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
notes: document.getElementById('notes').value.trim() || null,
|
notes: document.getElementById('notes').value.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await API.post('/api/flock', data);
|
await API.post('/api/flock', data);
|
||||||
showMessage(msg, 'Flock change saved!');
|
showMessage(msg, 'Flock change saved!');
|
||||||
@@ -112,6 +114,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadFlock();
|
loadFlock();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showMessage(msg, `Error: ${err.message}`, 'error');
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const btn = form.querySelector('[type=submit]');
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
date: document.getElementById('date').value,
|
date: document.getElementById('date').value,
|
||||||
@@ -14,6 +15,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
notes: document.getElementById('notes').value.trim() || null,
|
notes: document.getElementById('notes').value.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await API.post('/api/eggs', data);
|
await API.post('/api/eggs', data);
|
||||||
showMessage(msg, 'Entry saved!');
|
showMessage(msg, 'Entry saved!');
|
||||||
@@ -22,6 +24,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadHistory();
|
loadHistory();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showMessage(msg, `Error: ${err.message}`, 'error');
|
showMessage(msg, `Error: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user