generated from coulomb/repo-seed
Introduce ui/ dashboard (dark observatory layout), JSON API, and local dev server. All metrics load from expense and payment record ledgers. Links Claude design reference for visual alignment.
178 lines
6.0 KiB
JavaScript
178 lines
6.0 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.classList.toggle("ok", status === "generating" || status === "neutral");
|
|
}
|
|
|
|
function renderHero(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("hero-grid").innerHTML = cards
|
|
.map(
|
|
(card) => `
|
|
<article class="hero-card">
|
|
<div class="label">${card.label}</div>
|
|
<div class="value">${card.value}</div>
|
|
<div class="sub">${card.sub}</div>
|
|
</article>`
|
|
)
|
|
.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));
|
|
document.getElementById("budget-fill").style.width = `${pct}%`;
|
|
document.getElementById("budget-caption").textContent =
|
|
remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
|
|
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(
|
|
([k, v]) => `
|
|
<div class="stat">
|
|
<div class="k">${k}</div>
|
|
<div class="v">${v}</div>
|
|
</div>`
|
|
)
|
|
.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 bar =
|
|
value < 0
|
|
? `<div class="chart-bar neg" style="width:${width}%"></div>`
|
|
: `<div class="chart-bar pos" style="width:${width}%"></div>`;
|
|
return `
|
|
<div class="chart-row">
|
|
<div class="chart-label">${row.period}</div>
|
|
<div class="chart-bar-wrap">${bar}</div>
|
|
<div class="chart-value">${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 items = [
|
|
...domains.map(
|
|
(d) =>
|
|
`<div class="infra-item"><strong>${d.name}</strong><span>${d.monthly_eur} EUR/mo · ${d.tld}</span></div>`
|
|
),
|
|
...servers.map(
|
|
(s) =>
|
|
`<div class="infra-item"><strong>${s.name}</strong><span>${s.monthly_eur} EUR/mo · since ${s.started}</span></div>`
|
|
),
|
|
`<div class="infra-item"><strong>Stripe · ${stripe.member_username ?? "member"}</strong><span>${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${data.infrastructure.stripe?.payout_account ?? "payout"}</span></div>`,
|
|
];
|
|
document.getElementById("infra-stack").innerHTML = `<div class="infra-list">${items.join("")}</div>`;
|
|
}
|
|
|
|
function renderTables(data) {
|
|
document.getElementById("history-body").innerHTML = data.history
|
|
.map(
|
|
(row) => `
|
|
<tr>
|
|
<td>${row.period}</td>
|
|
<td class="num">${row.active_members}</td>
|
|
<td class="num">${euro(row.gross_revenue)}</td>
|
|
<td class="num">${euro(row.infrastructure_cost)}</td>
|
|
<td class="num">${euro(row.payment_processing_cost)}</td>
|
|
<td class="num">${euro(row.net_liquidity)}</td>
|
|
</tr>`
|
|
)
|
|
.join("");
|
|
|
|
document.getElementById("pricing-body").innerHTML = data.pricing_models
|
|
.map(
|
|
(model) => `
|
|
<tr>
|
|
<td>${model.id}</td>
|
|
<td>${model.name}</td>
|
|
<td>${model.model_type}</td>
|
|
<td>${model.status}</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.onchange = async () => {
|
|
const next = await loadDashboard(select.value);
|
|
render(next);
|
|
};
|
|
}
|
|
|
|
function render(data) {
|
|
document.getElementById("design-link").href = data.design_reference;
|
|
setBadge(document.getElementById("liquidity-badge"), data.snapshot.liquidity_status);
|
|
renderHero(data);
|
|
renderBudget(data);
|
|
renderChart(data.history);
|
|
renderInfra(data);
|
|
renderTables(data);
|
|
populatePeriods(data.history, data.period);
|
|
}
|
|
|
|
loadDashboard()
|
|
.then(render)
|
|
.catch((error) => {
|
|
document.body.innerHTML = `<pre style="padding:24px;color:#fb7185">${error}</pre>`;
|
|
}); |