Skip to content

Reporting Dashboard Improvement Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Transform the Cavai reporting dashboard from a functional prototype into a polished, production-quality analytics tool with proper code quality, complete Bunny Analytics integration, and a widget-based UX.

Architecture: Vue 3 Composition API + TanStack Vue Query + Chart.js. URL-driven report state (no server persistence yet). Dual data sources: aggregation API (standard metrics) and Bunny Analytics CSV (custom + standard Bunny metrics). Pinia for auth + saved reports, URL query params for report state.

Tech Stack: Vue 3.5, TypeScript 5.7, TanStack Vue Query 5, Chart.js 4, Pinia 2, Vite 6, Cloudflare Pages (wrangler), Vitest 4


Codebase Overview (for context)

src/
  api/         -- 5 files: client, endpoints, bunny, types, chunkRequests
  components/  -- AppHeader, ReportToolbar, WorkspaceSwitcher, builder/
  composables/ -- 11 files: useCounts, useBuckets, useReportTotals, useReportTimeSeries, etc.
  stores/      -- auth.ts, currentReport.ts, reports.ts (Pinia)
  utils/       -- formatters, metricCodes, reportUrl, bunnyCsvParser, sliceBucketsByDateRange
  views/       -- ReportViewer, ReportsListView, SplashView, ReportLinkView
  widgets/     -- KpiCardGrid, EngagementFunnel, TimeSeriesChart, PerResourceBreakdown,
                  CreativePreview, WidgetHost, registry, types, states/
  styles/      -- tokens.css, main.css

~5800 lines total. Clean Vue 3 Composition API with <script setup>. No tests yet.


Chunk 1: Code Quality Foundation

Task 1: Extract shared DateRange type

DateRange is defined independently in 3 files:

  • composables/useCounts.ts:6-9 (inline interface)
  • composables/useReportTotals.ts:15-18 (exported interface)
  • utils/sliceBucketsByDateRange.ts:6-9 (exported interface)
  • stores/currentReport.ts:8-10 (as ResolvedDateRange)

Files:

  • Modify: src/widgets/types.ts (add DateRange)

  • Modify: src/composables/useCounts.ts (import from types)

  • Modify: src/composables/useReportTotals.ts (import from types, remove local def)

  • Modify: src/composables/useReportTimeSeries.ts (import from types, remove re-export)

  • Modify: src/utils/sliceBucketsByDateRange.ts (import from types, remove local def)

  • Modify: src/stores/currentReport.ts (import from types, remove ResolvedDateRange)

  • [ ] Step 1: Add DateRange to widgets/types.ts

Add to src/widgets/types.ts:

ts
export type DateRange = {
  from: string
  to: string
}
  • [ ] Step 2: Update all consumers to import from widgets/types

In each file, remove the local DateRange/ResolvedDateRange interface and import from @/widgets/types. For currentReport.ts, rename ResolvedDateRange usage to DateRange.

  • [ ] Step 3: Verify build

Run: cd /Users/nicolay/CavaiProduct/reporting && npm run type-check

  • [ ] Step 4: Commit
feat: extract shared DateRange type to widgets/types

Task 2: Extract DataTreeNode types from inline definitions

CreativeNode, CreativeGroupNode, CampaignNode are defined inline in 3 files:

  • components/builder/ResourcePicker.vue:12-26
  • views/ReportViewer.vue:18-35
  • components/builder/ReportBuilderModal.vue:73-85

The API already has DataTreeNode in api/types.ts:39-46 but it uses a recursive children shape. The inline types use creativeGroups and creatives which match the actual API response shape better.

Files:

  • Create: src/api/dataTree.ts (tree node types + utility)

  • Modify: src/components/builder/ResourcePicker.vue (import types)

  • Modify: src/views/ReportViewer.vue (import types)

  • Modify: src/components/builder/ReportBuilderModal.vue (import types)

  • [ ] Step 1: Create src/api/dataTree.ts

