- 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>
128 lines
4.8 KiB
JavaScript
128 lines
4.8 KiB
JavaScript
// api.js — shared fetch helpers and utilities used by every page
|
|
|
|
const API = {
|
|
async _fetch(url, options = {}) {
|
|
const token = localStorage.getItem('token');
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(url, { headers, ...options });
|
|
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('token');
|
|
window.location.href = '/login';
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
|
throw new Error(err.detail || `Request failed (${res.status})`);
|
|
}
|
|
if (res.status === 204) return null; // DELETE returns No Content
|
|
return res.json();
|
|
},
|
|
|
|
// 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;
|
|
el.className = `message ${type} visible`;
|
|
setTimeout(() => { el.className = 'message'; }, 4000);
|
|
}
|
|
|
|
// Set an input[type=date] to today's date in the user's configured timezone
|
|
function setToday(inputEl) {
|
|
const tz = (typeof Auth !== 'undefined' && Auth.getUser()?.timezone)
|
|
|| Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
|| 'UTC';
|
|
// en-CA locale produces YYYY-MM-DD which is what date inputs expect
|
|
inputEl.value = new Date().toLocaleDateString('en-CA', { timeZone: tz });
|
|
}
|
|
|
|
// Format YYYY-MM-DD → MM/DD/YYYY for display
|
|
function fmtDate(str) {
|
|
if (!str) return '—';
|
|
const [y, m, d] = str.split('-');
|
|
return `${m}/${d}/${y}`;
|
|
}
|
|
|
|
// Format a number as a dollar amount
|
|
function fmtMoney(val) {
|
|
if (val == null || val === '' || isNaN(Number(val))) return '—';
|
|
return '$' + Number(val).toFixed(2);
|
|
}
|
|
|
|
// Format a small decimal (cost per egg) with 4 decimal places
|
|
function fmtMoneyFull(val) {
|
|
if (val == null || val === '' || isNaN(Number(val))) return '—';
|
|
return '$' + Number(val).toFixed(4);
|
|
}
|
|
|
|
// Escape HTML special characters to prevent XSS when rendering user content
|
|
function escHtml(str) {
|
|
if (!str) return '';
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Highlight the nav link that matches the current page
|
|
function highlightNav() {
|
|
const path = window.location.pathname.replace(/\.html$/, '').replace(/\/$/, '') || '/';
|
|
document.querySelectorAll('.nav-links a').forEach(a => {
|
|
const href = a.getAttribute('href').replace(/\/$/, '') || '/';
|
|
if (href === path) a.classList.add('active');
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', highlightNav);
|