Appearance
Reporting Dashboard - Architecture & Data Flow
Source:
/reporting/(Vue 3 + Vite + TanStack Query v5) Last updated: 2026-07-02
The reporting dashboard is a standalone Vue 3 SPA that visualizes creative performance data from two pipelines: the legacy Aggregation API (Cloudflare) and Bunny Analytics (DuckDB/Parquet). This document covers the frontend architecture, data flow, and lessons learned during development.
High-Level Architecture
Browser (Vue 3 SPA)
|
|-- Composables (useReportTotals, useBunnyHourly, useBuckets, useCounts)
| |-- TanStack Vue Query v5 (caching, dedup, refetch)
| |
| +---> /api/* (Cloudflare Pages Function proxy)
| |
| +---> application-backend.cavai.com/v2/...
| |
| +-- /aggregation-api/counts (CF pipeline)
| +-- /aggregation-api/buckets (CF pipeline)
| +-- /analytics/bunny/hourly-summary/:id (Bunny pipeline)
| +-- /analytics/bunny/metrics/:id (Bunny custom metric discovery)Key Files
| File | Purpose |
|---|---|
src/stores/currentReport.ts | Pinia store: parsed report state from URL (metrics, resources, dates) |
src/stores/reports.ts | Pinia store: saved reports in localStorage |
src/utils/metricCodes.ts | Metric definitions, URL encoding/decoding, computed metrics |
src/utils/reportUrl.ts | Report state <-> URL query params serialization |
src/utils/bunnyCsvParser.ts | Parses Bunny hourly-summary CSV into typed rows |
src/utils/formatters.ts | Number/percentage/duration formatting |
src/composables/useReportTotals.ts | Aggregates totals across resources for KPI cards |
src/composables/useReportTimeSeries.ts | Time series data for charts (aggregation or bunny) |
src/composables/useBunnyHourly.ts | TanStack query for bunny hourly-summary endpoint |
src/composables/useBuckets.ts | TanStack query for aggregation buckets endpoint |
src/composables/useCounts.ts | TanStack query for aggregation counts endpoint |
src/composables/useWidgetLayout.ts | Resolves layout string into widget specs |
src/widgets/WidgetGrid.vue | Grid container, fetches totals once, passes to children |
src/widgets/WidgetCard.vue | Individual KPI card, receives data via props |
src/widgets/TimeSeriesChart.vue | Daily metrics chart with multi-metric selection |
Data Sources
Aggregation API (Legacy / Cloudflare)
- 16 standard metrics via
metric[]=base - Two endpoints:
/counts(totals) and/buckets(time series) - Source of truth for older creatives without Bunny data
- Data comes from Cloudflare Worker log processing pipeline
Bunny Analytics (New / DuckDB Parquet)
- Backend reads hive-partitioned parquet files via DuckDB
- Returns CSV with hourly granularity per device type
- 9 standard columns exposed in CSV (impressions, clicks, link_clicks, watched_*, seconds_total_active)
- 22 columns exist in parquet (see reporting-dashboard-progress.md for full list)
- Custom metrics via MAP column, discovered per-creative
- 204 No Content = creative has no Bunny data
Source Conflict Problem
Metrics with the same key (e.g. impressions, interacted) exist in both sources with different values. Bunny counts COUNT(DISTINCT session_id) while CF counts CDN-level events. Mixing sources in one report produces nonsensical rates (e.g. "552% of impressions" when dividing CF reached_end by Bunny impressions).
Decision (2026-07-02): Go all-in on Bunny. Hide CF metrics behind a devTools flag for localhost. All creatives have used Bunny for 6+ months per Kevin.
Metric System
MetricSpec
Every metric in the system is a MetricSpec:
ts
interface MetricSpec {
key: string // e.g. 'impressions', 'link_clicks', 'scratch_done'
label: string // e.g. 'Impressions', 'Link Clicks', 'Scratch done'
format: 'number' | 'percentage' | 'duration'
source: 'aggregation' | 'bunny'
}URL Encoding
Metrics are encoded as short codes in the URL m= parameter:
- Standard aggregation:
imp,vimp,interacted,ir,ctr,start,cont,ca,lc,hc,bc,re,plays,w25,w50,w75,w100,siv,vtr - Bunny standard:
b_imp,b_clicks,b_link_clicks,b_interacted,b_w25,b_w50,b_w75,b_w100,b_stotal - Custom bunny:
custom:metric_name
Computed Metrics
interaction-rate, ctr, and vtr are calculated client-side from raw counts. They return ratios (0-1); formatPercentage handles the *100 display conversion.
Dependencies are declared in COMPUTED_DEPENDENCIES so the composables know which raw metrics to fetch.
Rate Display
KPI cards can show a secondary rate (e.g. "0.04% of impressions"). Defined in METRIC_RATES:
ts
METRIC_RATES: Record<string, { denominatorKey: string, denominatorLabel: string }>WidgetGrid pre-fetches rate denominators alongside the selected metrics to avoid extra API calls.
Widget Layout System
URL Format
?layout=imp:4,b_clicks:3,custom:scratch_done:2
Each entry is metricCode:colspan. Order = display position. Resolved by useWidgetLayout.ts.
Data Flow (KPI Cards)
WidgetGrid
|-- useReportTotals(ALL metrics + rate denominators) // single API call
| Returns: ResourceTotals = Map<resourceId, Map<metricKey, number>>
|
+-- WidgetCard (receives totals, totalsLoading, bunnyNoData as props)
|-- totalFor(key): sums across all resources
|-- rateDisplay: computes secondary percentage
|-- isBunnyUnavailable: shows "No data" instead of "0"This architecture was a deliberate refactor from the original design where each WidgetCard created its own useReportTotals — that caused N duplicate API calls (one per card) because TanStack Query cache keys differed per metric set.
Data Flow (Time Series Chart)
TimeSeriesChart
|-- useReportTimeSeries(metric) x8 (pre-allocated slots)
| |-- aggregation path: useBuckets → aggregateStandard()
| |-- bunny path: useBunnyHourly per creative → aggregateBunny()
| |-- computed path: fetch dependencies → calculate
|
|-- ALL_CHARTABLE (computed): prefers bunny source when report uses bunny
|-- useReportTotals(ALL_CHARTABLE): determines which pill buttons to show
|-- categorizedMetrics: groups by METRIC_CATEGORIES, hides empty categories
|-- activeRange: zooms chart to days with data > 0Bunny CSV Parser
parseBunnyCsv(csv: string): BunnyRow[]
Maps CSV column headers to metric keys via STANDARD_COL_TO_KEY:
| CSV Header | Metric Key |
|---|---|
| Impressions | impressions |
| Click | link_clicks |
| Clicks | clicks |
| Total Interactions | interacted |
| Watched 25% | watched_25 |
| Watched 50% | watched_50 |
| Watched 75% | watched_75 |
| Watched 100% | watched_100 |
| Seconds Total Active | seconds_total_active |
Non-reserved columns are treated as custom metrics (passed through as-is).
API Client
src/api/client.ts implements a concurrency-limited fetch wrapper:
MAX_CONCURRENT = 2semaphore prevents overwhelming the backend- 204 responses return
undefined(type lie but handled by parseBunnyCsv) - Auth token from Pinia auth store
Known Issues & Debugging Notes
Parquet Format Mismatch (2026-07-02)
Logs-Parser bunny_analytics branch writes hourly_summary.parquet (single file). Backend queries hourly_summary/*/*/*/*.parquet (hive-partitioned glob). Result: backend finds almost no data. See todos/Humology/bunny-parquet-mismatch.md.
Impression Counting Differences
- CF aggregation counts CDN-level events → higher numbers
- Bunny Analytics counts
COUNT(DISTINCT session_id)→ closer to real impressions - Bunny is recommended for Humology (see
todos/Humology/22a-impression-discrepancy.md)
TanStack Query Cache Key Dedup
Cache keys include the sorted list of metric API keys. Different metric sets = different cache keys = separate API calls. This is why WidgetGrid fetches all metrics in one call rather than letting each card fetch its own subset.
204 No Content Handling
When a creative has no Bunny data, the backend returns 204. apiFetchText returns undefined, parseBunnyCsv returns []. The hasBunnyNoData computed in composables detects this and widgets show "No data" instead of "0".