Appearance
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(asResolvedDateRange)
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/typesTask 2: Extract DataTreeNode types from inline definitions
CreativeNode, CreativeGroupNode, CampaignNode are defined inline in 3 files:
components/builder/ResourcePicker.vue:12-26views/ReportViewer.vue:18-35components/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 moduleTask 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, ApiErrorBodysrc/api/bunny.ts-- BunnyMetricsResponsesrc/widgets/types.ts-- MetricSpec, WidgetDescriptor, ViewDescriptor, ScopeResource, ReportScope (keep WidgetKind as union type)src/composables/useReportTotals.ts-- UseReportTotalsArgssrc/composables/useReportTimeSeries.ts-- UseReportTimeSeriesArgs, SeriesPointsrc/composables/useCounts.ts-- UseCountsArgssrc/composables/useBuckets.ts-- UseBucketsArgssrc/composables/useAvailableCustomMetrics.ts-- AvailableCustomMetric, UseAvailableCustomMetricsArgssrc/composables/useBunnyHourly.ts-- UseBunnyHourlyArgssrc/stores/currentReport.ts-- UseReportStoreArgssrc/stores/reports.ts-- SavedReportsrc/utils/reportUrl.ts-- ReportState[ ] Step 1: Convert all
interface Foo {totype 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 conventionsTask 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 conventionsTask 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 projectChunk 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 impressionsTask 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 ResourcePickerTask 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 ResourcePickerChunk 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 keysTask 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:
- The metric keys are genuinely 0 for these creatives
- The aggregation API returns different key names than expected
- The
basemetric 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 formatTask 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 breakdownChunk 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 typesTask 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 encodingModify:
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 paramsTask 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 visualizationTask 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 breakdownTask 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 chartTask 18: Better empty/loading states
Improve the generic state components with more helpful messaging.
Files:
Modify:
src/widgets/states/EmptyState.vueModify:
src/widgets/states/ErrorState.vueModify:
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 messagingChunk 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.tsCreate:
src/utils/__tests__/metricCodes.test.tsCreate:
src/utils/__tests__/bunnyCsvParser.test.tsCreate:
src/utils/__tests__/reportUrl.test.tsCreate:
src/utils/__tests__/sliceBucketsByDateRange.test.tsCreate:
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 helpersTask 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 logicSummary
| Chunk | Tasks | Focus |
|---|---|---|
| 1 | 1-5 | Code quality: shared types, convention alignment, config |
| 2 | 6-8 | ResourcePicker: split, sort, inactive styling |
| 3 | 9-11 | Bunny Analytics: CSV columns, API debugging, per-creative status |
| 4 | 12-14 | Widget system: typed configs, URL persistence, CRUD UI |
| 5 | 15-18 | UX polish: funnel, breakdown metrics, chart selector, states |
| 6 | 19-20 | Testing: 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