How do I author a new app on the tree?¶
Customization walkthrough — Developer / Product Owner. Building a fifth (or sixth) dashboard.
The story¶
You've read the shared schema, pointed your
production data at spec_example_transactions +
spec_example_daily_balances, and the four shipped apps (L1
Reconciliation Dashboard, L2 Flow Tracing, Investigation,
Executives) cover most of what your operations team needs. But
you have one more reporting cadence — say a board-level summary
beyond the Executives view, a fraud-team triage view, a marketing
rollup — that doesn't fit any of the four existing apps' question
shapes.
You want to build a fifth dashboard from scratch. You don't want to fork an existing app, because most of the wiring you'd inherit is the wrong shape for your question. And you don't want to hand-roll the low-level dashboard wiring, because that's how the constants-heavy maintenance burden the tree replaced started.
The tree primitives in common/tree/ are the answer. This
walkthrough walks the Executives app — the codebase's own
greenfield example — start to finish: five content sheets plus the
App Info canary, a mix of KPI, bar-chart and table visuals,
date-range narrowing pushed into the dataset SQL (no analysis-level
FilterGroup), no cross-app drills. It's the smallest end-to-end app
in the codebase, carrying none of the old boilerplate — per-app
visual builders, a constant-flooded constants.py or manual
visual-ID bookkeeping.
The question¶
"What's the minimum I need to write to add a fifth standalone
dashboard, given that the dataset interface
(spec_example_transactions + spec_example_daily_balances emitted by
common/l2/schema.py::emit_schema) is already in place?"
Where to look¶
Five reference points:
src/recon_gen/apps/executives/app.py— the worked example. Five sheet specs (plus the App Info canary), five populator functions, the date-range pushdown wiring and onebuild_executives_app()entry point. Read the whole file before starting.src/recon_gen/apps/executives/datasets.py— the dataset side. Several custom-SQL datasets, oneDatasetContractper shape and onebuild_all_datasets()helper that registers the contracts viaregister_contract()as a module-import side effect.- API Reference — Tree primitives — the L1 API surface. Each typed Visual subtype, Filter wrapper and Drill action's signature is the canonical place to look up parameter shape.
src/recon_gen/cli/_html_serve.py— thebuild_real_app()if/elif dispatch. Add a sibling branch (mirror theexecutivesbranch), then append your slug toREAL_APPS+APP_TITLES— that's all a new app needs to render underdashboards/studio. (The old_helpers.py::APPStuple is legacy — it fed the removedjsondeploy and drives nothing now.)tests/json/test_executives.py— the starter pack that walks the tree to assert structural invariants (sheet count, visual presence, filter scoping, CLI smoke);tests/e2e/app2_browser/test_exec_sheet_visuals.pyadds the live-render checks. Mirror this shape in your app's tests.
What you'll see in the demo¶
Build your app as apps/<myapp>/ alongside the existing four. The
file layout is:
src/recon_gen/apps/myapp/
__init__.py # one-line docstring
app.py # everything except datasets — sheet IDs, populators, build_myapp_app()
datasets.py # build_all_datasets(), DatasetContract declarations, register_contract() calls
Three things you do not need:
- No
constants.py. Sheet IDs are inline inapp.py(URL-facing, must stay stable, ~4 lines). Internal IDs (visual_id, filter_group_id, action_id, layout element IDs) are auto-derived from tree position by the resolver — you never write them. - No
visuals.py/filters.py/analysis.py. The tree's typed builders (row.add_kpi(...),row.add_table(...),FilterGroup.with_numeric_range_filter(...)) replace the per-app builder modules entirely. Wiring lives inapp.pypopulator functions, one per sheet. - No
demo_data.py. Because the four shipped apps all read the same per-instance prefixed base tables, the L2 instance's seed (and any per-app overlay seed) populates your new app for free. If your app needs its own seed shape, adddemo_data.pynext todatasets.py; otherwise skip it.
The skeleton of app.py looks like:
from recon_gen.common.config import Config
from recon_gen.common.ids import SheetId
from recon_gen.common.tree import App, Sheet
from recon_gen.common.tree.filters import FilterGroup
from recon_gen.apps.myapp.datasets import (
DS_MYAPP_FOO,
build_all_datasets,
)
# Sheet IDs (URL-facing, stable; inline since there's no constants.py).
SHEET_MYAPP_OVERVIEW = SheetId("myapp-sheet-overview")
SHEET_MYAPP_DETAIL = SheetId("myapp-sheet-detail")
def build_myapp_app(cfg: Config) -> App:
app = App(name="myapp", cfg=cfg)
# Register datasets (typed Dataset nodes; visuals reference these).
datasets = build_all_datasets(cfg)
ds_foo = app.add_dataset(Dataset(identifier=DS_MYAPP_FOO))
# ... add_dataset the rest (build_all_datasets(cfg) above already
# registered the contracts; visuals reference these Dataset nodes)
# Pre-register sheet shells so cross-sheet drills can target them
# by Sheet object ref (not string ID) before they're populated.
overview_sheet = app.analysis.add_sheet(Sheet(
sheet_id=SHEET_MYAPP_OVERVIEW, name="Overview",
title="Overview", description="...",
))
detail_sheet = app.analysis.add_sheet(Sheet(
sheet_id=SHEET_MYAPP_DETAIL, name="Detail",
title="Detail", description="...",
))
# Populate each sheet (one function per sheet — keeps `app.py`
# readable as the dashboard grows).
_populate_overview(overview_sheet, ds_foo, drill_target=detail_sheet)
_populate_detail(detail_sheet, ds_foo)
# Create the dashboard mirroring the analysis.
app.create_dashboard(
dashboard_id_suffix="myapp-dashboard",
name="My App",
)
return app
def _populate_overview(sheet: Sheet, ds_foo, drill_target: Sheet) -> None:
row = sheet.layout.row(height=8)
row.add_kpi(
title="Total Foos",
subtitle="Count of all foo records.",
value=ds_foo["foo_id"].distinct_count(),
)
table = row.add_table(
title="Foo Detail",
subtitle="Click any row to drill into the Detail sheet.",
group_by=[ds_foo["foo_id"].dim(), ds_foo["foo_name"].dim()],
values=[ds_foo["amount"].sum()],
actions=[Drill(
writes=[(some_param, ds_foo["foo_id"].dim())],
name="See detail for this foo",
trigger="DATA_POINT_CLICK",
target_sheet=drill_target, # Sheet *object*, not string ID
)],
)
# Visual-pinned filter (sheet-wide also available — see filter_group docs).
fg = FilterGroup.with_numeric_range_filter(
column=ds_foo["amount"], min_value=100,
filter_group_id=FilterGroupId("fg-myapp-amount-min"),
)
fg.scope_visuals(table)
sheet.filter_groups.append(fg)
def _populate_detail(sheet: Sheet, ds_foo) -> None:
# ... same shape
pass
Datasets follow the same pattern as the four shipped apps —
DatasetContract lists the column projection; the SQL must produce
exactly those columns; register_contract() wires the contract
into the typed-Column validation that catches column-name typos at
the wiring site (loud KeyError) instead of at deploy (silent
broken visual).
What it means¶
Four properties of the tree-built app pattern that internalize once you've shipped one:
- Object refs, not string IDs. Visuals reference
Datasetnodes, not dataset identifier strings; drills referenceSheetnodes, not sheet IDs.app.validate()runs validation walks (dataset / calc-field / parameter / drill-destination references) — a missing reference fails at construction with a stack trace pointing at the wiring site, not at render time as a silent broken visual. - Pre-register all sheets. Cross-sheet drills need their target
Sheetref to exist before the source visual is constructed. The pattern: declare every sheet shell first (app.analysis.add_sheet(...)for each), THEN populate them one at a time with the already-resolvedSheetreferences in scope. - Sheet IDs explicit, internal IDs auto. URL-facing identifiers
(
SheetId,ParameterName) and analyst-facing identifiers (Datasetidentifier,CalcFieldname) stay explicit because they show up in URLs / DOM / analyst tooltips; internal IDs are auto-derived because they're positional and only the tree itself reads them. - The tree IS the source of truth. Tests walk the tree to derive
expected sets —
tests/json/test_executives.pyis a good template. Don't maintain a parallel hand-listed set of expected visual titles in the test fixture; the tree walks every sheet's visuals and the test asserts what the tree emits, not what someone hand-typed.
Drilling in¶
The tree primitives expose more than this walkthrough surfaces:
- Calculated fields:
CalcFieldfor analysis-level computed columns. Ties to oneDataset; usable across visuals. recon_gen.common.tree (data). - Parameters + parameter controls:
StringParam/IntegerParam/DateTimeParam+ theirControlwrappers (dropdown, slider, datetime picker). Drills can write to parameters; filters can read from them. recon_gen.common.tree (filters / controls). - Cross-app drills:
CrossAppDrill(common/tree/actions.py) for jumping to another app's dashboard — App2 threads itstarget_path+?param_<name>=into the destination's filter form.
Next step¶
- Skim
apps/executives/app.pyend-to-end — it's the shortest reference implementation in the codebase. - Skim
tests/json/test_executives.pyfor the test pattern (walk the tree, assert what's emitted). - Build a minimal
app.pywith one sheet and one visual;pytest tests/json/test_<myapp>.py -vto confirm it builds. - Wire it into the CLI: append your app slug to the
APPStuple incli/_helpers.pyand add abuild_real_app()branch incli/_html_serve.py(mirror theexecutivesbranch) + append your slug toREAL_APPS+APP_TITLES.recon-gen dashboardsthen serves your app alongside the others. - Add e2e tests mirroring
tests/e2e/app2_browser/test_exec_*.py(+tests/e2e/app2/) once your app renders.
Related shape¶
- How do I swap the SQL behind a dataset?
— same
DatasetContractmechanism your app's datasets use. - How do I self-host the dashboards?
— once your app slug is in the
APPStuple,recon-gen dashboardsserves it alongside the existing four. - How do I reskin the dashboards for my brand?
— theme presets in
common/theme.pyapply to your app the same way.