2026-06-22 02:48:52 +02:00
|
|
|
|
const euro = (value) => `€${Number(value).toFixed(2)}`;
|
|
|
|
|
|
|
2026-06-22 23:05:05 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 02:48:52 +02:00
|
|
|
|
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;
|
2026-06-22 03:09:44 +02:00
|
|
|
|
el.active = status === "generating" || status === "neutral";
|
|
|
|
|
|
el.draft = status === "burning";
|
2026-06-22 02:48:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 23:05:05 +02:00
|
|
|
|
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) {
|
2026-06-22 02:48:52 +02:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-06-22 23:05:05 +02:00
|
|
|
|
label: "Cost per member",
|
|
|
|
|
|
value: euro(data.cost_floor.cost_per_member),
|
|
|
|
|
|
sub: `Platform ${euro(data.cost_floor.monthly_total_platform_cost)} / mo`,
|
2026-06-22 02:48:52 +02:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-06-22 23:05:05 +02:00
|
|
|
|
label: "Gross margin",
|
|
|
|
|
|
value: euro(snapshot.gross_margin),
|
|
|
|
|
|
sub: `${snapshot.gross_margin_pct}% of gross revenue`,
|
2026-06-22 02:48:52 +02:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: "Infrastructure / month",
|
|
|
|
|
|
value: euro(snapshot.monthly_infrastructure_cost),
|
|
|
|
|
|
sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-06-22 23:05:05 +02:00
|
|
|
|
label: "Active price",
|
|
|
|
|
|
value: euro(data.cost_floor.active_price),
|
|
|
|
|
|
sub: data.cost_floor.active_model_name ?? "—",
|
2026-06-22 02:48:52 +02:00
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-06-22 03:09:44 +02:00
|
|
|
|
document.getElementById("metric-grid").innerHTML = cards
|
2026-06-22 02:48:52 +02:00
|
|
|
|
.map(
|
|
|
|
|
|
(card) => `
|
2026-06-22 03:09:44 +02:00
|
|
|
|
<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>`
|
2026-06-22 02:48:52 +02:00
|
|
|
|
)
|
|
|
|
|
|
.join("");
|
|
|
|
|
|
|
|
|
|
|
|
const initial = Number(liquidity.initial_budget);
|
|
|
|
|
|
const remaining = Number(liquidity.remaining_budget);
|
|
|
|
|
|
const pct = Math.max(0, Math.min(100, (remaining / initial) * 100));
|
2026-06-22 03:09:44 +02:00
|
|
|
|
const banner = document.getElementById("budget-banner");
|
2026-06-22 02:48:52 +02:00
|
|
|
|
document.getElementById("budget-fill").style.width = `${pct}%`;
|
2026-06-22 23:05:05 +02:00
|
|
|
|
document.getElementById("budget-caption").textContent =
|
|
|
|
|
|
remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
|
2026-06-22 03:09:44 +02:00
|
|
|
|
banner.variant = remaining < initial * 0.25 ? "warn" : "info";
|
|
|
|
|
|
|
2026-06-22 02:48:52 +02:00
|
|
|
|
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(
|
2026-06-22 03:09:44 +02:00
|
|
|
|
([label, value]) => `
|
|
|
|
|
|
<wn-field-row label="${label}" narrow>
|
|
|
|
|
|
<span class="wn-field-row__value">${value}</span>
|
|
|
|
|
|
</wn-field-row>`
|
2026-06-22 02:48:52 +02:00
|
|
|
|
)
|
|
|
|
|
|
.join("");
|
|
|
|
|
|
|
2026-06-22 23:05:05 +02:00
|
|
|
|
const max = Math.max(...data.history.map((row) => Math.abs(Number(row.net_liquidity))), 1);
|
|
|
|
|
|
document.getElementById("liquidity-chart").innerHTML = data.history
|
2026-06-22 02:48:52 +02:00
|
|
|
|
.map((row) => {
|
|
|
|
|
|
const value = Number(row.net_liquidity);
|
|
|
|
|
|
const width = (Math.abs(value) / max) * 50;
|
2026-06-22 03:09:44 +02:00
|
|
|
|
const barClass = value < 0 ? "obs-liquidity-row__bar--neg" : "obs-liquidity-row__bar--pos";
|
|
|
|
|
|
const valueClass = value < 0 ? "obs-liquidity-row__value--neg" : "";
|
2026-06-22 02:48:52 +02:00
|
|
|
|
return `
|
2026-06-22 03:09:44 +02:00
|
|
|
|
<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>
|
2026-06-22 02:48:52 +02:00
|
|
|
|
</div>`;
|
|
|
|
|
|
})
|
|
|
|
|
|
.join("");
|
|
|
|
|
|
|
|
|
|
|
|
const domains = data.infrastructure.domains?.domains ?? [];
|
|
|
|
|
|
const servers = data.infrastructure.virtual_servers?.servers ?? [];
|
|
|
|
|
|
const stripe = data.infrastructure.stripe?.membership ?? {};
|
2026-06-22 03:09:44 +02:00
|
|
|
|
const payout = data.infrastructure.stripe?.payout_account ?? "payout";
|
2026-06-22 23:05:05 +02:00
|
|
|
|
const infraItems = [
|
2026-06-22 02:48:52 +02:00
|
|
|
|
...domains.map(
|
|
|
|
|
|
(d) =>
|
2026-06-22 03:09:44 +02:00
|
|
|
|
`<wn-field-row label="${d.name}" narrow><span class="wn-field-row__value">${d.monthly_eur} EUR/mo · ${d.tld}</span></wn-field-row>`
|
2026-06-22 02:48:52 +02:00
|
|
|
|
),
|
|
|
|
|
|
...servers.map(
|
|
|
|
|
|
(s) =>
|
2026-06-22 03:09:44 +02:00
|
|
|
|
`<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>`
|
2026-06-22 02:48:52 +02:00
|
|
|
|
),
|
2026-06-22 03:09:44 +02:00
|
|
|
|
`<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>`,
|
2026-06-22 02:48:52 +02:00
|
|
|
|
];
|
2026-06-22 23:05:05 +02:00
|
|
|
|
document.getElementById("infra-stack").innerHTML = `<div class="obs-infra-list">${infraItems.join("")}</div>`;
|
2026-06-22 02:48:52 +02:00
|
|
|
|
|
|
|
|
|
|
document.getElementById("history-body").innerHTML = data.history
|
|
|
|
|
|
.map(
|
|
|
|
|
|
(row) => `
|
|
|
|
|
|
<tr>
|
2026-06-22 03:09:44 +02:00
|
|
|
|
<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>
|
2026-06-22 02:48:52 +02:00
|
|
|
|
</tr>`
|
|
|
|
|
|
)
|
|
|
|
|
|
.join("");
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("pricing-body").innerHTML = data.pricing_models
|
|
|
|
|
|
.map(
|
|
|
|
|
|
(model) => `
|
|
|
|
|
|
<tr>
|
2026-06-22 03:09:44 +02:00
|
|
|
|
<td class="mono">${model.id}</td>
|
2026-06-22 02:48:52 +02:00
|
|
|
|
<td>${model.name}</td>
|
2026-06-22 03:09:44 +02:00
|
|
|
|
<td class="mono">${model.model_type}</td>
|
|
|
|
|
|
<td><wn-tag>${model.status}</wn-tag></td>
|
2026-06-22 02:48:52 +02:00
|
|
|
|
</tr>`
|
|
|
|
|
|
)
|
|
|
|
|
|
.join("");
|
2026-06-22 23:05:05 +02:00
|
|
|
|
|
|
|
|
|
|
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("");
|
2026-06-22 02:48:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>`
|
|
|
|
|
|
);
|
2026-06-22 03:09:44 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-06-22 02:48:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function render(data) {
|
2026-06-22 23:05:05 +02:00
|
|
|
|
currentData = normalizeData(data);
|
2026-06-22 02:48:52 +02:00
|
|
|
|
document.getElementById("design-link").href = data.design_reference;
|
2026-06-22 03:09:44 +02:00
|
|
|
|
loadDesignRefLabel();
|
2026-06-22 23:05:05 +02:00
|
|
|
|
setSection(activeSection);
|
|
|
|
|
|
renderCostFloor(data);
|
|
|
|
|
|
renderValueRange(data);
|
|
|
|
|
|
renderMarketPrice(data);
|
2026-06-22 02:48:52 +02:00
|
|
|
|
populatePeriods(data.history, data.period);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 23:05:05 +02:00
|
|
|
|
bindNavigation();
|
|
|
|
|
|
|
2026-06-22 02:48:52 +02:00
|
|
|
|
loadDashboard()
|
|
|
|
|
|
.then(render)
|
|
|
|
|
|
.catch((error) => {
|
2026-06-22 03:09:44 +02:00
|
|
|
|
document.body.innerHTML = `<main class="wn-main"><wn-banner variant="error" title="Load failed">${error}</wn-banner></main>`;
|
2026-06-22 02:48:52 +02:00
|
|
|
|
});
|