From 9c1c2142fc9015b63694fc18d74653e01c8002ce Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 02:48:52 +0200 Subject: [PATCH] Add Economic Observatory web UI with ledger-backed API 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. --- AGENTS.md | 1 + projects/coulomb-pricing/README.md | 5 + projects/coulomb-pricing/observatory/api.py | 98 ++++++ .../coulomb-pricing/observatory/server.py | 72 ++++ projects/coulomb-pricing/tests/test_api.py | 25 ++ projects/coulomb-pricing/ui/app.js | 178 ++++++++++ projects/coulomb-pricing/ui/index.html | 107 ++++++ projects/coulomb-pricing/ui/styles.css | 309 ++++++++++++++++++ 8 files changed, 795 insertions(+) create mode 100644 projects/coulomb-pricing/observatory/api.py create mode 100644 projects/coulomb-pricing/observatory/server.py create mode 100644 projects/coulomb-pricing/tests/test_api.py create mode 100644 projects/coulomb-pricing/ui/app.js create mode 100644 projects/coulomb-pricing/ui/index.html create mode 100644 projects/coulomb-pricing/ui/styles.css diff --git a/AGENTS.md b/AGENTS.md index 5f279eb..d64fee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Framework docs live at the repo root. The Coulomb MVP implementation lives in | Lint / format | none configured — match surrounding style | | Build | none | | Run: economics dashboard | `cd projects/coulomb-pricing && python3 -m observatory --period YYYY-MM` | +| Run: observatory UI | `cd projects/coulomb-pricing && python3 -m observatory.server` → http://127.0.0.1:8765/ | | Workplan / hub sync | `cd ~/state-hub && make fix-consistency REPO=adaptive-pricing REPO_PATH=~/adaptive-pricing` | | Registry sanity | `grep -q '^version:' registry/indexes/capabilities.yaml && echo OK` | diff --git a/projects/coulomb-pricing/README.md b/projects/coulomb-pricing/README.md index e40a109..d3a0185 100644 --- a/projects/coulomb-pricing/README.md +++ b/projects/coulomb-pricing/README.md @@ -35,7 +35,12 @@ cd projects/coulomb-pricing python3 -m pytest -q python3 -m observatory --period 2026-06 python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md +python3 -m observatory.server ``` +Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from +`ui/`, data via `/api/dashboard`). Design reference: +https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511 + Manual ledgers will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and OpenRouter (Sprint 4) importers. \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/api.py b/projects/coulomb-pricing/observatory/api.py new file mode 100644 index 0000000..008956f --- /dev/null +++ b/projects/coulomb-pricing/observatory/api.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path +from typing import Any + +from .economics import build_liquidity_summary, build_snapshot +from .load import ( + default_data_dir, + latest_period, + load_budget, + load_expense_records, + load_membership, + load_monthly_ledger, + load_payment_records, + load_pricing_models, + load_product, +) + + +def _serialize(value: Any) -> Any: + if isinstance(value, Decimal): + return str(value) + if hasattr(value, "__dataclass_fields__"): + return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__} + if isinstance(value, list): + return [_serialize(item) for item in value] + if isinstance(value, dict): + return {key: _serialize(item) for key, item in value.items()} + return value + + +def _load_json_catalog(data_dir: Path, name: str) -> dict: + path = data_dir / "infrastructure" / name + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def build_dashboard_payload(data_dir: Path | None = None, period: str | None = None) -> dict: + root = data_dir or default_data_dir() + product = load_product(root) + budget = load_budget(root) + models = load_pricing_models(root) + members = load_membership(root) + payments = load_payment_records(root) + expenses = load_expense_records(root) + ledger = load_monthly_ledger(root) + target_period = period or latest_period(ledger) + + snapshot = build_snapshot(target_period, product, models, members, payments, ledger) + liquidity = build_liquidity_summary(budget, payments, ledger, target_period) + payment_by_period = {record.period: record for record in payments} + + history = [] + for month in sorted(ledger, key=lambda row: row.period): + if month.period > target_period: + continue + payment = payment_by_period.get(month.period) + net_payment = payment.net_amount if payment else Decimal("0") + history.append( + { + "period": month.period, + "active_members": month.active_members, + "gross_revenue": month.gross_revenue, + "infrastructure_cost": month.infrastructure_cost, + "payment_processing_cost": month.payment_processing_cost, + "total_platform_cost": month.total_platform_cost, + "net_payment": net_payment, + "net_liquidity": net_payment - month.infrastructure_cost, + } + ) + + return _serialize( + { + "design_reference": "https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511", + "period": target_period, + "product": product, + "budget": budget, + "snapshot": snapshot, + "liquidity": liquidity, + "history": history, + "pricing_models": models, + "members": members, + "payments": payments, + "expense_record_count": len(expenses), + "infrastructure": { + "domains": _load_json_catalog(root, "domains.json"), + "virtual_servers": _load_json_catalog(root, "virtual_servers.json"), + "stripe": _load_json_catalog(root, "stripe.json"), + }, + } + ) + + +def payload_json(data_dir: Path | None = None, period: str | None = None) -> str: + return json.dumps(build_dashboard_payload(data_dir, period), indent=2) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/server.py b/projects/coulomb-pricing/observatory/server.py new file mode 100644 index 0000000..fa6774b --- /dev/null +++ b/projects/coulomb-pricing/observatory/server.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import argparse +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from .api import build_dashboard_payload + +ROOT = Path(__file__).resolve().parent.parent +UI_DIR = ROOT / "ui" + + +class ObservatoryHandler(BaseHTTPRequestHandler): + data_dir: Path = ROOT / "data" + + def _send(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/api/dashboard": + query = parse_qs(parsed.query) + period = query.get("period", [None])[0] + payload = build_dashboard_payload(self.data_dir, period) + self._send(200, json.dumps(payload).encode("utf-8"), "application/json") + return + + if parsed.path == "/": + return self._serve_file(UI_DIR / "index.html", "text/html; charset=utf-8") + + if parsed.path.startswith("/ui/"): + relative = parsed.path.removeprefix("/ui/") + target = UI_DIR / relative + if target.exists() and target.is_file(): + content_type = "text/css" if target.suffix == ".css" else "application/javascript" + return self._serve_file(target, f"{content_type}; charset=utf-8") + + self._send(404, b"Not found", "text/plain") + + def _serve_file(self, path: Path, content_type: str) -> None: + self._send(200, path.read_bytes(), content_type) + + def log_message(self, format: str, *args) -> None: + return + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Coulomb Economic Observatory UI server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--data-dir", type=Path, default=ROOT / "data") + args = parser.parse_args(argv) + + ObservatoryHandler.data_dir = args.data_dir + server = ThreadingHTTPServer((args.host, args.port), ObservatoryHandler) + print(f"Economic Observatory UI: http://{args.host}:{args.port}/") + print(f"API: http://{args.host}:{args.port}/api/dashboard") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/projects/coulomb-pricing/tests/test_api.py b/projects/coulomb-pricing/tests/test_api.py new file mode 100644 index 0000000..212d53c --- /dev/null +++ b/projects/coulomb-pricing/tests/test_api.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path + +from observatory.api import build_dashboard_payload, payload_json + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" + + +def test_dashboard_payload_contains_live_ledger_totals() -> None: + payload = build_dashboard_payload(DATA_DIR, "2026-06") + + assert payload["period"] == "2026-06" + assert payload["liquidity"]["remaining_budget"] == "659.12" + assert payload["liquidity"]["cumulative_infrastructure_cost"] == "409.28" + assert payload["snapshot"]["monthly_infrastructure_cost"] == "29.73" + assert len(payload["history"]) == 18 + assert payload["expense_record_count"] == 58 + + +def test_payload_json_is_valid() -> None: + parsed = json.loads(payload_json(DATA_DIR, "2026-06")) + assert Decimal(parsed["payments"][0]["fees_amount"]) == Decimal("0.44") \ No newline at end of file diff --git a/projects/coulomb-pricing/ui/app.js b/projects/coulomb-pricing/ui/app.js new file mode 100644 index 0000000..a917d5c --- /dev/null +++ b/projects/coulomb-pricing/ui/app.js @@ -0,0 +1,178 @@ +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) => ` +
+
${card.label}
+
${card.value}
+
${card.sub}
+
` + ) + .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]) => ` +
+
${k}
+
${v}
+
` + ) + .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 + ? `
` + : `
`; + return ` +
+
${row.period}
+
${bar}
+
${euro(value)}
+
`; + }) + .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) => + `
${d.name}${d.monthly_eur} EUR/mo · ${d.tld}
` + ), + ...servers.map( + (s) => + `
${s.name}${s.monthly_eur} EUR/mo · since ${s.started}
` + ), + `
Stripe · ${stripe.member_username ?? "member"}${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${data.infrastructure.stripe?.payout_account ?? "payout"}
`, + ]; + document.getElementById("infra-stack").innerHTML = `
${items.join("")}
`; +} + +function renderTables(data) { + document.getElementById("history-body").innerHTML = data.history + .map( + (row) => ` + + ${row.period} + ${row.active_members} + ${euro(row.gross_revenue)} + ${euro(row.infrastructure_cost)} + ${euro(row.payment_processing_cost)} + ${euro(row.net_liquidity)} + ` + ) + .join(""); + + document.getElementById("pricing-body").innerHTML = data.pricing_models + .map( + (model) => ` + + ${model.id} + ${model.name} + ${model.model_type} + ${model.status} + ` + ) + .join(""); +} + +function populatePeriods(history, current) { + const select = document.getElementById("period-select"); + select.innerHTML = [...history].reverse().map( + (row) => `` + ); + 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 = `
${error}
`; + }); \ No newline at end of file diff --git a/projects/coulomb-pricing/ui/index.html b/projects/coulomb-pricing/ui/index.html new file mode 100644 index 0000000..e05ba7e --- /dev/null +++ b/projects/coulomb-pricing/ui/index.html @@ -0,0 +1,107 @@ + + + + + + Coulomb Economic Observatory + + + +
+
+
+

Adaptive Pricing · Coulomb Social MVP

+

Economic Observatory

+
+
+ + +
+
+ +
+ +
+
+

Liquidity & Budget

+

Operator liquidity pool

+
+
+
+
+
+
+ +
+
+
+

Monthly Net Liquidity

+

Member net payments minus infrastructure

+
+
+
+ +
+
+

Infrastructure Stack

+

Domains, hosting, and Stripe reference

+
+
+
+
+ +
+
+

Monthly Ledger

+

Computed from expense and payment record tables

+
+
+ + + + + + + + + + + + +
PeriodMembersGrossInfrastructureProcessingNet liquidity
+
+
+ +
+
+

Pricing Model Registry

+
+
+ + + + + + + + + + +
IDNameTypeStatus
+
+
+ +
+

+ Design reference: + Claude design share + · Totals computed programmatically from ledgers · Customer cost-pass-through billing not active +

+
+
+ + + \ No newline at end of file diff --git a/projects/coulomb-pricing/ui/styles.css b/projects/coulomb-pricing/ui/styles.css new file mode 100644 index 0000000..75b638d --- /dev/null +++ b/projects/coulomb-pricing/ui/styles.css @@ -0,0 +1,309 @@ +:root { + color-scheme: dark; + --bg: #0b1020; + --panel: #121a2f; + --panel-border: #24304d; + --text: #e8edf8; + --muted: #93a0bf; + --accent: #5eead4; + --accent-soft: rgba(94, 234, 212, 0.12); + --warn: #fb7185; + --warn-soft: rgba(251, 113, 133, 0.14); + --ok: #86efac; + --ok-soft: rgba(134, 239, 172, 0.14); + --shadow: 0 18px 50px rgba(0, 0, 0, 0.28); + font-family: "Segoe UI", system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: + radial-gradient(circle at top left, rgba(94, 234, 212, 0.08), transparent 28%), + radial-gradient(circle at top right, rgba(96, 165, 250, 0.08), transparent 24%), + var(--bg); + color: var(--text); +} + +.app { + max-width: 1200px; + margin: 0 auto; + padding: 28px 20px 48px; +} + +.topbar, +.panel, +.hero-card { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 18px; + box-shadow: var(--shadow); +} + +.topbar { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: center; + padding: 22px 24px; + margin-bottom: 20px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--muted); + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; + font-weight: 650; +} + +.topbar-actions { + display: flex; + gap: 14px; + align-items: end; +} + +label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 0.82rem; +} + +select { + background: #0d1426; + color: var(--text); + border: 1px solid var(--panel-border); + border-radius: 10px; + padding: 10px 12px; + min-width: 140px; +} + +.badge { + padding: 10px 14px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; + background: var(--warn-soft); + color: var(--warn); +} + +.badge.ok { + background: var(--ok-soft); + color: var(--ok); +} + +.hero-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; + margin-bottom: 20px; +} + +.hero-card { + padding: 18px 18px 16px; +} + +.hero-card .label { + color: var(--muted); + font-size: 0.82rem; + margin-bottom: 8px; +} + +.hero-card .value { + font-size: 1.7rem; + font-weight: 700; + letter-spacing: -0.03em; +} + +.hero-card .sub { + margin-top: 8px; + color: var(--muted); + font-size: 0.85rem; +} + +.panel { + padding: 20px 22px; + margin-bottom: 20px; +} + +.panel-head { + margin-bottom: 16px; +} + +.panel-head p { + margin: 6px 0 0; + color: var(--muted); + font-size: 0.92rem; +} + +.split-grid { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 20px; +} + +.budget-bar { + height: 14px; + border-radius: 999px; + background: #0d1426; + overflow: hidden; + margin-bottom: 14px; +} + +.budget-fill { + height: 100%; + width: 0; + background: linear-gradient(90deg, var(--accent), #60a5fa); + transition: width 0.35s ease; +} + +.budget-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.stat { + padding: 12px 14px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.stat .k { + color: var(--muted); + font-size: 0.8rem; +} + +.stat .v { + margin-top: 6px; + font-size: 1.1rem; + font-weight: 650; +} + +.chart { + display: grid; + gap: 10px; +} + +.chart-row { + display: grid; + grid-template-columns: 72px 1fr 72px; + gap: 10px; + align-items: center; +} + +.chart-label, +.chart-value { + font-size: 0.82rem; + color: var(--muted); +} + +.chart-value { + text-align: right; +} + +.chart-bar-wrap { + height: 28px; + background: #0d1426; + border-radius: 8px; + overflow: hidden; + position: relative; +} + +.chart-bar { + position: absolute; + top: 0; + bottom: 0; + border-radius: 8px; +} + +.chart-bar.neg { + left: 50%; + background: linear-gradient(90deg, transparent, var(--warn)); +} + +.chart-bar.pos { + right: 50%; + background: linear-gradient(270deg, transparent, var(--ok)); +} + +.infra-list { + display: grid; + gap: 10px; +} + +.infra-item { + padding: 12px 14px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.infra-item strong { + display: block; + margin-bottom: 4px; +} + +.infra-item span { + color: var(--muted); + font-size: 0.88rem; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 11px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + text-align: left; + font-size: 0.92rem; +} + +th { + color: var(--muted); + font-weight: 600; +} + +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.footer { + color: var(--muted); + font-size: 0.86rem; +} + +.footer a { + color: var(--accent); +} + +@media (max-width: 900px) { + .split-grid { + grid-template-columns: 1fr; + } + + .topbar { + flex-direction: column; + align-items: flex-start; + } +} \ No newline at end of file