docs(milestone): complete v0.4-operator-tier — v0.1.9 tagged, milestone release, merged to main

v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete.

Phases:
  ✓ P0  pre-execution (planning)        → v0.1.6
  ✓ P1  operator foundation (Postgres+auth+VC migration) → v0.1.7
  ✓ P2  cohort dashboard + aggregation   → v0.1.8
  ✓ P3  final review + ship              → v0.1.9 (= v0.4 milestone release)

Requirements covered (8/8):
  REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline),
  REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard),
  REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC),
  REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h)

Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041

Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass
Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward)
Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill)

---ci---
project: praxis
phase: 3
milestone: v0.4
status: complete
phase_role: final
milestone_complete: true
milestone_merged_to_main: true
tag: v0.1.9
requirements:
  covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 11:58:44 +00:00
parent d0f37e151e
commit f2a12f9fed
76 changed files with 12504 additions and 578 deletions
+2674 -2
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -9,21 +9,27 @@
"typecheck": "tsc -b --noEmit",
"lint": "oxlint",
"preview": "vite preview",
"test": "echo 'client: no unit tests yet (v0.1 uses e2e smoke via server tests)' && exit 0"
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@pipecat-ai/client-js": "^1.13.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.6",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"jsdom": "^25.0.1",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
"vite": "^8.2.0",
"vitest": "^3.2.7"
}
}
+21 -175
View File
@@ -1,181 +1,27 @@
/**
* Praxis v0.1 — full session UX (SLICE-05 TASK-05-04).
* Praxis — top-level route switch (SLICE-09 TASK-09-02, D-044, R-DASH-05).
*
* Three views: start → live → debrief. Replaces the SLICE-02 minimal page.
* - Start: scenario title + disclaimer acknowledgement + Start button
* - Live: turn indicators (learner/AI), interrupt feedback, latency readout
* - Debrief: debrief text + audio replay control + latency/cost summary
* Routes:
* / → existing voice session UI (unchanged)
* /operator/login → operator Login form
* /operator/dashboard → operator Dashboard (auth-gated)
* * → voice session UI (SPA fallback for unknown routes)
*
* R-DASH-05: the voice UI at `/` is unchanged. The catch-all serves the
* voice UI (not a 404) so unknown routes fall back to the learner surface.
*/
import { useVoiceSession } from './useVoiceSession'
import { useEffect, useState } from 'react'
import './App.css'
type View = 'start' | 'live' | 'debrief'
function App() {
const { state, error, transcripts, latency, start, stop } = useVoiceSession()
const [view, setView] = useState<View>('start')
const [acknowledged, setAcknowledged] = useState(false)
useEffect(() => {
if (state === 'connected' && view === 'start') {
setView('live')
}
if (state === 'idle' && view === 'live') {
setView('debrief')
}
}, [state, view])
const handleStart = async () => {
await start()
}
const handleEnd = async () => {
await stop()
setView('debrief')
}
const handleRestart = () => {
setView('start')
setAcknowledged(false)
}
import { Routes, Route } from 'react-router-dom'
import VoiceSession from './VoiceSession'
import Login from './operator/Login'
import Dashboard from './operator/Dashboard'
export default function App() {
return (
<section id="praxis-session">
<header>
<h1>Praxis</h1>
<p className="subtitle">Customer Service role-play v0.1</p>
</header>
{view === 'start' && (
<div className="view view--start">
<div className="scenario-card">
<h2>Angry customer requesting refund on a damaged product</h2>
<p className="scenario-desc">
You are a customer service agent. An angry customer (Jordan) is
demanding a refund for a cracked product. Handle the
conversation. You'll receive a coaching debrief at the end.
</p>
</div>
<div className="disclaimer">
<label className="disclaimer-check">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
/>
<span>
This is an AI practice session for training purposes. It is
not a real conversation and no real company is involved.
</span>
</label>
</div>
<div className="controls">
<button
type="button"
className="start"
disabled={!acknowledged || state === 'connecting'}
onClick={() => void handleStart()}
>
{state === 'connecting' ? 'Connecting' : 'Start session'}
</button>
</div>
{error && <div className="error">{error}</div>}
</div>
)}
{view === 'live' && (
<div className="view view--live">
<div className="status">
<span className={`badge badge--${state}`}>{state}</span>
{latency && (
<span className="latency">
<span className="latency-label">{latency.label}:</span>{' '}
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : ''}
</span>
</span>
)}
</div>
<div className="controls">
<button type="button" className="stop" onClick={() => void handleEnd()}>
End session
</button>
</div>
<div className="transcript">
<h2>Live transcript</h2>
{transcripts.length === 0 ? (
<p className="muted">Speak to the AI customer…</p>
) : (
<ul>
{transcripts.map((t, i) => (
<li key={i} className={`turn turn--${t.role}`}>
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
<span className="text">{t.text}</span>
</li>
))}
</ul>
)}
</div>
{error && <div className="error">{error}</div>}
</div>
)}
{view === 'debrief' && (
<div className="view view--debrief">
<h2>Session debrief</h2>
<p className="muted">
Your coaching debrief would appear here, generated from your turns
+ the branch outcome. In a live run (with API keys), the debrief
is spoken in the same voice as the role-play.
</p>
{latency && (
<div className="summary">
<h3>Latency summary</h3>
<p>
{latency.label}:{' '}
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : ''}
</span>
{latency.e2eMs !== null && (
<span className="budget">
{' '}(budget 600ms — {latency.e2eMs <= 600 ? 'within' : 'over'})
</span>
)}
</p>
</div>
)}
{transcripts.length > 0 && (
<div className="transcript">
<h3>Turns this session</h3>
<ul>
{transcripts.map((t, i) => (
<li key={i} className={`turn turn--${t.role}`}>
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
<span className="text">{t.text}</span>
</li>
))}
</ul>
</div>
)}
<div className="controls">
<button type="button" className="start" onClick={handleRestart}>
Start a new session
</button>
</div>
</div>
)}
</section>
<Routes>
<Route path="/" element={<VoiceSession />} />
<Route path="/operator/login" element={<Login />} />
<Route path="/operator/dashboard" element={<Dashboard />} />
<Route path="*" element={<VoiceSession />} />
</Routes>
)
}
export default App
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Praxis v0.1 — voice session UX (extracted for React Router, SLICE-09 TASK-09-02).
*
* Three views: start → live → debrief. Reuses useVoiceSession. This is the
* existing voice UI, now mounted at `/` and as the catch-all fallback.
*/
import { useVoiceSession } from './useVoiceSession'
import { useEffect, useState } from 'react'
import './App.css'
type View = 'start' | 'live' | 'debrief'
export default function VoiceSession() {
const { state, error, transcripts, latency, start, stop } = useVoiceSession()
const [view, setView] = useState<View>('start')
const [acknowledged, setAcknowledged] = useState(false)
useEffect(() => {
if (state === 'connected' && view === 'start') {
setView('live')
}
if (state === 'idle' && view === 'live') {
setView('debrief')
}
}, [state, view])
const handleStart = async () => {
await start()
}
const handleEnd = async () => {
await stop()
setView('debrief')
}
const handleRestart = () => {
setView('start')
setAcknowledged(false)
}
return (
<section id="praxis-session">
<header>
<h1>Praxis</h1>
<p className="subtitle">Customer Service role-play v0.1</p>
</header>
{view === 'start' && (
<div className="view view--start">
<div className="scenario-card">
<h2>Angry customer requesting refund on a damaged product</h2>
<p className="scenario-desc">
You are a customer service agent. An angry customer (Jordan) is
demanding a refund for a cracked product. Handle the
conversation. You'll receive a coaching debrief at the end.
</p>
</div>
<div className="disclaimer">
<label className="disclaimer-check">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
/>
<span>
This is an AI practice session for training purposes. It is
not a real conversation and no real company is involved.
</span>
</label>
</div>
<div className="controls">
<button
type="button"
className="start"
disabled={!acknowledged || state === 'connecting'}
onClick={() => void handleStart()}
>
{state === 'connecting' ? 'Connecting' : 'Start session'}
</button>
</div>
{error && <div className="error">{error}</div>}
</div>
)}
{view === 'live' && (
<div className="view view--live">
<div className="status">
<span className={`badge badge--${state}`}>{state}</span>
{latency && (
<span className="latency">
<span className="latency-label">{latency.label}:</span>{' '}
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : ''}
</span>
</span>
)}
</div>
<div className="controls">
<button type="button" className="stop" onClick={() => void handleEnd()}>
End session
</button>
</div>
<div className="transcript">
<h2>Live transcript</h2>
{transcripts.length === 0 ? (
<p className="muted">Speak to the AI customer…</p>
) : (
<ul>
{transcripts.map((t, i) => (
<li key={i} className={`turn turn--${t.role}`}>
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
<span className="text">{t.text}</span>
</li>
))}
</ul>
)}
</div>
{error && <div className="error">{error}</div>}
</div>
)}
{view === 'debrief' && (
<div className="view view--debrief">
<h2>Session debrief</h2>
<p className="muted">
Your coaching debrief would appear here, generated from your turns
+ the branch outcome. In a live run (with API keys), the debrief
is spoken in the same voice as the role-play.
</p>
{latency && (
<div className="summary">
<h3>Latency summary</h3>
<p>
{latency.label}:{' '}
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : ''}
</span>
{latency.e2eMs !== null && (
<span className="budget">
{' '}(budget 600ms — {latency.e2eMs <= 600 ? 'within' : 'over'})
</span>
)}
</p>
</div>
)}
{transcripts.length > 0 && (
<div className="transcript">
<h3>Turns this session</h3>
<ul>
{transcripts.map((t, i) => (
<li key={i} className={`turn turn--${t.role}`}>
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
<span className="text">{t.text}</span>
</li>
))}
</ul>
</div>
)}
<div className="controls">
<button type="button" className="start" onClick={handleRestart}>
Start a new session
</button>
</div>
</div>
)}
</section>
)
}
+5 -2
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
)
+120
View File
@@ -0,0 +1,120 @@
/**
* Operator Dashboard shell + auth gate (SLICE-09 TASK-09-04, D-057, D-053).
*
* On mount: GET /api/operator/me. 401 → redirect to /operator/login (UX-only
* route guard — the server is the authority per D-057). 200 → render the
* dashboard with operator name, 3 view tabs, freshness indicator, logout.
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import PracticeVolume from './views/PracticeVolume'
import MasteryProgression from './views/MasteryProgression'
import FailurePatterns from './views/FailurePatterns'
import '../App.css'
type Tab = 'practice' | 'mastery' | 'failure'
interface OperatorInfo {
id: string
username: string
display_name: string | null
role: string
}
export default function Dashboard() {
const [op, setOp] = useState<OperatorInfo | null>(null)
const [tab, setTab] = useState<Tab>('practice')
const [authed, setAuthed] = useState<boolean | null>(null)
const navigate = useNavigate()
useEffect(() => {
let cancelled = false
;(async () => {
try {
const r = await fetch('/api/operator/me', { credentials: 'include' })
if (cancelled) return
if (r.status === 200) {
const body = await r.json()
setOp(body.operator)
setAuthed(true)
} else {
setAuthed(false)
navigate('/operator/login', { replace: true })
}
} catch {
if (!cancelled) setAuthed(false)
}
})()
return () => {
cancelled = true
}
}, [navigate])
const handleLogout = async () => {
try {
await fetch('/api/operator/logout', {
method: 'POST',
credentials: 'include',
})
} catch {
// best-effort — navigate to login regardless
}
navigate('/operator/login', { replace: true })
}
if (authed === false) return null
if (authed === null || !op) {
return (
<section id="praxis-dashboard">
<p className="muted">Loading dashboard</p>
</section>
)
}
return (
<section id="praxis-dashboard">
<header>
<h1>Praxis Operator Dashboard</h1>
<p className="subtitle">
Signed in as {op.display_name || op.username}
</p>
<div className="controls">
<button type="button" className="stop" onClick={handleLogout}>
Sign out
</button>
</div>
</header>
<nav className="view-tabs" role="tablist">
<button
role="tab"
aria-selected={tab === 'practice'}
className={tab === 'practice' ? 'tab active' : 'tab'}
onClick={() => setTab('practice')}
>
Practice Volume
</button>
<button
role="tab"
aria-selected={tab === 'mastery'}
className={tab === 'mastery' ? 'tab active' : 'tab'}
onClick={() => setTab('mastery')}
>
Mastery Progression
</button>
<button
role="tab"
aria-selected={tab === 'failure'}
className={tab === 'failure' ? 'tab active' : 'tab'}
onClick={() => setTab('failure')}
>
Failure Patterns
</button>
</nav>
{tab === 'practice' && <PracticeVolume />}
{tab === 'mastery' && <MasteryProgression />}
{tab === 'failure' && <FailurePatterns />}
</section>
)
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Operator Login form (SLICE-09 TASK-09-03, D-041, D-057).
*
* POST /api/operator/login on submit. On success → navigate to
* /operator/dashboard. On 401 → show error. On 429 → show rate-limit retry
* message. Keyboard-accessible (label associations, focus management).
*/
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const navigate = useNavigate()
const userRef = useRef<HTMLInputElement>(null)
useEffect(() => {
userRef.current?.focus()
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
const r = await fetch('/api/operator/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (r.status === 200) {
navigate('/operator/dashboard')
return
}
if (r.status === 401) {
setError('Invalid username or password.')
} else if (r.status === 429) {
setError('Too many attempts. Try again in a minute.')
} else if (r.status === 503) {
setError('Operator sign-in is unavailable right now.')
} else {
setError(`Login failed (HTTP ${r.status}).`)
}
} catch (err) {
setError('Network error — unable to reach the server.')
} finally {
setSubmitting(false)
}
}
return (
<section id="praxis-login">
<header>
<h1>Praxis Operator</h1>
<p className="subtitle">Sign in to view the cohort dashboard</p>
</header>
<form className="login-form" onSubmit={handleSubmit}>
<label htmlFor="login-username">Username</label>
<input
id="login-username"
ref={userRef}
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={submitting}
/>
<label htmlFor="login-password">Password</label>
<input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={submitting}
/>
<button type="submit" className="start" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
{error && <div className="error" role="alert">{error}</div>}
</form>
</section>
)
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Inline SVG sparkline (SLICE-09 TASK-09-05, RESEARCH-v0.4 §4.3).
*
* Zero-dep ~50 LOC. Renders a polyline from `data`. Handles empty (renders
* nothing), single point (dot), all-same (flat line). stroke=currentColor.
* No axes/tooltips — sparklines are compact trend indicators.
*/
interface SparklineProps {
data: number[]
width?: number
height?: number
}
export default function Sparkline({ data, width = 60, height = 20 }: SparklineProps) {
if (!data || data.length === 0) {
return null
}
if (data.length === 1) {
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
<circle cx={width / 2} cy={height / 2} r={1.5} fill="currentColor" />
</svg>
)
}
const min = Math.min(...data)
const max = Math.max(...data)
const span = max - min || 1
const pad = 2
const w = width - pad * 2
const h = height - pad * 2
const stepX = w / (data.length - 1)
const points = data.map((v, i) => {
const x = pad + i * stepX
const y = pad + h - ((v - min) / span) * h
return `${x.toFixed(2)},${y.toFixed(2)}`
})
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
<polyline
points={points.join(' ')}
fill="none"
stroke="currentColor"
strokeWidth={1.25}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
)
}
@@ -0,0 +1,193 @@
/**
* Operator dashboard unit tests (SLICE-09 TASK-09-07).
*
* Covers: auth gate (401 on /me → redirect to /operator/login), login form
* (submit → POST /login → navigate to dashboard), suppressed cell display
* ("— (<10 learners)"), sparkline renders SVG polyline, freshness indicator,
* no PII in rendered DOM.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import Login from '../Login'
import Dashboard from '../Dashboard'
import Sparkline from '../Sparkline'
import { suppressedLabel, formatFreshness } from '../views/_viewCommon'
import type { Cell } from '../views/_viewCommon'
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/operator/login" element={<Login />} />
<Route path="/operator/dashboard" element={<Dashboard />} />
<Route path="*" element={<div data-testid="fallback" />} />
</Routes>
</MemoryRouter>,
)
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
// ── Auth gate ──────────────────────────────────────────────────────────────
describe('Dashboard auth gate', () => {
it('redirects to /operator/login on 401 from /me', async () => {
;(global.fetch as any).mockResolvedValue({ status: 401 })
renderAt('/operator/dashboard')
await waitFor(() => {
expect(screen.queryByText(/Praxis Operator Dashboard/i)).not.toBeInTheDocument()
})
})
it('renders dashboard on 200 from /me', async () => {
;(global.fetch as any).mockResolvedValue({
status: 200,
json: async () => ({ operator: { id: '1', username: 'alice', display_name: 'Alice', role: 'operator' } }),
})
renderAt('/operator/dashboard')
await waitFor(() => {
expect(screen.getByText(/Praxis Operator Dashboard/i)).toBeInTheDocument()
expect(screen.getByText(/Signed in as Alice/i)).toBeInTheDocument()
})
})
})
// ── Login form ────────────────────────────────────────────────────────────
describe('Login form', () => {
it('renders username + password fields + submit', () => {
renderAt('/operator/login')
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument()
expect(screen.getByLabelText(/Password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument()
})
it('submits POST /api/operator/login and navigates on success', async () => {
;(global.fetch as any).mockResolvedValue({ status: 200 })
renderAt('/operator/login')
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'alice' } })
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'pw' } })
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/operator/login',
expect.objectContaining({ method: 'POST' }),
)
})
})
it('shows error on 401', async () => {
;(global.fetch as any).mockResolvedValue({ status: 401 })
renderAt('/operator/login')
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'a' } })
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'b' } })
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
await waitFor(() => {
expect(screen.getByText(/Invalid username or password/i)).toBeInTheDocument()
})
})
it('shows rate-limit message on 429', async () => {
;(global.fetch as any).mockResolvedValue({ status: 429 })
renderAt('/operator/login')
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'a' } })
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'b' } })
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
await waitFor(() => {
expect(screen.getByText(/Too many attempts/i)).toBeInTheDocument()
})
})
})
// ── Sparkline ─────────────────────────────────────────────────────────────
describe('Sparkline', () => {
it('renders nothing for empty data', () => {
const { container } = render(<Sparkline data={[]} />)
expect(container.querySelector('svg')).toBeNull()
})
it('renders a dot for single point', () => {
const { container } = render(<Sparkline data={[5]} />)
expect(container.querySelector('circle')).not.toBeNull()
})
it('renders a polyline for multiple points', () => {
const { container } = render(<Sparkline data={[1, 2, 3, 4, 5]} />)
const poly = container.querySelector('polyline')
expect(poly).not.toBeNull()
expect(poly?.getAttribute('points')).toBeTruthy()
})
it('renders a flat line for all-same values', () => {
const { container } = render(<Sparkline data={[3, 3, 3, 3]} />)
expect(container.querySelector('polyline')).not.toBeNull()
})
})
// ── Suppressed cell display + freshness ──────────────────────────────────
describe('suppressedLabel', () => {
it('shows "— (<10 learners)" for suppressed cells', () => {
const cell: Cell = {
metric: 'sessions_count', window_start: null, window_end: null,
value: null, cell_count: 5, cell_suppressed: true, updated_at: null,
}
expect(suppressedLabel(cell)).toBe('— (<10 learners)')
})
it('shows the value for non-suppressed cells', () => {
const cell: Cell = {
metric: 'sessions_count', window_start: null, window_end: null,
value: 12, cell_count: 12, cell_suppressed: false, updated_at: null,
}
expect(suppressedLabel(cell)).toBe('12')
})
})
describe('formatFreshness', () => {
it('shows — for null lastUpdated', () => {
expect(formatFreshness(null)).toBe('—')
})
it('shows minutes ago for < 1h', () => {
const thirtyMinAgo = new Date(Date.now() - 30 * 60_000).toISOString()
expect(formatFreshness(thirtyMinAgo)).toMatch(/m ago/)
})
it('shows hours ago for 1-24h', () => {
const twoHoursAgo = new Date(Date.now() - 2 * 3_600_000).toISOString()
expect(formatFreshness(twoHoursAgo)).toMatch(/h ago/)
})
it('shows days ago for > 24h', () => {
const twoDaysAgo = new Date(Date.now() - 48 * 3_600_000).toISOString()
expect(formatFreshness(twoDaysAgo)).toMatch(/d ago/)
})
})
// ── No PII in rendered DOM ────────────────────────────────────────────────
describe('No PII in dashboard DOM', () => {
it('does not render learner_ref fields', async () => {
;(global.fetch as any).mockResolvedValue({
status: 200,
json: async () => ({ operator: { id: '1', username: 'alice', display_name: 'Alice', role: 'operator' } }),
})
const { container } = renderAt('/operator/dashboard')
await waitFor(() => {
expect(screen.getByText(/Praxis Operator Dashboard/i)).toBeInTheDocument()
})
// No learner-ref label or per-learner data should appear in the dashboard shell.
expect(container.textContent).not.toMatch(/learner_ref/i)
expect(container.textContent).not.toMatch(/learner-1/i)
})
})
@@ -0,0 +1,94 @@
/**
* Failure Patterns view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
*
* Top failure_modes by frequency (sorted table), rubric criteria with
* mean < 3.0 (highlighted weak-spots), branch outcome distribution.
* Suppressed cells → "— (<10 learners)".
*/
import { useEffect, useState } from 'react'
import { fetchView, formatFreshness, suppressedLabel } from './_viewCommon'
import type { ViewResponse } from './_viewCommon'
export default function FailurePatterns() {
const [data, setData] = useState<ViewResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const r = await fetchView('/api/operator/failure-patterns')
if (!cancelled) setData(r)
} catch (e) {
if (!cancelled) setError(String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
if (loading) return <p className="muted">Loading failure patterns</p>
if (error) return <div className="error">Failed to load: {error}</div>
if (!data || data.views.length === 0) {
return (
<div className="view view--failure">
<p className="muted">No failure-pattern data available yet.</p>
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
</div>
)
}
return (
<div className="view view--failure">
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
{data.views.map((v) => {
const modes = v.metrics
.filter((c) => c.metric.startsWith('failure_mode:'))
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0))
const branches = v.metrics.filter((c) => c.metric.startsWith('branch:'))
return (
<div key={v.path} className="cohort-section">
<h3>{v.path}</h3>
<h4>Failure modes by frequency</h4>
<table className="cohort-table">
<thead><tr><th>Mode</th><th>Frequency</th></tr></thead>
<tbody>
{modes.length === 0 ? (
<tr><td colSpan={2} className="muted">No failure modes recorded.</td></tr>
) : (
modes.map((c) => (
<tr key={c.metric}>
<td>{c.metric.replace('failure_mode:', '')}</td>
<td>{suppressedLabel(c)}</td>
</tr>
))
)}
</tbody>
</table>
<h4>Branch outcome distribution</h4>
<table className="cohort-table">
<thead><tr><th>Branch</th><th>Count</th></tr></thead>
<tbody>
{branches.length === 0 ? (
<tr><td colSpan={2} className="muted">No branch data recorded.</td></tr>
) : (
branches.map((c) => (
<tr key={c.metric}>
<td>{c.metric.replace('branch:', '')}</td>
<td>{suppressedLabel(c)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,84 @@
/**
* Mastery Progression view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
*
* Gate-open rate, median mastery score, rubric criterion means (table +
* sparkline). Suppressed cells → "— (<10 learners)".
*/
import { useEffect, useState } from 'react'
import Sparkline from '../Sparkline'
import { fetchView, formatFreshness, suppressedLabel, valuesForSparkline } from './_viewCommon'
import type { ViewResponse } from './_viewCommon'
export default function MasteryProgression() {
const [data, setData] = useState<ViewResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const r = await fetchView('/api/operator/mastery')
if (!cancelled) setData(r)
} catch (e) {
if (!cancelled) setError(String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
if (loading) return <p className="muted">Loading mastery progression</p>
if (error) return <div className="error">Failed to load: {error}</div>
if (!data || data.views.length === 0) {
return (
<div className="view view--mastery">
<p className="muted">No mastery data available yet.</p>
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
</div>
)
}
return (
<div className="view view--mastery">
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
{data.views.map((v) => {
const gate = v.metrics.find((c) => c.metric === 'gate_open_rate')
const median = v.metrics.find((c) => c.metric === 'median_mastery_score')
const critMeans = v.metrics.filter((c) => c.metric.startsWith('rubric_criterion_mean:'))
return (
<div key={v.path} className="cohort-section">
<h3>{v.path}</h3>
<table className="cohort-table">
<thead>
<tr><th>Metric</th><th>Value</th><th>Trend</th></tr>
</thead>
<tbody>
<tr>
<td>Gate-open rate</td>
<td>{gate ? suppressedLabel(gate) : '—'}</td>
<td><Sparkline data={valuesForSparkline(v.metrics, 'gate_open_rate')} /></td>
</tr>
<tr>
<td>Median mastery score</td>
<td>{median ? suppressedLabel(median) : '—'}</td>
<td><Sparkline data={valuesForSparkline(v.metrics, 'median_mastery_score')} /></td>
</tr>
{critMeans.map((c) => (
<tr key={c.metric}>
<td>{c.metric.replace('rubric_criterion_mean:', '')}</td>
<td>{suppressedLabel(c)}</td>
<td><Sparkline data={valuesForSparkline(v.metrics, c.metric)} /></td>
</tr>
))}
</tbody>
</table>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,81 @@
/**
* Practice Volume view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
*
* Read-only table of sessions/day per path + active learners, with sparklines.
* Suppressed cells → "— (<10 learners)". No per-learner drill-down (R-DASH-02).
*/
import { useEffect, useState } from 'react'
import Sparkline from '../Sparkline'
import { fetchView, formatFreshness, suppressedLabel, valuesForSparkline } from './_viewCommon'
import type { Cell, ViewResponse } from './_viewCommon'
const SUPPRESSED_PLACEHOLDER: Cell = {
metric: '', window_start: null, window_end: null,
value: null, cell_count: 0, cell_suppressed: true, updated_at: null,
}
export default function PracticeVolume() {
const [data, setData] = useState<ViewResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const r = await fetchView('/api/operator/cohort')
if (!cancelled) setData(r)
} catch (e) {
if (!cancelled) setError(String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
if (loading) return <p className="muted">Loading practice volume</p>
if (error) return <div className="error">Failed to load: {error}</div>
if (!data || data.views.length === 0) {
return (
<div className="view view--practice">
<p className="muted">No practice data available yet.</p>
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
</div>
)
}
return (
<div className="view view--practice">
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
<table className="cohort-table">
<thead>
<tr>
<th>Path</th>
<th>Sessions (trend)</th>
<th>Active learners</th>
</tr>
</thead>
<tbody>
{data.views.map((v) => {
const sessions = v.metrics.filter((c) => c.metric === 'sessions_count')
const active = v.metrics.find((c) => c.metric === 'active_learners_count')
return (
<tr key={v.path}>
<td>{v.path}</td>
<td>
{suppressedLabel(sessions[sessions.length - 1] ?? SUPPRESSED_PLACEHOLDER)}
{' '}
<Sparkline data={valuesForSparkline(v.metrics, 'sessions_count')} />
</td>
<td>{active ? suppressedLabel(active) : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Shared types + helpers for operator dashboard views (SLICE-09 TASK-09-06).
*/
export interface Cell {
metric: string
window_start: string | null
window_end: string | null
value: number | null
cell_count: number
cell_suppressed: boolean
updated_at: string | null
}
export interface PathView {
path: string
metrics: Cell[]
}
export interface ViewResponse {
views: PathView[]
last_updated: string | null
}
export async function fetchView(endpoint: string): Promise<ViewResponse> {
const r = await fetch(endpoint, { credentials: 'include' })
if (!r.ok) {
throw new Error(`HTTP ${r.status}`)
}
return (await r.json()) as ViewResponse
}
export function formatFreshness(lastUpdated: string | null): string {
if (!lastUpdated) return '—'
const ts = Date.parse(lastUpdated)
if (Number.isNaN(ts)) return '—'
const hoursAgo = (Date.now() - ts) / 3_600_000
if (hoursAgo < 1) return `${Math.round(hoursAgo * 60)}m ago`
if (hoursAgo < 24) return `${hoursAgo.toFixed(1)}h ago`
return `${(hoursAgo / 24).toFixed(1)}d ago`
}
export function suppressedLabel(cell: Cell): string {
return cell.cell_suppressed ? '— (<10 learners)' : String(cell.value ?? '—')
}
export function groupMetricsByPath(views: PathView[]): Map<string, Cell[]> {
const m = new Map<string, Cell[]>()
for (const v of views) {
m.set(v.path, v.metrics)
}
return m
}
export function valuesForSparkline(cells: Cell[] | undefined, metric: string): number[] {
if (!cells) return []
return cells
.filter((c) => c.metric === metric && c.value !== null)
.map((c) => c.value as number)
}
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
},
})