fix(practice): make session plan page self-sufficient for data loading
- Update useActiveSessionPlan to accept initialData from server props - Page now fetches its own data if cache is empty (no abstraction hole) - Three loading scenarios handled: 1. Cache populated (from ConfigureClient mutation): instant display 2. Cache miss: fetches from API with loading state 3. Direct page load: uses server props as initialData - Add loading view while fetching session plan 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -133,11 +133,14 @@
|
|||||||
"Bash(.claude/CLAUDE.md)",
|
"Bash(.claude/CLAUDE.md)",
|
||||||
"Bash(mcp__sqlite__describe_table:*)",
|
"Bash(mcp__sqlite__describe_table:*)",
|
||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(mcp__sqlite__list_tables:*)"
|
"Bash(mcp__sqlite__list_tables:*)",
|
||||||
|
"Bash(mcp__sqlite__read_query:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
},
|
},
|
||||||
"enableAllProjectMcpServers": true,
|
"enableAllProjectMcpServers": true,
|
||||||
"enabledMcpjsonServers": ["sqlite"]
|
"enabledMcpjsonServers": [
|
||||||
|
"sqlite"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { PracticeSession } from '@/db/schema/practice-sessions'
|
|||||||
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
|
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
|
||||||
import {
|
import {
|
||||||
useAbandonSession,
|
useAbandonSession,
|
||||||
|
useActiveSessionPlan,
|
||||||
useApproveSessionPlan,
|
useApproveSessionPlan,
|
||||||
useEndSessionEarly,
|
useEndSessionEarly,
|
||||||
useGenerateSessionPlan,
|
useGenerateSessionPlan,
|
||||||
@@ -122,13 +123,23 @@ export function StudentPracticeClient({
|
|||||||
const endEarly = useEndSessionEarly()
|
const endEarly = useEndSessionEarly()
|
||||||
const abandonSession = useAbandonSession()
|
const abandonSession = useAbandonSession()
|
||||||
|
|
||||||
// Current plan from mutations or initial active session (use the latest successful result)
|
// Fetch active session plan from cache or API
|
||||||
|
// - If cache has data (from ConfigureClient mutation): uses cache immediately
|
||||||
|
// - If no cache but initialActiveSession exists: uses server props as initial data
|
||||||
|
// - If neither: fetches from API (shows loading state briefly)
|
||||||
|
const {
|
||||||
|
data: fetchedPlan,
|
||||||
|
isLoading: isPlanLoading,
|
||||||
|
} = useActiveSessionPlan(studentId, initialActiveSession)
|
||||||
|
|
||||||
|
// Current plan from mutations or fetched data (priority order)
|
||||||
|
// Mutations take priority (most recent user action), then fetched/cached data
|
||||||
const currentPlan =
|
const currentPlan =
|
||||||
recordResult.data ??
|
recordResult.data ??
|
||||||
startPlan.data ??
|
startPlan.data ??
|
||||||
approvePlan.data ??
|
approvePlan.data ??
|
||||||
generatePlan.data ??
|
generatePlan.data ??
|
||||||
initialActiveSession ??
|
fetchedPlan ??
|
||||||
null
|
null
|
||||||
|
|
||||||
// Derive error state from mutations
|
// Derive error state from mutations
|
||||||
@@ -144,13 +155,16 @@ export function StudentPracticeClient({
|
|||||||
|
|
||||||
// Derive view from session plan state - NO useState!
|
// Derive view from session plan state - NO useState!
|
||||||
// This eliminates the "bastard state" problem where viewState and currentPlan could diverge
|
// This eliminates the "bastard state" problem where viewState and currentPlan could diverge
|
||||||
const sessionView: SessionView = useMemo(() => {
|
const sessionView: SessionView | 'loading' = useMemo(() => {
|
||||||
|
// Show loading only if we're fetching AND don't have any data yet
|
||||||
|
// (mutations or initial data would give us something to show)
|
||||||
|
if (isPlanLoading && !currentPlan) return 'loading'
|
||||||
if (!currentPlan) return 'dashboard'
|
if (!currentPlan) return 'dashboard'
|
||||||
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
|
||||||
}, [currentPlan])
|
}, [currentPlan, isPlanLoading])
|
||||||
|
|
||||||
// Handle continue practice - navigate to configuration page
|
// Handle continue practice - navigate to configuration page
|
||||||
const handleContinuePractice = useCallback(() => {
|
const handleContinuePractice = useCallback(() => {
|
||||||
@@ -437,6 +451,29 @@ export function StudentPracticeClient({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content based on session view (derived from data) */}
|
{/* Content based on session view (derived from data) */}
|
||||||
|
{sessionView === 'loading' && (
|
||||||
|
<div
|
||||||
|
data-section="loading"
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '3rem',
|
||||||
|
gap: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '2rem',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Loading practice session...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{sessionView === 'continue' && currentPlan && (
|
{sessionView === 'continue' && currentPlan && (
|
||||||
<ContinueSessionCard
|
<ContinueSessionCard
|
||||||
studentName={selectedStudent.name}
|
studentName={selectedStudent.name}
|
||||||
|
|||||||
@@ -92,12 +92,24 @@ async function updateSessionPlan({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook: Fetch active session plan for a player
|
* Hook: Fetch active session plan for a player
|
||||||
|
*
|
||||||
|
* @param playerId - The player ID to fetch the session for
|
||||||
|
* @param initialData - Optional initial data from server-side props (avoids loading state on direct page load)
|
||||||
*/
|
*/
|
||||||
export function useActiveSessionPlan(playerId: string | null) {
|
export function useActiveSessionPlan(
|
||||||
|
playerId: string | null,
|
||||||
|
initialData?: SessionPlan | null
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: sessionPlanKeys.active(playerId ?? ''),
|
queryKey: sessionPlanKeys.active(playerId ?? ''),
|
||||||
queryFn: () => fetchActiveSessionPlan(playerId!),
|
queryFn: () => fetchActiveSessionPlan(playerId!),
|
||||||
enabled: !!playerId,
|
enabled: !!playerId,
|
||||||
|
// Use server-provided data as initial cache value
|
||||||
|
// This prevents a loading flash on direct page loads while still allowing refetch
|
||||||
|
initialData: initialData ?? undefined,
|
||||||
|
// Don't refetch on mount if we have initial data - trust the server
|
||||||
|
// The query will still refetch on window focus or after stale time
|
||||||
|
staleTime: initialData ? 30000 : 0, // 30s stale time if we have initial data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user