feat(worksheet-parsing): add parsing UI and fix parent access control

Worksheet Parsing UI (Slices 1-2):
- Add parse button to OfflineWorkSection thumbnails and PhotoViewerEditor
- Create ParsedProblemsList component to display extracted problems
- Add useWorksheetParsing hook with mutations for parse/review/approve
- Add attachmentKeys to queryKeys for cache management
- Wire up parsing workflow in SummaryClient

Fix parent upload access:
- Change /api/players/[id]/access to use getDbUserId() instead of getViewerId()
- Guest users' guestId was not matching parent_child.parent_user_id
- Parents can now see upload/camera buttons in offline work section

Fix curriculum type errors:
- Add missing 'advanced' property to createFullSkillSet()
- Fix enabledRequiredSkills -> enabledAllowedSkills in problem-generator
- Remove incorrect Partial<> wrapper from type casts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-02 14:10:29 -06:00
parent 5a4c751ebe
commit 91aaddbeab
25 changed files with 1721 additions and 175 deletions

View File

@ -21,10 +21,7 @@ import {
} from '@/db/schema/session-plans'
import { canPerformAction } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
import {
convertToSlotResults,
computeParsingStats,
} from '@/lib/worksheet-parsing'
import { convertToSlotResults, computeParsingStats } from '@/lib/worksheet-parsing'
interface RouteParams {
params: Promise<{ playerId: string; attachmentId: string }>
@ -65,18 +62,24 @@ export async function POST(_request: Request, { params }: RouteParams) {
// Check if already created a session
if (attachment.sessionCreated) {
return NextResponse.json({
error: 'Session already created from this attachment',
sessionId: attachment.createdSessionId,
}, { status: 400 })
return NextResponse.json(
{
error: 'Session already created from this attachment',
sessionId: attachment.createdSessionId,
},
{ status: 400 }
)
}
// Get the parsing result to convert (prefer approved result, fall back to raw)
const parsingResult = attachment.approvedResult ?? attachment.rawParsingResult
if (!parsingResult) {
return NextResponse.json({
error: 'No parsing results available. Parse the worksheet first.',
}, { status: 400 })
return NextResponse.json(
{
error: 'No parsing results available. Parse the worksheet first.',
},
{ status: 400 }
)
}
// Convert to slot results
@ -86,9 +89,12 @@ export async function POST(_request: Request, { params }: RouteParams) {
})
if (conversionResult.slotResults.length === 0) {
return NextResponse.json({
error: 'No valid problems to create session from',
}, { status: 400 })
return NextResponse.json(
{
error: 'No valid problems to create session from',
},
{ status: 400 }
)
}
// Create the session with completed status

View File

@ -119,7 +119,8 @@ export async function POST(_request: Request, { params }: RouteParams) {
usage: result.usage,
})
} catch (parseError) {
const errorMessage = parseError instanceof Error ? parseError.message : 'Unknown parsing error'
const errorMessage =
parseError instanceof Error ? parseError.message : 'Unknown parsing error'
console.error('Worksheet parsing error:', parseError)
// Update status to failed
@ -131,11 +132,14 @@ export async function POST(_request: Request, { params }: RouteParams) {
})
.where(eq(practiceAttachments.id, attachmentId))
return NextResponse.json({
success: false,
status: 'failed',
error: errorMessage,
}, { status: 500 })
return NextResponse.json(
{
success: false,
status: 'failed',
error: errorMessage,
},
{ status: 500 }
)
}
} catch (error) {
console.error('Error starting parse:', error)

View File

@ -53,10 +53,13 @@ export async function PATCH(request: Request, { params }: RouteParams) {
const body = await request.json()
const parseResult = ReviewRequestSchema.safeParse(body)
if (!parseResult.success) {
return NextResponse.json({
error: 'Invalid request body',
details: parseResult.error.issues,
}, { status: 400 })
return NextResponse.json(
{
error: 'Invalid request body',
details: parseResult.error.issues,
},
{ status: 400 }
)
}
const { corrections, markAsReviewed } = parseResult.data
@ -78,9 +81,12 @@ export async function PATCH(request: Request, { params }: RouteParams) {
// Check if we have parsing results to correct
if (!attachment.rawParsingResult) {
return NextResponse.json({
error: 'No parsing results to correct. Parse the worksheet first.',
}, { status: 400 })
return NextResponse.json(
{
error: 'No parsing results to correct. Parse the worksheet first.',
},
{ status: 400 }
)
}
// Apply corrections to the raw result

View File

@ -18,7 +18,7 @@ import { join } from 'path'
import { and, eq } from 'drizzle-orm'
import { db } from '@/db'
import { practiceAttachments, sessionPlans } from '@/db/schema'
import { canPerformAction } from '@/lib/classroom'
import { getPlayerAccess, generateAuthorizationError } from '@/lib/classroom'
import { getDbUserId } from '@/lib/viewer'
import { createId } from '@paralleldrive/cuid2'
@ -37,6 +37,16 @@ export interface SessionAttachment {
originalUrl: string | null
corners: Array<{ x: number; y: number }> | null
rotation: 0 | 90 | 180 | 270
// Parsing fields
parsingStatus: string | null
parsedAt: string | null
parsingError: string | null
rawParsingResult: unknown | null
approvedResult: unknown | null
confidenceScore: number | null
needsReview: boolean
sessionCreated: boolean
createdSessionId: string | null
}
/**
@ -52,9 +62,12 @@ export async function GET(_request: Request, { params }: RouteParams) {
// Authorization check
const userId = await getDbUserId()
const canView = await canPerformAction(userId, playerId, 'view')
if (!canView) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
const access = await getPlayerAccess(userId, playerId)
if (access.accessLevel === 'none') {
const authError = generateAuthorizationError(access, 'view', {
actionDescription: 'view attachments for this student',
})
return NextResponse.json(authError, { status: 403 })
}
// Get attachments for this session
@ -84,6 +97,16 @@ export async function GET(_request: Request, { params }: RouteParams) {
: null,
corners: att.corners ?? null,
rotation: (att.rotation ?? 0) as 0 | 90 | 180 | 270,
// Parsing fields
parsingStatus: att.parsingStatus ?? null,
parsedAt: att.parsedAt ?? null,
parsingError: att.parsingError ?? null,
rawParsingResult: att.rawParsingResult ?? null,
approvedResult: att.approvedResult ?? null,
confidenceScore: att.confidenceScore ?? null,
needsReview: att.needsReview === true,
sessionCreated: att.sessionCreated === true,
createdSessionId: att.createdSessionId ?? null,
}))
return NextResponse.json({ attachments: result })
@ -104,11 +127,18 @@ export async function POST(request: Request, { params }: RouteParams) {
return NextResponse.json({ error: 'Player ID and Session ID required' }, { status: 400 })
}
// Authorization check - require 'start-session' permission (parent or present teacher)
// Authorization check - require 'view' permission (parent, teacher-enrolled, or teacher-present)
// Adding photos to an existing session is less sensitive than starting a new session
const userId = await getDbUserId()
const canAdd = await canPerformAction(userId, playerId, 'start-session')
if (!canAdd) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
const access = await getPlayerAccess(userId, playerId)
if (access.accessLevel === 'none') {
console.error(
`[attachments POST] Authorization failed: userId=${userId} has no access to playerId=${playerId}`
)
const authError = generateAuthorizationError(access, 'view', {
actionDescription: 'add photos for this student',
})
return NextResponse.json(authError, { status: 403 })
}
// Verify session exists and belongs to player
@ -257,6 +287,16 @@ export async function POST(request: Request, { params }: RouteParams) {
: null,
corners,
rotation,
// New attachments have no parsing data yet
parsingStatus: null,
parsedAt: null,
parsingError: null,
rawParsingResult: null,
approvedResult: null,
confidenceScore: null,
needsReview: false,
sessionCreated: false,
createdSessionId: null,
})
}

View File

@ -1,6 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getPlayerAccess } from '@/lib/classroom'
import { getViewerId } from '@/lib/viewer'
import { getDbUserId } from '@/lib/viewer'
interface RouteParams {
params: Promise<{ id: string }>
@ -10,12 +10,14 @@ interface RouteParams {
* GET /api/players/[id]/access
* Check access level for specific player
*
* Returns: { accessLevel, isParent, isTeacher, isPresent }
* Returns: { accessLevel, isParent, isTeacher, isPresent, classroomId? }
*/
export async function GET(req: NextRequest, { params }: RouteParams) {
try {
const { id: playerId } = await params
const viewerId = await getViewerId()
// Use getDbUserId() to get the database user.id, not the guestId
// This is required because parent_child links to user.id
const viewerId = await getDbUserId()
const access = await getPlayerAccess(viewerId, playerId)
@ -24,6 +26,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
isParent: access.isParent,
isTeacher: access.isTeacher,
isPresent: access.isPresent,
classroomId: access.classroomId,
})
} catch (error) {
console.error('Failed to check player access:', error)

View File

@ -8,6 +8,7 @@ import {
AllProblemsSection,
ContentBannerSlot,
OfflineWorkSection,
type OfflineAttachment,
PhotoViewerEditor,
type PhotoViewerEditorPhoto,
PracticeSubNav,
@ -19,6 +20,7 @@ import {
SkillsPanel,
StartPracticeModal,
} from '@/components/practice'
import type { ParsingStatus } from '@/db/schema/practice-attachments'
import { calculateAutoPauseInfo } from '@/components/practice/autoPauseCalculator'
import { DocumentAdjuster } from '@/components/practice/DocumentAdjuster'
import { useDocumentDetection } from '@/components/practice/useDocumentDetection'
@ -36,9 +38,12 @@ import { VisualDebugProvider } from '@/contexts/VisualDebugContext'
import type { Player } from '@/db/schema/players'
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { useSessionMode } from '@/hooks/useSessionMode'
import { usePlayerAccess, canUploadPhotos } from '@/hooks/usePlayerAccess'
import { useStartParsing } from '@/hooks/useWorksheetParsing'
import { computeBktFromHistory, type SkillBktResult } from '@/lib/curriculum/bkt'
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
import { api } from '@/lib/queryClient'
import { attachmentKeys } from '@/lib/queryKeys'
import { css } from '../../../../../styled-system/css'
// Combined height of sticky elements above content area
@ -130,29 +135,55 @@ export function SummaryClient({
// Session mode - single source of truth for session planning decisions
const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId)
// Player access - pre-flight authorization check for upload capability
const { data: playerAccess } = usePlayerAccess(studentId)
const canUpload = canUploadPhotos(playerAccess)
const queryClient = useQueryClient()
// Fetch attachments for this session
// Type for session attachment from API
interface SessionAttachmentResponse {
id: string
url: string
originalUrl: string | null
corners: Array<{ x: number; y: number }> | null
rotation: 0 | 90 | 180 | 270
// Parsing fields
parsingStatus: string | null
parsedAt: string | null
parsingError: string | null
rawParsingResult: object | null
approvedResult: object | null
confidenceScore: number | null
needsReview: boolean
sessionCreated: boolean
createdSessionId: string | null
}
// Fetch attachments for this session (includes parsing data)
const { data: attachmentsData } = useQuery({
queryKey: ['session-attachments', studentId, session?.id],
queryFn: async () => {
queryKey: session?.id ? attachmentKeys.session(studentId, session.id) : ['no-session'],
queryFn: async (): Promise<{ attachments: SessionAttachmentResponse[] }> => {
if (!session?.id) return { attachments: [] }
const res = await api(`curriculum/${studentId}/sessions/${session.id}/attachments`)
if (!res.ok) return { attachments: [] }
return res.json() as Promise<{
attachments: Array<{
id: string
url: string
originalUrl: string | null
corners: Array<{ x: number; y: number }> | null
rotation: 0 | 90 | 180 | 270
}>
}>
return res.json() as Promise<{ attachments: SessionAttachmentResponse[] }>
},
enabled: !!session?.id,
})
const attachments = attachmentsData?.attachments ?? []
// Map API response to OfflineAttachment type (cast parsingStatus and rawParsingResult)
const attachments: OfflineAttachment[] = (attachmentsData?.attachments ?? []).map((att) => ({
id: att.id,
url: att.url,
parsingStatus: att.parsingStatus as ParsingStatus | null,
rawParsingResult: att.rawParsingResult as OfflineAttachment['rawParsingResult'],
needsReview: att.needsReview,
sessionCreated: att.sessionCreated,
}))
// Worksheet parsing mutation
const startParsing = useStartParsing(studentId, session?.id ?? '')
const hasPhotos = attachments.length > 0
const isInProgress = session?.startedAt && !session?.completedAt
@ -595,8 +626,13 @@ export function SummaryClient({
isUploading={isUploading}
uploadError={uploadError}
deletingId={deletingId}
parsingId={startParsing.isPending ? (startParsing.variables ?? null) : null}
dragOver={dragOver}
isDark={isDark}
canUpload={canUpload}
studentId={studentId}
studentName={player.name}
classroomId={playerAccess?.classroomId}
onFileSelect={handleFileSelect}
onDrop={handleDrop}
onDragOver={handleDragOver}
@ -604,6 +640,7 @@ export function SummaryClient({
onOpenCamera={() => setShowCamera(true)}
onOpenViewer={openViewer}
onDeletePhoto={deletePhoto}
onParse={(attachmentId) => startParsing.mutate(attachmentId)}
/>
{/* All Problems - complete session listing */}
{hasProblems && (
@ -690,18 +727,24 @@ export function SummaryClient({
{/* Photo Viewer/Editor */}
<PhotoViewerEditor
photos={attachments.map((att) => ({
photos={(attachmentsData?.attachments ?? []).map((att) => ({
id: att.id,
url: att.url,
originalUrl: att.originalUrl,
corners: att.corners,
rotation: att.rotation,
parsingStatus: att.parsingStatus as PhotoViewerEditorPhoto['parsingStatus'],
problemCount: (att.rawParsingResult as { problems?: unknown[] } | null)?.problems
?.length,
sessionCreated: att.sessionCreated,
}))}
initialIndex={viewerIndex}
initialMode={viewerMode}
isOpen={viewerOpen}
onClose={() => setViewerOpen(false)}
onEditConfirm={handlePhotoEditConfirm}
onParse={(attachmentId) => startParsing.mutate(attachmentId)}
parsingPhotoId={startParsing.isPending ? (startParsing.variables ?? null) : null}
/>
{/* Fullscreen Camera Modal */}

View File

@ -485,10 +485,7 @@ export function SessionObserverView({
<div
className={css({
flex: 1,
padding:
variant === 'page'
? { base: '12px', md: '28px' }
: { base: '12px', md: '24px' },
padding: variant === 'page' ? { base: '12px', md: '28px' } : { base: '12px', md: '24px' },
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',

View File

@ -1,12 +1,23 @@
'use client'
import { useCallback, useState } from 'react'
import type { RefObject } from 'react'
import { useMutation } from '@tanstack/react-query'
import type { ParsingStatus } from '@/db/schema/practice-attachments'
import type { WorksheetParsingResult } from '@/lib/worksheet-parsing'
import { api } from '@/lib/queryClient'
import { css } from '../../../styled-system/css'
import { ParsedProblemsList } from '../worksheet-parsing'
export interface OfflineAttachment {
id: string
url: string
filename?: string
// Parsing fields
parsingStatus?: ParsingStatus | null
rawParsingResult?: WorksheetParsingResult | null
needsReview?: boolean
sessionCreated?: boolean
}
export interface OfflineWorkSectionProps {
@ -20,10 +31,20 @@ export interface OfflineWorkSectionProps {
uploadError: string | null
/** ID of photo being deleted */
deletingId: string | null
/** ID of photo currently being parsed */
parsingId: string | null
/** Whether drag is over the drop zone */
dragOver: boolean
/** Dark mode */
isDark: boolean
/** Whether the user can upload photos (pre-flight auth check) */
canUpload?: boolean
/** Student ID for entry prompt */
studentId?: string
/** Student name for remediation message */
studentName?: string
/** Classroom ID for entry prompt (when canUpload is false) */
classroomId?: string
/** Handlers */
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
onDrop: (e: React.DragEvent) => void
@ -33,6 +54,8 @@ export interface OfflineWorkSectionProps {
/** Open photo viewer/editor at index with specified mode */
onOpenViewer: (index: number, mode: 'view' | 'edit') => void
onDeletePhoto: (id: string) => void
/** Start parsing a worksheet photo */
onParse?: (id: string) => void
}
/**
@ -50,8 +73,13 @@ export function OfflineWorkSection({
isUploading,
uploadError,
deletingId,
parsingId,
dragOver,
isDark,
canUpload = true,
studentId,
studentName,
classroomId,
onFileSelect,
onDrop,
onDragOver,
@ -59,10 +87,57 @@ export function OfflineWorkSection({
onOpenCamera,
onOpenViewer,
onDeletePhoto,
onParse,
}: OfflineWorkSectionProps) {
const photoCount = attachments.length
// Show add tile unless we have 8+ photos (max reasonable gallery size)
const showAddTile = photoCount < 8
// Also only show if user can upload
const showAddTile = photoCount < 8 && canUpload
// Show remediation when user can't upload but is a teacher with enrolled student
const showRemediation = !canUpload && classroomId && studentId
// Entry prompt state (for teachers who need student to enter classroom)
const [promptSent, setPromptSent] = useState(false)
// Mutation for sending entry prompt
const sendEntryPrompt = useMutation({
mutationFn: async (playerId: string) => {
if (!classroomId) throw new Error('No classroom ID')
const response = await api(`classrooms/${classroomId}/entry-prompts`, {
method: 'POST',
body: JSON.stringify({ playerIds: [playerId] }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to send prompt')
}
return response.json()
},
onSuccess: (data) => {
if (data.created > 0) {
setPromptSent(true)
}
},
})
const handleSendEntryPrompt = useCallback(() => {
if (studentId) {
sendEntryPrompt.mutate(studentId)
}
}, [sendEntryPrompt, studentId])
// Find all attachments with parsing results
const parsedAttachments = attachments.filter(
(att) =>
att.rawParsingResult?.problems &&
att.rawParsingResult.problems.length > 0 &&
(att.parsingStatus === 'needs_review' || att.parsingStatus === 'approved')
)
// Track which parsed result is currently expanded (default to first one)
const [expandedResultId, setExpandedResultId] = useState<string | null>(
parsedAttachments[0]?.id ?? null
)
return (
<div
@ -294,6 +369,115 @@ export function OfflineWorkSection({
>
{index + 1}
</div>
{/* Parse button - show if not parsed yet OR if failed (to allow retry) */}
{onParse &&
(!att.parsingStatus || att.parsingStatus === 'failed') &&
!att.sessionCreated && (
<button
type="button"
data-action="parse-worksheet"
onClick={(e) => {
e.stopPropagation()
onParse(att.id)
}}
disabled={parsingId === att.id}
className={css({
position: 'absolute',
bottom: '0.5rem',
right: '0.5rem',
height: '24px',
paddingX: '0.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
backgroundColor: att.parsingStatus === 'failed' ? 'orange.500' : 'blue.500',
color: 'white',
borderRadius: 'full',
border: 'none',
cursor: 'pointer',
fontSize: '0.6875rem',
fontWeight: '600',
transition: 'background-color 0.2s',
_hover: {
backgroundColor: att.parsingStatus === 'failed' ? 'orange.600' : 'blue.600',
},
_disabled: {
backgroundColor: 'gray.400',
cursor: 'wait',
},
})}
aria-label={att.parsingStatus === 'failed' ? 'Retry parsing' : 'Parse worksheet'}
>
{parsingId === att.id ? '⏳' : att.parsingStatus === 'failed' ? '🔄' : '🔍'}{' '}
{att.parsingStatus === 'failed' ? 'Retry' : 'Parse'}
</button>
)}
{/* Parsing status badge - don't show for 'failed' since retry button is shown instead */}
{att.parsingStatus && att.parsingStatus !== 'failed' && (
<div
data-element="parsing-status"
className={css({
position: 'absolute',
bottom: '0.5rem',
right: '0.5rem',
height: '24px',
paddingX: '0.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
borderRadius: 'full',
fontSize: '0.6875rem',
fontWeight: '600',
backgroundColor:
att.parsingStatus === 'processing'
? 'blue.500'
: att.parsingStatus === 'needs_review'
? 'yellow.500'
: att.parsingStatus === 'approved'
? 'green.500'
: 'gray.500',
color: att.parsingStatus === 'needs_review' ? 'yellow.900' : 'white',
})}
>
{att.parsingStatus === 'processing' && '⏳'}
{att.parsingStatus === 'needs_review' && '⚠️'}
{att.parsingStatus === 'approved' && '✓'}
{att.parsingStatus === 'processing'
? 'Analyzing...'
: att.parsingStatus === 'needs_review'
? `${att.rawParsingResult?.problems?.length ?? '?'} problems`
: att.parsingStatus === 'approved'
? `${att.rawParsingResult?.problems?.length ?? '?'} problems`
: att.parsingStatus}
</div>
)}
{/* Session created indicator */}
{att.sessionCreated && (
<div
data-element="session-created"
className={css({
position: 'absolute',
bottom: '0.5rem',
right: '0.5rem',
height: '24px',
paddingX: '0.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
borderRadius: 'full',
fontSize: '0.6875rem',
fontWeight: '600',
backgroundColor: 'green.600',
color: 'white',
})}
>
Session Created
</div>
)}
</div>
))}
@ -417,24 +601,219 @@ export function OfflineWorkSection({
)}
</div>
{/* Coming Soon footer - subtle, integrated */}
<div
data-element="coming-soon-hint"
className={css({
marginTop: '1rem',
paddingTop: '0.75rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
color: isDark ? 'gray.500' : 'gray.500',
fontSize: '0.8125rem',
})}
>
<span>🔮</span>
<span>Coming soon: Auto-analyze worksheets to track progress</span>
</div>
{/* Remediation banner - shown when teacher can't upload because student isn't present */}
{showRemediation && (
<div
data-element="upload-remediation"
className={css({
marginTop: '1rem',
padding: '1rem',
backgroundColor: isDark ? 'orange.900/30' : 'orange.50',
border: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.300',
borderRadius: '12px',
})}
>
{!promptSent ? (
<>
<h4
className={css({
fontSize: '0.9375rem',
fontWeight: '600',
color: isDark ? 'orange.300' : 'orange.700',
marginBottom: '0.5rem',
})}
>
{studentName || 'This student'} is not in your classroom
</h4>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '1rem',
lineHeight: '1.5',
})}
>
To upload photos for {studentName || 'this student'}, they need to enter your
classroom first. Send a notification to their parent to have them join.
</p>
<button
type="button"
onClick={handleSendEntryPrompt}
disabled={sendEntryPrompt.isPending}
data-action="send-entry-prompt"
className={css({
padding: '0.625rem 1rem',
fontSize: '0.875rem',
fontWeight: '600',
color: 'white',
backgroundColor: isDark ? 'orange.600' : 'orange.500',
borderRadius: '8px',
border: 'none',
cursor: sendEntryPrompt.isPending ? 'wait' : 'pointer',
opacity: sendEntryPrompt.isPending ? 0.7 : 1,
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'orange.500' : 'orange.600',
},
_disabled: {
cursor: 'wait',
opacity: 0.7,
},
})}
>
{sendEntryPrompt.isPending ? 'Sending...' : 'Send Entry Prompt'}
</button>
</>
) : (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span
className={css({
fontSize: '1.25rem',
})}
>
</span>
<p
className={css({
fontSize: '0.9375rem',
fontWeight: '500',
color: isDark ? 'green.300' : 'green.700',
})}
>
Entry prompt sent to {studentName || 'the student'}&apos;s parent
</p>
</div>
)}
</div>
)}
{/* Parsing hint footer - only show if no parsed results yet */}
{onParse && parsedAttachments.length === 0 && (
<div
data-element="parsing-hint"
className={css({
marginTop: '1rem',
paddingTop: '0.75rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
color: isDark ? 'gray.400' : 'gray.600',
fontSize: '0.8125rem',
})}
>
<span></span>
<span>
Click &ldquo;Parse&rdquo; on any photo to auto-extract problems from worksheets
</span>
</div>
)}
{/* Parsed Results Section - show when any photo has parsing results */}
{parsedAttachments.length > 0 && (
<div
data-element="parsed-results-section"
className={css({
marginTop: '1rem',
paddingTop: '1rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Section header with photo selector if multiple parsed photos */}
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '0.75rem',
})}
>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span>📊</span>
Extracted Problems
</h4>
{/* Photo selector tabs when multiple parsed photos */}
{parsedAttachments.length > 1 && (
<div
className={css({
display: 'flex',
gap: '0.25rem',
})}
>
{parsedAttachments.map((att, index) => {
const photoIndex = attachments.findIndex((a) => a.id === att.id)
return (
<button
key={att.id}
type="button"
onClick={() => setExpandedResultId(att.id)}
className={css({
px: 2,
py: 1,
fontSize: '0.75rem',
fontWeight: '500',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s',
backgroundColor:
expandedResultId === att.id
? isDark
? 'blue.600'
: 'blue.500'
: isDark
? 'gray.700'
: 'gray.100',
color:
expandedResultId === att.id ? 'white' : isDark ? 'gray.300' : 'gray.700',
_hover: {
backgroundColor:
expandedResultId === att.id
? isDark
? 'blue.500'
: 'blue.600'
: isDark
? 'gray.600'
: 'gray.200',
},
})}
>
Photo {photoIndex + 1}
</button>
)
})}
</div>
)}
</div>
{/* Show the selected parsed result */}
{parsedAttachments.map((att) => {
if (att.id !== expandedResultId) return null
if (!att.rawParsingResult) return null
return <ParsedProblemsList key={att.id} result={att.rawParsingResult} isDark={isDark} />
})}
</div>
)}
</div>
)
}

View File

@ -13,6 +13,12 @@ export interface PhotoViewerEditorPhoto {
originalUrl: string | null
corners: Array<{ x: number; y: number }> | null
rotation: 0 | 90 | 180 | 270
/** Parsing status for this photo */
parsingStatus?: 'pending' | 'processing' | 'needs_review' | 'approved' | 'failed' | null
/** Number of problems found (if parsed) */
problemCount?: number
/** Whether a session was created from this photo */
sessionCreated?: boolean
}
interface PhotoViewerEditorProps {
@ -33,6 +39,10 @@ interface PhotoViewerEditorProps {
corners: Array<{ x: number; y: number }>,
rotation: 0 | 90 | 180 | 270
) => Promise<void>
/** Callback to parse worksheet (optional - if not provided, no parse button shown) */
onParse?: (photoId: string) => void
/** ID of the photo currently being parsed (null if none) */
parsingPhotoId?: string | null
}
/**
@ -50,6 +60,8 @@ export function PhotoViewerEditor({
isOpen,
onClose,
onEditConfirm,
onParse,
parsingPhotoId = null,
}: PhotoViewerEditorProps): ReactNode {
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const [mode, setMode] = useState<'view' | 'edit'>(initialMode)
@ -359,51 +371,172 @@ export function PhotoViewerEditor({
×
</button>
{/* Edit button */}
<button
type="button"
data-action="enter-edit-mode"
onClick={(e) => {
e.stopPropagation()
handleEnterEditMode()
}}
disabled={isLoadingOriginal || !isDetectionReady}
{/* Toolbar - Edit and Parse buttons */}
<div
data-element="viewer-toolbar"
className={css({
position: 'absolute',
top: '1rem',
left: '1rem',
px: 4,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
fontSize: 'sm',
fontWeight: 'medium',
color: 'white',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
border: 'none',
borderRadius: 'lg',
cursor: 'pointer',
transition: 'background-color 0.2s',
_hover: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
aria-label="Edit photo"
onClick={(e) => e.stopPropagation()}
>
{isLoadingOriginal ? (
'Loading...'
) : (
<>
<span></span>
<span>Edit</span>
</>
{/* Edit button */}
<button
type="button"
data-action="enter-edit-mode"
onClick={handleEnterEditMode}
disabled={isLoadingOriginal || !isDetectionReady}
className={css({
px: 4,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
fontSize: 'sm',
fontWeight: 'medium',
color: 'white',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
border: 'none',
borderRadius: 'lg',
cursor: 'pointer',
transition: 'background-color 0.2s',
_hover: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
aria-label="Edit photo"
>
{isLoadingOriginal ? (
'Loading...'
) : (
<>
<span></span>
<span>Edit</span>
</>
)}
</button>
{/* Parse button - show if not parsed OR if failed (to allow retry) */}
{onParse &&
(!currentPhoto.parsingStatus || currentPhoto.parsingStatus === 'failed') &&
!currentPhoto.sessionCreated && (
<button
type="button"
data-action="parse-worksheet"
onClick={() => onParse(currentPhoto.id)}
disabled={parsingPhotoId === currentPhoto.id}
className={css({
px: 4,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
fontSize: 'sm',
fontWeight: 'medium',
color: 'white',
backgroundColor:
currentPhoto.parsingStatus === 'failed' ? 'orange.500' : 'blue.500',
border: 'none',
borderRadius: 'lg',
cursor: 'pointer',
transition: 'background-color 0.2s',
_hover: {
backgroundColor:
currentPhoto.parsingStatus === 'failed' ? 'orange.600' : 'blue.600',
},
_disabled: {
backgroundColor: 'gray.500',
cursor: 'wait',
},
})}
aria-label={
currentPhoto.parsingStatus === 'failed' ? 'Retry parsing' : 'Parse worksheet'
}
>
{parsingPhotoId === currentPhoto.id ? (
<>
<span></span>
<span>Analyzing...</span>
</>
) : currentPhoto.parsingStatus === 'failed' ? (
<>
<span>🔄</span>
<span>Retry Parse</span>
</>
) : (
<>
<span>🔍</span>
<span>Parse Worksheet</span>
</>
)}
</button>
)}
{/* Parsing status badge - don't show for 'failed' since retry button is shown instead */}
{currentPhoto.parsingStatus && currentPhoto.parsingStatus !== 'failed' && (
<div
data-element="parsing-status-badge"
className={css({
px: 4,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
fontSize: 'sm',
fontWeight: 'medium',
borderRadius: 'lg',
backgroundColor:
currentPhoto.parsingStatus === 'processing'
? 'blue.500'
: currentPhoto.parsingStatus === 'needs_review'
? 'yellow.500'
: currentPhoto.parsingStatus === 'approved'
? 'green.500'
: 'gray.500',
color: currentPhoto.parsingStatus === 'needs_review' ? 'yellow.900' : 'white',
})}
>
{currentPhoto.parsingStatus === 'processing' && '⏳'}
{currentPhoto.parsingStatus === 'needs_review' && '⚠️'}
{currentPhoto.parsingStatus === 'approved' && '✓'}
{currentPhoto.parsingStatus === 'processing'
? 'Analyzing...'
: currentPhoto.parsingStatus === 'needs_review'
? `${currentPhoto.problemCount ?? '?'} problems (needs review)`
: currentPhoto.parsingStatus === 'approved'
? `${currentPhoto.problemCount ?? '?'} problems`
: currentPhoto.parsingStatus}
</div>
)}
</button>
{/* Session created badge */}
{currentPhoto.sessionCreated && (
<div
data-element="session-created-badge"
className={css({
px: 4,
py: 2,
display: 'flex',
alignItems: 'center',
gap: 2,
fontSize: 'sm',
fontWeight: 'medium',
borderRadius: 'lg',
backgroundColor: 'green.600',
color: 'white',
})}
>
Session Created
</div>
)}
</div>
{/* Previous button */}
{hasMultiple && (

View File

@ -0,0 +1,401 @@
'use client'
/**
* ParsedProblemsList - Displays extracted problems from worksheet parsing
*
* Shows a compact list of parsed problems with:
* - Problem number and terms (e.g., "45 + 27")
* - Student answer with correct/incorrect indicator
* - Low confidence highlighting
* - Needs review badge
*/
import { css } from '../../../styled-system/css'
import type { ParsedProblem, WorksheetParsingResult } from '@/lib/worksheet-parsing'
export interface ParsedProblemsListProps {
/** The parsed result from worksheet parsing */
result: WorksheetParsingResult
/** Whether to use dark mode styling */
isDark: boolean
/** Optional callback when a problem is clicked (for highlighting on image) */
onProblemClick?: (problem: ParsedProblem) => void
/** Currently selected problem index (for highlighting) */
selectedProblemIndex?: number | null
/** Threshold below which confidence is considered "low" */
lowConfidenceThreshold?: number
}
/**
* Format terms into a readable string like "45 + 27 - 12"
*/
function formatTerms(terms: number[]): string {
if (terms.length === 0) return ''
if (terms.length === 1) return terms[0].toString()
return terms
.map((term, i) => {
if (i === 0) return term.toString()
if (term >= 0) return `+ ${term}`
return `- ${Math.abs(term)}`
})
.join(' ')
}
/**
* Get the minimum confidence for a problem (either terms or student answer)
*/
function getMinConfidence(problem: ParsedProblem): number {
// If student answer is null, only consider terms confidence
if (problem.studentAnswer === null) {
return problem.termsConfidence
}
return Math.min(problem.termsConfidence, problem.studentAnswerConfidence)
}
export function ParsedProblemsList({
result,
isDark,
onProblemClick,
selectedProblemIndex,
lowConfidenceThreshold = 0.7,
}: ParsedProblemsListProps) {
const { problems, needsReview, overallConfidence } = result
// Calculate summary stats
const totalProblems = problems.length
const answeredProblems = problems.filter((p) => p.studentAnswer !== null).length
const correctProblems = problems.filter(
(p) => p.studentAnswer !== null && p.studentAnswer === p.correctAnswer
).length
const lowConfidenceCount = problems.filter(
(p) => getMinConfidence(p) < lowConfidenceThreshold
).length
return (
<div
data-component="parsed-problems-list"
className={css({
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
backgroundColor: isDark ? 'gray.800' : 'white',
overflow: 'hidden',
})}
>
{/* Header with summary */}
<div
data-element="header"
className={css({
padding: '0.75rem 1rem',
backgroundColor: isDark ? 'gray.750' : 'gray.50',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
})}
>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
})}
>
{totalProblems} Problems
</span>
{answeredProblems > 0 && (
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{correctProblems}/{answeredProblems} correct
</span>
)}
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
{/* Needs review badge */}
{needsReview && (
<span
data-element="needs-review-badge"
className={css({
px: 2,
py: 0.5,
fontSize: '0.6875rem',
fontWeight: '600',
borderRadius: 'full',
backgroundColor: 'yellow.100',
color: 'yellow.800',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
})}
>
<span></span> Needs Review
</span>
)}
{/* Low confidence count */}
{lowConfidenceCount > 0 && !needsReview && (
<span
className={css({
px: 2,
py: 0.5,
fontSize: '0.6875rem',
fontWeight: '500',
borderRadius: 'full',
backgroundColor: isDark ? 'yellow.900/30' : 'yellow.50',
color: isDark ? 'yellow.400' : 'yellow.700',
})}
>
{lowConfidenceCount} low confidence
</span>
)}
{/* Confidence indicator */}
<span
className={css({
fontSize: '0.6875rem',
color:
overallConfidence >= 0.9
? isDark
? 'green.400'
: 'green.600'
: overallConfidence >= 0.7
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'red.400'
: 'red.600',
})}
>
{Math.round(overallConfidence * 100)}% confidence
</span>
</div>
</div>
{/* Problems list */}
<div
data-element="problems-list"
className={css({
maxHeight: '300px',
overflowY: 'auto',
})}
>
{problems.map((problem, index) => {
const isCorrect =
problem.studentAnswer !== null && problem.studentAnswer === problem.correctAnswer
const isIncorrect =
problem.studentAnswer !== null && problem.studentAnswer !== problem.correctAnswer
const isLowConfidence = getMinConfidence(problem) < lowConfidenceThreshold
const isSelected = selectedProblemIndex === index
return (
<button
key={problem.problemNumber}
type="button"
data-element="problem-row"
data-problem-number={problem.problemNumber}
data-is-correct={isCorrect}
data-is-low-confidence={isLowConfidence}
onClick={() => onProblemClick?.(problem)}
className={css({
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '0.5rem 1rem',
gap: '0.75rem',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.100',
backgroundColor: isSelected
? isDark
? 'blue.900/30'
: 'blue.50'
: isLowConfidence
? isDark
? 'yellow.900/20'
: 'yellow.50'
: 'transparent',
cursor: onProblemClick ? 'pointer' : 'default',
transition: 'background-color 0.15s',
border: 'none',
textAlign: 'left',
_hover: {
backgroundColor: isSelected
? isDark
? 'blue.900/40'
: 'blue.100'
: isDark
? 'gray.750'
: 'gray.50',
},
_last: {
borderBottom: 'none',
},
})}
>
{/* Problem number */}
<span
className={css({
minWidth: '24px',
fontSize: '0.75rem',
fontWeight: '600',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
#{problem.problemNumber}
</span>
{/* Terms */}
<span
className={css({
flex: 1,
fontSize: '0.875rem',
fontFamily: 'monospace',
color: isDark ? 'gray.200' : 'gray.700',
})}
>
{formatTerms(problem.terms)}
</span>
{/* Equals sign and answer */}
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
=
</span>
{/* Student answer */}
<span
className={css({
minWidth: '48px',
fontSize: '0.875rem',
fontFamily: 'monospace',
fontWeight: '500',
textAlign: 'right',
color:
problem.studentAnswer === null
? isDark
? 'gray.500'
: 'gray.400'
: isCorrect
? isDark
? 'green.400'
: 'green.600'
: isDark
? 'red.400'
: 'red.600',
})}
>
{problem.studentAnswer ?? '—'}
</span>
{/* Correct/incorrect indicator */}
<span
className={css({
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.875rem',
})}
>
{isCorrect && <span className={css({ color: 'green.500' })}></span>}
{isIncorrect && <span className={css({ color: 'red.500' })}></span>}
{problem.studentAnswer === null && (
<span className={css({ color: isDark ? 'gray.600' : 'gray.300' })}></span>
)}
</span>
{/* Low confidence indicator */}
{isLowConfidence && (
<span
className={css({
fontSize: '0.6875rem',
color: isDark ? 'yellow.400' : 'yellow.600',
})}
title={`${Math.round(getMinConfidence(problem) * 100)}% confidence`}
>
</span>
)}
</button>
)
})}
</div>
{/* Warnings section if any */}
{result.warnings.length > 0 && (
<div
data-element="warnings"
className={css({
padding: '0.75rem 1rem',
backgroundColor: isDark ? 'yellow.900/20' : 'yellow.50',
borderTop: '1px solid',
borderColor: isDark ? 'yellow.800/30' : 'yellow.200',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: '600',
color: isDark ? 'yellow.400' : 'yellow.700',
marginBottom: '0.25rem',
})}
>
Warnings:
</div>
<ul
className={css({
margin: 0,
padding: 0,
listStyle: 'none',
})}
>
{result.warnings.map((warning, i) => (
<li
key={i}
className={css({
fontSize: '0.6875rem',
color: isDark ? 'yellow.300' : 'yellow.800',
paddingLeft: '0.75rem',
position: 'relative',
_before: {
content: '"•"',
position: 'absolute',
left: 0,
},
})}
>
{warning}
</li>
))}
</ul>
</div>
)}
</div>
)
}
export default ParsedProblemsList

View File

@ -0,0 +1,7 @@
/**
* Worksheet Parsing UI Components
*
* Components for displaying and interacting with LLM-parsed worksheet data.
*/
export { ParsedProblemsList, type ParsedProblemsListProps } from './ParsedProblemsList'

View File

@ -60,7 +60,9 @@ export const practiceAttachments = sqliteTable('practice_attachments', {
parsingError: text('parsing_error'), // Error message if parsing failed
// LLM parsing results (raw from LLM, before user corrections)
rawParsingResult: text('raw_parsing_result', { mode: 'json' }).$type<WorksheetParsingResult | null>(),
rawParsingResult: text('raw_parsing_result', {
mode: 'json',
}).$type<WorksheetParsingResult | null>(),
// Approved results (after user corrections)
approvedResult: text('approved_result', { mode: 'json' }).$type<WorksheetParsingResult | null>(),
@ -71,7 +73,9 @@ export const practiceAttachments = sqliteTable('practice_attachments', {
// Session linkage (for parsed worksheets that created sessions)
sessionCreated: integer('session_created', { mode: 'boolean' }), // True if session was created from this parsing
createdSessionId: text('created_session_id').references(() => sessionPlans.id, { onDelete: 'set null' }),
createdSessionId: text('created_session_id').references(() => sessionPlans.id, {
onDelete: 'set null',
}),
// Audit
uploadedBy: text('uploaded_by')

View File

@ -57,10 +57,7 @@ interface LLMVisionRequest extends LLMCallRequest {
*/
export function useLLMCall<T extends z.ZodType>(
schema: T,
options?: Omit<
UseMutationOptions<LLMResponse<z.infer<T>>, Error, LLMCallRequest>,
'mutationFn'
>
options?: Omit<UseMutationOptions<LLMResponse<z.infer<T>>, Error, LLMCallRequest>, 'mutationFn'>
) {
const [progress, setProgress] = useState<LLMProgress | null>(null)
@ -103,10 +100,7 @@ export function useLLMCall<T extends z.ZodType>(
*/
export function useLLMVision<T extends z.ZodType>(
schema: T,
options?: Omit<
UseMutationOptions<LLMResponse<z.infer<T>>, Error, LLMVisionRequest>,
'mutationFn'
>
options?: Omit<UseMutationOptions<LLMResponse<z.infer<T>>, Error, LLMVisionRequest>, 'mutationFn'>
) {
const [progress, setProgress] = useState<LLMProgress | null>(null)

View File

@ -0,0 +1,107 @@
/**
* Hook to check viewer's access level to a player
*
* Used for pre-flight authorization checks before showing UI that requires
* specific access levels.
*/
import { useQuery } from '@tanstack/react-query'
import type { AccessLevel } from '@/lib/classroom'
import { api } from '@/lib/queryClient'
export interface PlayerAccessData {
accessLevel: AccessLevel
isParent: boolean
isTeacher: boolean
isPresent: boolean
/** Classroom ID if the viewer is a teacher */
classroomId?: string
}
/**
* Query key factory for player access
*/
export const playerAccessKeys = {
all: ['player-access'] as const,
detail: (playerId: string) => [...playerAccessKeys.all, playerId] as const,
}
/**
* Hook to get the current viewer's access level to a player
*
* Returns access information including:
* - accessLevel: 'none' | 'teacher-enrolled' | 'teacher-present' | 'parent'
* - isParent: true if viewer is a parent of the player
* - isTeacher: true if player is enrolled in viewer's classroom
* - isPresent: true if player is currently present in viewer's classroom
*/
export function usePlayerAccess(playerId: string) {
return useQuery({
queryKey: playerAccessKeys.detail(playerId),
queryFn: async (): Promise<PlayerAccessData> => {
const response = await api(`players/${playerId}/access`)
if (!response.ok) {
throw new Error('Failed to check player access')
}
return response.json()
},
// Refetch on window focus to catch presence changes
refetchOnWindowFocus: true,
// Keep data fresh - presence can change anytime
staleTime: 30 * 1000, // 30 seconds
})
}
/**
* Helper to check if the viewer can upload photos for a player
*
* Upload requires either:
* - Being a parent (full access)
* - Being a teacher with the student present in classroom
*
* Note: This mirrors the server-side logic in the attachments API
*/
export function canUploadPhotos(access: PlayerAccessData | undefined): boolean {
if (!access) return false
return access.isParent || access.isPresent
}
/**
* Helper to get remediation info for upload-restricted access
*/
export function getUploadRemediation(access: PlayerAccessData | undefined): {
type: 'send-entry-prompt' | 'enroll-student' | 'link-via-family-code' | 'no-access' | null
message: string | null
} {
if (!access) {
return { type: null, message: null }
}
// Can upload - no remediation needed
if (canUploadPhotos(access)) {
return { type: null, message: null }
}
// Teacher with enrolled student, but student not present
if (access.accessLevel === 'teacher-enrolled' && !access.isPresent) {
return {
type: 'send-entry-prompt',
message:
'This student is enrolled in your classroom but not currently present. To upload photos, they need to enter your classroom first.',
}
}
// User has some access but not enough
if (access.accessLevel !== 'none') {
return {
type: 'no-access',
message: "You don't have permission to upload photos for this student.",
}
}
// No access at all
return {
type: 'link-via-family-code',
message: 'Your account is not linked to this student.',
}
}

View File

@ -0,0 +1,309 @@
'use client'
/**
* React Query hooks for worksheet parsing workflow
*
* Provides mutations for:
* - Starting worksheet parsing (POST /parse)
* - Submitting corrections (PATCH /review)
* - Approving and creating session (POST /approve)
*
* Includes optimistic updates for immediate UI feedback.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/queryClient'
import { attachmentKeys, sessionPlanKeys, sessionHistoryKeys } from '@/lib/queryKeys'
import type { WorksheetParsingResult, computeParsingStats } from '@/lib/worksheet-parsing'
import type { ParsingStatus } from '@/db/schema/practice-attachments'
/** Stats returned from parsing */
type ParsingStats = ReturnType<typeof computeParsingStats>
// ============================================================================
// Types
// ============================================================================
/** Extended attachment data with parsing fields */
export interface AttachmentWithParsing {
id: string
filename: string
originalFilename: string | null
mimeType: string
fileSize: number
uploadedAt: string
url: string
originalUrl: string | null
corners: Array<{ x: number; y: number }> | null
rotation: 0 | 90 | 180 | 270
// Parsing fields
parsingStatus: ParsingStatus | null
parsedAt: string | null
parsingError: string | null
rawParsingResult: WorksheetParsingResult | null
approvedResult: WorksheetParsingResult | null
confidenceScore: number | null
needsReview: boolean
sessionCreated: boolean
createdSessionId: string | null
}
/** Response from parse API */
interface ParseResponse {
success: boolean
status: ParsingStatus
result?: WorksheetParsingResult
stats?: ParsingStats
error?: string
attempts?: number
}
/** Response from approve API */
interface ApproveResponse {
success: boolean
sessionId: string
problemCount: number
correctCount: number
accuracy: number | null
skillsExercised: string[]
stats: ParsingStats
}
/** Cached session attachments shape */
interface AttachmentsCache {
attachments: AttachmentWithParsing[]
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to start parsing a worksheet attachment
*/
export function useStartParsing(playerId: string, sessionId: string) {
const queryClient = useQueryClient()
const queryKey = attachmentKeys.session(playerId, sessionId)
return useMutation({
mutationFn: async (attachmentId: string) => {
const res = await api(`curriculum/${playerId}/attachments/${attachmentId}/parse`, {
method: 'POST',
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to start parsing')
}
return res.json() as Promise<ParseResponse>
},
onMutate: async (attachmentId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey })
// Snapshot current state
const previous = queryClient.getQueryData<AttachmentsCache>(queryKey)
// Optimistic update: mark as processing
if (previous) {
queryClient.setQueryData<AttachmentsCache>(queryKey, {
...previous,
attachments: previous.attachments.map((a) =>
a.id === attachmentId
? { ...a, parsingStatus: 'processing' as ParsingStatus, parsingError: null }
: a
),
})
}
return { previous }
},
onError: (_err, attachmentId, context) => {
// Revert on error
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous)
}
},
onSuccess: (data, attachmentId) => {
// Update cache with actual result
const current = queryClient.getQueryData<AttachmentsCache>(queryKey)
if (current && data.success) {
queryClient.setQueryData<AttachmentsCache>(queryKey, {
...current,
attachments: current.attachments.map((a) =>
a.id === attachmentId
? {
...a,
parsingStatus: data.status,
rawParsingResult: data.result ?? null,
confidenceScore: data.result?.overallConfidence ?? null,
needsReview: data.result?.needsReview ?? false,
parsedAt: new Date().toISOString(),
}
: a
),
})
}
},
onSettled: () => {
// Always refetch to ensure consistency
queryClient.invalidateQueries({ queryKey })
},
})
}
/**
* Hook to submit corrections to parsed problems
*/
export function useSubmitCorrections(playerId: string, sessionId: string) {
const queryClient = useQueryClient()
const queryKey = attachmentKeys.session(playerId, sessionId)
return useMutation({
mutationFn: async ({
attachmentId,
corrections,
markAsReviewed = false,
}: {
attachmentId: string
corrections: Array<{
problemNumber: number
correctedTerms?: number[] | null
correctedStudentAnswer?: number | null
shouldExclude?: boolean
}>
markAsReviewed?: boolean
}) => {
const res = await api(`curriculum/${playerId}/attachments/${attachmentId}/review`, {
method: 'PATCH',
body: JSON.stringify({ corrections, markAsReviewed }),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to submit corrections')
}
return res.json()
},
onSuccess: () => {
// Refetch to get updated data
queryClient.invalidateQueries({ queryKey })
},
})
}
/**
* Hook to approve parsing and create a practice session
*/
export function useApproveAndCreateSession(playerId: string, sessionId: string) {
const queryClient = useQueryClient()
const queryKey = attachmentKeys.session(playerId, sessionId)
return useMutation({
mutationFn: async (attachmentId: string) => {
const res = await api(`curriculum/${playerId}/attachments/${attachmentId}/approve`, {
method: 'POST',
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to approve and create session')
}
return res.json() as Promise<ApproveResponse>
},
onMutate: async (attachmentId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey })
// Snapshot current state
const previous = queryClient.getQueryData<AttachmentsCache>(queryKey)
// Optimistic update: mark as creating session
if (previous) {
queryClient.setQueryData<AttachmentsCache>(queryKey, {
...previous,
attachments: previous.attachments.map((a) =>
a.id === attachmentId ? { ...a, sessionCreated: true } : a
),
})
}
return { previous }
},
onError: (_err, _attachmentId, context) => {
// Revert on error
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous)
}
},
onSuccess: (data, attachmentId) => {
// Update cache with session ID
const current = queryClient.getQueryData<AttachmentsCache>(queryKey)
if (current && data.success) {
queryClient.setQueryData<AttachmentsCache>(queryKey, {
...current,
attachments: current.attachments.map((a) =>
a.id === attachmentId
? {
...a,
sessionCreated: true,
createdSessionId: data.sessionId,
parsingStatus: 'approved' as ParsingStatus,
}
: a
),
})
}
// Invalidate session-related queries so new session appears
queryClient.invalidateQueries({ queryKey: sessionPlanKeys.list(playerId) })
queryClient.invalidateQueries({ queryKey: sessionHistoryKeys.list(playerId) })
},
onSettled: () => {
// Always refetch attachments to ensure consistency
queryClient.invalidateQueries({ queryKey })
},
})
}
/**
* Get parsing status badge color
*/
export function getParsingStatusColor(status: ParsingStatus | null): string {
switch (status) {
case 'processing':
return 'blue.500'
case 'needs_review':
return 'yellow.500'
case 'approved':
return 'green.500'
case 'failed':
return 'red.500'
default:
return 'gray.500'
}
}
/**
* Get parsing status display text
*/
export function getParsingStatusText(status: ParsingStatus | null, problemCount?: number): string {
switch (status) {
case 'processing':
return 'Analyzing...'
case 'needs_review':
return problemCount ? `${problemCount} problems (needs review)` : 'Needs review'
case 'approved':
return problemCount ? `${problemCount} problems` : 'Ready'
case 'failed':
return 'Failed'
default:
return 'Not parsed'
}
}

View File

@ -15,7 +15,6 @@ import {
classrooms,
parentChild,
type Player,
players,
} from '@/db/schema'
/**
@ -236,3 +235,107 @@ export async function isTeacherOf(userId: string, playerId: string): Promise<boo
return !!enrollment
}
/**
* Remediation types for authorization errors
*/
export type RemediationType =
| 'send-entry-prompt' // Teacher needs student to enter classroom
| 'enroll-student' // Teacher needs to enroll student first
| 'link-via-family-code' // User can link via family code
| 'create-classroom' // User needs to create a classroom to be a teacher
| 'no-access' // No remediation available
/**
* Structured authorization error for API responses
*/
export interface AuthorizationError {
error: string
message: string
accessLevel: AccessLevel
remediation: {
type: RemediationType
description: string
/** For send-entry-prompt: the classroom to send the prompt from */
classroomId?: string
/** For send-entry-prompt/enroll-student: the player to act on */
playerId?: string
/** Label for the action button in the UI */
actionLabel?: string
}
}
/**
* Generate a personalized authorization error based on the user's relationship
* with the student and the action they're trying to perform.
*/
export function generateAuthorizationError(
access: PlayerAccess,
action: PlayerAction,
context?: { actionDescription?: string }
): AuthorizationError {
const actionDesc = context?.actionDescription ?? action
// Case 1: Teacher with enrolled student, but student not present
// This is the most common case - teacher needs student to enter classroom
if (access.accessLevel === 'teacher-enrolled' && !access.isPresent) {
return {
error: 'Student not in classroom',
message: `This student is enrolled in your classroom but not currently present. To ${actionDesc}, they need to enter your classroom first.`,
accessLevel: access.accessLevel,
remediation: {
type: 'send-entry-prompt',
description:
"Send a notification to the student's parent to have them enter your classroom.",
classroomId: access.classroomId,
playerId: access.playerId,
actionLabel: 'Send Entry Prompt',
},
}
}
// Case 2: User has a classroom but student is not enrolled
if (access.accessLevel === 'none' && access.classroomId) {
return {
error: 'Student not enrolled',
message: 'This student is not enrolled in your classroom.',
accessLevel: access.accessLevel,
remediation: {
type: 'enroll-student',
description:
'You need to enroll this student in your classroom first. Ask their parent for their family code to send an enrollment request.',
classroomId: access.classroomId,
playerId: access.playerId,
actionLabel: 'Enroll Student',
},
}
}
// Case 3: User has no classroom and no parent relationship
if (access.accessLevel === 'none') {
return {
error: 'No access to this student',
message: 'Your account is not linked to this student.',
accessLevel: access.accessLevel,
remediation: {
type: 'link-via-family-code',
description:
"To access this student, you need their Family Code. Ask their parent to share it with you from the student's profile page.",
playerId: access.playerId,
actionLabel: 'Enter Family Code',
},
}
}
// Fallback for any other case
return {
error: 'Not authorized',
message: `You do not have permission to ${actionDesc} for this student.`,
accessLevel: access.accessLevel,
remediation: {
type: 'no-access',
description: "Contact the student's parent or your administrator for access.",
playerId: access.playerId,
},
}
}

View File

@ -17,11 +17,14 @@ export {
type PlayerAccess,
type PlayerAction,
type AccessiblePlayers,
type RemediationType,
type AuthorizationError,
getPlayerAccess,
canPerformAction,
getAccessiblePlayers,
isParentOf,
isTeacherOf,
generateAuthorizationError,
} from './access-control'
// Family Management

View File

@ -366,7 +366,7 @@ export function getPhaseSkillConstraints(phaseId: string): PhaseSkillConstraints
if (phase.usesFiveComplement && phase.targetNumber <= 4) {
// Target the specific five-complement skill
const skill = FIVE_COMPLEMENT_ADD[phase.targetNumber]
target.fiveComplements = { [skill]: true } as Partial<SkillSet['fiveComplements']>
target.fiveComplements = { [skill]: true } as SkillSet['fiveComplements']
required.fiveComplements = {
[skill]: true,
} as SkillSet['fiveComplements']
@ -386,7 +386,7 @@ export function getPhaseSkillConstraints(phaseId: string): PhaseSkillConstraints
if (phase.usesFiveComplement && Math.abs(phase.targetNumber) <= 4) {
const skill = FIVE_COMPLEMENT_SUB[Math.abs(phase.targetNumber)]
target.fiveComplementsSub = { [skill]: true } as Partial<SkillSet['fiveComplementsSub']>
target.fiveComplementsSub = { [skill]: true } as SkillSet['fiveComplementsSub']
required.fiveComplementsSub = {
[skill]: true,
} as SkillSet['fiveComplementsSub']
@ -405,7 +405,7 @@ export function getPhaseSkillConstraints(phaseId: string): PhaseSkillConstraints
required.basic.heavenBead = true
const skill = TEN_COMPLEMENT_ADD[phase.targetNumber]
target.tenComplements = { [skill]: true } as Partial<SkillSet['tenComplements']>
target.tenComplements = { [skill]: true } as SkillSet['tenComplements']
required.tenComplements = { [skill]: true } as SkillSet['tenComplements']
if (!phase.usesFiveComplement) {
@ -423,7 +423,7 @@ export function getPhaseSkillConstraints(phaseId: string): PhaseSkillConstraints
required.basic.heavenBeadSubtraction = true
const skill = TEN_COMPLEMENT_SUB[Math.abs(phase.targetNumber)]
target.tenComplementsSub = { [skill]: true } as Partial<SkillSet['tenComplementsSub']>
target.tenComplementsSub = { [skill]: true } as SkillSet['tenComplementsSub']
required.tenComplementsSub = {
[skill]: true,
} as SkillSet['tenComplementsSub']
@ -529,6 +529,10 @@ function createFullSkillSet(): SkillSet {
'-2=+8-10': true,
'-1=+9-10': true,
},
advanced: {
cascadingCarry: true,
cascadingBorrow: true,
},
}
}

View File

@ -43,10 +43,10 @@ function formatDiagnosticsMessage(diagnostics: GenerationDiagnostics): string {
lines.push(
'This means no valid sequence of terms could be built with the given skill/budget constraints.'
)
if (diagnostics.enabledRequiredSkills.length === 0) {
lines.push('FIX: No required skills are enabled - enable at least some basic skills.')
if (diagnostics.enabledAllowedSkills.length === 0) {
lines.push('FIX: No allowed skills are enabled - enable at least some basic skills.')
} else {
lines.push(`Enabled skills: ${diagnostics.enabledRequiredSkills.slice(0, 5).join(', ')}...`)
lines.push(`Enabled skills: ${diagnostics.enabledAllowedSkills.slice(0, 5).join(', ')}...`)
}
} else if (diagnostics.skillMatchFailures > 0) {
lines.push(

View File

@ -58,3 +58,21 @@ export const entryPromptKeys = {
all: ['entry-prompts'] as const,
pending: () => [...entryPromptKeys.all, 'pending'] as const,
}
// Attachment query keys (for practice photos and worksheet parsing)
export const attachmentKeys = {
// All attachments for a player
all: (playerId: string) => ['attachments', playerId] as const,
// Attachments for a specific session
session: (playerId: string, sessionId: string) =>
[...attachmentKeys.all(playerId), 'session', sessionId] as const,
// Single attachment detail (includes parsing data)
detail: (playerId: string, attachmentId: string) =>
[...attachmentKeys.all(playerId), attachmentId] as const,
// Parsing-specific data for an attachment
parsing: (playerId: string, attachmentId: string) =>
[...attachmentKeys.detail(playerId, attachmentId), 'parsing'] as const,
}

View File

@ -138,9 +138,7 @@ export function computeParsingStats(result: WorksheetParsingResult) {
const answeredProblems = problems.filter((p) => p.studentAnswer !== null)
// Compute accuracy if answers are present
const correctAnswers = answeredProblems.filter(
(p) => p.studentAnswer === p.correctAnswer
)
const correctAnswers = answeredProblems.filter((p) => p.studentAnswer === p.correctAnswer)
return {
totalProblems: problems.length,
@ -190,9 +188,7 @@ export function applyCorrections(
// Boost confidence since user verified
termsConfidence: correction.correctedTerms ? 1.0 : problem.termsConfidence,
studentAnswerConfidence:
correction.correctedStudentAnswer !== undefined
? 1.0
: problem.studentAnswerConfidence,
correction.correctedStudentAnswer !== undefined ? 1.0 : problem.studentAnswerConfidence,
}
})
.filter((p): p is NonNullable<typeof p> => p !== null)

View File

@ -17,22 +17,18 @@ export const BoundingBoxSchema = z
.number()
.min(0)
.max(1)
.describe('Left edge of the box as a fraction of image width (0 = left edge, 1 = right edge)'),
.describe(
'Left edge of the box as a fraction of image width (0 = left edge, 1 = right edge)'
),
y: z
.number()
.min(0)
.max(1)
.describe('Top edge of the box as a fraction of image height (0 = top edge, 1 = bottom edge)'),
width: z
.number()
.min(0)
.max(1)
.describe('Width of the box as a fraction of image width'),
height: z
.number()
.min(0)
.max(1)
.describe('Height of the box as a fraction of image height'),
.describe(
'Top edge of the box as a fraction of image height (0 = top edge, 1 = bottom edge)'
),
width: z.number().min(0).max(1).describe('Width of the box as a fraction of image width'),
height: z.number().min(0).max(1).describe('Height of the box as a fraction of image height'),
})
.describe('Rectangular region on the worksheet image, in normalized 0-1 coordinates')
@ -95,10 +91,7 @@ export const ParsedProblemSchema = z
'Subsequent terms are positive for addition, negative for subtraction. ' +
'Example: "45 - 17 + 8" → [45, -17, 8]'
),
correctAnswer: z
.number()
.int()
.describe('The mathematically correct answer to this problem'),
correctAnswer: z.number().int().describe('The mathematically correct answer to this problem'),
// Student work
studentAnswer: z
@ -107,14 +100,14 @@ export const ParsedProblemSchema = z
.nullable()
.describe(
'The answer the student wrote, if readable. Null if the answer box is empty, ' +
'illegible, or you cannot confidently read the student\'s handwriting'
"illegible, or you cannot confidently read the student's handwriting"
),
studentAnswerConfidence: z
.number()
.min(0)
.max(1)
.describe(
'Confidence in reading the student\'s answer (0 = not readable/empty, 1 = perfectly clear). ' +
"Confidence in reading the student's answer (0 = not readable/empty, 1 = perfectly clear). " +
'Use 0.5-0.7 for somewhat legible, 0.8-0.9 for mostly clear, 1.0 for crystal clear'
),
@ -133,7 +126,7 @@ export const ParsedProblemSchema = z
'Bounding box around the entire problem (including all terms and answer area)'
),
answerBoundingBox: BoundingBoxSchema.nullable().describe(
'Bounding box around just the student\'s answer area. Null if no answer area is visible'
"Bounding box around just the student's answer area. Null if no answer area is visible"
),
})
.describe('A single arithmetic problem extracted from the worksheet')
@ -175,9 +168,7 @@ export const PageMetadataSchema = z
.number()
.int()
.nullable()
.describe(
'Page number if printed on the page. Null if no page number is visible'
),
.describe('Page number if printed on the page. Null if no page number is visible'),
detectedFormat: WorksheetFormatSchema,
totalRows: z
.number()
@ -240,11 +231,7 @@ export type WorksheetParsingResult = z.infer<typeof WorksheetParsingResultSchema
*/
export const ProblemCorrectionSchema = z
.object({
problemNumber: z
.number()
.int()
.min(1)
.describe('The problem number being corrected'),
problemNumber: z.number().int().min(1).describe('The problem number being corrected'),
correctedTerms: z
.array(ProblemTermSchema)
.nullable()
@ -257,10 +244,7 @@ export const ProblemCorrectionSchema = z
shouldExclude: z
.boolean()
.describe('True to exclude this problem from the session (e.g., illegible)'),
note: z
.string()
.nullable()
.describe('Optional note explaining the correction'),
note: z.string().nullable().describe('Optional note explaining the correction'),
})
.describe('User correction to a single parsed problem')
@ -271,9 +255,7 @@ export type ProblemCorrection = z.infer<typeof ProblemCorrectionSchema>
*/
export const ReparseRequestSchema = z
.object({
problemNumbers: z
.array(z.number().int().min(1))
.describe('Which problems to re-parse'),
problemNumbers: z.array(z.number().int().min(1)).describe('Which problems to re-parse'),
additionalContext: z
.string()
.describe(

View File

@ -156,9 +156,10 @@ export function convertToSlotResults(
*
* Returns warnings for any issues found.
*/
export function validateParsedProblems(
problems: ParsedProblem[]
): { valid: boolean; warnings: string[] } {
export function validateParsedProblems(problems: ParsedProblem[]): {
valid: boolean
warnings: string[]
} {
const warnings: string[] = []
for (const problem of problems) {

6
apps/web/src/types/css.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
// Type declaration for CSS imports
// This allows TypeScript to understand CSS imports in @soroban/abacus-react's declaration files
declare module '*.css' {
const content: { [className: string]: string }
export default content
}

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "es6"],
"lib": ["dom", "dom.iterable", "es2020"],
"types": ["vitest/globals", "@testing-library/jest-dom"],
"allowJs": true,
"skipLibCheck": true,
@ -8,12 +8,12 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"target": "es2015",
"target": "es2020",
"downlevelIteration": true,
"plugins": [
{