/* ============================================================ ZENITH — shared library: context, i18n, UI primitives ============================================================ */ const { useState, useEffect, useRef, useContext, createContext, useCallback } = React; /* ---------- App context ---------- */ const AppCtx = createContext(null); const useApp = () => useContext(AppCtx); /* translate helper: tx(lang, en, si) */ function tx(lang, en, si) { return lang === "si" && si ? si : en; } /* ---------- Brand logo (wordmark) ---------- "islandrank" in navy (white on dark surfaces), ".lk" in teal. */ function Logo({ light = true, size = 26 }) { return ( islandrank.lk ); } /* ---------- Buttons ---------- */ function Btn({ kind = "primary", size, icon, iconRight, children, ...rest }) { const cls = `btn btn--${kind}` + (size ? ` btn--${size}` : ""); return ( ); } /* ---------- Chip ---------- */ function Chip({ tone = "grey", icon, children }) { return ( {icon && } {children} ); } /* ---------- Avatar ---------- */ function Avatar({ name, size = 38, src }) { const [broken, setBroken] = useState(false); useEffect(() => { setBroken(false); }, [src]); // retry the image if the src changes const initials = (name || "").trim().split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join("").toUpperCase() || "·"; // Show the Google profile photo when available; fall back to initials on missing/blocked image. // referrerPolicy=no-referrer avoids googleusercontent 403s on some networks. if (src && !broken) { return {name setBroken(true)} style={{ width: size, height: size, objectFit: "cover" }} />; } return
{initials}
; } /* ---------- Stat ---------- */ function Stat({ num, label, tone }) { return (
{num}
{label}
); } /* ---------- Progress bar ---------- */ function PBar({ value, max = 100, color = "blue" }) { const pct = Math.max(0, Math.min(100, (value / max) * 100)); return
; } /* ---------- Field ---------- */ function Field({ label, children }) { return ( ); } /* ---------- Z-score radial gauge ---------- Maps z (0..3) onto a 270° arc. */ function ZGauge({ z, size = 200, label }) { const r = size / 2 - 14; const c = size / 2; const circ = 2 * Math.PI * r; const sweep = 0.75; // 270deg const frac = Math.max(0, Math.min(1, z / 3)); const dash = circ * sweep; const fill = dash * frac; return (
{z.toFixed(2)}
{label &&
{label}
}
); } /* ---------- Next Saturday 21:00 (the SRS publish CRON) + live countdown ---------- */ function nextSaturday(hour) { const now = new Date(); const d = new Date(now); const day = d.getDay(); // 0 Sun … 6 Sat let add = (6 - day + 7) % 7; d.setHours(hour, 0, 0, 0); if (add === 0 && now > d) add = 7; // already past Sat 21:00 → next week d.setDate(d.getDate() + add); return d; } function useCountdown(hour = 21) { const [ms, setMs] = useState(() => nextSaturday(hour) - new Date()); useEffect(() => { const id = setInterval(() => setMs(nextSaturday(hour) - new Date()), 1000); return () => clearInterval(id); }, [hour]); const s = Math.max(0, Math.floor(ms / 1000)); return { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 }; } /* format an integer with thousands separators; "—" when absent (sparse Z-uploads carry only z + ranks — no totals/percentile) */ function fmtInt(n) { return (n === null || n === undefined) ? "—" : Number(n).toLocaleString(); } /* ---------- Sparkline ---------- */ function Spark({ data, w = 220, h = 56, color = "var(--blue)" }) { if (!data || data.length < 2) return ; // need ≥2 points to plot a line const max = Math.max(...data), min = Math.min(...data); const rng = max - min || 1; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * w; const y = h - 6 - ((v - min) / rng) * (h - 12); return [x, y]; }); const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" "); const area = d + ` L ${w} ${h} L 0 ${h} Z`; return ( {pts.map((p, i) => i === pts.length - 1 && )} ); } /* ---------- Empty helper to share components across babel scopes ---------- */ Object.assign(window, { AppCtx, useApp, tx, useState, useEffect, useRef, useCallback, Logo, Btn, Chip, Avatar, Stat, PBar, Field, ZGauge, Spark, nextSaturday, useCountdown, });