From 2202716f563053624dbe5c6abb969a3b0d452fd1 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 22 Dec 2025 13:07:40 -0600 Subject: [PATCH] feat(classroom): implement teacher classroom dashboard (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add teacher classroom functionality allowing users to create a classroom and manage students: - Add classroom query keys for React Query cache management - Create useClassroom hooks (useMyClassroom, useClassroomByCode, useCreateClassroom, useIsTeacher) - Add classroom UI components: - ClassroomCodeShare: Display join code with copy button - CreateClassroomForm: Form to create a classroom - ClassroomTab: Live classroom view (empty state for Phase 6) - StudentManagerTab: Enrolled students list (empty state for Phase 4) - ClassroomDashboard: Main teacher dashboard with tabs - Integrate into PracticeClient with conditional routing: - Teachers see ClassroomDashboard with own children shown separately - Parents see normal student list with "Become a Teacher" option - Fix API route to remove non-existent 'image' field from User type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../app/api/classrooms/code/[code]/route.ts | 1 - apps/web/src/app/api/classrooms/mine/route.ts | 21 +- apps/web/src/app/api/classrooms/route.ts | 24 +- apps/web/src/app/api/family/link/route.ts | 5 +- apps/web/src/app/api/players/route.ts | 5 +- apps/web/src/app/practice/PracticeClient.tsx | 88 +++++++ .../classroom/ClassroomCodeShare.tsx | 149 ++++++++++++ .../classroom/ClassroomDashboard.tsx | 227 ++++++++++++++++++ .../src/components/classroom/ClassroomTab.tsx | 116 +++++++++ .../classroom/CreateClassroomForm.tsx | 182 ++++++++++++++ .../classroom/StudentManagerTab.tsx | 117 +++++++++ apps/web/src/components/classroom/index.ts | 5 + apps/web/src/hooks/useClassroom.ts | 129 +++++----- apps/web/src/lib/queryKeys.ts | 10 + 14 files changed, 1007 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/components/classroom/ClassroomCodeShare.tsx create mode 100644 apps/web/src/components/classroom/ClassroomDashboard.tsx create mode 100644 apps/web/src/components/classroom/ClassroomTab.tsx create mode 100644 apps/web/src/components/classroom/CreateClassroomForm.tsx create mode 100644 apps/web/src/components/classroom/StudentManagerTab.tsx create mode 100644 apps/web/src/components/classroom/index.ts diff --git a/apps/web/src/app/api/classrooms/code/[code]/route.ts b/apps/web/src/app/api/classrooms/code/[code]/route.ts index bba7b92e..05f9954f 100644 --- a/apps/web/src/app/api/classrooms/code/[code]/route.ts +++ b/apps/web/src/app/api/classrooms/code/[code]/route.ts @@ -32,7 +32,6 @@ export async function GET(req: NextRequest, { params }: RouteParams) { ? { id: classroom.teacher.id, name: classroom.teacher.name, - image: classroom.teacher.image, } : null, }) diff --git a/apps/web/src/app/api/classrooms/mine/route.ts b/apps/web/src/app/api/classrooms/mine/route.ts index 67198318..652ce9b2 100644 --- a/apps/web/src/app/api/classrooms/mine/route.ts +++ b/apps/web/src/app/api/classrooms/mine/route.ts @@ -1,7 +1,25 @@ +import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { db, schema } from '@/db' import { getTeacherClassroom } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + /** * GET /api/classrooms/mine * Get current user's classroom (if teacher) @@ -11,8 +29,9 @@ import { getViewerId } from '@/lib/viewer' export async function GET() { try { const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) if (!classroom) { return NextResponse.json({ error: 'No classroom found' }, { status: 404 }) diff --git a/apps/web/src/app/api/classrooms/route.ts b/apps/web/src/app/api/classrooms/route.ts index f061c6ee..92f03d9b 100644 --- a/apps/web/src/app/api/classrooms/route.ts +++ b/apps/web/src/app/api/classrooms/route.ts @@ -1,7 +1,25 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { createClassroom, getTeacherClassroom } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + /** * GET /api/classrooms * Get current user's classroom (alias for /api/classrooms/mine) @@ -11,8 +29,9 @@ import { getViewerId } from '@/lib/viewer' export async function GET() { try { const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) return NextResponse.json({ classroom }) } catch (error) { @@ -31,6 +50,7 @@ export async function GET() { export async function POST(req: NextRequest) { try { const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) const body = await req.json() if (!body.name) { @@ -38,7 +58,7 @@ export async function POST(req: NextRequest) { } const result = await createClassroom({ - teacherId: viewerId, + teacherId: user.id, name: body.name, }) diff --git a/apps/web/src/app/api/family/link/route.ts b/apps/web/src/app/api/family/link/route.ts index dd60abf1..2911e082 100644 --- a/apps/web/src/app/api/family/link/route.ts +++ b/apps/web/src/app/api/family/link/route.ts @@ -13,10 +13,7 @@ async function getOrCreateUser(viewerId: string) { }) if (!user) { - const [newUser] = await db - .insert(schema.users) - .values({ guestId: viewerId }) - .returning() + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() user = newUser } diff --git a/apps/web/src/app/api/players/route.ts b/apps/web/src/app/api/players/route.ts index 117f6243..524b1188 100644 --- a/apps/web/src/app/api/players/route.ts +++ b/apps/web/src/app/api/players/route.ts @@ -26,10 +26,7 @@ export async function GET() { let players if (linkedIds.length > 0) { players = await db.query.players.findMany({ - where: or( - eq(schema.players.userId, user.id), - inArray(schema.players.id, linkedIds) - ), + where: or(eq(schema.players.userId, user.id), inArray(schema.players.id, linkedIds)), orderBy: (players, { desc }) => [desc(players.createdAt)], }) } else { diff --git a/apps/web/src/app/practice/PracticeClient.tsx b/apps/web/src/app/practice/PracticeClient.tsx index 5849b8f2..1a9e7c5e 100644 --- a/apps/web/src/app/practice/PracticeClient.tsx +++ b/apps/web/src/app/practice/PracticeClient.tsx @@ -3,10 +3,12 @@ import { useRouter } from 'next/navigation' import { useCallback, useMemo, useState } from 'react' import { Z_INDEX } from '@/constants/zIndex' +import { ClassroomDashboard, CreateClassroomForm } from '@/components/classroom' import { PageWithNav } from '@/components/PageWithNav' import { StudentFilterBar } from '@/components/practice/StudentFilterBar' import { StudentSelector, type StudentWithProgress } from '@/components/practice' import { useTheme } from '@/contexts/ThemeContext' +import { useMyClassroom } from '@/hooks/useClassroom' import { usePlayersWithSkillData, useUpdatePlayer } from '@/hooks/useUserPlayers' import type { StudentWithSkillData } from '@/utils/studentGrouping' import { filterStudents, getStudentsNeedingAttention, groupStudents } from '@/utils/studentGrouping' @@ -29,6 +31,10 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' + // Classroom state - check if user is a teacher + const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom() + const [showCreateClassroom, setShowCreateClassroom] = useState(false) + // Filter state const [searchQuery, setSearchQuery] = useState('') const [skillFilters, setSkillFilters] = useState([]) @@ -175,6 +181,61 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) { .length }, [players, searchQuery, skillFilters, showArchived]) + // Handle classroom creation + const handleBecomeTeacher = useCallback(() => { + setShowCreateClassroom(true) + }, []) + + const handleCloseCreateClassroom = useCallback(() => { + setShowCreateClassroom(false) + }, []) + + // If user is a teacher, show the classroom dashboard + if (classroom) { + return ( + +
+ +
+
+ ) + } + + // Show create classroom modal if requested + if (showCreateClassroom) { + return ( + +
+
+ +
+
+
+ ) + } + + // Parent view - show student list with filter bar return (
Build your soroban skills one step at a time

+ + {/* Become a Teacher option */} + {!isLoadingClassroom && !classroom && ( + + )} {/* Needs Attention Section - uses same bucket styling as other sections */} diff --git a/apps/web/src/components/classroom/ClassroomCodeShare.tsx b/apps/web/src/components/classroom/ClassroomCodeShare.tsx new file mode 100644 index 00000000..c82cf935 --- /dev/null +++ b/apps/web/src/components/classroom/ClassroomCodeShare.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useCallback, useState } from 'react' +import { useTheme } from '@/contexts/ThemeContext' +import { css } from '../../../styled-system/css' + +interface ClassroomCodeShareProps { + code: string + /** Compact display for inline use */ + compact?: boolean +} + +/** + * Display classroom join code with copy button + */ +export function ClassroomCodeShare({ code, compact = false }: ClassroomCodeShareProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Fallback for older browsers + const textarea = document.createElement('textarea') + textarea.value = code + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + }, [code]) + + if (compact) { + return ( + + ) + } + + return ( +
+
+

+ Classroom Code +

+ + {code} + +
+ +
+ ) +} diff --git a/apps/web/src/components/classroom/ClassroomDashboard.tsx b/apps/web/src/components/classroom/ClassroomDashboard.tsx new file mode 100644 index 00000000..4ceb6b83 --- /dev/null +++ b/apps/web/src/components/classroom/ClassroomDashboard.tsx @@ -0,0 +1,227 @@ +'use client' + +import { useState } from 'react' +import type { Classroom, Player } from '@/db/schema' +import { useTheme } from '@/contexts/ThemeContext' +import { css } from '../../../styled-system/css' +import { ClassroomCodeShare } from './ClassroomCodeShare' +import { ClassroomTab } from './ClassroomTab' +import { StudentManagerTab } from './StudentManagerTab' + +type TabId = 'classroom' | 'students' + +interface ClassroomDashboardProps { + classroom: Classroom + /** Teacher's own children (get special "parent access" treatment) */ + ownChildren?: Player[] +} + +/** + * ClassroomDashboard - Main teacher dashboard + * + * Two tabs: + * - Classroom: Live view of present students + * - Student Manager: Enrolled students list with progress + * + * Teacher's own children appear separately with full parent access. + */ +export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDashboardProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const [activeTab, setActiveTab] = useState('classroom') + + const tabs: { id: TabId; label: string; icon: string }[] = [ + { id: 'classroom', label: 'Classroom', icon: '🏫' }, + { id: 'students', label: 'Student Manager', icon: '👥' }, + ] + + return ( +
+ {/* Header */} +
+
+

+ {classroom.name} +

+

+ Teacher Dashboard +

+
+ + +
+ + {/* Own children section (if teacher has kids) */} + {ownChildren.length > 0 && ( +
+

+ Your Children +

+
+ {ownChildren.map((child) => ( +
+ + {child.emoji} + + + {child.name} + +
+ ))} +
+
+ )} + + {/* Tab navigation */} + + + {/* Tab content */} +
+ {activeTab === 'classroom' ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/web/src/components/classroom/ClassroomTab.tsx b/apps/web/src/components/classroom/ClassroomTab.tsx new file mode 100644 index 00000000..07cbdbcf --- /dev/null +++ b/apps/web/src/components/classroom/ClassroomTab.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { Classroom } from '@/db/schema' +import { useTheme } from '@/contexts/ThemeContext' +import { css } from '../../../styled-system/css' +import { ClassroomCodeShare } from './ClassroomCodeShare' + +interface ClassroomTabProps { + classroom: Classroom +} + +/** + * ClassroomTab - Shows live classroom view + * + * Displays students currently "present" in the classroom. + * For Phase 3, this is an empty state. + * Phase 6 will add presence functionality. + */ +export function ClassroomTab({ classroom }: ClassroomTabProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + return ( +
+ {/* Empty state */} +
+
+ 🏫 +
+

+ No Students Present +

+

+ When students join your classroom for practice, they'll appear here. Share your classroom + code to get started. +

+ + +
+ + {/* Instructions */} +
+

+ How students join +

+
    +
  1. Share your classroom code with parents
  2. +
  3. Parents enroll their child using the code
  4. +
  5. Students appear here when they start practicing
  6. +
+
+
+ ) +} diff --git a/apps/web/src/components/classroom/CreateClassroomForm.tsx b/apps/web/src/components/classroom/CreateClassroomForm.tsx new file mode 100644 index 00000000..5779dfec --- /dev/null +++ b/apps/web/src/components/classroom/CreateClassroomForm.tsx @@ -0,0 +1,182 @@ +'use client' + +import { useCallback, useState } from 'react' +import { useTheme } from '@/contexts/ThemeContext' +import { useCreateClassroom } from '@/hooks/useClassroom' +import { css } from '../../../styled-system/css' + +interface CreateClassroomFormProps { + onSuccess?: () => void + onCancel?: () => void +} + +/** + * Form to create a new classroom (become a teacher) + */ +export function CreateClassroomForm({ onSuccess, onCancel }: CreateClassroomFormProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + const [name, setName] = useState('') + const createClassroom = useCreateClassroom() + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) return + + try { + await createClassroom.mutateAsync({ name: name.trim() }) + onSuccess?.() + } catch { + // Error is handled by mutation state + } + }, + [name, createClassroom, onSuccess] + ) + + return ( +
+

+ Create Your Classroom +

+

+ As a teacher, you can enroll students and observe their practice sessions. +

+ +
+ + setName(e.target.value)} + placeholder="e.g., Ms. Smith's Math Class" + data-input="classroom-name" + className={css({ + width: '100%', + padding: '12px 14px', + backgroundColor: isDark ? 'gray.700' : 'gray.50', + border: '2px solid', + borderColor: isDark ? 'gray.600' : 'gray.200', + borderRadius: '8px', + fontSize: '1rem', + color: isDark ? 'white' : 'gray.800', + marginBottom: '16px', + outline: 'none', + transition: 'border-color 0.15s ease', + _focus: { + borderColor: isDark ? 'blue.500' : 'blue.400', + }, + _placeholder: { + color: isDark ? 'gray.500' : 'gray.400', + }, + })} + /> + + {createClassroom.error && ( +

+ {createClassroom.error.message} +

+ )} + +
+ {onCancel && ( + + )} + +
+
+
+ ) +} diff --git a/apps/web/src/components/classroom/StudentManagerTab.tsx b/apps/web/src/components/classroom/StudentManagerTab.tsx new file mode 100644 index 00000000..1ccfdc1c --- /dev/null +++ b/apps/web/src/components/classroom/StudentManagerTab.tsx @@ -0,0 +1,117 @@ +'use client' + +import type { Classroom } from '@/db/schema' +import { useTheme } from '@/contexts/ThemeContext' +import { css } from '../../../styled-system/css' +import { ClassroomCodeShare } from './ClassroomCodeShare' + +interface StudentManagerTabProps { + classroom: Classroom +} + +/** + * StudentManagerTab - Manage enrolled students + * + * Shows all students enrolled in the classroom. + * For Phase 3, this is an empty state. + * Phase 4 will add enrollment functionality. + */ +export function StudentManagerTab({ classroom }: StudentManagerTabProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + return ( +
+ {/* Empty state */} +
+
+ 👥 +
+

+ No Students Enrolled +

+

+ Parents can enroll their children using your classroom code. Once enrolled, you can view + their progress and skills here. +

+ + +
+ + {/* What you'll see when students enroll */} +
+

+ When students enroll, you can: +

+
    +
  • View each student's skill mastery and progress
  • +
  • See their practice session history
  • +
  • Observe live practice sessions
  • +
  • Guide them through tutorials remotely
  • +
+
+
+ ) +} diff --git a/apps/web/src/components/classroom/index.ts b/apps/web/src/components/classroom/index.ts new file mode 100644 index 00000000..955b4efc --- /dev/null +++ b/apps/web/src/components/classroom/index.ts @@ -0,0 +1,5 @@ +export { ClassroomCodeShare } from './ClassroomCodeShare' +export { ClassroomDashboard } from './ClassroomDashboard' +export { ClassroomTab } from './ClassroomTab' +export { CreateClassroomForm } from './CreateClassroomForm' +export { StudentManagerTab } from './StudentManagerTab' diff --git a/apps/web/src/hooks/useClassroom.ts b/apps/web/src/hooks/useClassroom.ts index 441c44b4..87cfe9e0 100644 --- a/apps/web/src/hooks/useClassroom.ts +++ b/apps/web/src/hooks/useClassroom.ts @@ -1,76 +1,100 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import type { Classroom } from '@/db/schema/classrooms' +import type { Classroom, User } from '@/db/schema' import { api } from '@/lib/queryClient' +import { classroomKeys } from '@/lib/queryKeys' -// Query keys for classroom data -export const classroomKeys = { - all: ['classroom'] as const, - mine: () => [...classroomKeys.all, 'mine'] as const, - detail: (id: string) => [...classroomKeys.all, 'detail', id] as const, - byCode: (code: string) => [...classroomKeys.all, 'code', code] as const, +// Re-export query keys for consumers +export { classroomKeys } from '@/lib/queryKeys' + +// ============================================================================ +// Types +// ============================================================================ + +export interface ClassroomWithTeacher extends Classroom { + teacher?: User } +// ============================================================================ +// API Functions +// ============================================================================ + /** * Fetch current user's classroom */ async function fetchMyClassroom(): Promise { - const res = await api('classrooms') - if (!res.ok) { - // 404 means no classroom - if (res.status === 404) return null - throw new Error('Failed to fetch classroom') - } + const res = await api('classrooms/mine') + if (res.status === 404) return null + if (!res.ok) throw new Error('Failed to fetch classroom') const data = await res.json() - return data.classroom ?? null -} - -/** - * Create a classroom - */ -async function createClassroom(name: string): Promise { - const res = await api('classrooms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }) - const data = await res.json() - if (!res.ok || !data.success) { - throw new Error(data.error || 'Failed to create classroom') - } return data.classroom } /** - * Look up classroom by code + * Look up classroom by join code */ -async function fetchClassroomByCode( - code: string -): Promise<{ classroom: Classroom; teacherName: string } | null> { - const res = await api(`classrooms/code/${code.toUpperCase()}`) - if (!res.ok) { - if (res.status === 404) return null - throw new Error('Failed to look up classroom') - } +async function fetchClassroomByCode(code: string): Promise { + if (!code || code.length < 4) return null + const res = await api(`classrooms/code/${encodeURIComponent(code)}`) + if (res.status === 404) return null + if (!res.ok) throw new Error('Failed to fetch classroom') const data = await res.json() - return data + return data.classroom } /** - * Hook: Get current user's classroom (if they're a teacher) + * Create a new classroom + */ +async function createClassroom(params: { name: string }): Promise { + const res = await api('classrooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to create classroom') + } + const data = await res.json() + return data.classroom +} + +// ============================================================================ +// Hooks +// ============================================================================ + +/** + * Get current user's classroom (if they are a teacher) */ export function useMyClassroom() { return useQuery({ queryKey: classroomKeys.mine(), queryFn: fetchMyClassroom, - // Don't refetch too aggressively - classrooms rarely change - staleTime: 60_000, // 1 minute + staleTime: 5 * 60 * 1000, // 5 minutes }) } /** - * Hook: Create a classroom + * Look up classroom by join code + * + * Use this when a parent wants to enroll their child. + * The query is disabled until code has at least 4 characters. + */ +export function useClassroomByCode(code: string) { + return useQuery({ + queryKey: classroomKeys.byCode(code.toUpperCase()), + queryFn: () => fetchClassroomByCode(code), + enabled: code.length >= 4, + staleTime: 60 * 1000, // 1 minute + }) +} + +/** + * Create a new classroom + * + * Use this when a user wants to become a teacher. + * Each user can have only one classroom. */ export function useCreateClassroom() { const queryClient = useQueryClient() @@ -78,34 +102,19 @@ export function useCreateClassroom() { return useMutation({ mutationFn: createClassroom, onSuccess: (classroom) => { - // Update the cache with the new classroom + // Update the 'mine' query with the new classroom queryClient.setQueryData(classroomKeys.mine(), classroom) - // Invalidate to ensure consistency - queryClient.invalidateQueries({ queryKey: classroomKeys.all }) }, }) } -/** - * Hook: Look up classroom by join code - */ -export function useClassroomByCode(code: string | null) { - return useQuery({ - queryKey: classroomKeys.byCode(code ?? ''), - queryFn: () => (code ? fetchClassroomByCode(code) : null), - enabled: !!code && code.length >= 4, // Only query if code is entered - staleTime: 30_000, // 30 seconds - }) -} - /** * Check if current user is a teacher (has a classroom) */ export function useIsTeacher() { const { data: classroom, isLoading } = useMyClassroom() return { - isTeacher: classroom !== null && classroom !== undefined, + isTeacher: classroom !== null, isLoading, - classroom, } } diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 5af98b48..1a30eb89 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -34,3 +34,13 @@ export const sessionHistoryKeys = { all: ['sessionHistory'] as const, list: (playerId: string) => [...sessionHistoryKeys.all, playerId] as const, } + +// Classroom query keys +export const classroomKeys = { + all: ['classrooms'] as const, + mine: () => [...classroomKeys.all, 'mine'] as const, + byCode: (code: string) => [...classroomKeys.all, 'byCode', code] as const, + detail: (id: string) => [...classroomKeys.all, 'detail', id] as const, + enrollments: (id: string) => [...classroomKeys.all, 'enrollments', id] as const, + presence: (id: string) => [...classroomKeys.all, 'presence', id] as const, +}