feat(practice): add /resume route for "Welcome back" experience
- Create /practice/[studentId]/resume route for returning students - Student selector navigates to /resume instead of main practice page - /resume shows "Welcome back" card with session progress - Clicking "Continue" navigates to /practice/[studentId] (goes straight to practice) - Clicking "Start Fresh" abandons session and goes to /configure - Main practice page no longer shows welcome card (goes straight to practicing) - Reloading mid-session stays in practice (no welcome card) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7243502873
commit
7b476e80c1
|
|
@ -35,10 +35,11 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||||
createdAt: player.createdAt,
|
createdAt: player.createdAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Handle student selection - navigate to student's practice page
|
// Handle student selection - navigate to student's resume page
|
||||||
|
// The /resume route shows "Welcome back" for in-progress sessions
|
||||||
const handleSelectStudent = useCallback(
|
const handleSelectStudent = useCallback(
|
||||||
(student: StudentWithProgress) => {
|
(student: StudentWithProgress) => {
|
||||||
router.push(`/practice/${student.id}`, { scroll: false })
|
router.push(`/practice/${student.id}/resume`, { scroll: false })
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ export function StudentPracticeClient({
|
||||||
if (currentPlan.completedAt) return 'summary'
|
if (currentPlan.completedAt) return 'summary'
|
||||||
if (currentPlan.startedAt) return 'practicing'
|
if (currentPlan.startedAt) return 'practicing'
|
||||||
if (currentPlan.approvedAt) return 'reviewing'
|
if (currentPlan.approvedAt) return 'reviewing'
|
||||||
return 'continue' // Plan exists but not yet approved
|
return 'continue' // Plan exists but not yet approved (draft)
|
||||||
}, [currentPlan, isPlanLoading])
|
}, [currentPlan, isPlanLoading])
|
||||||
|
|
||||||
// Handle continue practice - navigate to configuration page
|
// Handle continue practice - navigate to configuration page
|
||||||
|
|
@ -175,9 +175,9 @@ export function StudentPracticeClient({
|
||||||
const handleResumeSession = useCallback(() => {
|
const handleResumeSession = useCallback(() => {
|
||||||
if (!currentPlan) return
|
if (!currentPlan) return
|
||||||
|
|
||||||
// Session already started → view will show 'practicing' automatically
|
// Session already started → navigate to main practice page (no ?returning)
|
||||||
if (currentPlan.startedAt) {
|
if (currentPlan.startedAt) {
|
||||||
// No action needed - sessionView is already 'practicing'
|
router.push(`/practice/${studentId}`, { scroll: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ export function StudentPracticeClient({
|
||||||
// Draft (not approved) → need to approve it first
|
// Draft (not approved) → need to approve it first
|
||||||
// This will update sessionView to 'reviewing'
|
// This will update sessionView to 'reviewing'
|
||||||
approvePlan.mutate({ playerId: studentId, planId: currentPlan.id })
|
approvePlan.mutate({ playerId: studentId, planId: currentPlan.id })
|
||||||
}, [currentPlan, studentId, startPlan, approvePlan])
|
}, [currentPlan, studentId, startPlan, approvePlan, router])
|
||||||
|
|
||||||
// Handle starting fresh (abandon current session)
|
// Handle starting fresh (abandon current session)
|
||||||
const handleStartFresh = useCallback(() => {
|
const handleStartFresh = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { PageWithNav } from '@/components/PageWithNav'
|
||||||
|
import { ContinueSessionCard } from '@/components/practice'
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
|
import type { Player } from '@/db/schema/players'
|
||||||
|
import type { SessionPlan } from '@/db/schema/session-plans'
|
||||||
|
import { useAbandonSession } from '@/hooks/useSessionPlan'
|
||||||
|
import { css } from '../../../../../styled-system/css'
|
||||||
|
|
||||||
|
interface ResumeClientProps {
|
||||||
|
studentId: string
|
||||||
|
player: Player
|
||||||
|
session: SessionPlan
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client component for the Resume page
|
||||||
|
*
|
||||||
|
* Shows the "Welcome back" card for students returning to an in-progress session.
|
||||||
|
*/
|
||||||
|
export function ResumeClient({ studentId, player, session }: ResumeClientProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
const abandonSession = useAbandonSession()
|
||||||
|
|
||||||
|
// Handle continuing the session - navigate to main practice page
|
||||||
|
const handleContinue = useCallback(() => {
|
||||||
|
router.push(`/practice/${studentId}`, { scroll: false })
|
||||||
|
}, [studentId, router])
|
||||||
|
|
||||||
|
// Handle starting fresh - abandon current session and go to configure
|
||||||
|
const handleStartFresh = useCallback(() => {
|
||||||
|
abandonSession.mutate(
|
||||||
|
{ playerId: studentId, planId: session.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push(`/practice/${studentId}/configure`, { scroll: false })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, [studentId, session.id, abandonSession, router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWithNav>
|
||||||
|
<main
|
||||||
|
data-component="resume-practice-page"
|
||||||
|
className={css({
|
||||||
|
minHeight: '100vh',
|
||||||
|
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||||
|
paddingTop: 'calc(80px + 2rem)',
|
||||||
|
paddingLeft: '2rem',
|
||||||
|
paddingRight: '2rem',
|
||||||
|
paddingBottom: '2rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<header
|
||||||
|
className={css({
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: '2rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className={css({
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isDark ? 'white' : 'gray.800',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Daily Practice
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: '1rem',
|
||||||
|
color: isDark ? 'gray.400' : 'gray.600',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Build your soroban skills one step at a time
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Continue Session Card */}
|
||||||
|
<ContinueSessionCard
|
||||||
|
studentName={player.name}
|
||||||
|
studentEmoji={player.emoji}
|
||||||
|
studentColor={player.color}
|
||||||
|
session={session}
|
||||||
|
onContinue={handleContinue}
|
||||||
|
onStartFresh={handleStartFresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</PageWithNav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
|
||||||
|
import { ResumeClient } from './ResumeClient'
|
||||||
|
|
||||||
|
// Disable caching for this page - session state must always be fresh
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface ResumePageProps {
|
||||||
|
params: Promise<{ studentId: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume Session Page - Server Component
|
||||||
|
*
|
||||||
|
* Shows "Welcome back" card for students returning to an in-progress session.
|
||||||
|
* If no active session exists, redirects to the main practice page.
|
||||||
|
*
|
||||||
|
* URL: /practice/[studentId]/resume
|
||||||
|
*/
|
||||||
|
export default async function ResumePage({ params }: ResumePageProps) {
|
||||||
|
const { studentId } = await params
|
||||||
|
|
||||||
|
// Fetch player and active session in parallel
|
||||||
|
const [player, activeSession] = await Promise.all([
|
||||||
|
getPlayer(studentId),
|
||||||
|
getActiveSessionPlan(studentId),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 404 if player doesn't exist
|
||||||
|
if (!player) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active session → redirect to main practice page (shows dashboard)
|
||||||
|
if (!activeSession) {
|
||||||
|
redirect(`/practice/${studentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is completed → redirect to main practice page (shows summary)
|
||||||
|
if (activeSession.completedAt) {
|
||||||
|
redirect(`/practice/${studentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResumeClient
|
||||||
|
studentId={studentId}
|
||||||
|
player={player}
|
||||||
|
session={activeSession}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue