generated from coulomb/repo-seed
observatory stuff
This commit is contained in:
@@ -1,5 +1,69 @@
|
||||
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}`);
|
||||
@@ -13,7 +77,36 @@ function setBadge(el, status) {
|
||||
el.draft = status === "burning";
|
||||
}
|
||||
|
||||
function renderMetrics(data) {
|
||||
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 = [
|
||||
{
|
||||
@@ -27,14 +120,14 @@ function renderMetrics(data) {
|
||||
sub: snapshot.liquidity_status,
|
||||
},
|
||||
{
|
||||
label: "Active members",
|
||||
value: snapshot.active_members,
|
||||
sub: data.members[0]?.username ? `@${data.members[0].username}` : "—",
|
||||
label: "Cost per member",
|
||||
value: euro(data.cost_floor.cost_per_member),
|
||||
sub: `Platform ${euro(data.cost_floor.monthly_total_platform_cost)} / mo`,
|
||||
},
|
||||
{
|
||||
label: "Member payment (gross)",
|
||||
value: euro(snapshot.monthly_revenue),
|
||||
sub: `Net ${euro(data.payments.at(-1)?.net_amount ?? 0)} after Stripe`,
|
||||
label: "Gross margin",
|
||||
value: euro(snapshot.gross_margin),
|
||||
sub: `${snapshot.gross_margin_pct}% of gross revenue`,
|
||||
},
|
||||
{
|
||||
label: "Infrastructure / month",
|
||||
@@ -42,9 +135,9 @@ function renderMetrics(data) {
|
||||
sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`,
|
||||
},
|
||||
{
|
||||
label: "Cumulative net liquidity",
|
||||
value: euro(liquidity.cumulative_net_liquidity),
|
||||
sub: liquidity.liquidity_status,
|
||||
label: "Active price",
|
||||
value: euro(data.cost_floor.active_price),
|
||||
sub: data.cost_floor.active_model_name ?? "—",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,18 +151,14 @@ function renderMetrics(data) {
|
||||
</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;
|
||||
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 = [
|
||||
@@ -85,11 +174,9 @@ function renderBudget(data) {
|
||||
</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
|
||||
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;
|
||||
@@ -105,14 +192,12 @@ function renderChart(history) {
|
||||
</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 = [
|
||||
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>`
|
||||
@@ -123,10 +208,8 @@ function renderInfra(data) {
|
||||
),
|
||||
`<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>`;
|
||||
}
|
||||
document.getElementById("infra-stack").innerHTML = `<div class="obs-infra-list">${infraItems.join("")}</div>`;
|
||||
|
||||
function renderTables(data) {
|
||||
document.getElementById("history-body").innerHTML = data.history
|
||||
.map(
|
||||
(row) => `
|
||||
@@ -152,6 +235,138 @@ function renderTables(data) {
|
||||
</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) {
|
||||
@@ -182,19 +397,18 @@ async function loadDesignRefLabel() {
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
currentData = normalizeData(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);
|
||||
setSection(activeSection);
|
||||
renderCostFloor(data);
|
||||
renderValueRange(data);
|
||||
renderMarketPrice(data);
|
||||
populatePeriods(data.history, data.period);
|
||||
}
|
||||
|
||||
bindNavigation();
|
||||
|
||||
loadDashboard()
|
||||
.then(render)
|
||||
.catch((error) => {
|
||||
|
||||
@@ -26,109 +26,182 @@
|
||||
<wn-tag slot="right" id="liquidity-badge">—</wn-tag>
|
||||
</wn-top-nav>
|
||||
|
||||
<main class="wn-main obs-main">
|
||||
<wn-page-header
|
||||
eyebrow="Economic Observatory · MVP"
|
||||
title="Operator liquidity"
|
||||
lede="Ledger-backed view of infrastructure spend, member payments, and remaining budget. Customer cost-pass-through billing is not active."
|
||||
id="page-header"
|
||||
></wn-page-header>
|
||||
<div class="wn-app obs-app">
|
||||
<wn-sidebar id="obs-sidebar">
|
||||
<wn-sidebar-group label="Observatory">
|
||||
<wn-sidebar-item icon="activity" active data-section="cost-floor" id="nav-cost-floor">
|
||||
Cost Floor
|
||||
</wn-sidebar-item>
|
||||
<wn-sidebar-item icon="lightbulb" data-section="value-range" id="nav-value-range">
|
||||
Value Range
|
||||
</wn-sidebar-item>
|
||||
<wn-sidebar-item icon="users" data-section="market-price" id="nav-market-price">
|
||||
Market Price
|
||||
</wn-sidebar-item>
|
||||
</wn-sidebar-group>
|
||||
</wn-sidebar>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Snapshot</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-metric-grid" id="metric-grid"></div>
|
||||
</section>
|
||||
<main class="wn-main obs-main">
|
||||
<wn-page-header id="page-header"></wn-page-header>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Liquidity & budget</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<wn-banner variant="info" title="Budget position" id="budget-banner">
|
||||
<p id="budget-caption">—</p>
|
||||
</wn-banner>
|
||||
<div class="obs-budget-meter" aria-hidden="true">
|
||||
<div class="obs-budget-meter__fill" id="budget-fill"></div>
|
||||
</div>
|
||||
<div class="obs-field-sheet" id="budget-stats"></div>
|
||||
</section>
|
||||
<section class="obs-panel obs-panel--active" id="panel-cost-floor" data-section="cost-floor">
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Snapshot</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-metric-grid" id="metric-grid"></div>
|
||||
</section>
|
||||
|
||||
<div class="obs-split">
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Monthly net liquidity</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Liquidity & budget</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<wn-banner variant="info" title="Budget position" id="budget-banner">
|
||||
<p id="budget-caption">—</p>
|
||||
</wn-banner>
|
||||
<div class="obs-budget-meter" aria-hidden="true">
|
||||
<div class="obs-budget-meter__fill" id="budget-fill"></div>
|
||||
</div>
|
||||
<div class="obs-field-sheet" id="budget-stats"></div>
|
||||
</section>
|
||||
|
||||
<div class="obs-split">
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Monthly net liquidity</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Member net payments minus infrastructure, by period.</p>
|
||||
<div class="obs-liquidity-list" id="liquidity-chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Infrastructure stack</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Domains, hosting, and Stripe reference rates.</p>
|
||||
<div id="infra-stack"></div>
|
||||
</section>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Member net payments minus infrastructure, by period.</p>
|
||||
<div class="obs-liquidity-list" id="liquidity-chart"></div>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Monthly ledger</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Computed from expense and payment record tables.</p>
|
||||
<div class="obs-table-wrap">
|
||||
<table class="wn-table--native obs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th>Members</th>
|
||||
<th class="obs-num">Gross</th>
|
||||
<th class="obs-num">Infrastructure</th>
|
||||
<th class="obs-num">Processing</th>
|
||||
<th class="obs-num">Net liquidity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Pricing model registry</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-table-wrap">
|
||||
<table class="wn-table--native obs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Infrastructure stack</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Domains, hosting, and Stripe reference rates.</p>
|
||||
<div id="infra-stack"></div>
|
||||
<section class="obs-panel" id="panel-value-range" data-section="value-range" hidden>
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Current position</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-metric-grid" id="value-summary"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Segment bands</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note" id="value-range-notes"></p>
|
||||
<div class="obs-value-grid" id="value-segments"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Value drivers</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-field-sheet" id="value-drivers"></div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Monthly ledger</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Computed from expense and payment record tables.</p>
|
||||
<div class="obs-table-wrap">
|
||||
<table class="wn-table--native obs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th>Members</th>
|
||||
<th class="obs-num">Gross</th>
|
||||
<th class="obs-num">Infrastructure</th>
|
||||
<th class="obs-num">Processing</th>
|
||||
<th class="obs-num">Net liquidity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="obs-panel" id="panel-market-price" data-section="market-price" hidden>
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Market span</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-metric-grid" id="market-summary"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Pricing model registry</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-table-wrap">
|
||||
<table class="wn-table--native obs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Competitive alternatives</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note" id="market-notes"></p>
|
||||
<div class="obs-table-wrap">
|
||||
<table class="wn-table--native obs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alternative</th>
|
||||
<th>Category</th>
|
||||
<th class="obs-num">Price / mo</th>
|
||||
<th>Signal</th>
|
||||
<th>Features</th>
|
||||
<th>Evidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="market-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer class="obs-footer">
|
||||
<p class="small">
|
||||
Design:
|
||||
<a id="design-link" href="#" target="_blank" rel="noreferrer">Claude design share</a>
|
||||
·
|
||||
<span class="mono" id="design-ref-label">whynot-design</span>
|
||||
· totals computed programmatically from ledgers
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
<footer class="obs-footer">
|
||||
<p class="small">
|
||||
Design:
|
||||
<a id="design-link" href="#" target="_blank" rel="noreferrer">Claude design share</a>
|
||||
·
|
||||
<span class="mono" id="design-ref-label">whynot-design</span>
|
||||
· totals computed programmatically from ledgers
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/ui/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -4,10 +4,70 @@ body {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.obs-app {
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.obs-main {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-6) var(--sp-5) var(--sp-9);
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.obs-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
wn-sidebar-item[data-section] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.obs-metric__value--sm {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.obs-value-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.obs-range {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.obs-range__labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
font-size: 12px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
.obs-range__current {
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.obs-range__track {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
.obs-range__fill {
|
||||
height: 100%;
|
||||
background: var(--line-strong);
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.obs-range__marker {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: var(--ink);
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.obs-period {
|
||||
|
||||
Reference in New Issue
Block a user