observatory stuff

This commit is contained in:
2026-06-22 23:05:05 +02:00
parent 1bdb518a94
commit 4ef04dd5e5
22 changed files with 1005 additions and 159 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("financials")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/financials/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/financials/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/ADAPTIVE-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured financials into N workstreams, M tasks",
event_type="milestone",
topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **adaptive-pricing** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** Auto-regulating market value exploring price engine.
**Domain:** financials
**Repo slug:** adaptive-pricing
**Topic ID:** f39fa2a3-c491-414c-a91b-b4c5fcc6139c

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("financials")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="adaptive-pricing", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=adaptive-pricing&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `financials` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:adaptive-pricing]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"f39fa2a3-c491-414c-a91b-b4c5fcc6139c","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=adaptive-pricing
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=adaptive-pricing
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/ADAPTIVE-WP-NNNN-<slug>.md`
ID prefix: `ADAPTIVE-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-ADAPTIVE-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:adaptive-pricing]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: ADAPTIVE-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -4,37 +4,13 @@
**Purpose:** Auto-regulating market value exploring price engine.
**Domain:** helix_forge
**Domain:** financials
**Repo slug:** adaptive-pricing
**Topic ID:** `f39fa2a3-c491-414c-a91b-b4c5fcc6139c`
**Workplan prefix:** `ADAPTIVE-WP-`
---
## Dev Workflow
Framework docs live at the repo root. The Coulomb MVP implementation lives in
`projects/coulomb-pricing/observatory/` (stdlib Python 3.11+, no runtime deps;
`pytest` for tests). Run MVP commands from `projects/coulomb-pricing/`.
| Need | Command |
|------|---------|
| Python | `python3` (3.11+) |
| Install | none — stdlib only; for tests: `pip install pytest` |
| Test (MVP) | `cd projects/coulomb-pricing && python3 -m pytest -q` |
| Test (repo) | same as MVP until other packages exist |
| 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` |
**Verify a change before declaring it done:** run `python3 -m pytest` under
`projects/coulomb-pricing`, then `make fix-consistency` (expect PASS).
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
@@ -176,8 +152,6 @@ get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
---
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# adaptive-pricing — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

View File

@@ -0,0 +1,19 @@
.PHONY: help design test serve
.DEFAULT_GOAL := help
REF ?=
help: ## List available make targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
design: ## Re-vendor whynot-design (optional: REF=v0.2.1 or commit SHA)
./scripts/sync-whynot-design.sh $(REF)
python3 -m pytest -q tests/test_ui_vendor.py
test: ## Run the full pytest suite
python3 -m pytest -q
serve: ## Start the Economic Observatory UI on :8765
python3 -m observatory.server

View File

@@ -32,11 +32,12 @@ cost-pass-through billing is not active.
```bash
cd projects/coulomb-pricing
python3 -m pytest -q
make test
make design # pull whynot-design tokens/CSS/components
make design REF=v0.2.1 # bump to a tag or commit
python3 -m observatory --period 2026-06
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
./scripts/sync-whynot-design.sh # pull whynot-design tokens/CSS/components
python3 -m observatory.server
make serve
```
Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from

View File

@@ -0,0 +1,56 @@
{
"version": 1,
"currency": "EUR",
"last_reviewed": "2026-06",
"alternatives": [
{
"id": "github-pro",
"name": "GitHub Pro",
"category": "indirect",
"price_monthly_eur": "4.00",
"billing": "monthly",
"features": ["private repos", "protected branches", "CI minutes"],
"signal_level": "S2",
"observed": "2026-06",
"source": "public pricing page",
"note": "Substitute for repository hosting; lacks community and pricing-intelligence positioning."
},
{
"id": "circle-community",
"name": "Circle (community platform)",
"category": "indirect",
"price_monthly_eur": "49.00",
"billing": "monthly",
"features": ["courses", "events", "member spaces", "payments"],
"signal_level": "S2",
"observed": "2026-06",
"source": "public pricing page",
"note": "Higher floor for hosted community; Coulomb is narrower and operator-led."
},
{
"id": "patreon-creator",
"name": "Patreon (creator membership)",
"category": "substitute",
"price_monthly_eur": "8.00",
"billing": "monthly",
"features": ["member posts", "Discord integration", "paywall"],
"signal_level": "S1",
"observed": "2026-06",
"source": "public pricing page",
"note": "Comparable entry price point; different delivery model (content vs repo access)."
},
{
"id": "build-in-house",
"name": "Build in-house community",
"category": "workaround",
"price_monthly_eur": "0.00",
"billing": "operator time",
"features": ["self-hosted forum", "manual onboarding", "no shared pricing lab"],
"signal_level": "S3",
"observed": "2026-06",
"source": "operator estimate",
"note": "Hidden cost in time; common alternative for technical founders."
}
],
"notes": "Market signals are manually curated until competitive intelligence imports exist."
}

View File

@@ -0,0 +1,40 @@
{
"version": 1,
"currency": "EUR",
"product_id": "coulomb-social-membership",
"segments": [
{
"id": "solo-builder",
"name": "Solo builder",
"low_eur": "8.99",
"high_eur": "19.00",
"confidence": "hypothesis",
"drivers": ["private repository access", "async community", "low switching cost"],
"evidence": "Founding member joined at list price without negotiation."
},
{
"id": "small-team",
"name": "Small product team",
"low_eur": "12.00",
"high_eur": "29.00",
"confidence": "hypothesis",
"drivers": ["shared norms", "pricing experiment sandbox", "operator proximity"],
"evidence": "No team-tier offer yet; inferred from comparable community memberships."
}
],
"value_drivers": [
{
"id": "repo-access",
"label": "Repository & community access",
"strength": "S2",
"note": "Core deliverable today; differentiated by operator involvement, not feature breadth."
},
{
"id": "pricing-lab",
"label": "Live pricing laboratory",
"strength": "S1",
"note": "Members observe real operator economics; value increases as observatory matures."
}
],
"notes": "Value range is observatory-only until willingness-to-pay interviews and usage signals exist."
}

View File

@@ -11,12 +11,15 @@ from .load import (
latest_period,
load_budget,
load_expense_records,
load_market_signals,
load_membership,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
load_value_range,
)
from .pricing_context import build_cost_floor, build_market_price_view, build_value_range_view
def _serialize(value: Any) -> Any:
@@ -72,6 +75,9 @@ def build_dashboard_payload(data_dir: Path | None = None, period: str | None = N
}
)
value_range_raw = load_value_range(root)
market_raw = load_market_signals(root)
return _serialize(
{
"design_reference": "https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511",
@@ -85,6 +91,9 @@ def build_dashboard_payload(data_dir: Path | None = None, period: str | None = N
"members": members,
"payments": payments,
"expense_record_count": len(expenses),
"cost_floor": build_cost_floor(snapshot, models),
"value_range": build_value_range_view(value_range_raw, snapshot, product, models),
"market_price": build_market_price_view(market_raw),
"infrastructure": {
"domains": _load_json_catalog(root, "domains.json"),
"virtual_servers": _load_json_catalog(root, "virtual_servers.json"),

View File

@@ -116,6 +116,14 @@ def load_payment_records(data_dir: Path | None = None) -> list[PaymentRecord]:
]
def load_value_range(data_dir: Path | None = None) -> dict:
return _read_json((data_dir or default_data_dir()) / "value_range.json")
def load_market_signals(data_dir: Path | None = None) -> dict:
return _read_json((data_dir or default_data_dir()) / "market_signals.json")
def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]:
raw = _read_json((data_dir or default_data_dir()) / "membership.json")
return [

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from .economics import active_pricing_model
from .models import EconomicsSnapshot, PricingModel, Product
TWOPLACES = Decimal("0.01")
def _quantize(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
def build_cost_floor(
snapshot: EconomicsSnapshot,
models: list[PricingModel],
) -> dict:
active = next((m for m in models if m.status == "active"), None)
return {
"period": snapshot.period,
"currency": snapshot.currency,
"monthly_infrastructure_cost": snapshot.monthly_infrastructure_cost,
"monthly_payment_processing_cost": snapshot.monthly_payment_processing_cost,
"monthly_total_platform_cost": snapshot.monthly_total_platform_cost,
"cost_per_member": snapshot.cost_per_member,
"active_members": snapshot.active_members,
"monthly_revenue": snapshot.monthly_revenue,
"gross_margin": snapshot.gross_margin,
"gross_margin_pct": snapshot.gross_margin_pct,
"active_price": active.access_fee_amount if active else Decimal("0"),
"active_model_id": active.id if active else None,
"active_model_name": active.name if active else None,
}
def build_value_range_view(
raw: dict,
snapshot: EconomicsSnapshot,
product: Product,
models: list[PricingModel],
) -> dict:
model = active_pricing_model(models, product)
current = model.access_fee_amount
segments = []
lows: list[Decimal] = []
highs: list[Decimal] = []
for item in raw.get("segments", []):
low = Decimal(str(item["low_eur"]))
high = Decimal(str(item["high_eur"]))
lows.append(low)
highs.append(high)
segments.append(
{
**item,
"headroom_to_high_eur": _quantize(high - current),
"below_floor": current < low,
}
)
aggregate_low = min(lows) if lows else current
aggregate_high = max(highs) if highs else current
return {
"currency": raw.get("currency", snapshot.currency),
"product_id": raw.get("product_id", product.id),
"current_price_eur": current,
"aggregate_low_eur": aggregate_low,
"aggregate_high_eur": aggregate_high,
"cost_per_member_eur": snapshot.cost_per_member,
"segments": segments,
"value_drivers": raw.get("value_drivers", []),
"notes": raw.get("notes", ""),
}
def build_market_price_view(raw: dict) -> dict:
alternatives = list(raw.get("alternatives", []))
prices = [
Decimal(str(item["price_monthly_eur"]))
for item in alternatives
if item.get("price_monthly_eur") not in (None, "", "0", "0.00")
]
return {
"currency": raw.get("currency", "EUR"),
"last_reviewed": raw.get("last_reviewed"),
"alternative_count": len(alternatives),
"priced_alternative_count": len(prices),
"market_low_eur": min(prices) if prices else None,
"market_high_eur": max(prices) if prices else None,
"alternatives": alternatives,
"notes": raw.get("notes", ""),
}

View File

@@ -18,6 +18,9 @@ def test_dashboard_payload_contains_live_ledger_totals() -> None:
assert payload["snapshot"]["monthly_infrastructure_cost"] == "29.73"
assert len(payload["history"]) == 18
assert payload["expense_record_count"] == 58
assert payload["cost_floor"]["active_price"] == "8.99"
assert len(payload["value_range"]["segments"]) == 2
assert payload["market_price"]["alternative_count"] == 4
def test_payload_json_is_valid() -> None:

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from observatory.economics import build_snapshot
from observatory.load import (
load_market_signals,
load_membership,
load_monthly_ledger,
load_payment_records,
load_pricing_models,
load_product,
load_value_range,
)
from observatory.pricing_context import (
build_cost_floor,
build_market_price_view,
build_value_range_view,
)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _snapshot_for(period: str = "2026-06"):
product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR)
members = load_membership(DATA_DIR)
payments = load_payment_records(DATA_DIR)
ledger = load_monthly_ledger(DATA_DIR)
return build_snapshot(period, product, models, members, payments, ledger)
def test_cost_floor_derives_from_snapshot() -> None:
snapshot = _snapshot_for()
models = load_pricing_models(DATA_DIR)
floor = build_cost_floor(snapshot, models)
assert floor["cost_per_member"] == snapshot.cost_per_member
assert floor["active_price"] == Decimal("8.99")
assert floor["active_model_id"] == "flat-899-eur-monthly"
def test_value_range_computes_headroom() -> None:
snapshot = _snapshot_for()
product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR)
view = build_value_range_view(load_value_range(DATA_DIR), snapshot, product, models)
assert view["current_price_eur"] == Decimal("8.99")
assert len(view["segments"]) == 2
solo = next(item for item in view["segments"] if item["id"] == "solo-builder")
assert solo["headroom_to_high_eur"] == Decimal("10.01")
def test_market_price_summarises_alternatives() -> None:
view = build_market_price_view(load_market_signals(DATA_DIR))
assert view["alternative_count"] == 4
assert view["market_low_eur"] == Decimal("4.00")
assert view["market_high_eur"] == Decimal("49.00")

View File

@@ -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) => {

View File

@@ -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 &amp; 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 &amp; 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>

View File

@@ -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 {