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:
parent
245cc269fe
commit
c40543ac64
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue