feat(practice): unify dashboard with session-aware progress display

- Make ProgressDashboard session-aware with single primary CTA
  - No session: "Start Practice →" (blue)
  - Active session: "Resume Practice →" (green) with progress count
  - Single "Start over" link replaces redundant Abandon/Regenerate buttons
- Add skill mismatch warning inline in level card
- Add masteredSkillIds to session_plans for mismatch detection
- Fix getActiveSessionPlan to check completedAt IS NULL (fixes loop bug)
- Remove separate Active Session Card from dashboard (now integrated)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-09 20:59:53 -06:00
parent 245cc269fe
commit c40543ac64
10 changed files with 1429 additions and 70 deletions

View File

@ -0,0 +1,3 @@
-- Custom SQL migration file, put your code below! --
-- Add mastered_skill_ids column to session_plans for skill mismatch detection
ALTER TABLE `session_plans` ADD `mastered_skill_ids` text DEFAULT '[]' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -197,6 +197,13 @@
"when": 1765055035935,
"tag": "0027_help_system_schema",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1765331044112,
"tag": "0028_medical_wolfsbane",
"breakpoints": true
}
]
}

View File

@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import {
type ActiveSessionState,
type CurrentPhaseInfo,
ProgressDashboard,
type SkillProgress,
@ -19,6 +20,7 @@ import type { PlayerCurriculum } from '@/db/schema/player-curriculum'
import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
import type { Player } from '@/db/schema/players'
import type { PracticeSession } from '@/db/schema/practice-sessions'
import type { SessionPlan } from '@/db/schema/session-plans'
import { css } from '../../../../../styled-system/css'
interface DashboardClientProps {
@ -27,6 +29,8 @@ interface DashboardClientProps {
curriculum: PlayerCurriculum | null
skills: PlayerSkillMastery[]
recentSessions: PracticeSession[]
activeSession: SessionPlan | null
currentMasteredSkillIds: string[]
}
// Mock curriculum phase data (until we integrate with actual curriculum)
@ -83,14 +87,18 @@ function formatSkillName(skillId: string): string {
* Dashboard Client Component
*
* Shows the student's progress dashboard.
* "Continue Practice" navigates to /configure to set up a new session.
* "Start Practice" navigates to /configure to set up a new session.
* "Resume Practice" continues an existing active session.
*/
export function DashboardClient({
studentId,
player,
curriculum,
skills,
recentSessions,
activeSession,
currentMasteredSkillIds,
}: DashboardClientProps) {
const router = useRouter()
const { resolvedTheme } = useTheme()
@ -99,6 +107,28 @@ export function DashboardClient({
// Modal states for onboarding features
const [showManualSkillModal, setShowManualSkillModal] = useState(false)
const [showOfflineSessionModal, setShowOfflineSessionModal] = useState(false)
const [isStartingOver, setIsStartingOver] = useState(false)
// Build ActiveSessionState for ProgressDashboard
const activeSessionState: ActiveSessionState | null = activeSession
? (() => {
const sessionSkillIds = activeSession.masteredSkillIds || []
const sessionSet = new Set(sessionSkillIds)
const currentSet = new Set(currentMasteredSkillIds)
const skillsAdded = currentMasteredSkillIds.filter((id) => !sessionSet.has(id)).length
const skillsRemoved = sessionSkillIds.filter((id) => !currentSet.has(id)).length
return {
id: activeSession.id,
status: activeSession.status as 'draft' | 'approved' | 'in_progress',
completedCount: activeSession.results.length,
totalCount: activeSession.summary.totalProblemCount,
hasSkillMismatch: skillsAdded > 0 || skillsRemoved > 0,
skillsAdded,
skillsRemoved,
}
})()
: null
// Build the student object
const selectedStudent: StudentWithProgress = {
@ -131,8 +161,8 @@ export function DashboardClient({
consecutiveCorrect: s.consecutiveCorrect,
}))
// Handle continue practice - navigate to configuration page
const handleContinuePractice = useCallback(() => {
// Handle start practice - navigate to configuration page
const handleStartPractice = useCallback(() => {
router.push(`/practice/${studentId}/configure`, { scroll: false })
}, [studentId, router])
@ -193,6 +223,30 @@ export function DashboardClient({
[]
)
// Handle starting over (abandon current session and create new one)
const handleStartOver = useCallback(async () => {
if (!activeSession) return
setIsStartingOver(true)
try {
// First abandon the old session
await fetch(`/api/curriculum/${studentId}/sessions/plans/${activeSession.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'abandon' }),
})
// Navigate to configure to create a new one
router.push(`/practice/${studentId}/configure`)
} catch (error) {
console.error('Failed to start over:', error)
setIsStartingOver(false)
}
}, [activeSession, studentId, router])
// Handle resuming the current session
const handleResumeSession = useCallback(() => {
router.push(`/practice/${studentId}/session`)
}, [studentId, router])
return (
<PageWithNav>
<main
@ -239,12 +293,16 @@ export function DashboardClient({
</p>
</header>
{/* Progress Dashboard */}
{/* Progress Dashboard - unified session-aware component */}
<ProgressDashboard
student={selectedStudent}
currentPhase={currentPhase}
recentSkills={recentSkillsDisplay}
onContinuePractice={handleContinuePractice}
activeSession={activeSessionState}
onStartPractice={handleStartPractice}
onResumePractice={handleResumeSession}
onStartOver={handleStartOver}
isStartingOver={isStartingOver}
onViewFullProgress={handleViewFullProgress}
onGenerateWorksheet={handleGenerateWorksheet}
onRunPlacementTest={handleRunPlacementTest}

View File

@ -5,6 +5,7 @@ import {
getPlayerCurriculum,
getRecentSessions,
} from '@/lib/curriculum/server'
import { getActiveSessionPlan } from '@/lib/curriculum/session-planner'
import { DashboardClient } from './DashboardClient'
// Disable caching for this page - progress data should be fresh
@ -31,11 +32,12 @@ export default async function DashboardPage({ params }: DashboardPageProps) {
const { studentId } = await params
// Fetch player data in parallel
const [player, curriculum, skills, recentSessions] = await Promise.all([
const [player, curriculum, skills, recentSessions, activeSession] = await Promise.all([
getPlayer(studentId),
getPlayerCurriculum(studentId),
getAllSkillMastery(studentId),
getRecentSessions(studentId, 10),
getActiveSessionPlan(studentId),
])
// 404 if player doesn't exist
@ -43,6 +45,11 @@ export default async function DashboardPage({ params }: DashboardPageProps) {
notFound()
}
// Calculate current mastered skill IDs for mismatch detection
const currentMasteredSkillIds = skills
.filter((s) => s.masteryLevel === 'mastered')
.map((s) => s.skillId)
return (
<DashboardClient
studentId={studentId}
@ -50,6 +57,8 @@ export default async function DashboardPage({ params }: DashboardPageProps) {
curriculum={curriculum}
skills={skills}
recentSessions={recentSessions}
activeSession={activeSession}
currentMasteredSkillIds={currentMasteredSkillIds}
/>
)
}

View File

@ -97,7 +97,7 @@ const sampleRecentSkills: SkillProgress[] = [
]
const handlers = {
onContinuePractice: () => alert('Continue Practice clicked!'),
onStartPractice: () => alert('Start Practice clicked!'),
onViewFullProgress: () => alert('View Full Progress clicked!'),
onGenerateWorksheet: () => alert('Generate Worksheet clicked!'),
onChangeStudent: () => alert('Change Student clicked!'),
@ -279,3 +279,57 @@ export const WithFocusAreas: Story = {
</DashboardWrapper>
),
}
/**
* With Active Session - shows resume button instead of start
*/
export const WithActiveSession: Story = {
render: () => (
<DashboardWrapper>
<ProgressDashboard
student={sampleStudent}
currentPhase={intermediatePhase}
recentSkills={sampleRecentSkills}
activeSession={{
id: 'session-123',
status: 'in_progress',
completedCount: 12,
totalCount: 30,
hasSkillMismatch: false,
skillsAdded: 0,
skillsRemoved: 0,
}}
onResumePractice={() => alert('Resume Practice clicked!')}
onStartOver={() => alert('Start over clicked!')}
{...handlers}
/>
</DashboardWrapper>
),
}
/**
* With Active Session and Skill Mismatch - shows warning
*/
export const WithActiveSessionMismatch: Story = {
render: () => (
<DashboardWrapper>
<ProgressDashboard
student={sampleStudent}
currentPhase={intermediatePhase}
recentSkills={sampleRecentSkills}
activeSession={{
id: 'session-123',
status: 'in_progress',
completedCount: 5,
totalCount: 30,
hasSkillMismatch: true,
skillsAdded: 2,
skillsRemoved: 1,
}}
onResumePractice={() => alert('Resume Practice clicked!')}
onStartOver={() => alert('Start over clicked!')}
{...handlers}
/>
</DashboardWrapper>
),
}

View File

@ -36,13 +36,42 @@ export interface CurrentPhaseInfo {
totalSkills: number
}
/**
* Active session state for unified display
*/
export interface ActiveSessionState {
/** Session ID */
id: string
/** Current status */
status: 'draft' | 'approved' | 'in_progress'
/** Problems completed so far */
completedCount: number
/** Total problems in session */
totalCount: number
/** Whether skills have changed since session was created */
hasSkillMismatch: boolean
/** Number of skills added since session creation */
skillsAdded: number
/** Number of skills removed since session creation */
skillsRemoved: number
}
interface ProgressDashboardProps {
student: StudentWithProgress
currentPhase: CurrentPhaseInfo
recentSkills?: SkillProgress[]
/** Skills that need extra practice (used heavy help recently) */
focusAreas?: SkillProgress[]
onContinuePractice: () => void
/** Active session state (if any) */
activeSession?: ActiveSessionState | null
/** Callback when no active session - start new practice */
onStartPractice: () => void
/** Callback when active session - resume it */
onResumePractice?: () => void
/** Callback to start over (abandon old session, start fresh) */
onStartOver?: () => void
/** Loading state for start over action */
isStartingOver?: boolean
onViewFullProgress: () => void
onGenerateWorksheet: () => void
/** Callback to run placement test */
@ -90,7 +119,11 @@ export function ProgressDashboard({
currentPhase,
recentSkills = [],
focusAreas = [],
onContinuePractice,
activeSession,
onStartPractice,
onResumePractice,
onStartOver,
isStartingOver = false,
onViewFullProgress,
onGenerateWorksheet,
onRunPlacementTest,
@ -107,6 +140,9 @@ export function ProgressDashboard({
? Math.round((currentPhase.masteredSkills / currentPhase.totalSkills) * 100)
: 0
// Determine if we have an active session
const hasActiveSession = !!activeSession
return (
<div
data-component="progress-dashboard"
@ -249,21 +285,106 @@ export function ProgressDashboard({
>
{currentPhase.description}
</p>
{/* Skill mismatch warning - inline in level card */}
{hasActiveSession && activeSession.hasSkillMismatch && (
<div
data-element="skill-mismatch-warning"
className={css({
marginTop: '1rem',
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'orange.900/50' : 'orange.50',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'orange.700' : 'orange.200',
})}
>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'orange.300' : 'orange.700',
})}
>
Skills changed since session was created
{activeSession.skillsAdded > 0 && ` (+${activeSession.skillsAdded} new)`}
{activeSession.skillsRemoved > 0 && ` (-${activeSession.skillsRemoved} removed)`}
</p>
</div>
)}
</div>
{/* Action buttons */}
{/* Primary action - session-aware */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
gap: '0.5rem',
width: '100%',
})}
>
{hasActiveSession ? (
<>
{/* Resume button with progress indicator */}
<button
type="button"
data-action="continue-practice"
onClick={onContinuePractice}
data-action="resume-practice"
onClick={onResumePractice}
className={css({
padding: '1rem',
fontSize: '1.125rem',
fontWeight: 'bold',
color: 'white',
backgroundColor: 'green.500',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
transition: 'background-color 0.2s ease',
_hover: {
backgroundColor: 'green.600',
},
})}
>
Resume Practice
</button>
{/* Session progress info */}
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
textAlign: 'center',
})}
>
{activeSession.completedCount} of {activeSession.totalCount} problems done
</p>
{/* Secondary session action */}
<button
type="button"
data-action="start-over"
onClick={onStartOver}
disabled={isStartingOver}
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
background: 'none',
border: 'none',
cursor: isStartingOver ? 'wait' : 'pointer',
opacity: isStartingOver ? 0.7 : 1,
textDecoration: 'underline',
marginTop: '0.25rem',
_hover: {
color: isDark ? 'gray.200' : 'gray.700',
},
})}
>
{isStartingOver ? 'Starting over...' : 'Start over'}
</button>
</>
) : (
/* Start new practice button */
<button
type="button"
data-action="start-practice"
onClick={onStartPractice}
className={css({
padding: '1rem',
fontSize: '1.125rem',
@ -279,13 +400,17 @@ export function ProgressDashboard({
},
})}
>
Continue Practice
Start Practice
</button>
)}
</div>
{/* Secondary action buttons */}
<div
className={css({
display: 'flex',
gap: '0.75rem',
width: '100%',
})}
>
<button
@ -332,7 +457,6 @@ export function ProgressDashboard({
Worksheet
</button>
</div>
</div>
{/* Focus Areas - Skills needing extra practice */}
{focusAreas.length > 0 && (

View File

@ -14,7 +14,7 @@ export { ContinueSessionCard } from './ContinueSessionCard'
export { useHasPhysicalKeyboard, useIsTouchDevice } from './hooks/useDeviceDetection'
export { NumericKeypad } from './NumericKeypad'
export { PracticeErrorBoundary } from './PracticeErrorBoundary'
export type { CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
export type { ActiveSessionState, CurrentPhaseInfo, SkillProgress } from './ProgressDashboard'
export { ProgressDashboard } from './ProgressDashboard'
export { SessionSummary } from './SessionSummary'
export type { StudentWithProgress } from './StudentSelector'

View File

@ -206,6 +206,12 @@ export const sessionPlans = sqliteTable(
/** Human-readable summary */
summary: text('summary', { mode: 'json' }).notNull().$type<SessionSummary>(),
/** Skill IDs that were mastered when this session was generated (for mismatch detection) */
masteredSkillIds: text('mastered_skill_ids', { mode: 'json' })
.notNull()
.default('[]')
.$type<string[]>(),
// ---- Session State ----
/** Current status */

View File

@ -14,7 +14,7 @@
*/
import { createId } from '@paralleldrive/cuid2'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { db, schema } from '@/db'
import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
import {
@ -164,6 +164,7 @@ export async function generateSessionPlan(
avgTimePerProblemSeconds: avgTimeSeconds,
parts,
summary,
masteredSkillIds: masteredSkills.map((s) => s.skillId),
status: 'draft',
currentPartIndex: 0,
currentSlotIndex: 0,
@ -291,10 +292,13 @@ function sessionHasPreGeneratedProblems(plan: SessionPlan): boolean {
export async function getActiveSessionPlan(playerId: string): Promise<SessionPlan | null> {
// Find any session that's not completed or abandoned
// This includes: draft, approved, in_progress
// IMPORTANT: Also check completedAt IS NULL to handle inconsistent data
// where status may be in_progress but completedAt is set
const result = await db.query.sessionPlans.findFirst({
where: and(
eq(schema.sessionPlans.playerId, playerId),
inArray(schema.sessionPlans.status, ['draft', 'approved', 'in_progress'])
inArray(schema.sessionPlans.status, ['draft', 'approved', 'in_progress']),
isNull(schema.sessionPlans.completedAt)
),
orderBy: (plans, { desc }) => [desc(plans.createdAt)],
})