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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user