/* ============================================================
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
setBroken(true)}
style={{ width: size, height: size, objectFit: "cover" }} />;
}
return
{initials}
;
}
/* ---------- Stat ---------- */
function Stat({ num, label, tone }) {
return (
);
}
/* ---------- 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 (
);
}
/* ---------- 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,
});