Files
adaptive-pricing/projects/coulomb-pricing/ui/app.js
2026-06-22 23:05:05 +02:00

416 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const euro = (value) => `${Number(value).toFixed(2)}`;
const SECTION_META = {
"cost-floor": {
eyebrow: "Economic Observatory · Cost Floor",
title: "Operator liquidity",
lede: (data) =>
`Period ${data.period}. Minimum viable economics: platform cost, margin, and liquidity burn from ledgers.`,
},
"value-range": {
eyebrow: "Economic Observatory · Value Range",
title: "Customer value bands",
lede: (data) =>
`Period ${data.period}. Segment-level willingness-to-pay hypotheses above the cost floor.`,
},
"market-price": {
eyebrow: "Economic Observatory · Market Price",
title: "Competitive context",
lede: (data) =>
`Reviewed ${data.market_price.last_reviewed ?? "—"}. Evidence about alternatives, capabilities, and public pricing.`,
},
};
let currentData = null;
let activeSection = "cost-floor";
function normalizeData(data) {
const snapshot = data.snapshot ?? {};
const active = (data.pricing_models ?? []).find((model) => model.status === "active");
if (!data.cost_floor) {
data.cost_floor = {
cost_per_member: snapshot.cost_per_member ?? "0",
monthly_total_platform_cost: snapshot.monthly_total_platform_cost ?? "0",
active_price: active?.access_fee_amount ?? snapshot.monthly_revenue ?? "0",
active_model_name: active?.name ?? null,
};
}
if (!data.value_range) {
data.value_range = {
current_price_eur: active?.access_fee_amount ?? snapshot.monthly_revenue ?? "0",
aggregate_low_eur: active?.access_fee_amount ?? "0",
aggregate_high_eur: active?.access_fee_amount ?? "0",
cost_per_member_eur: snapshot.cost_per_member ?? "0",
segments: [],
value_drivers: [],
notes: "Value range data unavailable — restart observatory.server to load the latest API.",
};
}
if (!data.market_price) {
data.market_price = {
alternative_count: 0,
priced_alternative_count: 0,
market_low_eur: null,
market_high_eur: null,
last_reviewed: null,
alternatives: [],
notes: "Market signals unavailable — restart observatory.server to load the latest API.",
};
}
return data;
}
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 setSection(sectionId) {
activeSection = sectionId;
document.querySelectorAll(".obs-panel").forEach((panel) => {
const active = panel.dataset.section === sectionId;
panel.hidden = !active;
panel.classList.toggle("obs-panel--active", active);
});
document.querySelectorAll("wn-sidebar-item[data-section]").forEach((item) => {
item.active = item.dataset.section === sectionId;
});
const meta = SECTION_META[sectionId];
const header = document.getElementById("page-header");
header.eyebrow = meta.eyebrow;
header.title = meta.title;
if (currentData) {
header.lede = meta.lede(currentData);
}
const badge = document.getElementById("liquidity-badge");
badge.style.display = sectionId === "cost-floor" ? "" : "none";
}
function bindNavigation() {
document.querySelectorAll("wn-sidebar-item[data-section]").forEach((item) => {
item.addEventListener("click", () => setSection(item.dataset.section));
});
}
function renderCostFloor(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: "Cost per member",
value: euro(data.cost_floor.cost_per_member),
sub: `Platform ${euro(data.cost_floor.monthly_total_platform_cost)} / mo`,
},
{
label: "Gross margin",
value: euro(snapshot.gross_margin),
sub: `${snapshot.gross_margin_pct}% of gross revenue`,
},
{
label: "Infrastructure / month",
value: euro(snapshot.monthly_infrastructure_cost),
sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`,
},
{
label: "Active price",
value: euro(data.cost_floor.active_price),
sub: data.cost_floor.active_model_name ?? "—",
},
];
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("");
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");
document.getElementById("budget-fill").style.width = `${pct}%`;
document.getElementById("budget-caption").textContent =
remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
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("");
const max = Math.max(...data.history.map((row) => Math.abs(Number(row.net_liquidity))), 1);
document.getElementById("liquidity-chart").innerHTML = data.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("");
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 infraItems = [
...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">${infraItems.join("")}</div>`;
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("");
setBadge(document.getElementById("liquidity-badge"), snapshot.liquidity_status);
}
function renderValueRangeBand(low, high, current) {
const min = Number(low);
const max = Number(high);
const cur = Number(current);
const span = Math.max(max - min, 0.01);
const curPct = Math.max(0, Math.min(100, ((cur - min) / span) * 100));
return `
<div class="obs-range">
<div class="obs-range__labels">
<span class="mono">${euro(min)}</span>
<span class="mono obs-range__current">${euro(cur)} now</span>
<span class="mono">${euro(max)}</span>
</div>
<div class="obs-range__track">
<div class="obs-range__fill" style="width:${curPct}%"></div>
<div class="obs-range__marker" style="left:${curPct}%"></div>
</div>
</div>`;
}
function renderValueRange(data) {
const vr = data.value_range;
document.getElementById("value-summary").innerHTML = [
{
label: "Current price",
value: euro(vr.current_price_eur),
sub: data.cost_floor.active_model_name ?? "—",
},
{
label: "Aggregate band",
value: `${euro(vr.aggregate_low_eur)} ${euro(vr.aggregate_high_eur)}`,
sub: `${vr.segments.length} segments`,
},
{
label: "Cost per member",
value: euro(vr.cost_per_member_eur),
sub: "Cost floor reference",
},
]
.map(
(card) => `
<wn-card size="sm">
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
<div class="obs-metric__value obs-metric__value--sm">${card.value}</div>
<span slot="footer">${card.sub}</span>
</wn-card>`
)
.join("");
document.getElementById("value-range-notes").textContent = vr.notes || "";
document.getElementById("value-segments").innerHTML = vr.segments
.map(
(segment) => `
<wn-card>
<wn-eyebrow slot="header">${segment.name}</wn-eyebrow>
<wn-tag slot="header">${segment.confidence}</wn-tag>
${renderValueRangeBand(segment.low_eur, segment.high_eur, vr.current_price_eur)}
<p class="small">${segment.evidence}</p>
<span slot="footer">Headroom ${euro(segment.headroom_to_high_eur)}</span>
<span slot="footer">${segment.drivers.join(" · ")}</span>
</wn-card>`
)
.join("");
document.getElementById("value-drivers").innerHTML = vr.value_drivers
.map(
(driver) => `
<wn-field-row label="${driver.label}" narrow>
<span class="wn-field-row__value">${driver.note}</span>
<span class="wn-field-row__aside">${driver.strength}</span>
</wn-field-row>`
)
.join("");
}
function renderMarketPrice(data) {
const market = data.market_price;
const span =
market.market_low_eur != null && market.market_high_eur != null
? `${euro(market.market_low_eur)} ${euro(market.market_high_eur)}`
: "—";
document.getElementById("market-summary").innerHTML = [
{
label: "Alternatives tracked",
value: market.alternative_count,
sub: `${market.priced_alternative_count} with public monthly price`,
},
{
label: "Priced span",
value: span,
sub: `Reviewed ${market.last_reviewed ?? "—"}`,
},
{
label: "Coulomb list price",
value: euro(data.value_range.current_price_eur),
sub: data.cost_floor.active_model_name ?? "—",
},
]
.map(
(card) => `
<wn-card size="sm">
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
<div class="obs-metric__value obs-metric__value--sm">${card.value}</div>
<span slot="footer">${card.sub}</span>
</wn-card>`
)
.join("");
document.getElementById("market-notes").textContent = market.notes || "";
document.getElementById("market-body").innerHTML = market.alternatives
.map((alt) => {
const price =
alt.price_monthly_eur && Number(alt.price_monthly_eur) > 0
? euro(alt.price_monthly_eur)
: "—";
const features = (alt.features ?? []).join(", ");
return `
<tr>
<td><strong>${alt.name}</strong><div class="small mono">${alt.id}</div></td>
<td><wn-tag>${alt.category}</wn-tag></td>
<td class="obs-num mono">${price}</td>
<td><wn-stage-dot level="${alt.signal_level}">${alt.signal_level}</wn-stage-dot></td>
<td>${features}</td>
<td class="small">${alt.source} · ${alt.observed}<br>${alt.note}</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) {
currentData = normalizeData(data);
document.getElementById("design-link").href = data.design_reference;
loadDesignRefLabel();
setSection(activeSection);
renderCostFloor(data);
renderValueRange(data);
renderMarketPrice(data);
populatePeriods(data.history, data.period);
}
bindNavigation();
loadDashboard()
.then(render)
.catch((error) => {
document.body.innerHTML = `<main class="wn-main"><wn-banner variant="error" title="Load failed">${error}</wn-banner></main>`;
});