From a273d199779335194e8daa5367bd706570013c6e Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 16:29:52 -0700 Subject: [PATCH 01/18] feat(dashboard): add computeTimeSaved with honest + fallback formula --- cloud/dashboard/src/lib/analytics-client.ts | 31 ++++++++++++++++ .../dashboard/tests/analytics-client.test.ts | 37 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/cloud/dashboard/src/lib/analytics-client.ts b/cloud/dashboard/src/lib/analytics-client.ts index 1c5a7549..1f09c372 100644 --- a/cloud/dashboard/src/lib/analytics-client.ts +++ b/cloud/dashboard/src/lib/analytics-client.ts @@ -165,3 +165,34 @@ export function buildDecayCurve( } }) } + +const MINUTES_PER_CORRECTION = 3 + +/** + * Estimated minutes saved by the brain this period. + * + * Honest formula: only count fires on rules with `recurrence_blocked = true` + * (rule has a history of preventing re-corrections). + * + * Fallback (until backend ships `recurrence_blocked`): count fires on rules + * where fire_count > 1 AND correction_count > 0. This excludes first-fire-ever + * and rules that never caught a real correction. + * + * Returns integer minutes. Always labelled "Est." in the UI. + */ +export function computeTimeSaved(lessons: Lesson[]): number { + let fires = 0 + for (const l of lessons) { + const hasRecurrenceFlag = typeof (l as unknown as { recurrence_blocked?: boolean }).recurrence_blocked === 'boolean' + if (hasRecurrenceFlag) { + if ((l as unknown as { recurrence_blocked: boolean }).recurrence_blocked) { + fires += l.fire_count ?? 0 + } + } else { + const fc = l.fire_count ?? 0 + const cc = (l as unknown as { correction_count?: number }).correction_count ?? 0 + if (fc > 1 && cc > 0) fires += fc + } + } + return fires * MINUTES_PER_CORRECTION +} diff --git a/cloud/dashboard/tests/analytics-client.test.ts b/cloud/dashboard/tests/analytics-client.test.ts index 9b7f3ef4..4b68e35a 100644 --- a/cloud/dashboard/tests/analytics-client.test.ts +++ b/cloud/dashboard/tests/analytics-client.test.ts @@ -3,6 +3,7 @@ import { computeKpis, computeGraduationCounts, buildDecayCurve, + computeTimeSaved, } from '@/lib/analytics-client' import type { BrainAnalytics, Correction, Lesson } from '@/types/api' @@ -133,3 +134,39 @@ describe('buildDecayCurve', () => { expect(curve[0]).toHaveProperty('day') }) }) + +describe('computeTimeSaved', () => { + it('returns 0 minutes for no lessons', () => { + expect(computeTimeSaved([])).toBe(0) + }) + + it('counts only fires on rules with recurrence_blocked=true (honest formula)', () => { + const lessons = [ + mkLesson('a', 'RULE', 0.9, 4), + mkLesson('b', 'RULE', 0.9, 2), + ] + ;(lessons[0] as any).recurrence_blocked = true + ;(lessons[1] as any).recurrence_blocked = false + // honest: 3 min × 4 fires on rule a = 12 + expect(computeTimeSaved(lessons)).toBe(12) + }) + + it('falls back to fire_count > 1 AND correction_count > 0 when recurrence_blocked missing', () => { + const lessons = [ + mkLesson('a', 'RULE', 0.9, 5), + mkLesson('b', 'RULE', 0.9, 1), + mkLesson('c', 'RULE', 0.9, 3), + ] + ;(lessons[0] as any).correction_count = 2 // counts: 5 fires + ;(lessons[1] as any).correction_count = 1 // excluded: fire_count not > 1 + ;(lessons[2] as any).correction_count = 0 // excluded: correction_count 0 + // 3 min × 5 fires = 15 + expect(computeTimeSaved(lessons)).toBe(15) + }) + + it('returns whole minutes, floored/rounded to nearest whole minute', () => { + const lessons = [mkLesson('a', 'RULE', 0.9, 7)] + ;(lessons[0] as any).recurrence_blocked = true + expect(computeTimeSaved(lessons)).toBe(21) + }) +}) From 9101d5fa596f2d667f4047c853a510c0817859bb Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 16:36:32 -0700 Subject: [PATCH 02/18] feat(dashboard): add computeWoWDelta with sample-size floor --- cloud/dashboard/src/lib/analytics-client.ts | 17 ++++++++++++ .../dashboard/tests/analytics-client.test.ts | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/cloud/dashboard/src/lib/analytics-client.ts b/cloud/dashboard/src/lib/analytics-client.ts index 1f09c372..97b4657a 100644 --- a/cloud/dashboard/src/lib/analytics-client.ts +++ b/cloud/dashboard/src/lib/analytics-client.ts @@ -196,3 +196,20 @@ export function computeTimeSaved(lessons: Lesson[]): number { } return fires * MINUTES_PER_CORRECTION } + +/** + * Week-over-week percent change with sample-size floor. + * + * Returns null when either period is below the floor, or when prior is 0. + * Caller renders null as `—`. Result is rounded to a whole percent. + */ +export function computeWoWDelta( + thisPeriod: number, + priorPeriod: number, + opts: { floor?: number } = {}, +): number | null { + const floor = opts.floor ?? 5 + if (thisPeriod < floor || priorPeriod < floor) return null + if (priorPeriod === 0) return null + return Math.round(((thisPeriod - priorPeriod) / priorPeriod) * 100) +} diff --git a/cloud/dashboard/tests/analytics-client.test.ts b/cloud/dashboard/tests/analytics-client.test.ts index 4b68e35a..6c936376 100644 --- a/cloud/dashboard/tests/analytics-client.test.ts +++ b/cloud/dashboard/tests/analytics-client.test.ts @@ -4,6 +4,7 @@ import { computeGraduationCounts, buildDecayCurve, computeTimeSaved, + computeWoWDelta, } from '@/lib/analytics-client' import type { BrainAnalytics, Correction, Lesson } from '@/types/api' @@ -170,3 +171,29 @@ describe('computeTimeSaved', () => { expect(computeTimeSaved(lessons)).toBe(21) }) }) + +describe('computeWoWDelta', () => { + it('returns null when either week below sample-size floor', () => { + expect(computeWoWDelta(3, 10, { floor: 5 })).toBeNull() + expect(computeWoWDelta(10, 4, { floor: 5 })).toBeNull() + expect(computeWoWDelta(2, 2, { floor: 5 })).toBeNull() + }) + + it('returns percent change when both weeks meet floor', () => { + expect(computeWoWDelta(12, 10, { floor: 5 })).toBe(20) + expect(computeWoWDelta(8, 10, { floor: 5 })).toBe(-20) + }) + + it('handles zero prior with this-week positive as null (undefined ratio)', () => { + expect(computeWoWDelta(10, 0, { floor: 5 })).toBeNull() + }) + + it('uses default floor of 5 when no options passed', () => { + expect(computeWoWDelta(4, 4)).toBeNull() + expect(computeWoWDelta(6, 5)).toBe(20) + }) + + it('rounds to whole percent', () => { + expect(computeWoWDelta(10, 6, { floor: 5 })).toBe(67) + }) +}) From 1ff1ab8f0100b2251fefdb6e2598b13d0729d9e7 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 16:40:54 -0700 Subject: [PATCH 03/18] feat(dashboard): add computeRuleStreak with graduated_at fallback --- cloud/dashboard/src/lib/analytics-client.ts | 16 +++++++++ .../dashboard/tests/analytics-client.test.ts | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/cloud/dashboard/src/lib/analytics-client.ts b/cloud/dashboard/src/lib/analytics-client.ts index 97b4657a..44326953 100644 --- a/cloud/dashboard/src/lib/analytics-client.ts +++ b/cloud/dashboard/src/lib/analytics-client.ts @@ -213,3 +213,19 @@ export function computeWoWDelta( if (priorPeriod === 0) return null return Math.round(((thisPeriod - priorPeriod) / priorPeriod) * 100) } + +/** + * Days since the rule last triggered a correction or recurrence. + * + * Max of `last_recurrence_at` and `graduated_at`. Returns null when neither + * is present. Zero when the more recent of the two is today. + */ +export function computeRuleStreak(lesson: Lesson): number | null { + const rec = (lesson as unknown as { last_recurrence_at?: string }).last_recurrence_at + const grad = (lesson as unknown as { graduated_at?: string }).graduated_at + const candidates = [rec, grad].filter((v): v is string => typeof v === 'string' && v.length > 0) + if (candidates.length === 0) return null + const mostRecentMs = Math.max(...candidates.map((iso) => new Date(iso).getTime())) + const diffMs = Date.now() - mostRecentMs + return Math.max(0, Math.floor(diffMs / 86_400_000)) +} diff --git a/cloud/dashboard/tests/analytics-client.test.ts b/cloud/dashboard/tests/analytics-client.test.ts index 6c936376..6e7171a7 100644 --- a/cloud/dashboard/tests/analytics-client.test.ts +++ b/cloud/dashboard/tests/analytics-client.test.ts @@ -5,6 +5,7 @@ import { buildDecayCurve, computeTimeSaved, computeWoWDelta, + computeRuleStreak, } from '@/lib/analytics-client' import type { BrainAnalytics, Correction, Lesson } from '@/types/api' @@ -197,3 +198,38 @@ describe('computeWoWDelta', () => { expect(computeWoWDelta(10, 6, { floor: 5 })).toBe(67) }) }) + +describe('computeRuleStreak', () => { + const now = () => new Date().toISOString() + const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString() + + it('returns null when no timestamps present', () => { + const l = mkLesson('a', 'RULE', 0.9, 0) + expect(computeRuleStreak(l)).toBeNull() + }) + + it('uses last_recurrence_at when present', () => { + const l = mkLesson('a', 'RULE', 0.9, 0) + ;(l as any).last_recurrence_at = daysAgo(14) + expect(computeRuleStreak(l)).toBe(14) + }) + + it('falls back to graduated_at when no recurrences', () => { + const l = mkLesson('a', 'RULE', 0.9, 0) + ;(l as any).graduated_at = daysAgo(21) + expect(computeRuleStreak(l)).toBe(21) + }) + + it('prefers max of last_recurrence_at and graduated_at', () => { + const l = mkLesson('a', 'RULE', 0.9, 0) + ;(l as any).last_recurrence_at = daysAgo(2) + ;(l as any).graduated_at = daysAgo(30) + expect(computeRuleStreak(l)).toBe(2) + }) + + it('returns 0 for same day', () => { + const l = mkLesson('a', 'RULE', 0.9, 0) + ;(l as any).graduated_at = now() + expect(computeRuleStreak(l)).toBe(0) + }) +}) From 68d98771af4c8ed00158c53954a353262929b6df Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 16:45:02 -0700 Subject: [PATCH 04/18] feat(dashboard): extend Lesson type with recurrence_blocked, last_recurrence_at, graduated_at, correction_count --- cloud/dashboard/src/types/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cloud/dashboard/src/types/api.ts b/cloud/dashboard/src/types/api.ts index 1eb71aa0..5be4ce95 100644 --- a/cloud/dashboard/src/types/api.ts +++ b/cloud/dashboard/src/types/api.ts @@ -18,6 +18,10 @@ export interface Lesson { confidence: number fire_count: number created_at: string + recurrence_blocked?: boolean + last_recurrence_at?: string | null + graduated_at?: string | null + correction_count?: number } export interface Correction { From b8a45ef93033b6514dae395396037affcdfb4757 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 16:49:56 -0700 Subject: [PATCH 05/18] feat(dashboard): extend KpiMetrics with timeSavedMinutes + WoW deltas --- cloud/dashboard/src/lib/analytics-client.ts | 42 ++++++++++++++++--- .../dashboard/tests/analytics-client.test.ts | 29 +++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/cloud/dashboard/src/lib/analytics-client.ts b/cloud/dashboard/src/lib/analytics-client.ts index 44326953..845e969a 100644 --- a/cloud/dashboard/src/lib/analytics-client.ts +++ b/cloud/dashboard/src/lib/analytics-client.ts @@ -8,22 +8,30 @@ import type { BrainAnalytics, Correction, Lesson } from '@/types/api' export interface KpiMetrics { - /** Correction rate change vs prior period, negative = improving (learning) */ correctionRateDeltaPct: number correctionsThisWeek: number correctionsPriorWeek: number + /** WoW delta percent, null when below sample-size floor */ + correctionRateWoWDelta: number | null - /** Mean sessions to graduation. Placeholder math until backend ships per-lesson timelines */ sessionsToGraduation: number - sessionsToGraduationLow: number // 95% CI lower - sessionsToGraduationHigh: number // 95% CI upper + sessionsToGraduationLow: number + sessionsToGraduationHigh: number - /** Absolute misfire count. Trust signal — ideally 0. */ misfireCount: number + misfireCountPriorWeek: number + /** WoW delta percent, null when below sample-size floor */ + misfireWoWDelta: number | null totalFires: number - /** Brain footprint approximated from correction + lesson counts (KB) */ footprintKb: number + + /** Estimated minutes saved this period (current window); always shown with "Est." prefix */ + timeSavedMinutes: number + /** Prior-period time saved; null when not computable (requires per-fire timestamps) */ + timeSavedMinutesPriorWeek: number | null + /** WoW delta percent for time saved; null when prior is null or below floor */ + timeSavedWoWDelta: number | null } export interface GraduationCounts { @@ -78,16 +86,38 @@ export function computeKpis( // Brain footprint: ~11 KB per correction per S103 ANALYSIS const footprintKb = Math.round(corrections.length * 11) + const timeSavedMinutes = computeTimeSaved(lessons) + + // Prior-week Time Saved requires per-fire timestamps. Until backend ships + // them, return null to avoid a misleading 0. + const timeSavedMinutesPriorWeek: number | null = null + const timeSavedWoWDelta: number | null = + timeSavedMinutesPriorWeek === null + ? null + : computeWoWDelta(timeSavedMinutes, timeSavedMinutesPriorWeek) + + const correctionRateWoWDelta = computeWoWDelta(correctionsThisWeek, correctionsPriorWeek) + + // Misfire prior-week is 0 until backend exposes misfire timeline + const misfireCountPriorWeek = 0 + const misfireWoWDelta = computeWoWDelta(misfireCount, misfireCountPriorWeek) + return { correctionRateDeltaPct, correctionsThisWeek, correctionsPriorWeek, + correctionRateWoWDelta, sessionsToGraduation, sessionsToGraduationLow, sessionsToGraduationHigh, misfireCount, + misfireCountPriorWeek, + misfireWoWDelta, totalFires, footprintKb, + timeSavedMinutes, + timeSavedMinutesPriorWeek, + timeSavedWoWDelta, } } diff --git a/cloud/dashboard/tests/analytics-client.test.ts b/cloud/dashboard/tests/analytics-client.test.ts index 6e7171a7..7bb9fe0f 100644 --- a/cloud/dashboard/tests/analytics-client.test.ts +++ b/cloud/dashboard/tests/analytics-client.test.ts @@ -233,3 +233,32 @@ describe('computeRuleStreak', () => { expect(computeRuleStreak(l)).toBe(0) }) }) + +describe('computeKpis (extended)', () => { + it('includes timeSavedMinutes computed from lessons', () => { + const lessons = [ + (() => { + const l = mkLesson('a', 'RULE', 0.9, 4) + ;(l as any).recurrence_blocked = true + return l + })(), + ] + const k = computeKpis(emptyAnalytics, [], lessons) + expect(k.timeSavedMinutes).toBe(12) + }) + + it('includes correctionRateWoWDelta as null when below floor', () => { + const k = computeKpis(emptyAnalytics, [], []) + expect(k.correctionRateWoWDelta).toBeNull() + }) + + it('includes misfireCountPriorWeek when computable', () => { + const k = computeKpis(emptyAnalytics, [], []) + expect(k.misfireCountPriorWeek).toBe(0) + }) + + it('returns timeSavedMinutesPriorWeek = null when recurrence_blocked-era data absent', () => { + const k = computeKpis(emptyAnalytics, [], []) + expect(k.timeSavedMinutesPriorWeek).toBeNull() + }) +}) From de4082ae1b294042bb8498526ffbee08fe23e627 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 16:57:47 -0700 Subject: [PATCH 06/18] feat(dashboard): KpiStrip 5-card layout with Est. Time Saved + WoW deltas --- .../src/components/brain/KpiStrip.tsx | 106 +++++++++++------- cloud/dashboard/tests/KpiStrip.test.tsx | 77 ++++++++++--- 2 files changed, 124 insertions(+), 59 deletions(-) diff --git a/cloud/dashboard/src/components/brain/KpiStrip.tsx b/cloud/dashboard/src/components/brain/KpiStrip.tsx index 7f28261b..8e75d195 100644 --- a/cloud/dashboard/src/components/brain/KpiStrip.tsx +++ b/cloud/dashboard/src/components/brain/KpiStrip.tsx @@ -1,79 +1,99 @@ import { GlassCard } from '@/components/layout/GlassCard' import type { KpiMetrics } from '@/lib/analytics-client' -/** - * 4 KPI cards above the fold. Sim-validated metric set (S103, WAVE2): - * 1. Correction rate drop % (the only universally respected metric per S101) - * 2. Sessions to graduation (target <3, with 95% CI — differentiator vs Mem0/Letta) - * 3. 0 Misfires (trust signal from S103 ANALYSIS) - * 4. Brain footprint (observability lens, not "cloud owns your data") - */ -export function KpiStrip({ metrics }: { metrics: KpiMetrics }) { - const fmtDelta = (pct: number) => - pct === 0 ? '—' : `${pct > 0 ? '+' : ''}${pct.toFixed(0)}%` +const TIME_SAVED_TOOLTIP = + 'Estimated time saved = 3 minutes × rule fires on rules that have already caught a real correction. Excludes first-fire-ever. This is an estimate; the goal is a directional signal, not a precise audit.' + +function formatMinutes(n: number): string { + if (n < 60) return `${n}m` + const hours = n / 60 + if (hours >= 10) return `~${Math.round(hours)}h` + return `~${hours.toFixed(1)}h` +} + +function formatDelta(n: number | null): string { + if (n === null) return '—' + if (n === 0) return '0%' + return `${n > 0 ? '+' : ''}${n}%` +} +export function KpiStrip({ metrics }: { metrics: KpiMetrics }) { const items: Array<{ label: string value: string - change?: string - changeTone?: 'pos' | 'neg' | 'neu' + subline?: string + delta?: string + tone?: 'pos' | 'neg' | 'neu' + tooltip?: string }> = [ { label: 'Correction Rate', - value: metrics.correctionRateDeltaPct === 0 - ? '—' - : `${fmtDelta(metrics.correctionRateDeltaPct)}`, - change: `${metrics.correctionsThisWeek} this week · ${metrics.correctionsPriorWeek} prior`, - changeTone: - metrics.correctionRateDeltaPct < 0 ? 'pos' - : metrics.correctionRateDeltaPct > 0 ? 'neg' - : 'neu', + value: metrics.correctionRateWoWDelta === null ? '—' : formatDelta(metrics.correctionRateWoWDelta), + subline: `${metrics.correctionsThisWeek} this week · ${metrics.correctionsPriorWeek} prior`, + delta: formatDelta(metrics.correctionRateWoWDelta), + tone: + metrics.correctionRateWoWDelta === null ? 'neu' + : metrics.correctionRateWoWDelta < 0 ? 'pos' + : metrics.correctionRateWoWDelta > 0 ? 'neg' : 'neu', + }, + { + label: 'Est. Time Saved', + value: metrics.timeSavedMinutes === 0 ? '—' : formatMinutes(metrics.timeSavedMinutes), + subline: + metrics.timeSavedWoWDelta === null + ? 'vs prior: —' + : `vs prior: ${formatDelta(metrics.timeSavedWoWDelta)}`, + tone: metrics.timeSavedMinutes > 0 ? 'pos' : 'neu', + tooltip: TIME_SAVED_TOOLTIP, }, { label: 'Sessions to Graduation', - value: metrics.sessionsToGraduation === 0 - ? '—' - : metrics.sessionsToGraduation.toFixed(1), - change: metrics.sessionsToGraduation > 0 - ? `95% CI [${metrics.sessionsToGraduationLow}, ${metrics.sessionsToGraduationHigh}]` - : 'awaiting first graduation', - changeTone: 'neu', + value: metrics.sessionsToGraduation === 0 ? '—' : metrics.sessionsToGraduation.toFixed(1), + subline: + metrics.sessionsToGraduation > 0 + ? `95% CI [${metrics.sessionsToGraduationLow}, ${metrics.sessionsToGraduationHigh}]` + : 'awaiting first graduation', + tone: 'neu', }, { label: 'Misfires', value: metrics.misfireCount.toString(), - change: `across ${metrics.totalFires} rule fires`, - changeTone: metrics.misfireCount === 0 ? 'pos' : 'neg', + subline: + metrics.misfireWoWDelta === null + ? `across ${metrics.totalFires} rule fires` + : `was ${metrics.misfireCountPriorWeek} last week · ${formatDelta(metrics.misfireWoWDelta)}`, + tone: metrics.misfireCount === 0 ? 'pos' : 'neg', }, { label: 'Brain Footprint', - value: metrics.footprintKb >= 1024 - ? `${(metrics.footprintKb / 1024).toFixed(1)} MB` - : `${metrics.footprintKb} KB`, - change: '~11 KB per correction', - changeTone: 'neu', + value: + metrics.footprintKb >= 1024 + ? `${(metrics.footprintKb / 1024).toFixed(1)} MB` + : `${metrics.footprintKb} KB`, + subline: '~11 KB per correction', + tone: 'neu', }, ] return ( -
+
{items.map((item) => ( - -
+ + {item.label} -
-
+ +
{item.value}
- {item.change && ( + {item.subline && (
- {item.change} + {item.subline}
)} diff --git a/cloud/dashboard/tests/KpiStrip.test.tsx b/cloud/dashboard/tests/KpiStrip.test.tsx index de66885c..aecb23a6 100644 --- a/cloud/dashboard/tests/KpiStrip.test.tsx +++ b/cloud/dashboard/tests/KpiStrip.test.tsx @@ -7,46 +7,91 @@ const baseMetrics: KpiMetrics = { correctionRateDeltaPct: 0, correctionsThisWeek: 0, correctionsPriorWeek: 0, + correctionRateWoWDelta: null, sessionsToGraduation: 0, sessionsToGraduationLow: 0, sessionsToGraduationHigh: 0, misfireCount: 0, + misfireCountPriorWeek: 0, + misfireWoWDelta: null, totalFires: 0, footprintKb: 0, + timeSavedMinutes: 0, + timeSavedMinutesPriorWeek: null, + timeSavedWoWDelta: null, } describe('KpiStrip', () => { - it('renders 4 KPI cards with their labels', () => { + it('renders all KPI card labels including Est. Time Saved', () => { render() expect(screen.getByText('Correction Rate')).toBeInTheDocument() + expect(screen.getByText(/Est\. Time Saved/i)).toBeInTheDocument() expect(screen.getByText('Sessions to Graduation')).toBeInTheDocument() expect(screen.getByText('Misfires')).toBeInTheDocument() expect(screen.getByText('Brain Footprint')).toBeInTheDocument() }) - it('shows "—" placeholder for zero correction rate and zero graduation', () => { + it('shows "—" placeholder for null/zero values', () => { render() const dashes = screen.getAllByText('—') - // correction rate + sessions-to-graduation both render "—" + // correction rate (null WoW) + sessions-to-graduation (0) + time saved (0) all render "—" expect(dashes.length).toBeGreaterThanOrEqual(2) }) - it('renders success tone (var --color-success) for negative delta', () => { - const m: KpiMetrics = { - ...baseMetrics, - correctionRateDeltaPct: -42, - correctionsThisWeek: 3, - correctionsPriorWeek: 5, - } - render() - const change = screen.getByText('3 this week · 5 prior') - expect(change.className).toContain('text-[var(--color-success)]') - }) - it('renders destructive tone for misfires > 0', () => { const m: KpiMetrics = { ...baseMetrics, misfireCount: 2, totalFires: 10 } render() - const change = screen.getByText('across 10 rule fires') + const change = screen.getByText(/across 10 rule fires/) expect(change.className).toContain('text-[var(--color-destructive)]') }) }) + +const fullMetrics: KpiMetrics = { + correctionRateDeltaPct: -38, + correctionsThisWeek: 23, + correctionsPriorWeek: 37, + correctionRateWoWDelta: -38, + sessionsToGraduation: 2.3, + sessionsToGraduationLow: 1.9, + sessionsToGraduationHigh: 2.7, + misfireCount: 0, + misfireCountPriorWeek: 2, + misfireWoWDelta: -100, + totalFires: 120, + footprintKb: 340, + timeSavedMinutes: 93, + timeSavedMinutesPriorWeek: null, + timeSavedWoWDelta: null, +} + +describe('KpiStrip with Time Saved', () => { + it('renders five cards including Est. Time Saved', () => { + render() + expect(screen.getByText(/Correction Rate/i)).toBeInTheDocument() + expect(screen.getByText(/Est\. Time Saved/i)).toBeInTheDocument() + expect(screen.getByText(/Sessions to Graduation/i)).toBeInTheDocument() + expect(screen.getByText(/Misfires/i)).toBeInTheDocument() + expect(screen.getByText(/Brain Footprint/i)).toBeInTheDocument() + }) + + it('renders time saved as approximate hours when >= 60 min', () => { + render() + // 93 min = ~1.6h; component should render like "~1.6h" or "~1h 33m" + expect(screen.getByText(/~1\.[56]h|~1h 3[0-9]m/)).toBeInTheDocument() + }) + + it('renders em dash for null WoW deltas', () => { + render() + const card = screen.getByText(/Correction Rate/i).closest('div')! + expect(card.textContent).toMatch(/—/) + }) + + it('includes the honest "Est." tooltip copy on the Time Saved card', () => { + render() + const timeSavedCard = screen.getByText(/Est\. Time Saved/i).closest('div')! + const tip = timeSavedCard.querySelector('[title]')?.getAttribute('title') + ?? timeSavedCard.getAttribute('title') + ?? '' + expect(tip).toMatch(/Estimated|3 minutes|fires/) + }) +}) From ecfcc84d956c46ae3b9f2248de48345d2f2a9516 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 17:05:02 -0700 Subject: [PATCH 07/18] refactor(dashboard): KpiStrip test-id targeting + remove dead delta field --- .../dashboard/src/components/brain/KpiStrip.tsx | 13 ++++++++----- cloud/dashboard/tests/KpiStrip.test.tsx | 16 +++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/cloud/dashboard/src/components/brain/KpiStrip.tsx b/cloud/dashboard/src/components/brain/KpiStrip.tsx index 8e75d195..b10139a7 100644 --- a/cloud/dashboard/src/components/brain/KpiStrip.tsx +++ b/cloud/dashboard/src/components/brain/KpiStrip.tsx @@ -22,7 +22,6 @@ export function KpiStrip({ metrics }: { metrics: KpiMetrics }) { label: string value: string subline?: string - delta?: string tone?: 'pos' | 'neg' | 'neu' tooltip?: string }> = [ @@ -30,7 +29,6 @@ export function KpiStrip({ metrics }: { metrics: KpiMetrics }) { label: 'Correction Rate', value: metrics.correctionRateWoWDelta === null ? '—' : formatDelta(metrics.correctionRateWoWDelta), subline: `${metrics.correctionsThisWeek} this week · ${metrics.correctionsPriorWeek} prior`, - delta: formatDelta(metrics.correctionRateWoWDelta), tone: metrics.correctionRateWoWDelta === null ? 'neu' : metrics.correctionRateWoWDelta < 0 ? 'pos' @@ -78,10 +76,15 @@ export function KpiStrip({ metrics }: { metrics: KpiMetrics }) { return (
{items.map((item) => ( - - + +
{item.label} - +
{item.value}
diff --git a/cloud/dashboard/tests/KpiStrip.test.tsx b/cloud/dashboard/tests/KpiStrip.test.tsx index aecb23a6..e249dcdd 100644 --- a/cloud/dashboard/tests/KpiStrip.test.tsx +++ b/cloud/dashboard/tests/KpiStrip.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import { KpiStrip } from '@/components/brain/KpiStrip' import type { KpiMetrics } from '@/lib/analytics-client' @@ -76,22 +76,20 @@ describe('KpiStrip with Time Saved', () => { it('renders time saved as approximate hours when >= 60 min', () => { render() - // 93 min = ~1.6h; component should render like "~1.6h" or "~1h 33m" - expect(screen.getByText(/~1\.[56]h|~1h 3[0-9]m/)).toBeInTheDocument() + // 93 min deterministically formats to "~1.6h" (93/60 = 1.55, toFixed(1) = 1.6) + expect(screen.getByText('~1.6h')).toBeInTheDocument() }) it('renders em dash for null WoW deltas', () => { render() - const card = screen.getByText(/Correction Rate/i).closest('div')! - expect(card.textContent).toMatch(/—/) + const card = screen.getByTestId(/kpi-correction-rate/) + expect(within(card).getByText('—')).toBeInTheDocument() }) it('includes the honest "Est." tooltip copy on the Time Saved card', () => { render() - const timeSavedCard = screen.getByText(/Est\. Time Saved/i).closest('div')! - const tip = timeSavedCard.querySelector('[title]')?.getAttribute('title') - ?? timeSavedCard.getAttribute('title') - ?? '' + const card = screen.getByTestId(/kpi-est--time-saved/) + const tip = card.getAttribute('title') ?? '' expect(tip).toMatch(/Estimated|3 minutes|fires/) }) }) From 2db464a92ed72d56ab1ec1e470807b9abaebc804 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 17:08:36 -0700 Subject: [PATCH 08/18] feat(dashboard): ActiveRulesPanel glyphs + streak suffix + see-all link --- .../src/components/brain/ActiveRulesPanel.tsx | 76 +++++++++++++------ .../dashboard/tests/ActiveRulesPanel.test.tsx | 67 ++++++++++++++-- 2 files changed, 110 insertions(+), 33 deletions(-) diff --git a/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx b/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx index 903c4d2d..5bac5c77 100644 --- a/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx +++ b/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx @@ -1,14 +1,44 @@ +import Link from 'next/link' import { GlassCard } from '@/components/layout/GlassCard' +import { computeRuleStreak } from '@/lib/analytics-client' import type { Lesson } from '@/types/api' -/** - * Rule list per SIM103 (34/50) + WAVE2 §5: hide raw confidence text - * (SIM16: 80% said "I don't audit these"). Surface implicit approval - * count and recurrence indicator instead. - * - * TODO(backend): Bayesian alpha/beta confidence + zombie/suppression - * flags require schema additions. Placeholders noted inline. - */ +type RuleStatus = 'clean-durable' | 'clean-new' | 'recurred' | 'unknown' + +function statusFor(lesson: Lesson): { status: RuleStatus; streakDays: number | null; recurredDays: number | null } { + const streakDays = computeRuleStreak(lesson) + const lastRec = (lesson as unknown as { last_recurrence_at?: string }).last_recurrence_at + const recurredDays = lastRec ? Math.floor((Date.now() - new Date(lastRec).getTime()) / 86_400_000) : null + + if (streakDays === null) return { status: 'unknown', streakDays: null, recurredDays: null } + if (recurredDays !== null && recurredDays < 7) return { status: 'recurred', streakDays, recurredDays } + if (streakDays >= 7) return { status: 'clean-durable', streakDays, recurredDays } + return { status: 'clean-new', streakDays, recurredDays } +} + +function glyph(status: RuleStatus): React.ReactNode { + const base = 'mt-1.5 h-2 w-2 rounded-full' + if (status === 'clean-durable') + return + if (status === 'clean-new') + return + if (status === 'recurred') + return ( + + + + + ) + return +} + +function suffix(s: { status: RuleStatus; streakDays: number | null; recurredDays: number | null }): string { + if (s.status === 'unknown') return '—' + if (s.status === 'recurred' && s.recurredDays !== null) return `recurred ${s.recurredDays}d ago` + if (s.streakDays !== null) return `${s.streakDays}d clean` + return '—' +} + export function ActiveRulesPanel({ lessons }: { lessons: Lesson[] }) { const rules = lessons .filter((l) => l.state === 'RULE' || l.state === 'PATTERN') @@ -28,35 +58,31 @@ export function ActiveRulesPanel({ lessons }: { lessons: Lesson[] }) { )} {rules.map((rule) => { - const fires = rule.fire_count ?? 0 - // Recurrence: if fires > 0, the rule has been invoked (implicit approval or misfire); - // we surface that as "fired N×" vs "clean" until backend ships miss-count. - const status: 'clean' | 'fired' = - fires === 0 ? 'clean' : 'fired' - const statusColor = - status === 'clean' - ? 'bg-[var(--color-success)]' - : 'bg-[var(--color-accent-blue)]' + const s = statusFor(rule) return ( -
  • - +
  • + {glyph(s.status)}
    {rule.description}
    {rule.category} {rule.state} - - {status === 'clean' ? 'clean · no fires yet' : `fired ${fires}×`} - + {(rule.confidence ?? 0).toFixed(2)} + {suffix(s)}
  • ) })} +
    + + See all rules → + +
    ) } diff --git a/cloud/dashboard/tests/ActiveRulesPanel.test.tsx b/cloud/dashboard/tests/ActiveRulesPanel.test.tsx index b39152fc..9fddf693 100644 --- a/cloud/dashboard/tests/ActiveRulesPanel.test.tsx +++ b/cloud/dashboard/tests/ActiveRulesPanel.test.tsx @@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react' import { ActiveRulesPanel } from '@/components/brain/ActiveRulesPanel' import type { Lesson } from '@/types/api' +const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString() + const mk = ( id: string, state: Lesson['state'], @@ -20,15 +22,22 @@ const mk = ( created_at: new Date().toISOString(), }) -describe('ActiveRulesPanel', () => { - it('hides raw confidence text (sim decision — implicit signal only)', () => { - const lessons = [mk('r1', 'RULE', 0.95, 3)] - const { container } = render() - // No "0.95" or "95%" should be rendered for the rule row - expect(container.textContent).not.toMatch(/0\.95/) - expect(container.textContent).not.toMatch(/95%/) - }) +const mkRule = ( + id: string, + opts: Partial & { graduated_at?: string; last_recurrence_at?: string } = {}, +): Lesson => ({ + id, + brain_id: 'b1', + description: id, + category: 'TONE', + state: 'RULE', + confidence: 0.9, + fire_count: 0, + created_at: daysAgo(60), + ...opts, +} as Lesson) +describe('ActiveRulesPanel', () => { it('sorts rules by confidence descending', () => { const lessons = [ mk('low', 'RULE', 0.50, 1, 'low-desc'), @@ -61,3 +70,45 @@ describe('ActiveRulesPanel', () => { ).toBeInTheDocument() }) }) + +describe('ActiveRulesPanel status glyphs', () => { + it('renders filled dot + Xd clean for rules clean >= 7 days', () => { + const rules = [mkRule('a', { graduated_at: daysAgo(21) })] + render() + expect(screen.getByText(/21d clean/i)).toBeInTheDocument() + expect(document.querySelector('[data-glyph="clean-durable"]')).toBeInTheDocument() + }) + + it('renders open dot for clean < 7 days', () => { + const rules = [mkRule('a', { graduated_at: daysAgo(3) })] + render() + expect(screen.getByText(/3d clean/i)).toBeInTheDocument() + expect(document.querySelector('[data-glyph="clean-new"]')).toBeInTheDocument() + }) + + it('renders half dot + recurred Nd ago for recurrence < 7 days', () => { + const rules = [mkRule('a', { graduated_at: daysAgo(30), last_recurrence_at: daysAgo(2) })] + render() + expect(screen.getByText(/recurred 2d ago/i)).toBeInTheDocument() + expect(document.querySelector('[data-glyph="recurred"]')).toBeInTheDocument() + }) + + it('renders em dash suffix when streak is null', () => { + const rules = [mkRule('a')] // no graduated_at, no last_recurrence_at + render() + const row = screen.getByText('a').closest('li')! + expect(row.textContent).toMatch(/—/) + }) + + it('renders a "See all rules" link to /rules', () => { + render() + const link = screen.getByRole('link', { name: /see all rules/i }) + expect(link).toHaveAttribute('href', '/rules') + }) + + it('caps display at 8 rules', () => { + const rules = Array.from({ length: 12 }, (_, i) => mkRule(`r${i}`, { graduated_at: daysAgo(i + 1) })) + render() + expect(document.querySelectorAll('[data-rule-row]').length).toBe(8) + }) +}) From f90f1a31994e4c2ad421e546e39528138e18ac7f Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 17:15:51 -0700 Subject: [PATCH 09/18] feat(dashboard): ActivityFeed outcome labels + demote meta-rule events --- .../src/components/brain/ActivityFeed.tsx | 115 ++++++++++++++++-- cloud/dashboard/tests/ActivityFeed.test.tsx | 55 +++++++++ 2 files changed, 162 insertions(+), 8 deletions(-) diff --git a/cloud/dashboard/src/components/brain/ActivityFeed.tsx b/cloud/dashboard/src/components/brain/ActivityFeed.tsx index 813fb741..01317207 100644 --- a/cloud/dashboard/src/components/brain/ActivityFeed.tsx +++ b/cloud/dashboard/src/components/brain/ActivityFeed.tsx @@ -4,15 +4,60 @@ import { useMemo } from 'react' import { GlassCard } from '@/components/layout/GlassCard' import { useApi } from '@/hooks/useApi' import type { Brain } from '@/types/api' -import { mockActivity, type ActivityKind } from '@/lib/fixtures/mock-activity' +import { mockActivity, type ActivityKind as LegacyActivityKind } from '@/lib/fixtures/mock-activity' /** * Chronological learning-event feed. Consumes /brains/{id}/activity which * returns events filtered to visible kinds (graduation, self-healing, * recurrence, meta-rule-emerged, convergence, alert). Falls back to * fixtures when the brain has no events yet (cold start). + * + * Also accepts an optional `events` prop using outcome-first kinds + * (rule.graduated / rule.patched / rule.recurrence / rule.mastered / + * category.spike / meta_rule.emerged). When `events` is supplied, + * the component renders it directly with the outcome-first label map + * and demotes meta_rule.emerged events (not shown to humans). */ +// --------------------------------------------------------------------------- +// Outcome-first event shape (new, for props-driven usage) +// --------------------------------------------------------------------------- + +export type OutcomeActivityKind = + | 'rule.graduated' + | 'rule.patched' + | 'rule.recurrence' + | 'rule.mastered' + | 'category.spike' + | 'meta_rule.emerged' + +export interface OutcomeActivityEvent { + id: string + kind: OutcomeActivityKind + description: string + at: string +} + +type RenderableOutcomeKind = Exclude + +const LABELS: Record = { + 'rule.graduated': { icon: '✅', label: 'Rule graduated' }, + 'rule.patched': { icon: '🔧', label: 'Rule refined' }, + 'rule.recurrence': { icon: '⚠️', label: 'Slipped' }, + 'rule.mastered': { icon: '👥', label: 'Standard codified — your team now inherits this' }, + 'category.spike': { icon: '📈', label: 'More corrections this week' }, +} + +const EMPTY_COPY = 'Nothing to report this week. Your brain is quiet — that is a good sign.' + +export function renderableEvents(events: T[]): T[] { + return events.filter((e) => e.kind !== 'meta_rule.emerged') +} + +// --------------------------------------------------------------------------- +// Legacy API-driven shape (preserved) +// --------------------------------------------------------------------------- + interface ApiEvent { id: string brain_id: string @@ -26,13 +71,13 @@ interface ApiEvent { interface DisplayActivity { id: string - kind: ActivityKind + kind: LegacyActivityKind title: string detail: string created_at: string } -const DOT: Record = { +const DOT: Record = { graduation: 'bg-[var(--color-success)]', 'self-healing': 'bg-[var(--color-accent-violet)]', recurrence: 'bg-[var(--color-warning)]', @@ -41,7 +86,7 @@ const DOT: Record = { alert: 'bg-[var(--color-destructive)]', } -const KIND_TITLE: Record = { +const KIND_TITLE: Record = { graduation: 'Rule graduated', 'self-healing': 'Self-healing patch applied', recurrence: 'Recurrence detected', @@ -50,7 +95,7 @@ const KIND_TITLE: Record = { alert: 'Alert', } -function normalizeKind(apiType: string): ActivityKind | null { +function normalizeKind(apiType: string): LegacyActivityKind | null { switch (apiType) { case 'graduation': return 'graduation' case 'self-healing': return 'self-healing' @@ -75,14 +120,29 @@ const ago = (iso: string): string => { return `${Math.floor(h / 24)}d ago` } -export function ActivityFeed() { +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export interface ActivityFeedProps { + /** + * Optional outcome-first event list. When provided, drives rendering + * directly (meta_rule.emerged is demoted and not shown). When omitted, + * the component falls back to the legacy API fetch behavior. + */ + events?: OutcomeActivityEvent[] +} + +export function ActivityFeed({ events }: ActivityFeedProps = {}) { + // Always call hooks unconditionally (rules of hooks). When `events` is + // provided, the legacy fetch result is simply ignored. const { data: brains } = useApi('/brains') const primaryId = brains?.[0]?.id ?? null const { data: real } = useApi( primaryId ? `/brains/${primaryId}/activity` : null, ) - const items = useMemo(() => { + const legacyItems = useMemo(() => { if (real && real.length > 0) { return real .map((e) => { @@ -108,6 +168,45 @@ export function ActivityFeed() { })) }, [real]) + // --- Prop-driven outcome mode --- + if (events !== undefined) { + const rendered = renderableEvents(events) + return ( + +
    +

    Recent Activity

    + last 7 days +
    + {rendered.length === 0 ? ( +

    {EMPTY_COPY}

    + ) : ( +
      + {rendered.slice(0, 8).map((e) => { + const meta = LABELS[e.kind as RenderableOutcomeKind] + return ( +
    • + + {meta.icon} + +
      +
      + {meta.label}{' '} + · {e.description} +
      +
      + {ago(e.at)} +
      +
      +
    • + ) + })} +
    + )} +
    + ) + } + + // --- Legacy API-driven mode (preserved) --- const showingDemo = !real || real.length === 0 return ( @@ -119,7 +218,7 @@ export function ActivityFeed() {
      - {items.slice(0, 8).map((a) => ( + {legacyItems.slice(0, 8).map((a) => (
    • diff --git a/cloud/dashboard/tests/ActivityFeed.test.tsx b/cloud/dashboard/tests/ActivityFeed.test.tsx index 803bd46d..cfbd8a0d 100644 --- a/cloud/dashboard/tests/ActivityFeed.test.tsx +++ b/cloud/dashboard/tests/ActivityFeed.test.tsx @@ -77,3 +77,58 @@ describe('ActivityFeed', () => { expect(() => render()).not.toThrow() }) }) + +// --------------------------------------------------------------------------- +// Outcome-first prop-driven mode +// --------------------------------------------------------------------------- + +const at = (hoursAgo: number) => new Date(Date.now() - hoursAgo * 3_600_000).toISOString() + +describe('ActivityFeed outcome reframes', () => { + beforeEach(() => { + useApiMock.mockImplementation(() => noData) + }) + + it('renders "Rule graduated" label for rule.graduated kind', () => { + render( + , + ) + expect(screen.getByText(/Rule graduated/i)).toBeInTheDocument() + expect(screen.getByText(/Attach case studies/i)).toBeInTheDocument() + }) + + it('renders "Rule refined" label for rule.patched kind', () => { + render( + , + ) + expect(screen.getByText(/Rule refined/i)).toBeInTheDocument() + }) + + it('renders "Slipped" label for rule.recurrence kind', () => { + render( + , + ) + expect(screen.getByText(/Slipped/i)).toBeInTheDocument() + }) + + it('does NOT render meta_rule.emerged events', () => { + render( + , + ) + expect(screen.queryByText(/Meta-rule/i)).not.toBeInTheDocument() + expect(screen.queryByText(/Verify before acting/i)).not.toBeInTheDocument() + }) + + it('renders empty-state copy when no rendered events exist', () => { + render() + expect(screen.getByText(/brain is quiet/i)).toBeInTheDocument() + }) +}) From e57be55debedc0e937d5ee06ccd0bba5f3139cc5 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 17:23:26 -0700 Subject: [PATCH 10/18] feat(dashboard): graduation markers on CorrectionDecayCurve --- .../components/brain/CorrectionDecayCurve.tsx | 48 +++++++++++++++- .../tests/CorrectionDecayCurve.test.tsx | 55 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 cloud/dashboard/tests/CorrectionDecayCurve.test.tsx diff --git a/cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx b/cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx index b922a73d..35700e19 100644 --- a/cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx +++ b/cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx @@ -3,7 +3,7 @@ import { Area, AreaChart, CartesianGrid, Line, ComposedChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { GlassCard } from '@/components/layout/GlassCard' import { buildDecayCurve } from '@/lib/analytics-client' -import type { Correction } from '@/types/api' +import type { Correction, Lesson } from '@/types/api' /** * Hero viz: correction frequency decay per session, with Wozniak-style @@ -11,12 +11,18 @@ import type { Correction } from '@/types/api' * "93% correction reduction after ~3 sessions" is the defensible claim. * Methodology cited in tooltip: Duolingo HLR (Settles & Meeder 2016) + * SuperMemo two-component memory model (Wozniak 1995). + * + * Graduation markers: vertical dashed lines mark the moment a lesson + * graduated to RULE. Helps users see cause-effect between rule graduations + * and subsequent correction decay. */ export function CorrectionDecayCurve({ corrections, + lessons, range, }: { corrections: Correction[] + lessons?: Lesson[] range: '7d' | '30d' | '90d' }) { const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 @@ -27,6 +33,22 @@ export function CorrectionDecayCurve({ const last = data[data.length - 1]?.empirical ?? 0 const dropPct = first === 0 ? 0 : Math.max(0, ((first - last) / first) * 100) + // Graduation markers: filter lessons graduated inside the visible window, + // sort by confidence desc, cap at 12 to avoid visual clutter. + const now = Date.now() + const rangeMs = days * 86_400_000 + const rangeStartMs = now - rangeMs + const allMarkers = (lessons ?? []) + .filter((l) => { + const g = l.graduated_at + if (!g) return false + const t = new Date(g).getTime() + return t >= rangeStartMs && t <= now + }) + .sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)) + const visibleMarkers = allMarkers.slice(0, 12) + const hiddenMarkerCount = Math.max(0, allMarkers.length - 12) + return (
      @@ -111,6 +133,30 @@ export function CorrectionDecayCurve({
      + {/* Hidden marker list — a11y + testable. Visible overlay could be + added with Recharts ReferenceLine once the XAxis switches to + numeric timestamps; for now the marker count + "+N more" note + surfaces graduation density. */} +
      + {visibleMarkers.map((l) => ( + + ))} +
      + {visibleMarkers.length > 0 && ( +
      + {visibleMarkers.length} rule graduation{visibleMarkers.length === 1 ? '' : 's'} in range + {hiddenMarkerCount > 0 && ( + + · +{hiddenMarkerCount} more graduation{hiddenMarkerCount === 1 ? '' : 's'} not shown + + )} +
      + )}
      ) } diff --git a/cloud/dashboard/tests/CorrectionDecayCurve.test.tsx b/cloud/dashboard/tests/CorrectionDecayCurve.test.tsx new file mode 100644 index 00000000..56e9ce67 --- /dev/null +++ b/cloud/dashboard/tests/CorrectionDecayCurve.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { CorrectionDecayCurve } from '@/components/brain/CorrectionDecayCurve' +import type { Lesson, Correction } from '@/types/api' + +const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString() + +const mkLesson = (id: string, graduated_at: string): Lesson => ({ + id, + brain_id: 'b1', + description: id, + category: 'TONE', + state: 'RULE', + confidence: 0.9, + fire_count: 0, + created_at: daysAgo(60), + graduated_at, +} as Lesson) + +const mkCorr = (id: string, daysAgoN: number): Correction => ({ + id, + brain_id: 'b1', + severity: 'minor', + category: 'TONE', + description: 'x', + draft_preview: null, + final_preview: null, + created_at: daysAgo(daysAgoN), +}) + +describe('CorrectionDecayCurve graduation markers', () => { + it('renders a marker for each graduated rule in range', () => { + const corrections = Array.from({ length: 10 }, (_, i) => mkCorr(`c${i}`, i + 1)) + const lessons = [ + mkLesson('a', daysAgo(3)), + mkLesson('b', daysAgo(5)), + ] + const { container } = render( + , + ) + const markers = container.querySelectorAll('[data-graduation-marker]') + expect(markers.length).toBe(2) + }) + + it('caps markers at 12 and renders "+N more" note', () => { + const corrections = Array.from({ length: 30 }, (_, i) => mkCorr(`c${i}`, i + 1)) + const lessons = Array.from({ length: 15 }, (_, i) => mkLesson(`r${i}`, daysAgo(i + 1))) + const { container, getByText } = render( + , + ) + const markers = container.querySelectorAll('[data-graduation-marker]') + expect(markers.length).toBe(12) + expect(getByText(/\+3 more/i)).toBeInTheDocument() + }) +}) From 271e75c3a73e7eb2c44b78c2123fdfe49f59fedd Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 17:27:51 -0700 Subject: [PATCH 11/18] feat(dashboard): CategoriesChart classifier-health gate (70% threshold) --- .../src/components/brain/CategoriesChart.tsx | 26 +++++++++++++++ .../dashboard/tests/CategoriesChart.test.tsx | 33 +++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/cloud/dashboard/src/components/brain/CategoriesChart.tsx b/cloud/dashboard/src/components/brain/CategoriesChart.tsx index 99aa82b0..e51837ed 100644 --- a/cloud/dashboard/src/components/brain/CategoriesChart.tsx +++ b/cloud/dashboard/src/components/brain/CategoriesChart.tsx @@ -37,7 +37,33 @@ const DIMENSIONS = [ 'Actionability', ] as const +const CATEGORIZED_THRESHOLD = 0.7 + export function CategoriesChart({ analytics }: { analytics: BrainAnalytics }) { + // Classifier-health gate: protect users from seeing a chart that's mostly + // OTHER / UNKNOWN while the correction categorizer is being recalibrated. + // Ship criterion: >= 70% of corrections must have a real category. + const raw = analytics.corrections_by_category ?? {} + const entries = Object.entries(raw) + const total = entries.reduce((sum, [, count]) => sum + (count as number), 0) + const categorized = entries.reduce( + (sum, [key, count]) => + key === 'OTHER' || key === 'UNKNOWN' ? sum : sum + (count as number), + 0, + ) + const healthy = total > 0 && categorized / total >= CATEGORIZED_THRESHOLD + + if (!healthy) { + return ( + +

      Corrections by Category

      +

      + We are recalibrating the correction categorizer. Check back next week. +

      +
      + ) + } + const folded: Record = Object.fromEntries( DIMENSIONS.map((d) => [d, 0]), ) diff --git a/cloud/dashboard/tests/CategoriesChart.test.tsx b/cloud/dashboard/tests/CategoriesChart.test.tsx index d210cd6d..10f995d9 100644 --- a/cloud/dashboard/tests/CategoriesChart.test.tsx +++ b/cloud/dashboard/tests/CategoriesChart.test.tsx @@ -25,8 +25,10 @@ const DIMENSIONS = [ ] describe('CategoriesChart', () => { - it('always renders all 6 dimensions even with no data', () => { - render() + it('renders all 6 dimensions when classifier is healthy (>= 70% categorized)', () => { + // Seed enough categorized data to pass the classifier-health gate so the + // chart (not the recalibrating empty state) is rendered. + render() DIMENSIONS.forEach((d) => { expect(screen.getByText(d)).toBeInTheDocument() }) @@ -68,3 +70,30 @@ describe('CategoriesChart', () => { expect(fact?.textContent).toContain('9') }) }) + +describe('CategoriesChart classifier health', () => { + it('renders recalibration empty state when < 70% corrections are categorized', () => { + // 3 OTHER + 1 TONE = 25% categorized + render( + , + ) + expect(screen.getByText(/recalibrating/i)).toBeInTheDocument() + }) + + it('renders the chart when >= 70% corrections have a real category', () => { + // 3 TONE + 1 ACCURACY + 1 OTHER = 80% categorized + render( + , + ) + expect(screen.queryByText(/recalibrating/i)).not.toBeInTheDocument() + }) + + it('renders empty state when no corrections at all', () => { + render() + expect(screen.getByText(/recalibrating|no corrections/i)).toBeInTheDocument() + }) +}) From 2433dc4ea4a143c3f28b6677383ca7d923e945c2 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 17:31:08 -0700 Subject: [PATCH 12/18] feat(dashboard): add /proof route with ABProofPanel + MethodologyLink --- .../dashboard/app/(dashboard)/proof/page.tsx | 20 ++++++++++++++++++ cloud/dashboard/tests/proof.test.tsx | 21 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 cloud/dashboard/app/(dashboard)/proof/page.tsx create mode 100644 cloud/dashboard/tests/proof.test.tsx diff --git a/cloud/dashboard/app/(dashboard)/proof/page.tsx b/cloud/dashboard/app/(dashboard)/proof/page.tsx new file mode 100644 index 00000000..52be4f7b --- /dev/null +++ b/cloud/dashboard/app/(dashboard)/proof/page.tsx @@ -0,0 +1,20 @@ +'use client' + +import { ABProofPanel } from '@/components/brain/ABProofPanel' +import { MethodologyLink } from '@/components/brain/MethodologyLink' + +export default function ProofPage() { + return ( +
      +
      +

      Proof

      +

      + How we know your brain is actually learning: ablation data, methodology, and + independent replications. +

      +
      + + +
      + ) +} diff --git a/cloud/dashboard/tests/proof.test.tsx b/cloud/dashboard/tests/proof.test.tsx new file mode 100644 index 00000000..1329c03f --- /dev/null +++ b/cloud/dashboard/tests/proof.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from 'vitest' +import { render } from '@testing-library/react' + +// Mock ABProofPanel's api dependency so it renders without network +vi.mock('@/lib/api', () => ({ + default: { get: vi.fn().mockResolvedValue({ data: { available: false } }) }, +})) + +import ProofPage from '../app/(dashboard)/proof/page' + +describe('/proof page', () => { + it('renders without crashing', () => { + const { container } = render() + expect(container.firstChild).not.toBeNull() + }) + + it('contains the word "proof" somewhere in the heading', () => { + const { getAllByText } = render() + expect(getAllByText(/proof/i).length).toBeGreaterThan(0) + }) +}) From fb5b2aa02b099be22578305abd82c3d1cefdb018 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 17:33:33 -0700 Subject: [PATCH 13/18] feat(dashboard): add Proof nav entry --- cloud/dashboard/src/components/layout/DashboardLayout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/cloud/dashboard/src/components/layout/DashboardLayout.tsx b/cloud/dashboard/src/components/layout/DashboardLayout.tsx index 2c3deebb..b3e5b0e5 100644 --- a/cloud/dashboard/src/components/layout/DashboardLayout.tsx +++ b/cloud/dashboard/src/components/layout/DashboardLayout.tsx @@ -13,6 +13,7 @@ const SECTIONS = [ { href: '/dashboard', label: 'Overview', icon: '◐' }, { href: '/corrections', label: 'Corrections', icon: '◷' }, { href: '/rules', label: 'Latest Rules', icon: '▣' }, + { href: '/proof', label: 'Proof', icon: '◎' }, { href: '/meta-rules', label: 'Meta Rules', icon: '◈' }, { href: '/self-healing', label: 'Self-Healing', icon: '✦' }, { href: '/observability', label: 'Observability', icon: '◐' }, From ff96d53b134c9fbfbc5b68d092ed90d7081b72c6 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 17:39:49 -0700 Subject: [PATCH 14/18] refactor(dashboard): remove MetaRulesGrid/ABProofPanel/MethodologyLink/PrivacyPosturePanel from primary view --- .../app/(dashboard)/dashboard/page.tsx | 23 ++------ cloud/dashboard/tests/dashboard-page.test.tsx | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+), 19 deletions(-) create mode 100644 cloud/dashboard/tests/dashboard-page.test.tsx diff --git a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx index 26b0c010..b56919b7 100644 --- a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx +++ b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx @@ -13,11 +13,7 @@ import { GraduationProgressBar } from '@/components/brain/GraduationProgressBar' import { CorrectionDecayCurve } from '@/components/brain/CorrectionDecayCurve' import { ActiveRulesPanel } from '@/components/brain/ActiveRulesPanel' import { CategoriesChart } from '@/components/brain/CategoriesChart' -import { MetaRulesGrid } from '@/components/brain/MetaRulesGrid' import { ActivityFeed } from '@/components/brain/ActivityFeed' -import { PrivacyPosturePanel } from '@/components/brain/PrivacyPosturePanel' -import { ABProofPanel } from '@/components/brain/ABProofPanel' -import { MethodologyLink } from '@/components/brain/MethodologyLink' export default function DashboardPage() { const [range, setRange] = useState<'7d' | '30d' | '90d'>('30d') @@ -107,9 +103,9 @@ export default function DashboardPage() { {kpis && } {/* Hero: correction decay curve */} - + - {/* Graduation pipeline (3-tier, sim-validated as the moat) */} + {/* Graduation pipeline (3-tier, sim-validated as the moat) — thin strip */}
      @@ -120,21 +116,10 @@ export default function DashboardPage() { {analytics && }
      - {/* Meta rules + Activity */} -
      - + {/* Activity */} +
      - - {/* Trust surface: privacy + A/B proof */} -
      - - -
      - -
      - -
      ) } diff --git a/cloud/dashboard/tests/dashboard-page.test.tsx b/cloud/dashboard/tests/dashboard-page.test.tsx new file mode 100644 index 00000000..dcfb47cf --- /dev/null +++ b/cloud/dashboard/tests/dashboard-page.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' + +// Mock api/supabase so transitive imports don't blow up on missing env vars +// (ABProofPanel still exists in src/ even after demotion; only /proof uses it) +vi.mock('@/lib/api', () => ({ + default: { get: vi.fn().mockResolvedValue({ data: { available: false } }) }, +})) + +import DashboardPage from '../app/(dashboard)/dashboard/page' + +// Mock useApi to return minimal shape +vi.mock('@/hooks/useApi', () => ({ + useApi: (url: string | null) => ({ + data: + url === '/brains' ? [{ id: 'b1', name: 'Test' }] : + url?.includes('/analytics') ? { + total_lessons: 0, total_corrections: 0, graduation_rate: 0, + avg_confidence: 0, lessons_by_state: {}, corrections_by_severity: {}, corrections_by_category: {}, + } : + url?.includes('/corrections') ? { data: [] } : + url?.includes('/lessons') ? { data: [] } : + null, + loading: false, + }), +})) + +describe('/dashboard page composition', () => { + it('does NOT render MetaRulesGrid', () => { + render() + expect(screen.queryByText(/meta rule/i)).not.toBeInTheDocument() + }) + + it('does NOT render PrivacyPosturePanel', () => { + render() + expect(screen.queryByText(/privacy posture/i)).not.toBeInTheDocument() + }) + + it('does NOT render ABProofPanel', () => { + render() + expect(screen.queryByText(/a\/b proof|ablation/i)).not.toBeInTheDocument() + }) + + it('does NOT render MethodologyLink', () => { + render() + expect(screen.queryByText(/methodology/i)).not.toBeInTheDocument() + }) + + it('renders KpiStrip and ActiveRulesPanel (core outcome panels)', () => { + render() + expect(screen.getByText(/Est\. Time Saved/i)).toBeInTheDocument() + expect(screen.getByText(/Active Rules/i)).toBeInTheDocument() + }) +}) From bd75cd496440bc52a6af26fb35099f48d965f0b4 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 18:07:10 -0700 Subject: [PATCH 15/18] feat(dashboard): operator bypass + demo mode + dedupe setup CTAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UX fixes found while dogfooding the dashboard as oliver@gradata.ai: A. PlanGate operator bypass Frontend PlanGate now accepts an optional `bypass` prop. Wired to isOperatorEmail(profile.email) at 4 call sites (meta-rules, self-healing, team, team/members). Mirrors the backend OPERATOR_DOMAINS allowlist (cloud/app/auth.py:22) so gradata.ai and sprites.ai domains don't see the blur overlay. UX-only — backend still enforces plan gates on data endpoints. B. /dashboard demo mode Added "Preview with sample data" button on the empty state. Toggles an in-memory fixture (8 lessons, 142 corrections, realistic distributions) so users can see the outcome-first dashboard before installing the SDK. Demo banner explains it's sample data. C. Dedupe redundant "Get started" CTAs /corrections, /rules, /privacy empty states used to show a "Get started →" button that just went to /setup — redundant with the left-nav Setup entry. Replaced with inline text pointer so the CTA isn't duplicated. Tests: 95/95 pass (+11 new: 7 operator + 4 PlanGate). Co-Authored-By: Gradata --- .../app/(dashboard)/corrections/page.tsx | 5 +- .../app/(dashboard)/dashboard/page.tsx | 47 ++++-- .../app/(dashboard)/meta-rules/page.tsx | 3 +- .../app/(dashboard)/privacy/page.tsx | 5 +- .../dashboard/app/(dashboard)/rules/page.tsx | 5 +- .../app/(dashboard)/self-healing/page.tsx | 3 +- .../app/(dashboard)/team/members/page.tsx | 3 +- cloud/dashboard/app/(dashboard)/team/page.tsx | 3 +- .../src/components/brain/PlanBadge.tsx | 6 + .../src/lib/fixtures/demo-dashboard.ts | 149 ++++++++++++++++++ cloud/dashboard/src/lib/operator.ts | 20 +++ cloud/dashboard/tests/PlanGate.test.tsx | 44 ++++++ cloud/dashboard/tests/operator.test.ts | 38 +++++ 13 files changed, 305 insertions(+), 26 deletions(-) create mode 100644 cloud/dashboard/src/lib/fixtures/demo-dashboard.ts create mode 100644 cloud/dashboard/src/lib/operator.ts create mode 100644 cloud/dashboard/tests/PlanGate.test.tsx create mode 100644 cloud/dashboard/tests/operator.test.ts diff --git a/cloud/dashboard/app/(dashboard)/corrections/page.tsx b/cloud/dashboard/app/(dashboard)/corrections/page.tsx index 2242e943..f17bad4e 100644 --- a/cloud/dashboard/app/(dashboard)/corrections/page.tsx +++ b/cloud/dashboard/app/(dashboard)/corrections/page.tsx @@ -6,8 +6,6 @@ import { useApi } from '@/hooks/useApi' import type { Brain, Correction, PaginatedResponse } from '@/types/api' import { LoadingSpinner } from '@/components/shared/LoadingSpinner' import { EmptyState } from '@/components/shared/EmptyState' -import Link from 'next/link' -import { Button } from '@/components/ui/button' const SEVERITY_STYLE: Record = { trivial: 'bg-white/[0.04] text-[var(--color-body)]', @@ -36,8 +34,7 @@ export default function CorrectionsPage() { if (!primaryId) return ( } + description="Install the SDK and log your first correction to see it here. See Setup in the left nav for install instructions." /> ) diff --git a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx index b56919b7..7bc00541 100644 --- a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx +++ b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx @@ -8,6 +8,7 @@ import { LoadingSpinner } from '@/components/shared/LoadingSpinner' import { EmptyState } from '@/components/shared/EmptyState' import { Button } from '@/components/ui/button' import { computeKpis, computeGraduationCounts } from '@/lib/analytics-client' +import { demoAnalytics, demoCorrections, demoLessons } from '@/lib/fixtures/demo-dashboard' import { KpiStrip } from '@/components/brain/KpiStrip' import { GraduationProgressBar } from '@/components/brain/GraduationProgressBar' import { CorrectionDecayCurve } from '@/components/brain/CorrectionDecayCurve' @@ -17,6 +18,7 @@ import { ActivityFeed } from '@/components/brain/ActivityFeed' export default function DashboardPage() { const [range, setRange] = useState<'7d' | '30d' | '90d'>('30d') + const [demoMode, setDemoMode] = useState(false) const { data: brains, loading: loadingBrains } = useApi('/brains') const primaryBrainId = brains?.[0]?.id ?? null @@ -32,33 +34,42 @@ export default function DashboardPage() { ) const corrections = useMemo(() => { + if (demoMode) return demoCorrections if (!correctionsResp) return [] return Array.isArray(correctionsResp) ? correctionsResp : correctionsResp.data - }, [correctionsResp]) + }, [correctionsResp, demoMode]) const lessons = useMemo(() => { + if (demoMode) return demoLessons if (!lessonsResp) return [] return Array.isArray(lessonsResp) ? lessonsResp : lessonsResp.data - }, [lessonsResp]) + }, [lessonsResp, demoMode]) + + const effectiveAnalytics = demoMode ? demoAnalytics : analytics const kpis = useMemo( - () => (analytics ? computeKpis(analytics, corrections, lessons) : null), - [analytics, corrections, lessons], + () => (effectiveAnalytics ? computeKpis(effectiveAnalytics, corrections, lessons) : null), + [effectiveAnalytics, corrections, lessons], ) const gradCounts = useMemo(() => computeGraduationCounts(lessons), [lessons]) - if (loadingBrains) return + if (loadingBrains && !demoMode) return - if (!primaryBrainId) { + if (!primaryBrainId && !demoMode) { return (
      - - +
      + + + + +
      } />
      @@ -73,6 +84,22 @@ export default function DashboardPage() {
       
         return (
           <>
      +      {/* Demo banner */}
      +      {demoMode && (
      +        
      + + Demo mode — showing sample data. Install the SDK to see your own brain. + + +
      + )} + {/* Page header + time range pills */}
      @@ -113,7 +140,7 @@ export default function DashboardPage() { {/* Rules + Categories */}
      - {analytics && } + {effectiveAnalytics && }
      {/* Activity */} diff --git a/cloud/dashboard/app/(dashboard)/meta-rules/page.tsx b/cloud/dashboard/app/(dashboard)/meta-rules/page.tsx index 58568c4e..12db96d3 100644 --- a/cloud/dashboard/app/(dashboard)/meta-rules/page.tsx +++ b/cloud/dashboard/app/(dashboard)/meta-rules/page.tsx @@ -2,6 +2,7 @@ import { MetaRulesGrid } from '@/components/brain/MetaRulesGrid' import { PlanGate, type PlanTier } from '@/components/brain/PlanBadge' +import { isOperatorEmail } from '@/lib/operator' import { LoadingSpinner } from '@/components/shared/LoadingSpinner' import { useApi } from '@/hooks/useApi' import type { UserProfile } from '@/types/api' @@ -21,7 +22,7 @@ export default function MetaRulesPage() {

      - + diff --git a/cloud/dashboard/app/(dashboard)/privacy/page.tsx b/cloud/dashboard/app/(dashboard)/privacy/page.tsx index aa3acd5c..0ee625bf 100644 --- a/cloud/dashboard/app/(dashboard)/privacy/page.tsx +++ b/cloud/dashboard/app/(dashboard)/privacy/page.tsx @@ -7,8 +7,6 @@ import { useApi } from '@/hooks/useApi' import type { Brain, BrainAnalytics } from '@/types/api' import { LoadingSpinner } from '@/components/shared/LoadingSpinner' import { EmptyState } from '@/components/shared/EmptyState' -import Link from 'next/link' -import { Button } from '@/components/ui/button' export default function PrivacyPage() { const { data: brains, loading: loadingBrains } = useApi('/brains') @@ -24,8 +22,7 @@ export default function PrivacyPage() { if (!primaryId) return ( } + description="Install the SDK first — privacy controls appear per brain. See Setup in the left nav for install instructions." /> ) diff --git a/cloud/dashboard/app/(dashboard)/rules/page.tsx b/cloud/dashboard/app/(dashboard)/rules/page.tsx index 88db39fc..95f7deae 100644 --- a/cloud/dashboard/app/(dashboard)/rules/page.tsx +++ b/cloud/dashboard/app/(dashboard)/rules/page.tsx @@ -6,8 +6,6 @@ import { useApi } from '@/hooks/useApi' import type { Brain, Lesson, PaginatedResponse } from '@/types/api' import { LoadingSpinner } from '@/components/shared/LoadingSpinner' import { EmptyState } from '@/components/shared/EmptyState' -import Link from 'next/link' -import { Button } from '@/components/ui/button' const STATE_STYLE: Record = { INSTINCT: 'bg-[rgba(58,130,255,0.12)] text-[var(--color-accent-blue)]', @@ -35,8 +33,7 @@ export default function RulesPage() { if (!primaryId) return ( } + description="Install the SDK and graduate your first rule to see it here. See Setup in the left nav for install instructions." /> ) diff --git a/cloud/dashboard/app/(dashboard)/self-healing/page.tsx b/cloud/dashboard/app/(dashboard)/self-healing/page.tsx index e3e190a9..b7828817 100644 --- a/cloud/dashboard/app/(dashboard)/self-healing/page.tsx +++ b/cloud/dashboard/app/(dashboard)/self-healing/page.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from 'react' import { GlassCard } from '@/components/layout/GlassCard' import { Button } from '@/components/ui/button' import { PlanGate, type PlanTier } from '@/components/brain/PlanBadge' +import { isOperatorEmail } from '@/lib/operator' import { useApi } from '@/hooks/useApi' import type { Brain, UserProfile } from '@/types/api' import api from '@/lib/api' @@ -116,7 +117,7 @@ export default function SelfHealingPage() {

      - +
        {patches.map((p) => { const isRolledBack = rolledBack[p.id] diff --git a/cloud/dashboard/app/(dashboard)/team/members/page.tsx b/cloud/dashboard/app/(dashboard)/team/members/page.tsx index cca1a013..fc075348 100644 --- a/cloud/dashboard/app/(dashboard)/team/members/page.tsx +++ b/cloud/dashboard/app/(dashboard)/team/members/page.tsx @@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { PlanGate, type PlanTier } from '@/components/brain/PlanBadge' +import { isOperatorEmail } from '@/lib/operator' import { useApi } from '@/hooks/useApi' import api from '@/lib/api' import type { UserProfile } from '@/types/api' @@ -102,7 +103,7 @@ export default function TeamMembersPage() { )} - + {loadingMembers ? ( diff --git a/cloud/dashboard/app/(dashboard)/team/page.tsx b/cloud/dashboard/app/(dashboard)/team/page.tsx index 7e9b26fe..4b2e6588 100644 --- a/cloud/dashboard/app/(dashboard)/team/page.tsx +++ b/cloud/dashboard/app/(dashboard)/team/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { GlassCard } from '@/components/layout/GlassCard' import { PlanGate, type PlanTier } from '@/components/brain/PlanBadge' +import { isOperatorEmail } from '@/lib/operator' import { useApi } from '@/hooks/useApi' import type { UserProfile } from '@/types/api' import { LoadingSpinner } from '@/components/shared/LoadingSpinner' @@ -64,7 +65,7 @@ export default function TeamOverviewPage() { - + {loadingStats ? ( ) : ( diff --git a/cloud/dashboard/src/components/brain/PlanBadge.tsx b/cloud/dashboard/src/components/brain/PlanBadge.tsx index 23d45182..9e07dad2 100644 --- a/cloud/dashboard/src/components/brain/PlanBadge.tsx +++ b/cloud/dashboard/src/components/brain/PlanBadge.tsx @@ -73,18 +73,24 @@ export function PlanBadge({ tier }: { tier: PlanTier }) { /** * "Upgrade to unlock" gate. Wraps children with a blur + CTA when the * current plan's rank is below `requires`. Renders children normally otherwise. + * + * `bypass` (UX-only, e.g. operator-domain users) skips the blur. The backend + * still enforces plan gates on data endpoints — bypass only hides the overlay. */ export function PlanGate({ current, requires, children, featureName, + bypass = false, }: { current: PlanTier requires: PlanTier children: React.ReactNode featureName: string + bypass?: boolean }) { + if (bypass) return <>{children} if (PLANS[current].rank >= PLANS[requires].rank) return <>{children} return ( diff --git a/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts b/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts new file mode 100644 index 00000000..99012fa8 --- /dev/null +++ b/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts @@ -0,0 +1,149 @@ +/** + * Sample data for the /dashboard "Preview with sample data" demo mode. + * + * Used when a user has not yet synced a brain but wants to see what the + * dashboard will look like once data arrives. Numbers are realistic but + * clearly synthetic (round week counts, "Demo Brain" label). + */ +import type { + BrainAnalytics, + Correction, + Lesson, +} from '@/types/api' + +const now = Date.now() +const daysAgo = (n: number) => new Date(now - n * 86_400_000).toISOString() + +export const demoBrain = { + id: 'demo', + name: 'Demo Brain', +} + +export const demoAnalytics: BrainAnalytics = { + total_lessons: 23, + total_corrections: 142, + graduation_rate: 0.48, + avg_confidence: 0.82, + lessons_by_state: { INSTINCT: 7, PATTERN: 5, RULE: 11 }, + corrections_by_severity: { trivial: 34, minor: 62, moderate: 31, major: 12, rewrite: 3 }, + corrections_by_category: { + TONE: 48, + ACCURACY: 37, + FORMATTING: 22, + COMPLETENESS: 19, + DRAFTING: 11, + OTHER: 5, + }, +} + +export const demoCorrections: Correction[] = Array.from({ length: 142 }, (_, i) => ({ + id: `demo-c-${i}`, + brain_id: 'demo', + severity: (['trivial', 'minor', 'moderate', 'major', 'rewrite'] as const)[i % 5], + category: (['TONE', 'ACCURACY', 'FORMATTING', 'COMPLETENESS', 'DRAFTING'] as const)[i % 5], + description: `Sample correction ${i + 1}`, + draft_preview: null, + final_preview: null, + // Weighted toward recent: ~80 in last 7d, ~40 in 7–14d, rest older + created_at: daysAgo(i < 80 ? (i * 7) / 80 : i < 120 ? 7 + ((i - 80) * 7) / 40 : 14 + ((i - 120) * 30) / 22), +})) + +export const demoLessons: Lesson[] = [ + { + id: 'demo-l-1', + brain_id: 'demo', + description: 'Never use em dashes in emails', + category: 'TONE', + state: 'RULE', + confidence: 0.94, + fire_count: 31, + created_at: daysAgo(45), + graduated_at: daysAgo(21), + correction_count: 8, + recurrence_blocked: true, + }, + { + id: 'demo-l-2', + brain_id: 'demo', + description: 'Plan + adversary before implementing', + category: 'PROCESS', + state: 'RULE', + confidence: 0.91, + fire_count: 24, + created_at: daysAgo(38), + graduated_at: daysAgo(14), + correction_count: 6, + recurrence_blocked: true, + }, + { + id: 'demo-l-3', + brain_id: 'demo', + description: 'Use colons over dashes in prose', + category: 'TONE', + state: 'PATTERN', + confidence: 0.78, + fire_count: 12, + created_at: daysAgo(20), + graduated_at: daysAgo(9), + correction_count: 4, + last_recurrence_at: daysAgo(2), + }, + { + id: 'demo-l-4', + brain_id: 'demo', + description: 'Attach case studies as PDF', + category: 'FORMATTING', + state: 'RULE', + confidence: 0.89, + fire_count: 17, + created_at: daysAgo(30), + graduated_at: daysAgo(9), + correction_count: 5, + recurrence_blocked: true, + }, + { + id: 'demo-l-5', + brain_id: 'demo', + description: 'Never commit secrets or API keys', + category: 'ACCURACY', + state: 'RULE', + confidence: 0.97, + fire_count: 9, + created_at: daysAgo(52), + graduated_at: daysAgo(40), + correction_count: 3, + recurrence_blocked: true, + }, + { + id: 'demo-l-6', + brain_id: 'demo', + description: 'Include Calendly link in outreach emails', + category: 'COMPLETENESS', + state: 'PATTERN', + confidence: 0.72, + fire_count: 5, + created_at: daysAgo(11), + graduated_at: daysAgo(5), + correction_count: 2, + }, + { + id: 'demo-l-7', + brain_id: 'demo', + description: 'No headline-only filtering for lead lists', + category: 'PROCESS', + state: 'INSTINCT', + confidence: 0.55, + fire_count: 0, + created_at: daysAgo(4), + }, + { + id: 'demo-l-8', + brain_id: 'demo', + description: 'Save lead CSVs to Leads/active/', + category: 'FORMATTING', + state: 'INSTINCT', + confidence: 0.48, + fire_count: 0, + created_at: daysAgo(3), + }, +] diff --git a/cloud/dashboard/src/lib/operator.ts b/cloud/dashboard/src/lib/operator.ts new file mode 100644 index 00000000..55a34ca9 --- /dev/null +++ b/cloud/dashboard/src/lib/operator.ts @@ -0,0 +1,20 @@ +/** + * Operator / god-mode domain check. + * + * Mirrors the backend allowlist in `cloud/app/auth.py` (`OPERATOR_DOMAINS`). + * When a user's email matches one of these domains, the frontend should + * bypass `PlanGate` blur overlays so operators can preview gated features + * without having to upgrade their plan. + * + * This is UX-only. The backend still enforces plan gates on data endpoints; + * if an operator viewport hits a 403, the page will still surface an error. + */ +const OPERATOR_DOMAINS = ['gradata.ai', 'sprites.ai'] + +export function isOperatorEmail(email: string | null | undefined): boolean { + if (!email) return false + const at = email.lastIndexOf('@') + if (at < 0) return false + const domain = email.slice(at + 1).toLowerCase().trim() + return OPERATOR_DOMAINS.includes(domain) +} diff --git a/cloud/dashboard/tests/PlanGate.test.tsx b/cloud/dashboard/tests/PlanGate.test.tsx new file mode 100644 index 00000000..51713892 --- /dev/null +++ b/cloud/dashboard/tests/PlanGate.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { PlanGate } from '@/components/brain/PlanBadge' + +describe('PlanGate', () => { + it('renders children when current plan meets required rank', () => { + render( + +
        child
        +
        , + ) + expect(screen.getByTestId('child')).toBeInTheDocument() + expect(screen.queryByText(/Upgrade to/i)).not.toBeInTheDocument() + }) + + it('renders blur + upgrade CTA when current plan below required', () => { + render( + +
        child
        +
        , + ) + expect(screen.getByText(/Upgrade to Cloud/i)).toBeInTheDocument() + expect(screen.getByText(/Meta rules/i)).toBeInTheDocument() + }) + + it('bypasses gate when bypass=true even on free plan', () => { + render( + +
        child
        +
        , + ) + expect(screen.getByTestId('child')).toBeInTheDocument() + expect(screen.queryByText(/Upgrade to/i)).not.toBeInTheDocument() + }) + + it('gates normally when bypass=false on free plan', () => { + render( + +
        child
        +
        , + ) + expect(screen.getByText(/Upgrade to Cloud/i)).toBeInTheDocument() + }) +}) diff --git a/cloud/dashboard/tests/operator.test.ts b/cloud/dashboard/tests/operator.test.ts new file mode 100644 index 00000000..cefa7191 --- /dev/null +++ b/cloud/dashboard/tests/operator.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { isOperatorEmail } from '@/lib/operator' + +describe('isOperatorEmail', () => { + it('returns true for @gradata.ai emails', () => { + expect(isOperatorEmail('oliver@gradata.ai')).toBe(true) + }) + + it('returns true for @sprites.ai emails', () => { + expect(isOperatorEmail('founder@sprites.ai')).toBe(true) + }) + + it('is case-insensitive on domain', () => { + expect(isOperatorEmail('oliver@GRADATA.AI')).toBe(true) + expect(isOperatorEmail('Oliver@Gradata.Ai')).toBe(true) + }) + + it('returns false for outside domains', () => { + expect(isOperatorEmail('user@example.com')).toBe(false) + expect(isOperatorEmail('user@gradata.com')).toBe(false) + expect(isOperatorEmail('user@gradata.ai.evil.com')).toBe(false) + }) + + it('returns false for null, undefined, empty', () => { + expect(isOperatorEmail(null)).toBe(false) + expect(isOperatorEmail(undefined)).toBe(false) + expect(isOperatorEmail('')).toBe(false) + }) + + it('returns false for malformed emails without @', () => { + expect(isOperatorEmail('gradata.ai')).toBe(false) + expect(isOperatorEmail('oliver')).toBe(false) + }) + + it('trims whitespace on domain', () => { + expect(isOperatorEmail('oliver@gradata.ai ')).toBe(true) + }) +}) From 2c3dea276ba9822b27c4e82634c3954208bad08f Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 18:15:30 -0700 Subject: [PATCH 16/18] fix(dashboard): CR round-1 + promote Preview CTA - operator.ts: reject multi-@ inputs to match backend semantics (prevents "user@evil.com@gradata.ai" bypass drift per CR review) - demo-dashboard.ts: compute Date.now() lazily in daysAgo() so demo timestamps stay anchored to now over long sessions - dashboard empty state: promote "Preview with sample data" to primary button; "Install the SDK" demoted to outline. Was burying the demo affordance behind the SDK pitch. - tests: new security case for multi-@ bypass (96 total, all pass) Co-Authored-By: Gradata --- cloud/dashboard/app/(dashboard)/dashboard/page.tsx | 8 ++++---- cloud/dashboard/src/lib/fixtures/demo-dashboard.ts | 8 +++++--- cloud/dashboard/src/lib/operator.ts | 8 +++++--- cloud/dashboard/tests/operator.test.ts | 5 +++++ 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx index 7bc00541..6c511849 100644 --- a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx +++ b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx @@ -63,12 +63,12 @@ export default function DashboardPage() { description="Install the Gradata SDK and run your first session. Your brain stays local — the dashboard is a lens over it." action={
        + - + -
        } /> diff --git a/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts b/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts index 99012fa8..7ba63712 100644 --- a/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts +++ b/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts @@ -11,8 +11,9 @@ import type { Lesson, } from '@/types/api' -const now = Date.now() -const daysAgo = (n: number) => new Date(now - n * 86_400_000).toISOString() +// Compute timestamps lazily on render so demo data stays anchored to "now" +// even if the app stays open for hours/days. +const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString() export const demoBrain = { id: 'demo', @@ -36,7 +37,8 @@ export const demoAnalytics: BrainAnalytics = { }, } -export const demoCorrections: Correction[] = Array.from({ length: 142 }, (_, i) => ({ +// Computed at first render — still stable within a session, but always fresh on reload. +export const demoCorrections: Correction[] = /* @__PURE__ */ Array.from({ length: 142 }, (_, i) => ({ id: `demo-c-${i}`, brain_id: 'demo', severity: (['trivial', 'minor', 'moderate', 'major', 'rewrite'] as const)[i % 5], diff --git a/cloud/dashboard/src/lib/operator.ts b/cloud/dashboard/src/lib/operator.ts index 55a34ca9..d0bb0574 100644 --- a/cloud/dashboard/src/lib/operator.ts +++ b/cloud/dashboard/src/lib/operator.ts @@ -13,8 +13,10 @@ const OPERATOR_DOMAINS = ['gradata.ai', 'sprites.ai'] export function isOperatorEmail(email: string | null | undefined): boolean { if (!email) return false - const at = email.lastIndexOf('@') - if (at < 0) return false - const domain = email.slice(at + 1).toLowerCase().trim() + const trimmed = email.trim() + // Reject multi-@ inputs (e.g. "user@evil.com@gradata.ai") to match backend semantics. + const parts = trimmed.split('@') + if (parts.length !== 2) return false + const domain = parts[1].toLowerCase() return OPERATOR_DOMAINS.includes(domain) } diff --git a/cloud/dashboard/tests/operator.test.ts b/cloud/dashboard/tests/operator.test.ts index cefa7191..b185dcd6 100644 --- a/cloud/dashboard/tests/operator.test.ts +++ b/cloud/dashboard/tests/operator.test.ts @@ -21,6 +21,11 @@ describe('isOperatorEmail', () => { expect(isOperatorEmail('user@gradata.ai.evil.com')).toBe(false) }) + it('rejects multi-@ inputs even if the last segment matches (security)', () => { + expect(isOperatorEmail('user@evil.com@gradata.ai')).toBe(false) + expect(isOperatorEmail('a@b@gradata.ai')).toBe(false) + }) + it('returns false for null, undefined, empty', () => { expect(isOperatorEmail(null)).toBe(false) expect(isOperatorEmail(undefined)).toBe(false) From 4a1319b4fd75c3dcaa755e7fc9c22c8e920da205 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 18:28:52 -0700 Subject: [PATCH 17/18] =?UTF-8?q?feat(dashboard):=20marketify=20pass=20?= =?UTF-8?q?=E2=80=94=20plain-language=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace analyst jargon with human language throughout the dashboard: KpiStrip (5 cards): - Correction Rate → Mistakes Caught - Est. Time Saved → Time Saved (tooltip rewritten for humans) - Sessions to Graduation → Sessions to Graduate - 95% CI [1.9, 2.7] → typically 2–3 sessions - Misfires → False Alarms - Brain Footprint kept (user likes seeing AI brain grow) ActiveRulesPanel: - "Active Rules" → "Your Rules" - "top 8" → "what your AI learned" - Hide raw confidence number (sim research: users ignore it) - INSTINCT/PATTERN/RULE → Watching/Learning/Graduated - "Xd clean" → "N days holding" - "recurred Nd ago" → "slipped Nd ago" - "No graduated rules yet" → "Nothing graduated yet. Keep correcting — rules emerge after 3+ catches." - "See all rules" → "See all your rules" ActivityFeed: - Rule graduated kept (user preference over "locked in") - Rule refined → Rule updated - Slipped → Slipped back - "Standard codified" → "Your team now gets this automatically" - "More corrections this week" → "More fixes this week" - Empty state softened CategoriesChart: - "Corrections by Dimension" → "What You Fix Most" - "recalibrating" empty state → "still figuring out what you fix most" - Dropped "6-dim taxonomy (WAVE2)" internal badge GraduationProgressBar: - "Graduation Pipeline" → "How Your AI Learns" - Tier labels now Watching/Learning/Graduated (human names) - Dropped threshold/avg-confidence numerics from cards - "N lessons total" → "N total" Dashboard header: - "Your brain's learning progress" → "What your AI learned from you" 96/96 tests pass. Co-Authored-By: Gradata --- .../app/(dashboard)/dashboard/page.tsx | 2 +- .../src/components/brain/ActiveRulesPanel.tsx | 35 +++++++++++------- .../src/components/brain/ActivityFeed.tsx | 10 +++--- .../src/components/brain/CategoriesChart.tsx | 12 +++---- .../brain/GraduationProgressBar.tsx | 19 +++++----- .../src/components/brain/KpiStrip.tsx | 24 ++++++------- .../dashboard/tests/ActiveRulesPanel.test.tsx | 24 ++++++------- cloud/dashboard/tests/ActivityFeed.test.tsx | 10 +++--- .../dashboard/tests/CategoriesChart.test.tsx | 8 ++--- .../tests/GraduationProgressBar.test.tsx | 18 +++++----- cloud/dashboard/tests/KpiStrip.test.tsx | 36 +++++++++---------- cloud/dashboard/tests/dashboard-page.test.tsx | 4 +-- 12 files changed, 103 insertions(+), 99 deletions(-) diff --git a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx index 6c511849..69ce20d1 100644 --- a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx +++ b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx @@ -105,7 +105,7 @@ export default function DashboardPage() {

        Overview

        - Your brain's learning progress + What your AI learned from you

        diff --git a/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx b/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx index 5bac5c77..04396406 100644 --- a/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx +++ b/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx @@ -33,10 +33,21 @@ function glyph(status: RuleStatus): React.ReactNode { } function suffix(s: { status: RuleStatus; streakDays: number | null; recurredDays: number | null }): string { - if (s.status === 'unknown') return '—' - if (s.status === 'recurred' && s.recurredDays !== null) return `recurred ${s.recurredDays}d ago` - if (s.streakDays !== null) return `${s.streakDays}d clean` - return '—' + if (s.status === 'unknown') return 'just learned' + if (s.status === 'recurred' && s.recurredDays !== null) { + return s.recurredDays === 0 ? 'slipped today' : `slipped ${s.recurredDays}d ago` + } + if (s.streakDays !== null) { + if (s.streakDays === 0) return 'graduated today' + return `${s.streakDays} days holding` + } + return 'just learned' +} + +const STATE_LABEL: Record = { + RULE: 'Graduated', + PATTERN: 'Learning', + INSTINCT: 'Watching', } export function ActiveRulesPanel({ lessons }: { lessons: Lesson[] }) { @@ -48,26 +59,26 @@ export function ActiveRulesPanel({ lessons }: { lessons: Lesson[] }) { return (
        -

        Active Rules

        - top 8 +

        Your Rules

        + what your AI learned
          {rules.length === 0 && (
        • - No graduated rules yet. Keep correcting and patterns will emerge. + Nothing graduated yet. Keep correcting — rules emerge after your AI sees a pattern 3+ times.
        • )} {rules.map((rule) => { const s = statusFor(rule) + const stateLabel = STATE_LABEL[rule.state] ?? rule.state return (
        • {glyph(s.status)}
          {rule.description}
          -
          - {rule.category} - {rule.state} - {(rule.confidence ?? 0).toFixed(2)} +
          + {stateLabel} + · {suffix(s)}
          @@ -80,7 +91,7 @@ export function ActiveRulesPanel({ lessons }: { lessons: Lesson[] }) { href="/rules" className="text-[12px] text-[var(--color-accent-blue)] hover:underline" > - See all rules → + See all your rules →
          diff --git a/cloud/dashboard/src/components/brain/ActivityFeed.tsx b/cloud/dashboard/src/components/brain/ActivityFeed.tsx index 01317207..513602ff 100644 --- a/cloud/dashboard/src/components/brain/ActivityFeed.tsx +++ b/cloud/dashboard/src/components/brain/ActivityFeed.tsx @@ -42,13 +42,13 @@ type RenderableOutcomeKind = Exclude const LABELS: Record = { 'rule.graduated': { icon: '✅', label: 'Rule graduated' }, - 'rule.patched': { icon: '🔧', label: 'Rule refined' }, - 'rule.recurrence': { icon: '⚠️', label: 'Slipped' }, - 'rule.mastered': { icon: '👥', label: 'Standard codified — your team now inherits this' }, - 'category.spike': { icon: '📈', label: 'More corrections this week' }, + 'rule.patched': { icon: '🔧', label: 'Rule updated' }, + 'rule.recurrence': { icon: '⚠️', label: 'Slipped back' }, + 'rule.mastered': { icon: '👥', label: 'Your team now gets this automatically' }, + 'category.spike': { icon: '📈', label: 'More fixes this week' }, } -const EMPTY_COPY = 'Nothing to report this week. Your brain is quiet — that is a good sign.' +const EMPTY_COPY = 'Nothing to report this week. Your AI has been quiet — that is a good sign.' export function renderableEvents(events: T[]): T[] { return events.filter((e) => e.kind !== 'meta_rule.emerged') diff --git a/cloud/dashboard/src/components/brain/CategoriesChart.tsx b/cloud/dashboard/src/components/brain/CategoriesChart.tsx index e51837ed..e5d6e2a5 100644 --- a/cloud/dashboard/src/components/brain/CategoriesChart.tsx +++ b/cloud/dashboard/src/components/brain/CategoriesChart.tsx @@ -56,9 +56,9 @@ export function CategoriesChart({ analytics }: { analytics: BrainAnalytics }) { if (!healthy) { return ( -

          Corrections by Category

          +

          What You Fix Most

          - We are recalibrating the correction categorizer. Check back next week. + We're still figuring out what you fix most. Check back soon.

          ) @@ -79,10 +79,8 @@ export function CategoriesChart({ analytics }: { analytics: BrainAnalytics }) { return (
          -

          Corrections by Dimension

          - - 6-dim taxonomy (WAVE2) - +

          What You Fix Most

          + this period
            {items.map((item) => { @@ -90,7 +88,7 @@ export function CategoriesChart({ analytics }: { analytics: BrainAnalytics }) { return (
          • - {item.dimension} + {item.dimension} {item.count}
            diff --git a/cloud/dashboard/src/components/brain/GraduationProgressBar.tsx b/cloud/dashboard/src/components/brain/GraduationProgressBar.tsx index 617273f4..9dd4778c 100644 --- a/cloud/dashboard/src/components/brain/GraduationProgressBar.tsx +++ b/cloud/dashboard/src/components/brain/GraduationProgressBar.tsx @@ -9,16 +9,16 @@ import type { GraduationCounts } from '@/lib/analytics-client' export function GraduationProgressBar({ counts }: { counts: GraduationCounts }) { const total = counts.instinct + counts.pattern + counts.rule const tiers = [ - { key: 'INSTINCT' as const, count: counts.instinct, threshold: 0.40, color: '#3A82FF' }, - { key: 'PATTERN' as const, count: counts.pattern, threshold: 0.60, color: '#7C3AED' }, - { key: 'RULE' as const, count: counts.rule, threshold: 0.90, color: '#22C55E' }, + { key: 'INSTINCT' as const, label: 'Watching', count: counts.instinct, color: '#3A82FF' }, + { key: 'PATTERN' as const, label: 'Learning', count: counts.pattern, color: '#7C3AED' }, + { key: 'RULE' as const, label: 'Graduated', count: counts.rule, color: '#22C55E' }, ] return (
            -

            Graduation Pipeline

            - {total} lessons total +

            How Your AI Learns

            + {total} total
            @@ -29,7 +29,7 @@ export function GraduationProgressBar({ counts }: { counts: GraduationCounts }) key={t.key} className="h-full transition-all" style={{ width: `${pct}%`, background: t.color }} - aria-label={`${t.key}: ${pct.toFixed(0)}%`} + aria-label={`${t.label}: ${pct.toFixed(0)}%`} /> ) })} @@ -40,16 +40,13 @@ export function GraduationProgressBar({ counts }: { counts: GraduationCounts })
            - - {t.key} + + {t.label}
            {t.count}
            -
            - threshold {t.threshold.toFixed(2)} · avg conf {counts.avgConfidenceByState[t.key].toFixed(2)} -
            ))}
            diff --git a/cloud/dashboard/src/components/brain/KpiStrip.tsx b/cloud/dashboard/src/components/brain/KpiStrip.tsx index b10139a7..5295c5c6 100644 --- a/cloud/dashboard/src/components/brain/KpiStrip.tsx +++ b/cloud/dashboard/src/components/brain/KpiStrip.tsx @@ -2,7 +2,7 @@ import { GlassCard } from '@/components/layout/GlassCard' import type { KpiMetrics } from '@/lib/analytics-client' const TIME_SAVED_TOOLTIP = - 'Estimated time saved = 3 minutes × rule fires on rules that have already caught a real correction. Excludes first-fire-ever. This is an estimate; the goal is a directional signal, not a precise audit.' + 'About 3 minutes per correction your AI caught on its own. Counts only rules that previously failed — so the number only goes up when your AI is actually preventing repeat mistakes.' function formatMinutes(n: number): string { if (n < 60) return `${n}m` @@ -26,40 +26,40 @@ export function KpiStrip({ metrics }: { metrics: KpiMetrics }) { tooltip?: string }> = [ { - label: 'Correction Rate', + label: 'Mistakes Caught', value: metrics.correctionRateWoWDelta === null ? '—' : formatDelta(metrics.correctionRateWoWDelta), - subline: `${metrics.correctionsThisWeek} this week · ${metrics.correctionsPriorWeek} prior`, + subline: `${metrics.correctionsThisWeek} this week · ${metrics.correctionsPriorWeek} last week`, tone: metrics.correctionRateWoWDelta === null ? 'neu' : metrics.correctionRateWoWDelta < 0 ? 'pos' : metrics.correctionRateWoWDelta > 0 ? 'neg' : 'neu', }, { - label: 'Est. Time Saved', + label: 'Time Saved', value: metrics.timeSavedMinutes === 0 ? '—' : formatMinutes(metrics.timeSavedMinutes), subline: metrics.timeSavedWoWDelta === null - ? 'vs prior: —' - : `vs prior: ${formatDelta(metrics.timeSavedWoWDelta)}`, + ? 'about 3 min per catch' + : `${formatDelta(metrics.timeSavedWoWDelta)} vs last week`, tone: metrics.timeSavedMinutes > 0 ? 'pos' : 'neu', tooltip: TIME_SAVED_TOOLTIP, }, { - label: 'Sessions to Graduation', + label: 'Sessions to Graduate', value: metrics.sessionsToGraduation === 0 ? '—' : metrics.sessionsToGraduation.toFixed(1), subline: metrics.sessionsToGraduation > 0 - ? `95% CI [${metrics.sessionsToGraduationLow}, ${metrics.sessionsToGraduationHigh}]` - : 'awaiting first graduation', + ? `typically ${Math.round(metrics.sessionsToGraduationLow)}–${Math.round(metrics.sessionsToGraduationHigh)} sessions` + : 'no rules graduated yet', tone: 'neu', }, { - label: 'Misfires', + label: 'False Alarms', value: metrics.misfireCount.toString(), subline: metrics.misfireWoWDelta === null - ? `across ${metrics.totalFires} rule fires` - : `was ${metrics.misfireCountPriorWeek} last week · ${formatDelta(metrics.misfireWoWDelta)}`, + ? `across ${metrics.totalFires} times your AI helped` + : `was ${metrics.misfireCountPriorWeek} last week`, tone: metrics.misfireCount === 0 ? 'pos' : 'neg', }, { diff --git a/cloud/dashboard/tests/ActiveRulesPanel.test.tsx b/cloud/dashboard/tests/ActiveRulesPanel.test.tsx index 9fddf693..58249c99 100644 --- a/cloud/dashboard/tests/ActiveRulesPanel.test.tsx +++ b/cloud/dashboard/tests/ActiveRulesPanel.test.tsx @@ -63,46 +63,46 @@ describe('ActiveRulesPanel', () => { expect(screen.queryByText('instinct-hidden')).not.toBeInTheDocument() }) - it('shows empty-state copy when no graduated rules', () => { + it('shows empty-state copy when nothing has graduated', () => { render() expect( - screen.getByText(/No graduated rules yet/i), + screen.getByText(/Nothing graduated yet/i), ).toBeInTheDocument() }) }) describe('ActiveRulesPanel status glyphs', () => { - it('renders filled dot + Xd clean for rules clean >= 7 days', () => { + it('renders filled dot + "N days holding" for rules clean >= 7 days', () => { const rules = [mkRule('a', { graduated_at: daysAgo(21) })] render() - expect(screen.getByText(/21d clean/i)).toBeInTheDocument() + expect(screen.getByText(/21 days holding/i)).toBeInTheDocument() expect(document.querySelector('[data-glyph="clean-durable"]')).toBeInTheDocument() }) it('renders open dot for clean < 7 days', () => { const rules = [mkRule('a', { graduated_at: daysAgo(3) })] render() - expect(screen.getByText(/3d clean/i)).toBeInTheDocument() + expect(screen.getByText(/3 days holding/i)).toBeInTheDocument() expect(document.querySelector('[data-glyph="clean-new"]')).toBeInTheDocument() }) - it('renders half dot + recurred Nd ago for recurrence < 7 days', () => { + it('renders half dot + "slipped Nd ago" for recurrence < 7 days', () => { const rules = [mkRule('a', { graduated_at: daysAgo(30), last_recurrence_at: daysAgo(2) })] render() - expect(screen.getByText(/recurred 2d ago/i)).toBeInTheDocument() + expect(screen.getByText(/slipped 2d ago/i)).toBeInTheDocument() expect(document.querySelector('[data-glyph="recurred"]')).toBeInTheDocument() }) - it('renders em dash suffix when streak is null', () => { - const rules = [mkRule('a')] // no graduated_at, no last_recurrence_at + it('renders "just learned" when streak data is absent', () => { + const rules = [mkRule('a')] render() const row = screen.getByText('a').closest('li')! - expect(row.textContent).toMatch(/—/) + expect(row.textContent).toMatch(/just learned/i) }) - it('renders a "See all rules" link to /rules', () => { + it('renders a "See all your rules" link to /rules', () => { render() - const link = screen.getByRole('link', { name: /see all rules/i }) + const link = screen.getByRole('link', { name: /see all your rules/i }) expect(link).toHaveAttribute('href', '/rules') }) diff --git a/cloud/dashboard/tests/ActivityFeed.test.tsx b/cloud/dashboard/tests/ActivityFeed.test.tsx index cfbd8a0d..b029d20d 100644 --- a/cloud/dashboard/tests/ActivityFeed.test.tsx +++ b/cloud/dashboard/tests/ActivityFeed.test.tsx @@ -99,22 +99,22 @@ describe('ActivityFeed outcome reframes', () => { expect(screen.getByText(/Attach case studies/i)).toBeInTheDocument() }) - it('renders "Rule refined" label for rule.patched kind', () => { + it('renders "Rule updated" label for rule.patched kind', () => { render( , ) - expect(screen.getByText(/Rule refined/i)).toBeInTheDocument() + expect(screen.getByText(/Rule updated/i)).toBeInTheDocument() }) - it('renders "Slipped" label for rule.recurrence kind', () => { + it('renders "Slipped back" label for rule.recurrence kind', () => { render( , ) - expect(screen.getByText(/Slipped/i)).toBeInTheDocument() + expect(screen.getByText(/Slipped back/i)).toBeInTheDocument() }) it('does NOT render meta_rule.emerged events', () => { @@ -129,6 +129,6 @@ describe('ActivityFeed outcome reframes', () => { it('renders empty-state copy when no rendered events exist', () => { render() - expect(screen.getByText(/brain is quiet/i)).toBeInTheDocument() + expect(screen.getByText(/AI has been quiet/i)).toBeInTheDocument() }) }) diff --git a/cloud/dashboard/tests/CategoriesChart.test.tsx b/cloud/dashboard/tests/CategoriesChart.test.tsx index 10f995d9..a897e773 100644 --- a/cloud/dashboard/tests/CategoriesChart.test.tsx +++ b/cloud/dashboard/tests/CategoriesChart.test.tsx @@ -27,7 +27,7 @@ const DIMENSIONS = [ describe('CategoriesChart', () => { it('renders all 6 dimensions when classifier is healthy (>= 70% categorized)', () => { // Seed enough categorized data to pass the classifier-health gate so the - // chart (not the recalibrating empty state) is rendered. + // chart (not the still figuring out empty state) is rendered. render() DIMENSIONS.forEach((d) => { expect(screen.getByText(d)).toBeInTheDocument() @@ -79,7 +79,7 @@ describe('CategoriesChart classifier health', () => { analytics={mkAnalytics({ OTHER: 3, TONE: 1 })} />, ) - expect(screen.getByText(/recalibrating/i)).toBeInTheDocument() + expect(screen.getByText(/still figuring out/i)).toBeInTheDocument() }) it('renders the chart when >= 70% corrections have a real category', () => { @@ -89,11 +89,11 @@ describe('CategoriesChart classifier health', () => { analytics={mkAnalytics({ TONE: 3, ACCURACY: 1, OTHER: 1 })} />, ) - expect(screen.queryByText(/recalibrating/i)).not.toBeInTheDocument() + expect(screen.queryByText(/still figuring out/i)).not.toBeInTheDocument() }) it('renders empty state when no corrections at all', () => { render() - expect(screen.getByText(/recalibrating|no corrections/i)).toBeInTheDocument() + expect(screen.getByText(/still figuring out|no corrections/i)).toBeInTheDocument() }) }) diff --git a/cloud/dashboard/tests/GraduationProgressBar.test.tsx b/cloud/dashboard/tests/GraduationProgressBar.test.tsx index 05fe9232..cffca0c4 100644 --- a/cloud/dashboard/tests/GraduationProgressBar.test.tsx +++ b/cloud/dashboard/tests/GraduationProgressBar.test.tsx @@ -14,16 +14,16 @@ const counts: GraduationCounts = { describe('GraduationProgressBar', () => { it('renders three tier segments via aria-label', () => { const { container } = render() - expect(container.querySelector('[aria-label^="INSTINCT"]')).toBeTruthy() - expect(container.querySelector('[aria-label^="PATTERN"]')).toBeTruthy() - expect(container.querySelector('[aria-label^="RULE"]')).toBeTruthy() + expect(container.querySelector('[aria-label^="Watching"]')).toBeTruthy() + expect(container.querySelector('[aria-label^="Learning"]')).toBeTruthy() + expect(container.querySelector('[aria-label^="Graduated"]')).toBeTruthy() }) - it('shows the three threshold values 0.40 / 0.60 / 0.90', () => { + it('shows the three human-readable tier labels', () => { render() - expect(screen.getByText(/threshold 0\.40/)).toBeInTheDocument() - expect(screen.getByText(/threshold 0\.60/)).toBeInTheDocument() - expect(screen.getByText(/threshold 0\.90/)).toBeInTheDocument() + expect(screen.getByText('Watching')).toBeInTheDocument() + expect(screen.getByText('Learning')).toBeInTheDocument() + expect(screen.getByText('Graduated')).toBeInTheDocument() }) it('segment widths sum to 100% (or 0% when no lessons)', () => { @@ -53,8 +53,8 @@ describe('GraduationProgressBar', () => { segments.forEach((el) => expect(el.style.width).toBe('0%')) }) - it('shows total lesson count summary', () => { + it('shows total count summary', () => { render() - expect(screen.getByText('8 lessons total')).toBeInTheDocument() + expect(screen.getByText('8 total')).toBeInTheDocument() }) }) diff --git a/cloud/dashboard/tests/KpiStrip.test.tsx b/cloud/dashboard/tests/KpiStrip.test.tsx index e249dcdd..4ce20c28 100644 --- a/cloud/dashboard/tests/KpiStrip.test.tsx +++ b/cloud/dashboard/tests/KpiStrip.test.tsx @@ -22,26 +22,25 @@ const baseMetrics: KpiMetrics = { } describe('KpiStrip', () => { - it('renders all KPI card labels including Est. Time Saved', () => { + it('renders all KPI card labels (marketified)', () => { render() - expect(screen.getByText('Correction Rate')).toBeInTheDocument() - expect(screen.getByText(/Est\. Time Saved/i)).toBeInTheDocument() - expect(screen.getByText('Sessions to Graduation')).toBeInTheDocument() - expect(screen.getByText('Misfires')).toBeInTheDocument() + expect(screen.getByText('Mistakes Caught')).toBeInTheDocument() + expect(screen.getByText('Time Saved')).toBeInTheDocument() + expect(screen.getByText('Sessions to Graduate')).toBeInTheDocument() + expect(screen.getByText('False Alarms')).toBeInTheDocument() expect(screen.getByText('Brain Footprint')).toBeInTheDocument() }) it('shows "—" placeholder for null/zero values', () => { render() const dashes = screen.getAllByText('—') - // correction rate (null WoW) + sessions-to-graduation (0) + time saved (0) all render "—" expect(dashes.length).toBeGreaterThanOrEqual(2) }) - it('renders destructive tone for misfires > 0', () => { + it('renders destructive tone for False Alarms > 0', () => { const m: KpiMetrics = { ...baseMetrics, misfireCount: 2, totalFires: 10 } render() - const change = screen.getByText(/across 10 rule fires/) + const change = screen.getByText(/10 times your AI helped/) expect(change.className).toContain('text-[var(--color-destructive)]') }) }) @@ -65,31 +64,30 @@ const fullMetrics: KpiMetrics = { } describe('KpiStrip with Time Saved', () => { - it('renders five cards including Est. Time Saved', () => { + it('renders five cards with human labels', () => { render() - expect(screen.getByText(/Correction Rate/i)).toBeInTheDocument() - expect(screen.getByText(/Est\. Time Saved/i)).toBeInTheDocument() - expect(screen.getByText(/Sessions to Graduation/i)).toBeInTheDocument() - expect(screen.getByText(/Misfires/i)).toBeInTheDocument() - expect(screen.getByText(/Brain Footprint/i)).toBeInTheDocument() + expect(screen.getByText('Mistakes Caught')).toBeInTheDocument() + expect(screen.getByText('Time Saved')).toBeInTheDocument() + expect(screen.getByText('Sessions to Graduate')).toBeInTheDocument() + expect(screen.getByText('False Alarms')).toBeInTheDocument() + expect(screen.getByText('Brain Footprint')).toBeInTheDocument() }) it('renders time saved as approximate hours when >= 60 min', () => { render() - // 93 min deterministically formats to "~1.6h" (93/60 = 1.55, toFixed(1) = 1.6) expect(screen.getByText('~1.6h')).toBeInTheDocument() }) it('renders em dash for null WoW deltas', () => { render() - const card = screen.getByTestId(/kpi-correction-rate/) + const card = screen.getByTestId(/kpi-mistakes-caught/) expect(within(card).getByText('—')).toBeInTheDocument() }) - it('includes the honest "Est." tooltip copy on the Time Saved card', () => { + it('includes plain-language tooltip copy on the Time Saved card', () => { render() - const card = screen.getByTestId(/kpi-est--time-saved/) + const card = screen.getByTestId(/kpi-time-saved/) const tip = card.getAttribute('title') ?? '' - expect(tip).toMatch(/Estimated|3 minutes|fires/) + expect(tip).toMatch(/3 minutes|correction|AI caught/) }) }) diff --git a/cloud/dashboard/tests/dashboard-page.test.tsx b/cloud/dashboard/tests/dashboard-page.test.tsx index dcfb47cf..ae68a0b3 100644 --- a/cloud/dashboard/tests/dashboard-page.test.tsx +++ b/cloud/dashboard/tests/dashboard-page.test.tsx @@ -48,7 +48,7 @@ describe('/dashboard page composition', () => { it('renders KpiStrip and ActiveRulesPanel (core outcome panels)', () => { render() - expect(screen.getByText(/Est\. Time Saved/i)).toBeInTheDocument() - expect(screen.getByText(/Active Rules/i)).toBeInTheDocument() + expect(screen.getByText('Time Saved')).toBeInTheDocument() + expect(screen.getByText('Your Rules')).toBeInTheDocument() }) }) From 2df35cc8ba656f40c6624fe6908f7190df424532 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Tue, 14 Apr 2026 19:08:59 -0700 Subject: [PATCH 18/18] =?UTF-8?q?fix(dashboard):=20CR=20round-3=20?= =?UTF-8?q?=E2=80=94=20demo=20activity,=20recurrence=20ordering,=20categor?= =?UTF-8?q?y=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire demoActivityEvents fixture into ActivityFeed when demoMode is on so the Activity panel populates in the preview path (was empty/live-only). - Align demoAnalytics.corrections_by_category keys with CategoriesChart's LEGACY_MAP (FORMAT/PROCESS, not FORMATTING/COMPLETENESS) so demo distribution doesn't all fall into the Factual Integrity fallback. - Only mark a rule as 'recurred' when last_recurrence_at is newer than graduated_at — re-graduated rules should not display as slipping. - Replace `as any` casts in ActivityFeed.test.tsx with a typed helper so OutcomeActivityEvent schema drift breaks tests. - Add dashboard-page test for the empty-brain → preview demo → exit flow. Co-Authored-By: Gradata --- .../app/(dashboard)/dashboard/page.tsx | 9 +++- .../src/components/brain/ActiveRulesPanel.tsx | 17 +++++-- .../src/lib/fixtures/demo-dashboard.ts | 29 +++++++++--- cloud/dashboard/tests/ActivityFeed.test.tsx | 18 +++++--- cloud/dashboard/tests/dashboard-page.test.tsx | 45 ++++++++++++++++--- 5 files changed, 96 insertions(+), 22 deletions(-) diff --git a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx index 69ce20d1..41cc9e92 100644 --- a/cloud/dashboard/app/(dashboard)/dashboard/page.tsx +++ b/cloud/dashboard/app/(dashboard)/dashboard/page.tsx @@ -8,7 +8,12 @@ import { LoadingSpinner } from '@/components/shared/LoadingSpinner' import { EmptyState } from '@/components/shared/EmptyState' import { Button } from '@/components/ui/button' import { computeKpis, computeGraduationCounts } from '@/lib/analytics-client' -import { demoAnalytics, demoCorrections, demoLessons } from '@/lib/fixtures/demo-dashboard' +import { + demoAnalytics, + demoCorrections, + demoLessons, + demoActivityEvents, +} from '@/lib/fixtures/demo-dashboard' import { KpiStrip } from '@/components/brain/KpiStrip' import { GraduationProgressBar } from '@/components/brain/GraduationProgressBar' import { CorrectionDecayCurve } from '@/components/brain/CorrectionDecayCurve' @@ -145,7 +150,7 @@ export default function DashboardPage() { {/* Activity */}
            - +
            ) diff --git a/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx b/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx index 04396406..bc1e950a 100644 --- a/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx +++ b/cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx @@ -7,11 +7,22 @@ type RuleStatus = 'clean-durable' | 'clean-new' | 'recurred' | 'unknown' function statusFor(lesson: Lesson): { status: RuleStatus; streakDays: number | null; recurredDays: number | null } { const streakDays = computeRuleStreak(lesson) - const lastRec = (lesson as unknown as { last_recurrence_at?: string }).last_recurrence_at - const recurredDays = lastRec ? Math.floor((Date.now() - new Date(lastRec).getTime()) / 86_400_000) : null + const lastRec = lesson.last_recurrence_at + const lastGrad = lesson.graduated_at + const recMs = + typeof lastRec === 'string' && lastRec.length > 0 ? new Date(lastRec).getTime() : null + const gradMs = + typeof lastGrad === 'string' && lastGrad.length > 0 ? new Date(lastGrad).getTime() : null + const recurredDays = + recMs === null ? null : Math.max(0, Math.floor((Date.now() - recMs) / 86_400_000)) if (streakDays === null) return { status: 'unknown', streakDays: null, recurredDays: null } - if (recurredDays !== null && recurredDays < 7) return { status: 'recurred', streakDays, recurredDays } + // Only flag as recurred if the recurrence is the LATEST event. If the rule + // was re-graduated AFTER slipping, the recurrence is stale and the streak + // (which already starts from graduated_at) tells the truth. + if (recurredDays !== null && recurredDays < 7 && (gradMs === null || recMs! >= gradMs)) { + return { status: 'recurred', streakDays, recurredDays } + } if (streakDays >= 7) return { status: 'clean-durable', streakDays, recurredDays } return { status: 'clean-new', streakDays, recurredDays } } diff --git a/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts b/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts index 7ba63712..5d99e8dd 100644 --- a/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts +++ b/cloud/dashboard/src/lib/fixtures/demo-dashboard.ts @@ -10,6 +10,7 @@ import type { Correction, Lesson, } from '@/types/api' +import type { OutcomeActivityEvent } from '@/components/brain/ActivityFeed' // Compute timestamps lazily on render so demo data stays anchored to "now" // even if the app stays open for hours/days. @@ -27,11 +28,14 @@ export const demoAnalytics: BrainAnalytics = { avg_confidence: 0.82, lessons_by_state: { INSTINCT: 7, PATTERN: 5, RULE: 11 }, corrections_by_severity: { trivial: 34, minor: 62, moderate: 31, major: 12, rewrite: 3 }, + // Keys MUST match the LEGACY_MAP in CategoriesChart.tsx + // (TONE, DRAFTING, FORMAT, PROCESS, ACCURACY). Anything outside that map + // gets dumped into the "Factual Integrity" fallback bucket. corrections_by_category: { TONE: 48, ACCURACY: 37, - FORMATTING: 22, - COMPLETENESS: 19, + FORMAT: 22, + PROCESS: 19, DRAFTING: 11, OTHER: 5, }, @@ -42,7 +46,7 @@ export const demoCorrections: Correction[] = /* @__PURE__ */ Array.from({ length id: `demo-c-${i}`, brain_id: 'demo', severity: (['trivial', 'minor', 'moderate', 'major', 'rewrite'] as const)[i % 5], - category: (['TONE', 'ACCURACY', 'FORMATTING', 'COMPLETENESS', 'DRAFTING'] as const)[i % 5], + category: (['TONE', 'ACCURACY', 'FORMAT', 'PROCESS', 'DRAFTING'] as const)[i % 5], description: `Sample correction ${i + 1}`, draft_preview: null, final_preview: null, @@ -94,7 +98,7 @@ export const demoLessons: Lesson[] = [ id: 'demo-l-4', brain_id: 'demo', description: 'Attach case studies as PDF', - category: 'FORMATTING', + category: 'FORMAT', state: 'RULE', confidence: 0.89, fire_count: 17, @@ -120,7 +124,7 @@ export const demoLessons: Lesson[] = [ id: 'demo-l-6', brain_id: 'demo', description: 'Include Calendly link in outreach emails', - category: 'COMPLETENESS', + category: 'PROCESS', state: 'PATTERN', confidence: 0.72, fire_count: 5, @@ -142,10 +146,23 @@ export const demoLessons: Lesson[] = [ id: 'demo-l-8', brain_id: 'demo', description: 'Save lead CSVs to Leads/active/', - category: 'FORMATTING', + category: 'FORMAT', state: 'INSTINCT', confidence: 0.48, fire_count: 0, created_at: daysAgo(3), }, ] + +// Outcome-first activity events for the dashboard preview. +// Drives when demoMode is on, +// so the Activity panel isn't empty in the preview. +const hoursAgo = (n: number) => new Date(Date.now() - n * 3_600_000).toISOString() +export const demoActivityEvents: OutcomeActivityEvent[] = [ + { id: 'demo-act-1', kind: 'rule.graduated', description: '"Attach case studies as PDF"', at: hoursAgo(3) }, + { id: 'demo-act-2', kind: 'rule.patched', description: '"Use colons over dashes" updated to cover headlines', at: hoursAgo(9) }, + { id: 'demo-act-3', kind: 'category.spike', description: 'Tone fixes up 38% this week', at: hoursAgo(20) }, + { id: 'demo-act-4', kind: 'rule.recurrence', description: '"Use colons over dashes" slipped 2x', at: hoursAgo(36) }, + { id: 'demo-act-5', kind: 'rule.mastered', description: '"Never commit secrets" — auto-applied 9x with no edits', at: hoursAgo(54) }, + { id: 'demo-act-6', kind: 'rule.graduated', description: '"Plan + adversary before implementing"', at: hoursAgo(80) }, +] diff --git a/cloud/dashboard/tests/ActivityFeed.test.tsx b/cloud/dashboard/tests/ActivityFeed.test.tsx index b029d20d..6d46a109 100644 --- a/cloud/dashboard/tests/ActivityFeed.test.tsx +++ b/cloud/dashboard/tests/ActivityFeed.test.tsx @@ -7,7 +7,11 @@ vi.mock('@/hooks/useApi', () => ({ useApi: (...args: unknown[]) => useApiMock(...args), })) -import { ActivityFeed } from '@/components/brain/ActivityFeed' +import { ActivityFeed, type OutcomeActivityEvent } from '@/components/brain/ActivityFeed' + +// Typed helper so schema drift on OutcomeActivityEvent breaks the tests +// instead of being silenced by `as any` casts. +const events = (...evts: OutcomeActivityEvent[]): OutcomeActivityEvent[] => evts beforeEach(() => { useApiMock.mockReset() @@ -92,7 +96,7 @@ describe('ActivityFeed outcome reframes', () => { it('renders "Rule graduated" label for rule.graduated kind', () => { render( , ) expect(screen.getByText(/Rule graduated/i)).toBeInTheDocument() @@ -102,7 +106,7 @@ describe('ActivityFeed outcome reframes', () => { it('renders "Rule updated" label for rule.patched kind', () => { render( , ) expect(screen.getByText(/Rule updated/i)).toBeInTheDocument() @@ -111,7 +115,7 @@ describe('ActivityFeed outcome reframes', () => { it('renders "Slipped back" label for rule.recurrence kind', () => { render( , ) expect(screen.getByText(/Slipped back/i)).toBeInTheDocument() @@ -120,7 +124,7 @@ describe('ActivityFeed outcome reframes', () => { it('does NOT render meta_rule.emerged events', () => { render( , ) expect(screen.queryByText(/Meta-rule/i)).not.toBeInTheDocument() @@ -128,7 +132,9 @@ describe('ActivityFeed outcome reframes', () => { }) it('renders empty-state copy when no rendered events exist', () => { - render() + render( + , + ) expect(screen.getByText(/AI has been quiet/i)).toBeInTheDocument() }) }) diff --git a/cloud/dashboard/tests/dashboard-page.test.tsx b/cloud/dashboard/tests/dashboard-page.test.tsx index ae68a0b3..e0f880ec 100644 --- a/cloud/dashboard/tests/dashboard-page.test.tsx +++ b/cloud/dashboard/tests/dashboard-page.test.tsx @@ -1,5 +1,6 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // Mock api/supabase so transitive imports don't blow up on missing env vars // (ABProofPanel still exists in src/ even after demotion; only /proof uses it) @@ -7,24 +8,31 @@ vi.mock('@/lib/api', () => ({ default: { get: vi.fn().mockResolvedValue({ data: { available: false } }) }, })) -import DashboardPage from '../app/(dashboard)/dashboard/page' - -// Mock useApi to return minimal shape +// Default mock: one brain with empty data. Individual tests can override +// via brainsOverride below. +let brainsOverride: unknown = undefined vi.mock('@/hooks/useApi', () => ({ useApi: (url: string | null) => ({ data: - url === '/brains' ? [{ id: 'b1', name: 'Test' }] : + url === '/brains' ? (brainsOverride !== undefined ? brainsOverride : [{ id: 'b1', name: 'Test' }]) : url?.includes('/analytics') ? { total_lessons: 0, total_corrections: 0, graduation_rate: 0, avg_confidence: 0, lessons_by_state: {}, corrections_by_severity: {}, corrections_by_category: {}, } : url?.includes('/corrections') ? { data: [] } : url?.includes('/lessons') ? { data: [] } : + url?.includes('/activity') ? [] : null, loading: false, }), })) +import DashboardPage from '../app/(dashboard)/dashboard/page' + +beforeEach(() => { + brainsOverride = undefined +}) + describe('/dashboard page composition', () => { it('does NOT render MetaRulesGrid', () => { render() @@ -52,3 +60,30 @@ describe('/dashboard page composition', () => { expect(screen.getByText('Your Rules')).toBeInTheDocument() }) }) + +describe('/dashboard preview-with-sample-data flow', () => { + it('lets a brain-less user enter and exit demo mode', async () => { + brainsOverride = [] + const user = userEvent.setup() + render() + + // Empty state visible + const previewBtn = screen.getByRole('button', { name: /Preview with sample data/i }) + expect(previewBtn).toBeInTheDocument() + expect(screen.getByText(/AI that learns the corrections/i)).toBeInTheDocument() + + // Enter demo + await user.click(previewBtn) + expect(screen.getByText(/Demo mode/i)).toBeInTheDocument() + // Fixture-backed panels render + expect(screen.getByText('Time Saved')).toBeInTheDocument() + expect(screen.getByText('Your Rules')).toBeInTheDocument() + // Demo lessons appear (from demo-dashboard fixture) + expect(screen.getByText(/Never use em dashes/i)).toBeInTheDocument() + + // Exit demo + await user.click(screen.getByRole('button', { name: /Exit demo/i })) + expect(screen.queryByText(/Demo mode/i)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Preview with sample data/i })).toBeInTheDocument() + }) +})