// FreshnessBanner.jsx — top-level data-freshness indicator bar. // Displays contextual banners for stale, offline, or error states with // a retry button and human-readable "last updated" timestamp. // // Props: // freshness {string} — "live" | "stale" | "offline" | "error" // fetchedAt {string} — ISO 8601 timestamp of last successful API fetch // error {string} — optional error message to display in error state // onRetry {func} — callback fired when the Retry / Refresh button is pressed // loading {bool} — whether a retry/refresh is currently in progress // // Freshness states (mirrors _classifyDataFreshness in screens-overview.jsx): // live → banner hidden (returns null) // stale → warning-toned banner (amber) — data is ageing but usable // offline → critical-toned banner (red) — data is outdated / source unreachable // error → danger-toned banner (red) — explicit API or network error (function () { // ── i18n helper (local copy, same pattern as App.jsx) ─────────────── function t(key, fallback, params) { if (window.Keeper && window.Keeper.i18n && typeof window.Keeper.i18n.t === "function") { return window.Keeper.i18n.t(key, params, { fallback: fallback }); } if (params && typeof fallback === "string") { return fallback.replace(/\{(\w+)\}/g, function (_, k) { return params[k] !== undefined ? params[k] : "{" + k + "}"; }); } return fallback; } // ── Relative time formatter ───────────────────────────────────────── function formatAge(isoString) { if (!isoString) return null; var d = new Date(isoString); if (Number.isNaN(d.getTime())) return null; var diffSec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000)); if (diffSec < 5) return t("freshness.age.justNow", "just now"); if (diffSec < 60) return t("freshness.age.seconds", "{count}s ago", { count: diffSec }); var min = Math.floor(diffSec / 60); if (min < 60) return t("freshness.age.minutes", "{count} min ago", { count: min }); var hr = Math.floor(min / 60); if (hr < 24) return t("freshness.age.hours", "{count}h ago", { count: hr }); var days = Math.floor(hr / 24); return t("freshness.age.days", "{count}d ago", { count: days }); } // ── State configuration ───────────────────────────────────────────── var STATE_CONFIG = { stale: { icon: "ri-time-line", cls: "freshness-banner--stale", role: "status", ariaLive: "polite", messageKey: "freshness.stale.message", messageFallback: "Data may be outdated \u00b7 last updated {age}", noAgeKey: "freshness.stale.noAge", messageNoAge: "Data may be outdated", retryKey: "freshness.retry", retryFallback: "Refresh", }, offline: { icon: "ri-wifi-off-line", cls: "freshness-banner--offline", role: "alert", ariaLive: "assertive", messageKey: "freshness.offline.message", messageFallback: "Connection lost \u00b7 showing last known data from {age}", noAgeKey: "freshness.offline.noAge", messageNoAge: "Connection lost \u00b7 showing last known data", retryKey: "freshness.reconnect", retryFallback: "Reconnect", }, error: { icon: "ri-error-warning-line", cls: "freshness-banner--error", role: "alert", ariaLive: "assertive", messageKey: "freshness.error.message", messageFallback: "Could not load dashboard data \u2014 try again or check your connection", noAgeKey: "freshness.error.noAge", messageNoAge: "Could not load dashboard data \u2014 try again or check your connection", retryKey: "freshness.retryLoad", retryFallback: "Retry", }, }; // ── Component ─────────────────────────────────────────────────────── function FreshnessBanner(props) { var safeProps = props || {}; var freshness = safeProps.freshness || "live"; var fetchedAt = safeProps.fetchedAt || null; var error = safeProps.error || ""; var onRetry = typeof safeProps.onRetry === "function" ? safeProps.onRetry : null; var loading = !!safeProps.loading; // Live state → no banner if (freshness === "live") return null; var config = STATE_CONFIG[freshness] || STATE_CONFIG.stale; var age = formatAge(fetchedAt); // Build the display message var message; if (freshness === "error" && error) { message = error; } else if (age) { message = t(config.messageKey, config.messageFallback, { age: age }); } else { message = t(config.noAgeKey, config.messageNoAge); } // Tick the "X min ago" label once per minute var _forceUpdate = React.useState(0)[1]; React.useEffect(function () { if (freshness === "live" || !fetchedAt) return undefined; var timer = setInterval(function () { _forceUpdate(function (c) { return c + 1; }); }, 60000); return function () { clearInterval(timer); }; }, [freshness, fetchedAt]); // Show the separate