Vendor whynot-design Layer 1 (tokens, CSS) and Layer 2 (<wn-*> components) via scripts/sync-whynot-design.sh with a pinned ref. Migrate the observatory shell to canonical web components, keep observatory-specific layout in styles.css, and add vendor integrity tests plus correct JS MIME types on the dev server.
202 lines
No EOL
7.3 KiB
JavaScript
202 lines
No EOL
7.3 KiB
JavaScript
const euro = (value) => `€${Number(value).toFixed(2)}`;
|
|
|
|
async function loadDashboard(period) {
|
|
const query = period ? `?period=${encodeURIComponent(period)}` : "";
|
|
const response = await fetch(`/api/dashboard${query}`);
|
|
if (!response.ok) throw new Error("Failed to load dashboard");
|
|
return response.json();
|
|
}
|
|
|
|
function setBadge(el, status) {
|
|
el.textContent = status;
|
|
el.active = status === "generating" || status === "neutral";
|
|
el.draft = status === "burning";
|
|
}
|
|
|
|
function renderMetrics(data) {
|
|
const { snapshot, liquidity } = data;
|
|
const cards = [
|
|
{
|
|
label: "Remaining budget",
|
|
value: euro(liquidity.remaining_budget),
|
|
sub: `${liquidity.months_tracked} months tracked`,
|
|
},
|
|
{
|
|
label: "Period net liquidity",
|
|
value: euro(snapshot.period_net_liquidity),
|
|
sub: snapshot.liquidity_status,
|
|
},
|
|
{
|
|
label: "Active members",
|
|
value: snapshot.active_members,
|
|
sub: data.members[0]?.username ? `@${data.members[0].username}` : "—",
|
|
},
|
|
{
|
|
label: "Member payment (gross)",
|
|
value: euro(snapshot.monthly_revenue),
|
|
sub: `Net ${euro(data.payments.at(-1)?.net_amount ?? 0)} after Stripe`,
|
|
},
|
|
{
|
|
label: "Infrastructure / month",
|
|
value: euro(snapshot.monthly_infrastructure_cost),
|
|
sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`,
|
|
},
|
|
{
|
|
label: "Cumulative net liquidity",
|
|
value: euro(liquidity.cumulative_net_liquidity),
|
|
sub: liquidity.liquidity_status,
|
|
},
|
|
];
|
|
|
|
document.getElementById("metric-grid").innerHTML = cards
|
|
.map(
|
|
(card) => `
|
|
<wn-card size="sm">
|
|
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
|
|
<div class="obs-metric__value">${card.value}</div>
|
|
<span slot="footer">${card.sub}</span>
|
|
</wn-card>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderBudget(data) {
|
|
const { liquidity } = data;
|
|
const initial = Number(liquidity.initial_budget);
|
|
const remaining = Number(liquidity.remaining_budget);
|
|
const pct = Math.max(0, Math.min(100, (remaining / initial) * 100));
|
|
const banner = document.getElementById("budget-banner");
|
|
const caption = remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
|
|
|
|
document.getElementById("budget-fill").style.width = `${pct}%`;
|
|
document.getElementById("budget-caption").textContent = caption;
|
|
banner.variant = remaining < initial * 0.25 ? "warn" : "info";
|
|
|
|
document.getElementById("budget-stats").innerHTML = [
|
|
["Initial budget", euro(initial)],
|
|
["Cumulative member payments (net)", euro(liquidity.cumulative_member_payments)],
|
|
["Cumulative infrastructure", euro(liquidity.cumulative_infrastructure_cost)],
|
|
["Cumulative processing", euro(liquidity.cumulative_payment_processing_cost)],
|
|
]
|
|
.map(
|
|
([label, value]) => `
|
|
<wn-field-row label="${label}" narrow>
|
|
<span class="wn-field-row__value">${value}</span>
|
|
</wn-field-row>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function renderChart(history) {
|
|
const max = Math.max(...history.map((row) => Math.abs(Number(row.net_liquidity))), 1);
|
|
document.getElementById("liquidity-chart").innerHTML = history
|
|
.map((row) => {
|
|
const value = Number(row.net_liquidity);
|
|
const width = (Math.abs(value) / max) * 50;
|
|
const barClass = value < 0 ? "obs-liquidity-row__bar--neg" : "obs-liquidity-row__bar--pos";
|
|
const valueClass = value < 0 ? "obs-liquidity-row__value--neg" : "";
|
|
return `
|
|
<div class="obs-liquidity-row">
|
|
<div class="obs-liquidity-row__period">${row.period}</div>
|
|
<div class="obs-liquidity-row__bar-wrap">
|
|
<div class="obs-liquidity-row__bar ${barClass}" style="width:${width}%"></div>
|
|
</div>
|
|
<div class="obs-liquidity-row__value ${valueClass}">${euro(value)}</div>
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function renderInfra(data) {
|
|
const domains = data.infrastructure.domains?.domains ?? [];
|
|
const servers = data.infrastructure.virtual_servers?.servers ?? [];
|
|
const stripe = data.infrastructure.stripe?.membership ?? {};
|
|
const payout = data.infrastructure.stripe?.payout_account ?? "payout";
|
|
const items = [
|
|
...domains.map(
|
|
(d) =>
|
|
`<wn-field-row label="${d.name}" narrow><span class="wn-field-row__value">${d.monthly_eur} EUR/mo · ${d.tld}</span></wn-field-row>`
|
|
),
|
|
...servers.map(
|
|
(s) =>
|
|
`<wn-field-row label="${s.name}" narrow><span class="wn-field-row__value">${s.monthly_eur} EUR/mo · since ${s.started}</span></wn-field-row>`
|
|
),
|
|
`<wn-field-row label="Stripe · ${stripe.member_username ?? "member"}" narrow><span class="wn-field-row__value">${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${payout}</span></wn-field-row>`,
|
|
];
|
|
document.getElementById("infra-stack").innerHTML = `<div class="obs-infra-list">${items.join("")}</div>`;
|
|
}
|
|
|
|
function renderTables(data) {
|
|
document.getElementById("history-body").innerHTML = data.history
|
|
.map(
|
|
(row) => `
|
|
<tr>
|
|
<td class="mono">${row.period}</td>
|
|
<td class="mono">${row.active_members}</td>
|
|
<td class="obs-num mono">${euro(row.gross_revenue)}</td>
|
|
<td class="obs-num mono">${euro(row.infrastructure_cost)}</td>
|
|
<td class="obs-num mono">${euro(row.payment_processing_cost)}</td>
|
|
<td class="obs-num mono">${euro(row.net_liquidity)}</td>
|
|
</tr>`
|
|
)
|
|
.join("");
|
|
|
|
document.getElementById("pricing-body").innerHTML = data.pricing_models
|
|
.map(
|
|
(model) => `
|
|
<tr>
|
|
<td class="mono">${model.id}</td>
|
|
<td>${model.name}</td>
|
|
<td class="mono">${model.model_type}</td>
|
|
<td><wn-tag>${model.status}</wn-tag></td>
|
|
</tr>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function populatePeriods(history, current) {
|
|
const select = document.getElementById("period-select");
|
|
select.innerHTML = [...history].reverse().map(
|
|
(row) => `<option value="${row.period}" ${row.period === current ? "selected" : ""}>${row.period}</option>`
|
|
);
|
|
select.value = current;
|
|
if (!select._obsBound) {
|
|
select.addEventListener("wn-change", async (event) => {
|
|
const next = await loadDashboard(event.detail.value);
|
|
render(next);
|
|
});
|
|
select._obsBound = true;
|
|
}
|
|
}
|
|
|
|
async function loadDesignRefLabel() {
|
|
try {
|
|
const response = await fetch("/ui/vendor/whynot-design/.whynot-design-ref");
|
|
if (!response.ok) return;
|
|
const ref = (await response.text()).trim().slice(0, 7);
|
|
const label = document.getElementById("design-ref-label");
|
|
if (label && ref) label.textContent = `whynot-design @ ${ref}`;
|
|
} catch {
|
|
// optional footer detail
|
|
}
|
|
}
|
|
|
|
function render(data) {
|
|
document.getElementById("design-link").href = data.design_reference;
|
|
loadDesignRefLabel();
|
|
const header = document.getElementById("page-header");
|
|
header.lede = `Period ${data.period}. Ledger-backed view of infrastructure spend, member payments, and remaining budget. Customer cost-pass-through billing is not active.`;
|
|
setBadge(document.getElementById("liquidity-badge"), data.snapshot.liquidity_status);
|
|
renderMetrics(data);
|
|
renderBudget(data);
|
|
renderChart(data.history);
|
|
renderInfra(data);
|
|
renderTables(data);
|
|
populatePeriods(data.history, data.period);
|
|
}
|
|
|
|
loadDashboard()
|
|
.then(render)
|
|
.catch((error) => {
|
|
document.body.innerHTML = `<main class="wn-main"><wn-banner variant="error" title="Load failed">${error}</wn-banner></main>`;
|
|
}); |