feat: improve session summary header and add practice type badges

- Show celebration header only when just completed a session (?completed=1)
- Show session date as header when viewing historical sessions
- Add practice type badges (e.g., "Use Abacus", "Visualize") to main stats
- Pass justCompleted flag through page -> client -> SessionSummary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-31 06:13:49 -06:00
parent 6d36da47b3
commit 518fe153c9
4 changed files with 239 additions and 137 deletions

View File

@@ -14,16 +14,16 @@ import {
} from '@/components/practice'
import type { Player } from '@/db/schema/players'
import type { SessionHealth, SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import {
type ReceivedAbacusControl,
type TeacherPauseRequest,
useSessionBroadcast,
} from '@/hooks/useSessionBroadcast'
import {
useActiveSessionPlan,
useEndSessionEarly,
useRecordSlotResult,
} from '@/hooks/useSessionPlan'
import {
useSessionBroadcast,
type ReceivedAbacusControl,
type TeacherPauseRequest,
} from '@/hooks/useSessionBroadcast'
import { css } from '../../../../styled-system/css'
interface PracticeClientProps {
@@ -106,9 +106,9 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
result,
})
// If session just completed, redirect to summary
// If session just completed, redirect to summary with completed flag
if (updatedPlan.completedAt) {
router.push(`/practice/${studentId}/summary`, { scroll: false })
router.push(`/practice/${studentId}/summary?completed=1`, { scroll: false })
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
@@ -134,8 +134,8 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
planId: currentPlan.id,
reason,
})
// Redirect to summary after ending early
router.push(`/practice/${studentId}/summary`, { scroll: false })
// Redirect to summary after ending early with completed flag
router.push(`/practice/${studentId}/summary?completed=1`, { scroll: false })
} catch (err) {
// Check if it's an authorization error
const message = err instanceof Error ? err.message : 'Unknown error'
@@ -154,8 +154,8 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
// Handle session completion (called by ActiveSession when all problems done)
const handleSessionComplete = useCallback(() => {
// Redirect to summary
router.push(`/practice/${studentId}/summary`, { scroll: false })
// Redirect to summary with completed flag
router.push(`/practice/${studentId}/summary?completed=1`, { scroll: false })
}, [studentId, router])
// Broadcast session state if student is in a classroom

View File

@@ -54,6 +54,8 @@ interface SummaryClientProps {
avgSecondsPerProblem?: number
/** Problem history for BKT computation in weak skills targeting */
problemHistory?: ProblemResultWithContext[]
/** Whether we just transitioned from active practice to this summary */
justCompleted?: boolean
}
/**
@@ -71,6 +73,7 @@ export function SummaryClient({
session,
avgSecondsPerProblem = 40,
problemHistory,
justCompleted = false,
}: SummaryClientProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -285,6 +288,7 @@ export function SummaryClient({
studentName={player.name}
onPracticeAgain={handlePracticeAgain}
problemHistory={problemHistory}
justCompleted={justCompleted}
/>
)}

View File

@@ -12,6 +12,7 @@ export const dynamic = 'force-dynamic'
interface SummaryPageProps {
params: Promise<{ studentId: string }>
searchParams: Promise<{ completed?: string }>
}
/**
@@ -29,8 +30,10 @@ interface SummaryPageProps {
*
* URL: /practice/[studentId]/summary
*/
export default async function SummaryPage({ params }: SummaryPageProps) {
export default async function SummaryPage({ params, searchParams }: SummaryPageProps) {
const { studentId } = await params
const { completed } = await searchParams
const justCompleted = completed === '1'
// Fetch player, active session, most recent completed session, and problem history in parallel
const [player, activeSession, completedSession, problemHistory] = await Promise.all([
@@ -58,6 +61,7 @@ export default async function SummaryPage({ params }: SummaryPageProps) {
session={sessionToShow}
avgSecondsPerProblem={avgSecondsPerProblem}
problemHistory={problemHistory}
justCompleted={justCompleted}
/>
)
}

View File

@@ -1,8 +1,9 @@
'use client'
import { useMemo } from 'react'
import { PRACTICE_TYPES, type PracticeTypeId } from '@/constants/practiceTypes'
import { useTheme } from '@/contexts/ThemeContext'
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
import type { SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { computeBktFromHistory, type SkillBktResult } from '@/lib/curriculum/bkt'
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
import { css } from '../../../styled-system/css'
@@ -19,6 +20,8 @@ interface SessionSummaryProps {
onPracticeAgain: () => void
/** Problem history for BKT computation (optional - if not provided, weak skills won't be shown) */
problemHistory?: ProblemResultWithContext[]
/** Whether we just transitioned from active practice - shows celebration header */
justCompleted?: boolean
}
interface SkillBreakdown {
@@ -69,10 +72,38 @@ export function SessionSummary({
studentName,
onPracticeAgain,
problemHistory,
justCompleted = false,
}: SessionSummaryProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const results = plan.results as SlotResult[]
const parts = (plan.parts ?? []) as SessionPart[]
// Get unique practice types from the session parts
const practiceTypesInSession = useMemo(() => {
const typeIds = new Set<PracticeTypeId>()
for (const part of parts) {
if (part.slots && part.slots.length > 0) {
typeIds.add(part.type as PracticeTypeId)
}
}
return PRACTICE_TYPES.filter((t) => typeIds.has(t.id as PracticeTypeId))
}, [parts])
// Format session date
const sessionDate = useMemo(() => {
const timestamp = plan.startedAt ?? plan.createdAt
if (!timestamp) return null
// Handle both Date objects and millisecond timestamps
const date =
typeof timestamp === 'number' ? new Date(timestamp) : new Date(timestamp as unknown as string)
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}, [plan.startedAt, plan.createdAt])
// Compute BKT from problem history to get skill masteries
const skillMasteries = useMemo<Record<string, SkillBktResult>>(() => {
@@ -130,169 +161,232 @@ export function SessionSummary({
margin: '0 auto',
})}
>
{/* Header with celebration */}
<div
data-section="summary-header"
className={css({
textAlign: 'center',
padding: '1.5rem',
backgroundColor: isDark
? accuracy >= 0.8
? 'green.900'
: accuracy >= 0.6
? 'yellow.900'
: 'orange.900'
: accuracy >= 0.8
? 'green.50'
: accuracy >= 0.6
? 'yellow.50'
: 'orange.50',
borderRadius: '16px',
border: '2px solid',
borderColor: isDark
? accuracy >= 0.8
? 'green.700'
: accuracy >= 0.6
? 'yellow.700'
: 'orange.700'
: accuracy >= 0.8
? 'green.200'
: accuracy >= 0.6
? 'yellow.200'
: 'orange.200',
})}
>
{/* Header - celebration when just completed, date otherwise */}
{justCompleted ? (
<div
data-section="summary-header"
className={css({
fontSize: '4rem',
textAlign: 'center',
padding: '1.5rem',
backgroundColor: isDark
? accuracy >= 0.8
? 'green.900'
: accuracy >= 0.6
? 'yellow.900'
: 'orange.900'
: accuracy >= 0.8
? 'green.50'
: accuracy >= 0.6
? 'yellow.50'
: 'orange.50',
borderRadius: '16px',
border: '2px solid',
borderColor: isDark
? accuracy >= 0.8
? 'green.700'
: accuracy >= 0.6
? 'yellow.700'
: 'orange.700'
: accuracy >= 0.8
? 'green.200'
: accuracy >= 0.6
? 'yellow.200'
: 'orange.200',
})}
>
<div
className={css({
fontSize: '4rem',
marginBottom: '0.5rem',
})}
>
{accuracy >= 0.9 ? '🌟' : accuracy >= 0.8 ? '🎉' : accuracy >= 0.6 ? '👍' : '💪'}
</div>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
marginBottom: '0.25rem',
})}
>
Great Work, {studentName}!
</h1>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{performanceMessage}
</p>
</div>
) : (
<div
data-section="session-date-header"
className={css({
textAlign: 'center',
marginBottom: '0.5rem',
})}
>
{accuracy >= 0.9 ? '🌟' : accuracy >= 0.8 ? '🎉' : accuracy >= 0.6 ? '👍' : '💪'}
<h1
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
})}
>
{sessionDate ?? 'Practice Session'}
</h1>
</div>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.800',
marginBottom: '0.25rem',
})}
>
Great Work, {studentName}!
</h1>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{performanceMessage}
</p>
</div>
)}
{/* Main stats */}
<div
data-section="main-stats"
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
<div
data-element="stat-accuracy"
className={css({
textAlign: 'center',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
boxShadow: 'sm',
})}
>
{/* Practice type badges */}
{practiceTypesInSession.length > 0 && (
<div
data-element="practice-types"
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark
? accuracy >= 0.8
? 'green.400'
: accuracy >= 0.6
? 'yellow.400'
: 'orange.400'
: accuracy >= 0.8
? 'green.600'
: accuracy >= 0.6
? 'yellow.600'
: 'orange.600',
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
justifyContent: 'center',
})}
>
{Math.round(accuracy * 100)}%
{practiceTypesInSession.map((type) => (
<span
key={type.id}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
px: '0.75rem',
py: '0.25rem',
fontSize: '0.8125rem',
fontWeight: 'medium',
borderRadius: 'full',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
<span>{type.icon}</span>
<span>{type.label}</span>
</span>
))}
</div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Accuracy
</div>
</div>
)}
{/* Stats grid */}
<div
data-element="stat-problems"
className={css({
textAlign: 'center',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
boxShadow: 'sm',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
})}
>
<div
data-element="stat-accuracy"
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'blue.400' : 'blue.600',
textAlign: 'center',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
boxShadow: 'sm',
})}
>
{correctProblems}/{totalProblems}
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark
? accuracy >= 0.8
? 'green.400'
: accuracy >= 0.6
? 'yellow.400'
: 'orange.400'
: accuracy >= 0.8
? 'green.600'
: accuracy >= 0.6
? 'yellow.600'
: 'orange.600',
})}
>
{Math.round(accuracy * 100)}%
</div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Accuracy
</div>
</div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Correct
</div>
</div>
<div
data-element="stat-time"
className={css({
textAlign: 'center',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
boxShadow: 'sm',
})}
>
<div
data-element="stat-problems"
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'purple.400' : 'purple.600',
textAlign: 'center',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
boxShadow: 'sm',
})}
>
{Math.round(sessionDurationMinutes)}
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'blue.400' : 'blue.600',
})}
>
{correctProblems}/{totalProblems}
</div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Correct
</div>
</div>
<div
data-element="stat-time"
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
textAlign: 'center',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
boxShadow: 'sm',
})}
>
Minutes
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: isDark ? 'purple.400' : 'purple.600',
})}
>
{Math.round(sessionDurationMinutes)}
</div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Minutes
</div>
</div>
</div>
</div>