// api.js — shared fetch helpers and utilities used by every page const API = { async _fetch(url, options = {}) { const headers = { 'Content-Type': 'application/json' }; const res = await fetch(url, { credentials: 'include', headers, ...options }); if (res.status === 401) { localStorage.removeItem('user'); 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, '''); } // 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);