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
No EOL
6 KiB
JavaScript
178 lines
No EOL
6 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>`;
|
|
}); |