ts
/**
 * Data tree types matching the shape returned by /campaigns/data_tree.
 * The API serializes campaigns -> creativeGroups -> creatives with these
 * exact property names (not the generic DataTreeNode.children shape).
 */

export type CreativeNode = {
  id: string
  name: string
  created_at?: string
}

export type CreativeGroupNode = {
  id: string
  name: string
  creatives?: CreativeNode[]
}

export type CampaignNode = {
  id: string
  name: string
  first_active?: string | null
  created_at?: string
  creativeGroups?: CreativeGroupNode[]
}
  • [ ] Step 2: Update ResourcePicker, ReportViewer, and ReportBuilderModal

Replace the inline type definitions with imports from @/api/dataTree.

  • [ ] Step 3: Verify build

Run: npm run type-check

  • [ ] Step 4: Commit
refactor: extract data tree types into shared api/dataTree module

Task 3: Convention alignment -- interface to type

AF coding conventions mandate type over interface. The reporting codebase uses interface in many places.

Files to update (all interface -> type =):

  • src/api/types.ts -- CavaiId, User, Workspace, Enterprise, MeResponse, DataTreeNode, DataTreeResponse, BucketPoint, FlowResponse, PaginationMeta, WorkspaceSelectionResponse, OkResponse, ApiErrorBody

  • src/api/bunny.ts -- BunnyMetricsResponse

  • src/widgets/types.ts -- MetricSpec, WidgetDescriptor, ViewDescriptor, ScopeResource, ReportScope (keep WidgetKind as union type)

  • src/composables/useReportTotals.ts -- UseReportTotalsArgs

  • src/composables/useReportTimeSeries.ts -- UseReportTimeSeriesArgs, SeriesPoint

  • src/composables/useCounts.ts -- UseCountsArgs

  • src/composables/useBuckets.ts -- UseBucketsArgs

  • src/composables/useAvailableCustomMetrics.ts -- AvailableCustomMetric, UseAvailableCustomMetricsArgs

  • src/composables/useBunnyHourly.ts -- UseBunnyHourlyArgs

  • src/stores/currentReport.ts -- UseReportStoreArgs

  • src/stores/reports.ts -- SavedReport

  • src/utils/reportUrl.ts -- ReportState

  • [ ] Step 1: Convert all interface Foo { to type Foo = {

Mechanical replacement. The only syntax difference: interface Foo { becomes type Foo = {.

  • [ ] Step 2: Verify build

Run: npm run type-check

  • [ ] Step 3: Commit
refactor: use type instead of interface per AF conventions

Task 4: Add ESLint/Prettier config aligned with AF

The reporting repo has ESLint configured but should match AF conventions (no semicolons, trailing commas, double quotes).

  • [ ] Step 1: Check existing eslint config

Read eslint.config.ts or eslint.config.js in the reporting root.

  • [ ] Step 2: Align rules with AF

Ensure: semi: ['error', 'never'], comma-dangle: ['error', 'always-multiline'], quotes: ['error', 'single'] (AF uses no-semicolons + trailing commas; quotes vary -- check the existing code which uses single quotes).

  • [ ] Step 3: Run lint:fix

Run: npm run lint:fix

  • [ ] Step 4: Commit
chore: align eslint config with AF conventions

Task 5: Add CLAUDE.md for the reporting project

Files:

  • Create: /Users/nicolay/CavaiProduct/reporting/CLAUDE.md

  • [ ] Step 1: Write CLAUDE.md

Document: tech stack, project structure, coding conventions (type not interface, arrow functions, breathing room), data flow (URL -> store -> composable -> widget), dual data sources, testing approach.

  • [ ] Step 2: Commit
docs: add CLAUDE.md for reporting project

Chunk 2: ResourcePicker Improvements

Task 6: Split ResourcePicker into composable + smaller components

ResourcePicker.vue is 693 lines -- the largest file in the codebase. It mixes: tree data fetching, selection state, impression count queries, search/filter, and template rendering.

Files:

  • Create: src/composables/useResourceSelection.ts (selection state + derived check states)

  • Create: src/composables/useImpressionCounts.ts (impression fetching + formatting)

  • Modify: src/components/builder/ResourcePicker.vue (use new composables, reduce to ~350 lines)

  • [ ] Step 1: Extract useResourceSelection composable

Move all selection logic: selected, toggleCreative, toggleCampaign, toggleGroup, clearAll, deriveCheckState, campaignDerived, groupDerived into a composable.

ts
// src/composables/useResourceSelection.ts
export type CheckState = 'unchecked' | 'partial' | 'checked'

export const useResourceSelection = (
  modelValue: Ref<string[]>,
  emit: (ids: string[]) => void,
  campaigns: Ref<CampaignNode[]>,
) => {
  // ... selection state and toggle logic
}
  • [ ] Step 2: Extract useImpressionCounts composable

Move impression fetching: expandedCreativeIds, chunks, impressionQueries, impressionMap, formatCount, creativeImpressions, groupImpressions into a composable.

ts
// src/composables/useImpressionCounts.ts
export const useImpressionCounts = (
  campaigns: Ref<CampaignNode[]>,
  expanded: Ref<Set<string>>,
  activeOnly: Ref<boolean>,
) => {
  // ... impression fetching and formatting logic
}
  • [ ] Step 3: Refactor ResourcePicker to use composables

Import and use the new composables. The component should be ~350 lines (template + CSS + wiring).

  • [ ] Step 4: Verify build + visual test

Run: npm run type-check Visually test: open the report builder, verify picker works (expand, search, toggle, impressions).

  • [ ] Step 5: Commit
refactor: split ResourcePicker into composables for selection and impressions

Task 7: Date sorting in ResourcePicker

Newer creatives should appear first. DataTreeNode has created_at.

Files:

  • Modify: src/components/builder/ResourcePicker.vue

  • [ ] Step 1: Sort campaigns and creatives by created_at descending

In the campaigns computed, sort by created_at descending:

ts
const campaigns = computed<CampaignNode[]>(() => {
  const raw = (data.value?.data ?? []) as CampaignNode[]
  return [...raw].sort((a, b) =>
    (b.created_at ?? '').localeCompare(a.created_at ?? '')
  )
})

Also sort creatives within groups in the template filter or computed.

  • [ ] Step 2: Commit
feat: sort campaigns/creatives by date (newest first) in ResourcePicker

Task 8: Visual indicator for inactive creatives

Creatives with 0 impressions should be visually faded in the picker.

Files:

  • Modify: src/components/builder/ResourcePicker.vue

  • [ ] Step 1: Add faded class for zero-impression creatives

vue
<button
  :class="{ 'inactive': !hasImpressions(cr.id) && impressionsLoaded }"
  ...
>
css
.creative.inactive {
  opacity: 0.5;
}
.creative.inactive .creative-name {
  font-style: italic;
}
  • [ ] Step 2: Commit
feat: fade inactive creatives (0 impressions) in ResourcePicker

Chunk 3: Bunny Analytics Completion

Task 9: Add missing standard Bunny CSV columns to parser

The Bunny CSV parser's STANDARD_COL_TO_KEY only maps 9 columns. The BUNNY_METRICS constant defines 22 metrics. Missing mappings for columns like: Viewable Impressions, Started, Continued, Reached End, Renderizations, Plays, Creative Actions, Header Clicks, Background Clicks, Seconds in View, Seconds to Active, Analytics Allowed, Analytics Disallowed.

Some may not exist as CSV columns (they might be named differently). This requires checking the actual CSV header.

Files:

  • Modify: src/utils/bunnyCsvParser.ts -- add missing column mappings

  • [ ] Step 1: Inspect actual Bunny CSV headers

Use the dev tools Network tab or the Cavai MCP to fetch a Bunny hourly-summary and examine the CSV header row. Document which column names exist and map to which metric keys.

  • [ ] Step 2: Add missing column mappings

Add entries to STANDARD_COL_TO_KEY and RESERVED_COLS for each missing standard column.

  • [ ] Step 3: Commit
fix: map all standard Bunny CSV columns to metric keys

Task 10: Investigate aggregation API missing metrics

viewable_impressions, seconds_in_view, continued, link_clicks all return 0 from the aggregation API despite the API responding 200. This could mean:

  1. The metric keys are genuinely 0 for these creatives
  2. The aggregation API returns different key names than expected
  3. The base metric bundle doesn't include these keys

Files:

  • Potentially modify: src/composables/_merge.ts, src/composables/useCounts.ts

  • [ ] Step 1: Add debug logging for API response

Temporarily log the raw aggregation API response to see exactly what keys are returned:

ts
// In _merge.ts mergeCounts:
if (import.meta.env.DEV) {
  console.log('[mergeCounts] raw response:', queries.map(q => q.data))
}
  • [ ] Step 2: Compare API response keys with expected metric keys

Check if the API returns viewable_impressions or viewableImpressions or some other format. Document findings.

  • [ ] Step 3: Fix key mapping if needed

Update STANDARD_METRICS in metricCodes.ts or add a key normalization step in mergeCounts.

  • [ ] Step 4: Remove debug logging and commit
fix: align metric key names with aggregation API response format

Task 11: Per-creative Bunny data availability indicator

When some creatives in a report have Bunny data and others don't (204), show which ones are missing.

Files:

  • Modify: src/widgets/PerResourceBreakdown.vue -- show Bunny status per creative

  • [ ] Step 1: Track per-creative Bunny availability

Add Bunny query status to useReportTotals (or a separate composable). The per-creative Bunny queries already exist; expose which ones returned empty.

  • [ ] Step 2: Show indicator in PerResourceBreakdown

Add a small badge or note on creative cards that lack Bunny data:

vue
<span v-if="!hasBunnyData(r.id)" class="no-bunny-badge">No Bunny data</span>
  • [ ] Step 3: Commit
feat: show per-creative Bunny data availability in breakdown

Chunk 4: Widget System Architecture

Task 12: Widget configuration types

Prepare the type system for widget CRUD -- each widget needs a typed configuration.

Files:

  • Modify: src/widgets/types.ts

  • [ ] Step 1: Add typed widget config types

ts
export type KpiCardGridConfig = {
  metrics: MetricSpec[]
}

export type TimeSeriesChartConfig = {
  metric?: MetricSpec
  left?: MetricSpec
  right?: MetricSpec
}

export type EngagementFunnelConfig = {
  // No configurable props yet
}

export type PerResourceBreakdownConfig = {
  // No configurable props yet
}

export type WidgetConfig =
  | { kind: 'kpi-card-grid' } & KpiCardGridConfig
  | { kind: 'time-series-chart' } & TimeSeriesChartConfig
  | { kind: 'engagement-funnel' } & EngagementFunnelConfig
  | { kind: 'per-resource-breakdown' } & PerResourceBreakdownConfig
  • [ ] Step 2: Update WidgetDescriptor to use typed props
ts
export type WidgetDescriptor = {
  kind: WidgetKind
  id: string
  props?: Record<string, unknown>
}
  • [ ] Step 3: Commit
feat: add typed widget configuration types

Task 13: Widget layout persistence in URL

Store widget layout configuration in the URL so it survives refreshes and can be shared.

Files:

  • Modify: src/utils/reportUrl.ts -- add widget layout to URL encoding

  • Modify: src/stores/currentReport.ts -- expose widget layout

  • [ ] Step 1: Design URL encoding for widget layout

Widgets are encoded as a comma-separated list of kind:id pairs, or a JSON blob in a layout query param. Keep it simple -- start with the default layout derived from metrics, allow overrides later.

  • [ ] Step 2: Add layout parsing/encoding
ts
// In reportUrl.ts
export type WidgetLayout = {
  widgets: Array<{ kind: WidgetKind, id: string, props?: Record<string, unknown> }>
}
  • [ ] Step 3: Commit
feat: persist widget layout in URL query params

Task 14: Widget add/remove/reorder UI

Allow users to add, remove, and reorder widgets in the report view.

Files:

  • Create: src/components/WidgetControls.vue (add/remove/move buttons per widget)

  • Modify: src/views/ReportViewer.vue (manage widget list state)

  • [ ] Step 1: Add per-widget controls

Each widget gets a toolbar with: move up, move down, remove, and optionally a settings icon.

  • [ ] Step 2: Add "Add widget" button

A button at the bottom of the widget list that opens a picker for widget types.

  • [ ] Step 3: Wire up state management

Widget list becomes a ref on ReportViewer, with add/remove/reorder methods that update the URL.

  • [ ] Step 4: Commit
feat: add widget CRUD controls (add, remove, reorder)

Chunk 5: UX Polish

Task 15: Engagement funnel as horizontal stacked bar or treemap

The current logarithmic funnel works but looks unusual. Consider a cleaner visualization.

Files:

  • Modify: src/widgets/EngagementFunnel.vue

  • [ ] Step 1: Research alternatives

Options:

  • Keep current log-scale bars but improve visual styling (gradient, rounded, labeled)

  • Switch to a true funnel shape (trapezoids narrowing downward)

  • Use a horizontal waterfall chart

  • [ ] Step 2: Implement chosen approach

Polish the current implementation with better styling and clearer labels.

  • [ ] Step 3: Commit
feat: improve engagement funnel visualization

Task 16: Per-resource breakdown should use selected metrics

Currently PerResourceBreakdown uses hardcoded SUMMARY_METRICS. It should use the report's selected metrics.

Files:

  • Modify: src/widgets/PerResourceBreakdown.vue

  • [ ] Step 1: Use report metrics instead of hardcoded list

ts
const report = useReportStore()
const metrics = computed(() => report.metrics.value.slice(0, 5)) // cap at 5 for layout
  • [ ] Step 2: Adjust grid layout for variable metric count
css
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
  gap: var(--space-3);
}
  • [ ] Step 3: Commit
feat: use selected metrics in per-resource breakdown

Task 17: Time series chart metric selector

The chart currently shows the first selected metric. Add a dropdown to switch between any selected metric.

Files:

  • Modify: src/widgets/TimeSeriesChart.vue

  • [ ] Step 1: Add metric selector dropdown

vue
<select v-model="selectedMetricKey" class="metric-select">
  <option v-for="m in availableMetrics" :key="m.key" :value="m.key">
    {{ m.label }}
  </option>
</select>

Wire the selected metric to the useReportTimeSeries call.

  • [ ] Step 2: Commit
feat: add metric selector to time series chart

Task 18: Better empty/loading states

Improve the generic state components with more helpful messaging.

Files:

  • Modify: src/widgets/states/EmptyState.vue

  • Modify: src/widgets/states/ErrorState.vue

  • Modify: src/widgets/states/LoadingSkeleton.vue

  • [ ] Step 1: Add contextual messages to EmptyState

Accept a message prop (already partially done in some usages).

  • [ ] Step 2: Add retry info to ErrorState

Show what went wrong if an error message is available.

  • [ ] Step 3: Commit
feat: improve empty and error state messaging

Chunk 6: Testing Foundation

Task 19: Unit tests for pure utility functions

The codebase has 0 tests. Start with the pure functions that have the most risk.

Files:

  • Create: src/utils/__tests__/formatters.test.ts

  • Create: src/utils/__tests__/metricCodes.test.ts

  • Create: src/utils/__tests__/bunnyCsvParser.test.ts

  • Create: src/utils/__tests__/reportUrl.test.ts

  • Create: src/utils/__tests__/sliceBucketsByDateRange.test.ts

  • Create: src/composables/__tests__/merge.test.ts

  • [ ] Step 1: Test formatters

ts
import { describe, it, expect } from 'vitest'
import { formatNumber, formatPercentage, formatDuration, formatByMetricSpec } from '../formatters'

describe('formatNumber', () => {
  it('formats integers', () => expect(formatNumber(1234567)).toBe('1,234,567'))
  it('rounds decimals', () => expect(formatNumber(1234.5)).toBe('1,235'))
  it('handles zero', () => expect(formatNumber(0)).toBe('0'))
})

describe('formatPercentage', () => {
  it('converts ratio to percentage', () => expect(formatPercentage(0.1257)).toBe('12.6%'))
  it('handles alreadyScaled', () => expect(formatPercentage(12.57, { alreadyScaled: true })).toBe('12.6%'))
})

describe('formatDuration', () => {
  it('formats seconds', () => expect(formatDuration(45)).toBe('45s'))
  it('formats minutes', () => expect(formatDuration(125)).toBe('2m 5s'))
})
  • [ ] Step 2: Test metricCodes

Test parseMetricCodes, encodeMetricCodes, humanize, round-trip encoding.

  • [ ] Step 3: Test bunnyCsvParser

Test parseBunnyCsv with: empty input, header-only, standard columns, custom columns, edge cases (commas in quotes, empty cells, missing Date/Device).

  • [ ] Step 4: Test reportUrl

Test parseReportQuery and encodeReportUrl round-trip, edge cases (missing params, array values).

  • [ ] Step 5: Test sliceBucketsByDateRange

Test inclusive boundaries, empty input, single-day ranges.

  • [ ] Step 6: Test merge helpers

Test mergeCounts and mergeBuckets with partial data, empty queries.

  • [ ] Step 7: Run all tests

Run: npm run test

  • [ ] Step 8: Commit
test: add unit tests for formatters, metricCodes, CSV parser, URL codec, and merge helpers

Task 20: Unit tests for computed metrics

Test the COMPUTED_METRICS functions and the integration with useReportTotals merge logic.

Files:

  • Create: src/composables/__tests__/useReportTotals.test.ts

  • [ ] Step 1: Test mergeTotalsFromStandard

ts
import { describe, it, expect } from 'vitest'
import { mergeTotalsFromStandard, mergeTotalsFromBunny } from '../useReportTotals'

describe('mergeTotalsFromStandard', () => {
  it('converts CountsResponse to ResourceTotals', () => {
    const result = mergeTotalsFromStandard({
      '123': { impressions: 1000, interacted: 50 },
    })
    expect(result.get('123')?.get('impressions')).toBe(1000)
  })
})
  • [ ] Step 2: Test computed metric calculation

Test that interaction-rate = interacted/impressions and ctr = link_clicks/impressions, and that they return ratios (not percentages).

  • [ ] Step 3: Test mergeTotalsFromBunny with date filtering

Verify rows outside the date range are excluded.

  • [ ] Step 4: Commit
test: add unit tests for computed metrics and totals merge logic

Summary

ChunkTasksFocus
11-5Code quality: shared types, convention alignment, config
26-8ResourcePicker: split, sort, inactive styling
39-11Bunny Analytics: CSV columns, API debugging, per-creative status
412-14Widget system: typed configs, URL persistence, CRUD UI
515-18UX polish: funnel, breakdown metrics, chart selector, states
619-20Testing: utils, computed metrics, merge logic

Prioritization

Do first (high impact, low risk):

  • Tasks 1-3 (type extraction + convention alignment) -- quick wins, reduce confusion
  • Task 9 (CSV column mapping) -- root cause of missing Bunny metrics
  • Task 10 (aggregation API debugging) -- root cause of zero standard metrics
  • Task 19 (unit tests) -- safety net for everything else

Do second (medium impact):

  • Tasks 6-8 (ResourcePicker improvements)
  • Tasks 15-18 (UX polish)

Do last (high effort, can iterate):

  • Tasks 12-14 (Widget grid system) -- significant new feature, needs design iteration
  • Task 5 (CLAUDE.md) -- write after conventions stabilize

Dependencies

  • Task 6 (ResourcePicker split) depends on Task 2 (shared tree types)
  • Tasks 12-14 (widget system) are sequential
  • Tasks 9-10 (Bunny/aggregation debugging) are independent and can be done in parallel
  • Testing tasks (19-20) can run in parallel with all others

Internal documentation