AI Dashboard
The AI Dashboard is the canvas-style surface where Mia's answers are rendered as interactive widgets, not just text. When you ask "how was labor last week?" the pipeline returns a sentence-level answer plus a JSON description of one or more charts — and the dashboard renders them on a free-form draggable canvas.
This page covers how the canvas works, the catalog of pre-built widget components, and how the AI produces widget configs that hydrate them.
Where it lives
- Frontend route: same as Normal Conversations —
src/routes/PivotAiAgentDashboard/ - Canvas:
src/routes/PivotAiAgentDashboard/premade/WidgetCanvas.tsx - Dashboard wrapper / placement logic:
src/routes/PivotAiAgentDashboard/premade/DashboardCanvas.tsx - Global state + SSE stream consumer:
src/routes/PivotAiAgentDashboard/PivotAiAgentGlobal.tsx - Shared design tokens:
src/routes/PivotAiAgentDashboard/premade/shared.ts - Widget type definitions:
src/routes/PivotAiAgentDashboard/premade/types.ts - Backend widget-prompt builder:
functions/pivotAiAgent/pipeline.ts(buildWidgetSystem())
The canvas
Free-form, absolute-positioned, drag-and-drop. Not a CSS grid, not react-grid-layout — pure pointer events + custom collision detection.
- Widget positions are stored as
{ x, y, w, h }in pixel/rem units (thePlacedWidgetshape inWidgetCanvas.tsx). - Drag, resize, and close handles are wired up directly on the widget cell.
- Collision detection (
findNearestValid/findNearestValidSize) snaps the dragged widget to the nearest non-overlapping slot when it would collide. autoPlace()lays out newly-arrived widgets sequentially, top-to-bottom-left-to-right, picking the next collision-free slot.- A
ResizeObserverauto-fits each cell's height to its content after the entrance animation finishes.
Desktop-only today. Widgets use absolute pixel positioning; there's no responsive mobile breakpoint. Light theme only — colors are hard-coded in shared.ts (TOKENS).
Widget catalog (30 premade components)
All under src/routes/PivotAiAgentDashboard/premade/<Name>.tsx. The AI picks a component name from this list when emitting widget JSON; the canvas dispatches to the matching React component.
Descriptions below are pulled verbatim from each file's top doc comment.
KPIs and single-value indicators
| Component | What it shows |
|---|---|
KPICard | Single metric with trend indicator |
ScoreCard | Big centered number with contextual status indicator |
Sparkline | Compact card with a mini inline SVG line chart |
ProgressBar | Target vs actual progress indicator |
CoverageGauge | Vertical list of coverage groups with progress bars |
MetricGrid | Compact grid of KPI-style metrics |
StatComparison | Side-by-side metric comparison |
Charts
| Component | What it shows |
|---|---|
BarChart | Vertical or horizontal bar chart |
LineChart | Time series with multiple data lines |
PieChart | Category breakdown (donut variant available) |
Heatmap | Grid of colored cells with intensity based on value |
CalendarHeatmap | Month grid showing day-by-day intensity |
ComparisonBar | Side-by-side horizontal bars for comparing values |
RankChart | Horizontal bars ranked by value (highest first) |
Lists, tables, feeds
| Component | What it shows |
|---|---|
DataTable | Sortable rows/columns |
BreakdownTable | Expandable category table with percentage bars |
ListWidget | Ranked/ordered list of items (e.g. top sellers, most hours) |
AlertList | Notification / alert list with priority-colored dots |
TimelineFeed | Vertical activity feed with timeline dots and connector line |
StatusBoard | Grid of status indicators with colored dots |
Employees
| Component | What it shows |
|---|---|
EmployeeCard | Profile snapshot card for a single employee |
EmployeeProfile | Full employee detail card |
EmployeeList | Compact multi-employee rows with status badges |
Schedule and attendance
| Component | What it shows |
|---|---|
ScheduleTimeline | Horizontal timeline showing shifts as colored blocks |
ShiftCard | Single shift detail card |
AttendanceSummary | Table of attendance entries for a given date |
Payroll and tips
| Component | What it shows |
|---|---|
PayrollSummary | Table showing payroll data per employee |
TipDistribution | Tip pool breakdown by employee |
Misc
| Component | What it shows |
|---|---|
WeatherCard | Current weather + 7-day forecast. Exception to the pre-computed-data rule: fetches from Open-Meteo at render time using the company city from Redux. |
TextCard | Simple freeform text card |
Each component accepts a shared WidgetLayoutProps envelope (size, position, theming tokens — see premade/types.ts) plus its own props shape. Required input fields per component are encoded in the widget-prompt builder so Mia's outputs match what the renderer expects.
Widget JSON shape
The pipeline's Phase 2 widget call (Haiku) emits one JSON blob with this top-level structure:
{
widgets: [
{
id: string, // unique within this response
type: 'premade', // always 'premade' today
title: string, // user-facing label
size?: 'small' | 'medium' | 'large',
cols?: 3 | 6 | 12, // grid-fraction hint
rows?: 1 | 2 | 3,
accentColor?: string,
backgroundColor?: string,
textColor?: string,
order?: number,
data: {
component: string, // one of the 30 names above
props: Record<string, unknown> // shape depends on component
}
}
// ...more widgets
]
}
Type definitions live in src/routes/PivotAiAgentDashboard/premade/types.ts. The parser that turns the streamed string into this object is parseWidgetsFromResponse() in PivotAiAgentGlobal.tsx — it tolerates fenced ```json blocks, bare JSON, and JSON embedded in prose (regex-extracted) so the model has some forgiveness on output formatting.
How widgets stream in
The pipeline interleaves widget bytes into the same SSE stream as the chat answer. The frontend handles the partial JSON eagerly:
- The backend emits a
widget_startmarker event. - From that point on, every
content_block_deltais appended to the localwidgetTextbuffer. - After each delta, the frontend calls
parseWidgetsFromResponse(widgetText). As soon as the partial JSON is parseable (e.g., the first widget's closing brace lands), the resulting array is dispatched to the store and the canvas renders before the full payload arrives. - Later widgets pop in as the buffer grows; positions are picked by
autoPlace()so they don't collide. - The stream ends with
done; final widget JSON is persisted to the session record (see below).
This is why the first KPI card can appear within a second or two even though the full multi-chart payload may stream for 5+ seconds.
Where the data comes from
Pre-computed inline, not re-fetched at render time. The widget-generator Haiku call receives:
- The user's question
- The final answer text from the answer Haiku call (so widgets and prose are coherent)
- The full tool-loop results from Phase 1 (all the rows / numbers the data path collected)
- The employee name map (so IDs become readable names)
It bakes the actual numbers into the JSON (data: [1234, 5678, ...], value: "$4,850"). The React component only does one runtime lookup — the Redux store, for employee metadata (name, avatar, position) when a widget references an employeeId.
Consequence: rendering a widget doesn't issue any HTTP calls (with one exception — see below). Reloading a saved session re-renders instantly from RTDB.
Exception: WeatherCard fetches live from Open-Meteo at render time using the company's city from Redux. That's the only widget that hits an external API on render — Mia just emits the widget shell, and the component handles its own data.
Auto-spawned widgets (planner markers)
Beyond the explicit widget JSON from Phase 2b, the dashboard auto-creates a few specific widgets from inline markers the planner leaves in its tool-loop output. The planner is instructed:
"PIVOT COMPONENTS: When analysis involves a specific employee — ALWAYS call
find_employeefor the top 1-3 employees and include their IDs in your summary as:EMPLOYEE_ID:<firebase_id>:<name>. The dashboard renderer will automatically build EmployeeProfile panels for them. When analysis involves schedule/shifts: includeCOMPONENT_HINT:WeekSchedule. Shift history:COMPONENT_HINT:ShiftHistory. Labor snapshot:COMPONENT_HINT:LaborSnapshot."
Source: functions/pivotAiAgent/pipeline.ts:1295-1297.
How it works
After the tool loop returns, pipeline.ts (around lines 364 and 739) parses the planner's text output with two regexes:
const employeeMarkers = [...summary.matchAll(/EMPLOYEE_ID:([^:\n]+):([^\n]+)/g)]
.map(m => ({ id: m[1].trim(), name: m[2].trim() }))
const componentHints = [...summary.matchAll(/COMPONENT_HINT:(\w+)/g)]
.map(m => m[1])
The first regex captures { id, name } pairs (Firebase ID + human name). The second captures bare hint tokens. Both are pre-validated against the collected data — if the planner referenced an employee whose name appears in the tool results but didn't emit the marker, pipeline.ts:1063 backfills it.
What each marker does
| Marker | Auto-spawned widget |
|---|---|
EMPLOYEE_ID:<id>:<name> | EmployeeProfile panel for that employee (up to ~3 per response) |
COMPONENT_HINT:WeekSchedule | ScheduleTimeline for the current week |
COMPONENT_HINT:ShiftHistory | Shift-history view for the referenced employee(s) |
COMPONENT_HINT:LaborSnapshot | Labor-cost rollup widget |
Markers don't reach the user
After the pipeline reads them, the markers are stripped from the answer text before it's streamed:
summary
.replace(/EMPLOYEE_ID:[^\n]+/g, '')
.replace(/COMPONENT_HINT:\w+/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
The frontend also strips them defensively (PivotAiAgentGlobal.tsx), so a marker that slipped through wouldn't render as user-visible text.
Why a marker side-channel instead of just emitting widgets?
The planner runs Sonnet, the widget call runs Haiku — they're different model passes. The planner is the only one that has fresh tool-call context for choosing "the right 1-3 employees to spotlight." Having it emit cheap text markers lets the dashboard renderer (which has access to the marker list) compose those high-value widgets without re-running Sonnet for layout.
User vs. AI control
Widgets are AI-created. There's no "add widget" UI for users. What the user can do:
- Move — drag any widget to a new position.
- Resize — drag the bottom-right corner.
- Remove — close button (×) in the widget's top-right.
Layout changes are persisted (see next section). The model can suggest follow-up questions in the same response, which appear as quick-reply buttons.
Persistence and session resume
Session storage is RTDB at AIZackboardSessions/{companyId}/{userId}/{sessionId} (typo in path — same as the backend; see Data: Memory & Conversations).
Stored fields:
| Field | Source of writes |
|---|---|
messages | Backend saveSession |
widgets | Backend saveSession (the parsed widget array) |
widgetLayouts | Frontend, debounced 800 ms on drag/resize |
suggestions | Backend saveSession |
title | First ~40 chars of the first user message |
createdAt / updatedAt | Backend / frontend |
tokenUsage | Backend (aggregated counters) |
pendingJobId | Backend (present while a run is streaming; removed when done) |
On resume:
- User clicks a saved session in the sidebar →
loadSessionData(sessionId). - Firebase returns the record; messages, widgets, and layouts hydrate React state.
- The canvas prefers
widgetLayouts(saved positions) overautoPlace()so the layout matches what the user last saw. - If
pendingJobIdis set, the frontend re-subscribes to the in-flight job and finishes streaming whatever widgets remain.
That last point matters: a 30-second data-path query can survive a page reload.
Architecture summary
Known gaps
- Mobile layout — desktop-only today; absolute-position widgets don't reflow well on small screens.
- Dark mode — design tokens are light-only.
- User-added widgets — no UI to add a widget manually. If a user wants a chart that Mia didn't generate, they have to ask for it.
- Custom dashboards — no concept of a pinned / favorite dashboard. Every session starts fresh; the canvas only persists for the lifetime of the conversation.