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:
2026-03-18 00:09:36 -07:00
parent 60fed6d464
commit ce1e9c5134
7 changed files with 122 additions and 17 deletions

View File

@@ -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;