Skip to main content

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 Conversationssrc/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 (the PlacedWidget shape in WidgetCanvas.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 ResizeObserver auto-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

ComponentWhat it shows
KPICardSingle metric with trend indicator
ScoreCardBig centered number with contextual status indicator
SparklineCompact card with a mini inline SVG line chart
ProgressBarTarget vs actual progress indicator
CoverageGaugeVertical list of coverage groups with progress bars
MetricGridCompact grid of KPI-style metrics
StatComparisonSide-by-side metric comparison

Charts

ComponentWhat it shows
BarChartVertical or horizontal bar chart
LineChartTime series with multiple data lines
PieChartCategory breakdown (donut variant available)
HeatmapGrid of colored cells with intensity based on value
CalendarHeatmapMonth grid showing day-by-day intensity
ComparisonBarSide-by-side horizontal bars for comparing values
RankChartHorizontal bars ranked by value (highest first)

Lists, tables, feeds

ComponentWhat it shows
DataTableSortable rows/columns
BreakdownTableExpandable category table with percentage bars
ListWidgetRanked/ordered list of items (e.g. top sellers, most hours)
AlertListNotification / alert list with priority-colored dots
TimelineFeedVertical activity feed with timeline dots and connector line
StatusBoardGrid of status indicators with colored dots

Employees

ComponentWhat it shows
EmployeeCardProfile snapshot card for a single employee
EmployeeProfileFull employee detail card
EmployeeListCompact multi-employee rows with status badges

Schedule and attendance

ComponentWhat it shows
ScheduleTimelineHorizontal timeline showing shifts as colored blocks
ShiftCardSingle shift detail card
AttendanceSummaryTable of attendance entries for a given date

Payroll and tips

ComponentWhat it shows
PayrollSummaryTable showing payroll data per employee
TipDistributionTip pool breakdown by employee

Misc

ComponentWhat it shows
WeatherCardCurrent weather + 7-day forecast. Exception to the pre-computed-data rule: fetches from Open-Meteo at render time using the company city from Redux.
TextCardSimple 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:

  1. The backend emits a widget_start marker event.
  2. From that point on, every content_block_delta is appended to the local widgetText buffer.
  3. 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.
  4. Later widgets pop in as the buffer grows; positions are picked by autoPlace() so they don't collide.
  5. 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_employee for 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: include COMPONENT_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

MarkerAuto-spawned widget
EMPLOYEE_ID:<id>:<name>EmployeeProfile panel for that employee (up to ~3 per response)
COMPONENT_HINT:WeekScheduleScheduleTimeline for the current week
COMPONENT_HINT:ShiftHistoryShift-history view for the referenced employee(s)
COMPONENT_HINT:LaborSnapshotLabor-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:

FieldSource of writes
messagesBackend saveSession
widgetsBackend saveSession (the parsed widget array)
widgetLayoutsFrontend, debounced 800 ms on drag/resize
suggestionsBackend saveSession
titleFirst ~40 chars of the first user message
createdAt / updatedAtBackend / frontend
tokenUsageBackend (aggregated counters)
pendingJobIdBackend (present while a run is streaming; removed when done)

On resume:

  1. User clicks a saved session in the sidebar → loadSessionData(sessionId).
  2. Firebase returns the record; messages, widgets, and layouts hydrate React state.
  3. The canvas prefers widgetLayouts (saved positions) over autoPlace() so the layout matches what the user last saw.
  4. If pendingJobId is 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.