// Dashboard page — Bloomberg-dense KPI overview for Raudhah clinic group
// Data source: window.RAUDHAH_DATA (server-rendered from FastAPI)
const D = window.RAUDHAH_DATA;
const URLS = window.RAUDHAH_URLS || { dashboard: "/", entry: "/entry", targets: "/targets", branch: "/branch/" };
// ---- Summary KPIs -------------------------------------------------------
function KpiSummary({ mode, branches, windowSize }) {
const last = D.weeks.length - 1;
const start = Math.max(0, last - windowSize + 1);
const sumAt = (obj, wk) => branches.reduce((a, b) => a + ((obj[b.id] && obj[b.id][wk]) || 0), 0);
const fbSeries = D.weeks.map((_, i) => sumAt(D.fb, i));
const waSeries = D.weeks.map((_, i) => sumAt(D.wa, i));
const ttSeries = D.weeks.map((_, i) => sumAt(D.tt, i));
const igSeries = D.ig.total || [];
function windowBaseline(series) {
for (let i = start; i <= last; i++) {
if (series[i] != null) return { value: series[i], idx: i };
}
return { value: null, idx: null };
}
const cards = [
{ key: "fb", label: "FB FOLLOWERS", swatch: "var(--raudhah-pink)", series: fbSeries, color: "var(--raudhah-pink)" },
{ key: "wa", label: "WA MEMBERS", swatch: "var(--teal)", series: waSeries, color: "var(--teal)" },
{ key: "tt", label: "TIKTOK FOLLOWERS", swatch: "var(--b-bs)", series: ttSeries, color: "var(--b-bs)" },
{ key: "ig", label: "IG FOLLOWERS", swatch: "var(--b-mn)", series: igSeries, color: "var(--b-mn)" },
];
// Branch view: hide IG (not branch-scoped)
const visibleCards = mode === "branch" ? cards.filter(c => c.key !== "ig") : cards;
const grVals = Object.values(D.gr).filter(g => g);
const grTotal = grVals.reduce((a, b) => a + (b.total || 0), 0);
const grRating = grTotal ? (grVals.reduce((a, b) => a + (b.rating || 0) * (b.total || 0), 0) / grTotal).toFixed(2) : "—";
const pendingEntries = (D.missing || []).length;
const newBranchCount = D.BRANCHES.filter(b => b.isNew).length;
return (
{visibleCards.map(c => {
const value = c.series[last] || 0;
const baseline = windowBaseline(c.series);
const d = baseline.value != null ? value - baseline.value : null;
const pct = (baseline.value != null && baseline.value !== 0) ? +((d / baseline.value) * 100).toFixed(2) : null;
const since = baseline.idx != null && baseline.idx > start ? D.weeks[baseline.idx] : null;
const sparkSlice = c.series.slice(start);
return (
{c.label}
{value.toLocaleString()}
{d == null
?
—
:
}
{since &&
since {since.slice(5)}}
);
})}
GOOGLE REVIEWS · TOTAL
{grTotal.toLocaleString()}
ACTIVE BRANCHES
{D.BRANCHES.length}{newBranchCount ? · {newBranchCount} new : null}
WEEKLY ENTRIES DUE
{pendingEntries}pending
);
}
// ---- Multi-branch line chart (ApexCharts) -------------------------------
function LineChart({ id, title, eyebrow, sourceKey, branches, windowSize = 12, height = 240, foot }) {
const ref = useRef(null);
const chartRef = useRef(null);
const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light");
useEffect(() => {
const obs = new MutationObserver(() => setTheme(document.documentElement.getAttribute("data-theme") || "light"));
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
return () => obs.disconnect();
}, []);
useEffect(() => {
if (!ref.current || !window.ApexCharts) return;
const last = D.weeks.length - 1;
const start = Math.max(0, last - windowSize + 1);
const slicedWeeks = D.weeks.slice(start);
const series = (sourceKey === "ig")
? [{ name: "IG", data: (D.ig.total || []).slice(start).map((v, i) => ({ x: slicedWeeks[i], y: v })), color: "var(--b-mn)" }]
: branches.map(b => ({
name: b.code,
data: ((D[sourceKey] && D[sourceKey][b.id]) || []).slice(start).map((v, i) => ({ x: slicedWeeks[i], y: v })),
color: b.hex,
}));
const opts = {
chart: {
type: "line", height, toolbar: { show: false },
animations: { enabled: true, speed: 500 },
fontFamily: "Poppins", background: "transparent", parentHeightOffset: 0,
events: {
markerClick: (_, __, cfg) => {
const br = branches[cfg.seriesIndex];
if (br && br.slug) location.href = URLS.branch + br.slug;
},
},
},
theme: { mode: theme },
series,
stroke: { curve: "smooth", width: 2 },
colors: sourceKey === "ig" ? ["var(--b-mn)"] : branches.map(b => b.hex),
markers: { size: 0, hover: { size: 5 } },
grid: { borderColor: "var(--grid-line-strong)", strokeDashArray: 3, padding: { left: 10, right: 10, top: 0, bottom: 0 } },
xaxis: {
type: "datetime",
labels: { style: { colors: "var(--fg3)", fontSize: "10px", fontFamily: "JetBrains Mono" }, datetimeFormatter: { month: "MMM dd" } },
axisBorder: { show: false }, axisTicks: { show: false },
},
yaxis: {
labels: { style: { colors: "var(--fg3)", fontSize: "10px", fontFamily: "JetBrains Mono" }, formatter: v => v == null ? "" : Math.round(v).toLocaleString() },
},
legend: { show: false },
tooltip: { theme, x: { format: "dd MMM yyyy" }, y: { formatter: v => v == null ? "—" : v.toLocaleString() } },
dataLabels: { enabled: false },
};
chartRef.current = new ApexCharts(ref.current, opts);
chartRef.current.render();
return () => chartRef.current && chartRef.current.destroy();
}, [sourceKey, branches.map(b=>b.id).join(","), theme, windowSize]);
return (
{(sourceKey === "ig" ? [{ id: "ig", code: "IG", hex: "#6A8E3E" }] : branches).map(b => (
{b.code}
))}
{foot &&
{foot}
}
);
}
// ---- Target attainment panel -------------------------------------------
function TargetAttainment({ branches }) {
const rows = branches.map(b => {
const tgt = (D.targets || []).filter(t => t.kpi === "fb_followers" && t.branch === b.id);
tgt.sort((a, c) => a.value - c.value);
const primary = tgt[0];
const stretch = tgt[1];
const value = (D.fb[b.id] || []).slice(-1)[0] || 0;
return { b, value, target: primary ? primary.value : null, target2: stretch ? stretch.value : null };
}).filter(r => r.target != null);
if (!rows.length) {
return (
Target attainment
Facebook follower targets
No FB targets set yet.
Set targets →
);
}
return (
Target attainment
Facebook follower targets
Primary + stretch · progress to due date
Manage →
{rows.map((r, i) => (
{r.b.name}}
value={r.value} target={r.target} target2={r.target2} color={r.b.hex}
onClick={() => r.b.slug && (location.href = URLS.branch + r.b.slug)} />
))}
);
}
// ---- Ranking bars -------------------------------------------------------
function Ranking({ title, eyebrow, sourceKey, format = v => v.toLocaleString(), branches }) {
const data = branches.map(b => ({
b,
value: sourceKey === "gr"
? (D.gr[b.id] ? D.gr[b.id].total : 0)
: ((D[sourceKey] && D[sourceKey][b.id]) ? D[sourceKey][b.id].slice(-1)[0] || 0 : 0),
})).sort((a, b) => b.value - a.value);
const max = Math.max(1, ...data.map(d => d.value));
return (
{data.map(d => (
d.b.slug && (location.href = URLS.branch + d.b.slug)}>
0.35 ? "white" : "var(--fg1)", textShadow: d.value / max > 0.35 ? "0 1px 1px rgba(0,0,0,0.2)" : "none" }}>
{d.b.name}
{format(d.value)}
))}
);
}
// ---- Google reviews stacked ---------------------------------------------
function ReviewBreakdown({ branches }) {
const entries = branches.map(b => ({ b, g: D.gr[b.id] })).filter(x => x.g);
const maxTotal = Math.max(1, ...entries.map(x => x.g.total));
return (
Google Reviews
Star distribution by branch
Stacked 5★ → 1★ · width proportional to review count
5★
4★
3★
2★
1★
{entries.map(({ b, g }) => {
const width = (g.total / maxTotal * 100);
return (
b.slug && (location.href = URLS.branch + b.slug)}>
{[5,4,3,2,1].map(s => {
const n = g.stars ? (g.stars[s] || 0) : 0;
const pct = g.total ? (n / g.total) * 100 : 0;
return ;
})}
{g.rating != null ? g.rating.toFixed(1) : "—"}★
{(g.total || 0).toLocaleString()} reviews
);
})}
);
}
// ---- Posting heatmap ----------------------------------------------------
function PostingHeatmap({ branches, mode }) {
const labs14 = D.postingWeeks || [];
const [tab, setTab] = useState("fb");
const channelRows = (D.posting && D.posting[tab]) || [];
// Branch filter: when in single-branch mode, keep only rows whose label prefix matches the branch code
let rows = channelRows;
if (mode === "branch" && branches.length === 1) {
const code = branches[0].code;
rows = channelRows.filter(r => r.label.split("·")[0] === code);
} else if (mode === "hq") {
// HQ: show DR·TT row in TT tab, HQ·TH row in TH tab; FB tab effectively empty
if (tab === "fb") rows = [];
else if (tab === "tt") rows = channelRows.filter(r => r.label.startsWith("DR"));
else if (tab === "th") rows = channelRows; // single HQ·TH row
}
return (
Posting consistency
Weekly posts · 14-week heatmap
0 posts → 5+ posts per week · gaps flagged in alerts
{["fb", "tt", "th"].map(t => (
))}
Less
{[0,1,2,3,4,5].map(v => (
))}
More
{rows.length === 0 ? (
No data for this filter
) : (
<>
{labs14.map(l => {l})}
Σ
{rows.map(row => (
{row.label}
{row.values.map((v, i) => (
))}
{row.values.reduce((a,b)=>a+b,0)}
))}
>
)}
);
}
// ---- Alerts side-rail ---------------------------------------------------
function AlertsRail() {
const [dismissed, setDismissed] = useState(() => new Set());
const allAlerts = D.alerts || [];
const visibleAlerts = allAlerts.filter(a => !dismissed.has(`${a.group}::${a.text}::${a.sub}`));
const groups = {};
visibleAlerts.forEach(a => { (groups[a.group] = groups[a.group] || []).push(a); });
const tickChar = { bad: "!", warn: "▲", info: "i" };
const onClickAlert = (a) => {
if (a.branch) {
location.href = URLS.branch + a.branch;
} else {
location.href = URLS.entry;
}
};
const dismiss = (a, e) => {
e.stopPropagation();
const key = `${a.group}::${a.text}::${a.sub}`;
setDismissed(prev => {
const next = new Set(prev);
next.add(key);
return next;
});
};
return (
);
}
// ---- Snapshot strip (compact ticker) -----------------------------------
function SnapshotStrip({ branches, windowSize }) {
const last = D.weeks.length - 1;
const start = Math.max(0, last - windowSize + 1);
const baselineAt = (series) => {
for (let i = start; i <= last; i++) {
if (series[i] != null) return series[i];
}
return null;
};
const cells = branches.map(b => {
const fbSeries = D.fb[b.id] || [];
const waSeries = D.wa[b.id] || [];
const ttSeries = D.tt[b.id] || [];
const fb = fbSeries[last] || 0;
const wa = waSeries[last] || 0;
const tt = ttSeries[last] || 0;
const gr = (D.gr[b.id] && D.gr[b.id].total) || 0;
const rating = (D.gr[b.id] && D.gr[b.id].rating) || null;
const fbBase = baselineAt(fbSeries);
const waBase = baselineAt(waSeries);
const ttBase = baselineAt(ttSeries);
return {
b, fb, wa, tt, gr, rating,
fbDelta: fbBase != null ? fb - fbBase : 0,
waDelta: waBase != null ? wa - waBase : 0,
ttDelta: ttBase != null ? tt - ttBase : 0,
};
});
return (
Snapshot
Week ending
{D.weekLabel}
{cells.map((c, i) => (
c.b.slug && (location.href = URLS.branch + c.b.slug)}>
{c.b.name}
{c.b.isNew && NEW}
{[
{ k: "FB", v: c.fb, d: c.fbDelta },
{ k: "WA", v: c.wa, d: c.waDelta },
{ k: "TT", v: c.tt, d: c.ttDelta },
].map(row => (
{row.k}
{row.v.toLocaleString()}
))}
GR
{c.gr.toLocaleString()}
{c.rating != null && (
{c.rating.toFixed(1)}★
)}
))}
);
}
// ---- HQ panels ----------------------------------------------------------
function HqKpiSummary({ windowSize }) {
const last = D.weeks.length - 1;
const start = Math.max(0, last - windowSize + 1);
const igSeries = D.ig.total || [];
const drSeries = (D.tt && D.tt.doctor) || [];
const thFollowersSeries = D.thFollowers || [];
const thViewsSeries = D.thViews || [];
function windowBaseline(series) {
for (let i = start; i <= last; i++) if (series[i] != null) return { value: series[i], idx: i };
return { value: null, idx: null };
}
const cards = [
{ key: "ig", label: "IG FOLLOWERS", swatch: "var(--b-mn)", series: igSeries, color: "var(--b-mn)" },
{ key: "dr", label: "DOCTOR TIKTOK", swatch: "var(--b-bs)", series: drSeries, color: "var(--b-bs)" },
{ key: "thf", label: "THREADS · FOLLOWERS", swatch: "#1f2933", series: thFollowersSeries, color: "#1f2933" },
{ key: "thv", label: "THREADS · VIEWS", swatch: "#1f2933", series: thViewsSeries, color: "#1f2933", isSum: true },
];
return (
{cards.map(c => {
const value = c.isSum
? c.series.slice(start).reduce((a, b) => a + (b || 0), 0)
: (c.series[last] || 0);
const baseline = windowBaseline(c.series);
let d = null, pct = null;
if (c.isSum) {
const prevWindow = c.series.slice(Math.max(0, start - windowSize), start).reduce((a, b) => a + (b || 0), 0);
d = value - prevWindow;
pct = prevWindow ? +((d / prevWindow) * 100).toFixed(2) : null;
} else if (baseline.value != null) {
d = value - baseline.value;
pct = baseline.value ? +((d / baseline.value) * 100).toFixed(2) : null;
}
return (
{c.label}
{value.toLocaleString()}
);
})}
);
}
function HqTargets() {
const hqTargets = (D.targets || []).filter(t =>
(t.kpi === "tiktok" && t.account === "doctor") || t.kpi === "ig"
);
if (hqTargets.length === 0) {
return (
);
}
return (
HQ targets
IG · Doctor TT
Active HQ targets
Manage →
{hqTargets.map((t, i) => {
let value = 0;
let label = "";
if (t.kpi === "tiktok") {
value = ((D.tt && D.tt.doctor) || []).slice(-1)[0] || 0;
label = "Doctor TikTok followers";
} else if (t.kpi === "ig") {
value = (D.ig.total || []).slice(-1)[0] || 0;
label = "Instagram followers";
}
return (
);
})}
);
}
function HqRecentPosts() {
const items = [];
(D.th || []).forEach(p => items.push({ type: "TH", date: p.date, label: p.topic, extra: p.views != null ? `${p.views} views` : "" }));
(D.doctorRecent || []).forEach(p => items.push({ type: "TT", date: p.date, label: p.title, extra: "" }));
items.sort((a, b) => (a.date < b.date ? 1 : -1));
const top = items.slice(0, 10);
return (
HQ posts
Recent activity
Last 10 posts across Threads + Doctor TikTok
{top.length === 0 ? (
No recent posts logged.
) : (
{top.map((p, i) => (
{p.type}
{p.label}
{p.date}{p.extra ? " · " + p.extra : ""}
))}
)}
);
}
function HqGrowthRanking({ windowSize }) {
const last = D.weeks.length - 1;
const start = Math.max(0, last - windowSize + 1);
function windowBaseline(series) {
for (let i = start; i <= last; i++) if (series[i] != null) return series[i];
return null;
}
const ig = D.ig.total || [];
const dr = (D.tt && D.tt.doctor) || [];
const th = D.thViews || [];
const igCur = ig[last] || 0, igBase = windowBaseline(ig);
const drCur = dr[last] || 0, drBase = windowBaseline(dr);
const thWindow = th.slice(start).reduce((a, b) => a + (b || 0), 0);
const thPrior = th.slice(Math.max(0, start - windowSize), start).reduce((a, b) => a + (b || 0), 0);
const rows = [
{ label: "Instagram", delta: igBase != null ? igCur - igBase : 0, hex: "var(--b-mn)" },
{ label: "Doctor TT", delta: drBase != null ? drCur - drBase : 0, hex: "var(--b-bs)" },
{ label: "Threads views", delta: thWindow - thPrior, hex: "#1f2933" },
].sort((a, b) => b.delta - a.delta);
const max = Math.max(1, ...rows.map(r => Math.abs(r.delta)));
return (
HQ growth
By channel · this window
{rows.map(r => (
{r.label}
{r.delta >= 0 ? "+" : ""}{r.delta.toLocaleString()}
))}
);
}
// ---- Dashboard assembly -------------------------------------------------
function Dashboard() {
const [branchFilter, setBranchFilter] = useState("all");
const [timeframe, setTimeframe] = useState("12w");
const HQ_CHIP = { id: "hq", code: "HQ", name: "Headquarters", hex: "#1f2933", isHq: true };
const FILTER_CHIPS = [
{ id: "all", code: "ALL", hex: "var(--fg2)" },
HQ_CHIP,
...D.BRANCHES,
];
const mode = branchFilter === "all" ? "all" : (branchFilter === "hq" ? "hq" : "branch");
const activeBranch = mode === "branch" ? D.BRANCHES.find(b => b.id === branchFilter) : null;
const N = { "4w": 4, "12w": 12, "YTD": 52 }[timeframe] || 12;
const visible = mode === "all"
? D.BRANCHES
: mode === "hq"
? (D.HQ_ACCOUNTS || [])
: D.BRANCHES.filter(b => b.id === branchFilter);
const branchScope = mode === "branch" ? [activeBranch] : D.BRANCHES;
const totals = {
fb: D.BRANCHES.reduce((a, b) => a + ((D.fb[b.id] || []).slice(-1)[0] || 0), 0),
wa: D.BRANCHES.reduce((a, b) => a + ((D.wa[b.id] || []).slice(-1)[0] || 0), 0),
tt: Object.values(D.tt).reduce((a, arr) => a + ((arr || []).slice(-1)[0] || 0), 0),
gr: Object.values(D.gr).reduce((a, g) => a + (g.total || 0), 0),
};
return (
Group Dashboard
{mode === "all" && <>{D.BRANCHES.length} branches · >}
{mode === "branch" && <>{activeBranch.name} · >}
{mode === "hq" && <>IG · Doctor TT · Threads · >}
{N === 52 ? "52-week" : timeframe} view ending {D.weekLabel}
·
{totals.fb.toLocaleString()} FB · {totals.wa.toLocaleString()} WA · {totals.tt.toLocaleString()} TikTok · {totals.gr.toLocaleString()} Google Reviews
{FILTER_CHIPS.map(c => (
))}
{["4w", "12w", "YTD"].map(t => (
))}
{mode === "hq" ? (
<>
>
) : (
<>
>
)}
);
}
Object.assign(window, { Dashboard });