Plans004 2026 02 20 Format Layer
Set Up @packages/format
Overview
Build the Format layer at @packages/format. Reads all 4 dbt mart tables from DuckDB, applies period filtering, assembles a typed Report JSON per entity + period, and writes it to output/monthly/. The Format layer is a pure data assembler — it applies no business logic beyond what is already in the marts, except for joining targets to revenue at query time.
Result: bun run format --entity IONS --period 2026-02 writes output/monthly/2026-02-report.json with all sections populated from real mart data.
Goals
- Define the full
ReportTypeScript type hierarchy (types.ts) — source of truth for the JSON schema consumed by the Present layer - Scaffold
@packages/formatas a Bun workspace package - Implement a DuckDB client helper for the format layer
- Implement all 4 section readers:
revenue.ts,orders.ts,marketing.ts,schools.ts - Implement the report assembler (
report.ts) that joins all sections and writesreport.json - Wire the CLI with
--entity,--period,--unitflags - Verify:
bun run format --entity IONS --period 2026-02produces a valid, non-emptyreport.json
Non-Goals
- Rendering the report (Plan 006 — Present layer)
- Building
@packages/dbDrizzle schema (Plan 005) - Dashboard integration (Plan 007)
- Weekly report format (monthly is the current scope)
- Multi-period comparison in the format output (Format reads one period; cross-period comparison is computed at query time inside each section reader)
Phases
- Phase 1: Package scaffold + types
- Phase 2: DuckDB client helper
- Phase 3: Section readers (revenue, orders, marketing, schools)
- Phase 4: Report assembler + CLI
- Phase 5: Verification
Success
-
@packages/formatscaffolded as Bun workspace package -
types.tsdefinesReport,UnitReport,RevenueSection,ProgramProgressSection,ChannelMarketingSection,SchoolProgressSection -
bun run test:type --filter @packages/formatpasses -
bun run format --entity IONS --period 2026-02exits 0 -
output/monthly/2026-02-report.jsonexists and contains all 4 sections with non-null data - Revenue section includes
revenue_actuals,revenue_target,gap_to_target,achievement_pct,last_period,last_year,best_yearfields
Requirements
- Plan 003 completed: all 4 mart tables exist in
atlas.db - DuckDB node bindings available in workspace catalogs (
@duckdb/node-api) @repo/typescript/libtsconfig preset@repo/lint/baseESLint configoutput/monthly/directory (gitignored)
Context
Why This Approach
- Format layer reads marts directly via DuckDB node client — no HTTP layer needed (local-first)
- Revenue section joins
mart_revenue×stg_targetsat query time (per analytics.md spec) — targets are not materialized into the mart itself - Cross-period comparison (last_period, last_year, best_year) is computed by the revenue section reader via multiple mart queries, not stored in the mart
schools.tspivotsmart_school_progressto a year × organization matrix in TypeScript (not SQL PIVOT) for flexibility- Report is written to disk as JSON for auditability; also returned in-memory so the Present layer can call Format directly without disk I/O
Key Constraints
--periodacceptsYYYY-MMformat (e.g.2026-02) and is parsed intoyearandmonthintegers--unitis optional; if omitted, all units for the entity are included- Revenue section must compute: last_period (previous calendar month), last_year (same month prior year), best_year (highest all-time value for same month across all years in mart)
achievement_pct=revenue_actuals / revenue_target * 100; handle null target gracefully (NULL target → NULL achievement_pct)- School progress matrix: rows are organizations, columns are years (sorted ascending); cell values are student counts (0 if no students that year)
report.jsonoutput path:output/monthly/{period}-report.json(e.g.output/monthly/2026-02-report.json)
Edge Cases
- No target data for a unit+period:
revenue_target= null,gap_to_target= null,achievement_pct= null - No enrollment data for a unit+period: section returns empty arrays, not an error
--unitflag with a unit code not in the entity: warn and skip (no hard error)output/monthly/directory may not exist — create it if missing
Tradeoffs
- In-memory return + disk write: slight duplication but enables both direct Present invocation and disk-based audit trail
- TypeScript pivot for school matrix: more readable than SQL PIVOT; acceptable since it's a small dataset (hundreds of organizations × 4 years)
Skills
plan— plan file format and conventions
Boundaries
- Always: Update Progress.md after each task completion
- Always: All field names in
types.tsmust match the glossary.md terminology - Always: Handle null/missing mart data gracefully — never throw on missing data, return null values
- Ask first: Any new report section not in the spec
- Ask first: Changes to the
Reporttype shape (downstream Present layer depends on it) - Never: Apply business logic that belongs in dbt (e.g. computing customer type classification)
- Never: Write to LibSQL or DuckDB — format layer is read-only
Questions
- Output path format? →
output/monthly/{period}-report.json - What fields does the revenue section need? → actuals, target, gap, achievement_pct, last_period, last_year, best_year
- Should
best_yearbe computed per unit, or globally across all units? → Per unit (same-month max for the current unit) - Should the
Reporttype be exported from@packages/formatfor use by@services/presentand@services/dashboard? → Yes (export from@source/index.ts)