| name | add-session-card |
| description | Add a new analytics card to the session summary panel. Covers backend analyzer, database migration, API response, and frontend component with Storybook stories. |
Add Session Analytics Card
Add a new analytics card to the session summary panel following the card-per-table architecture.
Overview
The analytics system uses a card-per-table architecture where each card type has:
- Its own database table (
session_card_<name>)
- Independent version constant for cache invalidation
- An analyzer that extracts metrics from a
FileCollection
- Frontend component registered in the card registry
Instructions for Claude
Use TodoWrite to track all phases. This is a multi-step task requiring both backend and frontend changes.
Phase 1: Plan the Card
Before writing any code:
Phase 2: Backend - Database Migration
Create migration files in backend/internal/db/migrations/:
Up migration (000XXX_session_card_<name>.up.sql):
CREATE TABLE session_card_<name> (
session_id UUID PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
version INT NOT NULL DEFAULT 1,
computed_at TIMESTAMPTZ NOT NULL,
up_to_line BIGINT NOT NULL,
my_metric BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_session_card_<name>_version ON session_card_<name>(version);
Down migration (000XXX_session_card_<name>.down.sql):
DROP TABLE IF EXISTS session_card_<name>;
Get the next migration number:
ls backend/internal/db/migrations/*.up.sql | sort | tail -1
Phase 3: Backend - Card Types
In backend/internal/analytics/cards.go, add:
- Version constant (bump to invalidate all cached cards):
const <Name>CardVersion = 1
- Record type (matches database schema):
type <Name>CardRecord struct {
SessionID string `json:"session_id"`
Version int `json:"version"`
ComputedAt time.Time `json:"computed_at"`
UpToLine int64 `json:"up_to_line"`
MyMetric int64 `json:"my_metric"`
}
func (c *<Name>CardRecord) IsValid(currentLineCount int64) bool {
return c != nil &&
c.Version == <Name>CardVersion &&
c.UpToLine == currentLineCount
}
- API data type (JSON response):
type <Name>CardData struct {
MyMetric int64 `json:"my_metric"`
}
- Add to Cards struct and AllValid()
Phase 4: Backend - Analyzer
Create backend/internal/analytics/analyzer_<name>.go:
package analytics
type <Name>Result struct {
MyMetric int64
}
type <Name>Analyzer struct{}
func (a *<Name>Analyzer) Analyze(fc *FileCollection) (*<Name>Result, error) {
result := &<Name>Result{}
for _, line := range fc.Main.Lines {
if line.IsAssistantMessage() {
}
if line.IsUserMessage() {
}
}
return result, nil
}
FileCollection methods:
fc.Main - main transcript file
fc.Agents - slice of agent transcript files
fc.AllFiles() - iterate over main + all agents
TranscriptLine helpers:
IsUserMessage() - true for user messages
IsAssistantMessage() - true for assistant messages with usage
IsHumanMessage() - true for human prompts (not tool results)
IsToolResultMessage() - true for tool result messages
IsCompactBoundary() - true for compaction markers
GetTimestamp() - parse timestamp
GetToolUses() - extract tool_use blocks
GetContentBlocks() - get all content blocks
GetAgentResults() - get subagent/Task results
GetModel() - get model ID
GetStopReason() - get stop reason
HasTextContent() - true if message has text
HasThinking() - true if message has thinking block
Phase 5: Backend - Wire Up
- Add to ComputeResult in
compute_result.go:
MyMetric int64
- Run analyzer in
ComputeFromFileCollection():
<name>, err := (&<Name>Analyzer{}).Analyze(fc)
if err != nil {
return nil, err
}
- Populate result in
ComputeFromFileCollection():
return &ComputeResult{
MyMetric: <name>.MyMetric,
}, nil
-
Store operations in store.go:
- Add
get<Name>Card() method
- Add
upsert<Name>Card() method
- Update
GetCards() to include new card
- Update
UpsertCards() to include new card
-
Card creation in store.go ToCards():
<Name>: &<Name>CardRecord{
SessionID: sessionID,
Version: <Name>CardVersion,
ComputedAt: now,
UpToLine: lineCount,
MyMetric: r.MyMetric,
},
- API response in
store.go ToResponse():
if c.<Name> != nil {
response.Cards["<name>"] = <Name>CardData{
MyMetric: c.<Name>.MyMetric,
}
}
Phase 6: Backend - Tests
Run backend tests:
cd backend && DOCKER_HOST=unix:///Users/jackie/.orbstack/run/docker.sock go test ./...
Phase 7: Frontend - Zod Schema
In frontend/src/schemas/api.ts:
- Add card data schema:
export const <Name>CardDataSchema = z.object({
my_metric: z.number(),
});
- Add to
AnalyticsCardsSchema:
<name>: <Name>CardDataSchema.optional(),
- Export type:
export type <Name>CardData = z.infer<typeof <Name>CardDataSchema>;
Phase 8: Frontend - Card Component
Create frontend/src/components/session/cards/<Name>Card.tsx:
import { CardWrapper, StatRow, CardLoading } from './Card';
import type { <Name>CardData } from '@/schemas/api';
import type { CardProps } from './types';
const TOOLTIPS = {
myMetric: 'Description of what this metric means',
};
export function <Name>Card({ data, loading }: CardProps<<Name>CardData>) {
if (loading && !data) {
return (
<CardWrapper title="<Display Name>">
<CardLoading />
</CardWrapper>
);
}
if (!data) return null;
return (
<CardWrapper title="<Display Name>">
<StatRow
label="My Metric"
value={data.my_metric}
tooltip={TOOLTIPS.myMetric}
/>
</CardWrapper>
);
}
Phase 9: Frontend - Register Card
-
In registry.ts:
- Import the card component
- Add to
cardRegistry array with appropriate order
-
In index.ts:
- Export the card component
-
Update registry.test.ts:
- Add new card to expected cards list
Phase 10: Frontend - Storybook
Create frontend/src/components/session/cards/<Name>Card.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react-vite';
import { <Name>Card } from './<Name>Card';
const meta: Meta<typeof <Name>Card> = {
title: 'Session/Cards/<Name>Card',
component: <Name>Card,
parameters: { layout: 'centered' },
decorators: [
(Story) => (
<div style={{ width: '280px' }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof <Name>Card>;
export const Default: Story = {
args: {
data: { my_metric: 42 },
loading: false,
},
};
export const Loading: Story = {
args: {
data: undefined,
loading: true,
},
};
Phase 11: Frontend - Tests & Build
cd frontend && npm run build && npm run lint && npm test
cd frontend && npm run build-storybook
Phase 12: Documentation
Update backend/API.md with the new card schema in the analytics response.
Common Patterns
Time-based invalidation
For cards that should refresh periodically:
func (c *<Name>CardRecord) IsValid(currentLineCount int64) bool {
if c == nil || c.Version != <Name>CardVersion {
return false
}
return time.Since(c.ComputedAt) < time.Hour
}
Conditional rendering and grid layout
Cards that shouldn't show when empty need two things:
- Component returns null when data is empty:
if (data.total_count === 0) return null;
- Registry has
shouldRender to prevent empty grid cells:
{
key: 'my_card',
title: 'My Card',
component: MyCard,
order: 5,
span: 2,
shouldRender: (data: MyCardData | null) => !!data && data.total_count > 0,
}
Why both? The card component is wrapped in a <div> with grid span/size classes. If the component returns null but the wrapper is still rendered, CSS Grid reserves the columns for the empty div, creating gaps in the layout. The shouldRender function prevents the wrapper from rendering at all.
Rule of thumb: Any card that can return null based on data content (not just !data) should have a matching shouldRender function in the registry. This is especially critical for cards with span: 2 or span: 3.
Processing agent files
For metrics that include subagent data:
for _, file := range fc.AllFiles() {
for _, line := range file.Lines {
}
}
Building tool ID maps
For correlating tool_use with tool_result:
toolIDToName := file.BuildToolUseIDToNameMap()
Checklist Before Commit