{confirmAction === 'delete' ? 'Delete project' : confirmAction === 'checkpoint' ? 'Restore checkpoint' : 'Archive project'}
diff --git a/web/src/components/ProjectFeedback.test.tsx b/web/src/components/ProjectFeedback.test.tsx
new file mode 100644
index 0000000..c82034a
--- /dev/null
+++ b/web/src/components/ProjectFeedback.test.tsx
@@ -0,0 +1,74 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { api, type CloudProjectComment } from '../lib/api'
+import { ProjectFeedback } from './ProjectFeedback'
+
+vi.mock('../lib/api', () => ({
+ api: {
+ getProjectComments: vi.fn(),
+ markProjectCommentsRead: vi.fn(),
+ createProjectComment: vi.fn(),
+ resolveProjectComment: vi.fn(),
+ },
+}))
+
+const comment: CloudProjectComment = {
+ id: 1,
+ body: 'Explain why this loop stops.',
+ file_path: 'main.rb',
+ line_number: 3,
+ resolved_at: null,
+ edited_at: null,
+ created_at: '2026-07-25T01:00:00.000Z',
+ updated_at: '2026-07-25T01:00:00.000Z',
+ author: { id: 9, full_name: 'Teacher One', role: 'instructor' },
+ resolved_by: null,
+}
+
+describe('ProjectFeedback', () => {
+ beforeEach(() => {
+ vi.mocked(api.getProjectComments).mockResolvedValue({
+ data: { comments: [comment], unread_count: 1 },
+ error: null,
+ })
+ vi.mocked(api.markProjectCommentsRead).mockResolvedValue({ data: null, error: null, status: 204 })
+ vi.mocked(api.createProjectComment).mockResolvedValue({
+ data: { ...comment, id: 2, body: 'I used a counter.', author: { id: 4, full_name: 'Student One', role: 'student' } },
+ error: null,
+ })
+ vi.mocked(api.resolveProjectComment).mockResolvedValue({
+ data: { ...comment, resolved_at: '2026-07-25T02:00:00.000Z' },
+ error: null,
+ })
+ })
+
+ test('loads private feedback, posts a line reply, and resolves a thread', async () => {
+ const user = userEvent.setup()
+ render(
+
,
+ )
+
+ expect(await screen.findByText('Explain why this loop stops.')).toBeTruthy()
+ await waitFor(() => expect(api.markProjectCommentsRead).toHaveBeenCalledWith('42'))
+
+ await user.type(screen.getByLabelText(/Add feedback or reply/), 'I used a counter.')
+ await user.selectOptions(screen.getByLabelText('File (optional)'), 'main.rb')
+ await user.type(screen.getByLabelText('Line (optional)'), '3')
+ await user.click(screen.getByRole('button', { name: 'Post' }))
+
+ await waitFor(() => expect(api.createProjectComment).toHaveBeenCalledWith('42', {
+ body: 'I used a counter.',
+ file_path: 'main.rb',
+ line_number: 3,
+ }))
+ expect(await screen.findByText('I used a counter.')).toBeTruthy()
+
+ await user.click(screen.getAllByRole('button', { name: 'Resolve' })[0])
+ await waitFor(() => expect(api.resolveProjectComment).toHaveBeenCalledWith('42', 1, true))
+ })
+})
diff --git a/web/src/components/ProjectFeedback.tsx b/web/src/components/ProjectFeedback.tsx
new file mode 100644
index 0000000..575e228
--- /dev/null
+++ b/web/src/components/ProjectFeedback.tsx
@@ -0,0 +1,163 @@
+import { useEffect, useMemo, useState } from 'react'
+import { CheckCircle2, CornerDownRight, MessageSquare, RotateCcw, Send } from 'lucide-react'
+import { api, type CloudProjectComment } from '../lib/api'
+import type { ProjectFile } from '../lib/codeRunner'
+
+interface ProjectFeedbackProps {
+ projectId: string
+ files: ProjectFile[]
+ currentUserId?: number
+}
+
+export function ProjectFeedback({ projectId, files, currentUserId }: ProjectFeedbackProps) {
+ const [comments, setComments] = useState
([])
+ const [body, setBody] = useState('')
+ const [filePath, setFilePath] = useState('')
+ const [lineNumber, setLineNumber] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState('')
+ const unresolvedCount = useMemo(() => comments.filter((comment) => !comment.resolved_at).length, [comments])
+
+ useEffect(() => {
+ let cancelled = false
+ queueMicrotask(() => {
+ if (cancelled) return
+ setLoading(true)
+ setError('')
+ })
+
+ api.getProjectComments(projectId).then((res) => {
+ if (cancelled) return
+ if (res.data) {
+ setComments(res.data.comments)
+ api.markProjectCommentsRead(projectId)
+ } else {
+ setError(res.error || 'Could not load feedback.')
+ }
+ setLoading(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [projectId])
+
+ const submitComment = async (event: React.FormEvent) => {
+ event.preventDefault()
+ const trimmedBody = body.trim()
+ if (!trimmedBody || submitting) return
+
+ setSubmitting(true)
+ setError('')
+ const parsedLineNumber = Number.parseInt(lineNumber, 10)
+ const res = await api.createProjectComment(projectId, {
+ body: trimmedBody,
+ ...(filePath ? { file_path: filePath } : {}),
+ ...(filePath && Number.isInteger(parsedLineNumber) && parsedLineNumber > 0 ? { line_number: parsedLineNumber } : {}),
+ })
+
+ if (res.data) {
+ setComments((current) => [...current, res.data!])
+ setBody('')
+ setLineNumber('')
+ } else {
+ setError(res.error || 'Could not post feedback.')
+ }
+ setSubmitting(false)
+ }
+
+ const toggleResolved = async (comment: CloudProjectComment) => {
+ const res = await api.resolveProjectComment(projectId, comment.id, !comment.resolved_at)
+ if (res.data) {
+ setComments((current) => current.map((candidate) => candidate.id === res.data!.id ? res.data! : candidate))
+ } else {
+ setError(res.error || 'Could not update the feedback thread.')
+ }
+ }
+
+ return (
+
+
+
+
Review
+
Teacher feedback
+
Private to the student who owns this project and the class teaching staff.
+
+
{unresolvedCount} open
+
+
+ {loading ? (
+ Loading feedback…
+ ) : comments.length === 0 ? (
+ No feedback yet. Start a focused project conversation here.
+ ) : (
+
+ {comments.map((comment) => (
+
+
+
+ {comment.author.id === currentUserId ? 'You' : comment.author.full_name}
+ {comment.author.role}
+
+
+
+ {(comment.file_path || comment.line_number) && (
+
+ {comment.file_path}{comment.line_number ? ` · line ${comment.line_number}` : ''}
+
+ )}
+ {comment.body}
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/web/src/components/WebPreview.tsx b/web/src/components/WebPreview.tsx
index cc8880b..3b5bb77 100644
--- a/web/src/components/WebPreview.tsx
+++ b/web/src/components/WebPreview.tsx
@@ -97,7 +97,7 @@ export function WebPreview({ files, entryPath }: { files: ProjectFile[]; entryPa
diff --git a/web/src/hooks/useModalFocus.test.tsx b/web/src/hooks/useModalFocus.test.tsx
new file mode 100644
index 0000000..f887a1e
--- /dev/null
+++ b/web/src/hooks/useModalFocus.test.tsx
@@ -0,0 +1,50 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { useModalFocus } from './useModalFocus'
+
+function ModalHarness() {
+ const [open, setOpen] = useState(false)
+ const dialogRef = useModalFocus(open, () => setOpen(false))
+
+ return (
+ <>
+
+ {open && (
+
+
+
+
+ )}
+ >
+ )
+}
+
+describe('useModalFocus', () => {
+ it('moves focus inside, traps Tab, closes with Escape, and returns focus', () => {
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+ callback(0)
+ return 1
+ })
+
+ render()
+ const trigger = screen.getByRole('button', { name: 'Open dialog' })
+ trigger.focus()
+ fireEvent.click(trigger)
+
+ const first = screen.getByRole('button', { name: 'First action' })
+ const last = screen.getByRole('button', { name: 'Last action' })
+ expect(document.activeElement).toBe(first)
+
+ last.focus()
+ fireEvent.keyDown(document, { key: 'Tab' })
+ expect(document.activeElement).toBe(first)
+
+ fireEvent.keyDown(document, { key: 'Tab', shiftKey: true })
+ expect(document.activeElement).toBe(last)
+
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(screen.queryByRole('dialog')).toBeNull()
+ expect(document.activeElement).toBe(trigger)
+ })
+})
diff --git a/web/src/hooks/useModalFocus.ts b/web/src/hooks/useModalFocus.ts
new file mode 100644
index 0000000..560bfc7
--- /dev/null
+++ b/web/src/hooks/useModalFocus.ts
@@ -0,0 +1,63 @@
+import { useEffect, useRef, type RefObject } from 'react'
+
+const FOCUSABLE_SELECTOR = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+].join(',')
+
+export function useModalFocus(open: boolean, onClose: () => void): RefObject {
+ const dialogRef = useRef(null)
+ const closeRef = useRef(onClose)
+ const returnFocusRef = useRef(null)
+
+ useEffect(() => {
+ closeRef.current = onClose
+ }, [onClose])
+
+ useEffect(() => {
+ if (!open) return
+
+ returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
+ const dialog = dialogRef.current
+ const focusable = dialog ? Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)) : []
+ window.requestAnimationFrame(() => (focusable[0] ?? dialog)?.focus())
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ closeRef.current()
+ return
+ }
+ if (event.key !== 'Tab' || !dialog) return
+
+ const currentFocusable = Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR))
+ if (currentFocusable.length === 0) {
+ event.preventDefault()
+ dialog.focus()
+ return
+ }
+
+ const first = currentFocusable[0]
+ const last = currentFocusable[currentFocusable.length - 1]
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault()
+ last.focus()
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault()
+ first.focus()
+ }
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ window.requestAnimationFrame(() => returnFocusRef.current?.focus())
+ }
+ }, [open])
+
+ return dialogRef
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index c77c2f3..1ff8a1d 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -17,7 +17,9 @@ export interface CloudOrganization {
id: number
name: string
slug: string
- role: 'student' | 'instructor' | 'owner'
+ role: 'student' | 'instructor' | 'owner' | 'admin'
+ school_year?: string | null
+ archived_at?: string | null
}
export interface CloudOrgMember extends CloudUser {
@@ -29,10 +31,15 @@ export interface CloudOrgMember extends CloudUser {
export interface CloudOrgInvitation {
id?: number
token: string
- email: string
+ email?: string
role: 'student' | 'instructor'
invitation_url?: string
email_sent?: boolean
+ email_queued?: boolean
+ delivery_status?: 'pending' | 'queued' | 'sent' | 'failed'
+ delivery_error?: string
+ last_sent_at?: string
+ send_attempts?: number
accepted_at?: string | null
expires_at: string
created_at?: string
@@ -43,6 +50,36 @@ export interface CloudOrgInvitation {
}
}
+export interface CloudProjectComment {
+ id: number
+ body: string
+ file_path: string | null
+ line_number: number | null
+ resolved_at: string | null
+ edited_at: string | null
+ created_at: string
+ updated_at: string
+ author: {
+ id: number
+ full_name: string
+ role: 'student' | 'instructor' | 'owner' | 'admin'
+ }
+ resolved_by: {
+ id: number
+ full_name: string
+ } | null
+}
+
+export interface CloudAuditEvent {
+ id: number
+ action: string
+ actor: { id: number; full_name: string } | null
+ target_type: string | null
+ target_id: number | null
+ metadata: Record
+ created_at: string
+}
+
interface ApiProjectFile {
path: string
language: ProjectFile['language']
@@ -67,12 +104,16 @@ interface ApiProject {
archived_at: string | null
created_at: string
updated_at: string
+ lock_version: number
files: ApiProjectFile[]
}
interface ApiResponse {
data: T | null
error: string | null
+ status: number
+ code?: string | null
+ errorData?: Record | null
}
interface ApiCheckpoint {
@@ -112,12 +153,15 @@ async function fetchApi(endpoint: string, options: RequestInit = {}): Promise
return {
data: null,
error: errorBody.error || (Array.isArray(errorBody.errors) ? errorBody.errors.join(', ') : null) || `Request failed with status ${response.status}`,
+ status: response.status,
+ code: typeof errorBody.code === 'string' ? errorBody.code : null,
+ errorData: errorBody,
}
}
- if (response.status === 204) return { data: null, error: null }
- return { data: await response.json() as T, error: null }
+ if (response.status === 204) return { data: null, error: null, status: response.status }
+ return { data: await response.json() as T, error: null, status: response.status }
} catch (error) {
- return { data: null, error: error instanceof Error ? error.message : 'Network error' }
+ return { data: null, error: error instanceof Error ? error.message : 'Network error', status: 0 }
}
}
@@ -139,6 +183,7 @@ function apiProjectToSavedProject(project: ApiProject): SavedProject {
createdAt: project.created_at,
updatedAt: project.updated_at,
archivedAt: project.archived_at,
+ lockVersion: project.lock_version,
})
if (!normalized) throw new Error('Cloud project was not valid.')
@@ -152,6 +197,7 @@ function savedProjectPayload(project: SavedProject) {
entry_path: project.entryPath,
visibility: project.visibility,
organization_id: project.organizationId,
+ lock_version: project.lockVersion,
files: project.files.map((file, index) => ({ ...file, position: index })),
}
}
@@ -204,6 +250,29 @@ export const api = {
})
return res.error ? { data: null, error: res.error } : { data: res.data?.organization ?? null, error: null }
},
+ updateOrganization: async (organizationId: string, input: { name?: string; school_year?: string }) => {
+ const res = await fetchApi<{ organization: CloudOrganization }>(`/api/v1/organizations/${organizationId}`, {
+ method: 'PATCH',
+ body: JSON.stringify(input),
+ })
+ return res.error ? { data: null, error: res.error } : { data: res.data?.organization ?? null, error: null }
+ },
+ archiveOrganization: async (organizationId: string) => {
+ const res = await fetchApi<{ organization: CloudOrganization }>(`/api/v1/organizations/${organizationId}/archive`, { method: 'PATCH' })
+ return res.error ? { data: null, error: res.error } : { data: res.data?.organization ?? null, error: null }
+ },
+ unarchiveOrganization: async (organizationId: string) => {
+ const res = await fetchApi<{ organization: CloudOrganization }>(`/api/v1/organizations/${organizationId}/unarchive`, { method: 'PATCH' })
+ return res.error ? { data: null, error: res.error } : { data: res.data?.organization ?? null, error: null }
+ },
+ exportOrganization: async (organizationId: string) => {
+ const res = await fetchApi<{ export: Record }>(`/api/v1/organizations/${organizationId}/export`)
+ return res.error ? { data: null, error: res.error } : { data: res.data?.export ?? null, error: null }
+ },
+ getOrganizationAuditEvents: async (organizationId: string) => {
+ const res = await fetchApi<{ audit_events: CloudAuditEvent[] }>(`/api/v1/organizations/${organizationId}/audit_events`)
+ return res.error ? { data: null, error: res.error } : { data: res.data?.audit_events ?? [], error: null }
+ },
getOrgMembers: async (organizationId: string) => {
const res = await fetchApi<{ members: CloudOrgMember[] }>(`/api/v1/organizations/${organizationId}/members`)
return res.error ? { data: null, error: res.error } : { data: res.data?.members ?? [], error: null }
@@ -219,6 +288,27 @@ export const api = {
})
return res.error ? { data: null, error: res.error } : { data: res.data?.invitation ?? null, error: null }
},
+ createOrgInvitations: async (organizationId: string, emails: string[], role: CloudOrgInvitation['role']) => {
+ const res = await fetchApi<{ invitations: CloudOrgInvitation[]; errors: Array<{ email: string; errors: string[] }> }>(
+ `/api/v1/organizations/${organizationId}/bulk_invite`,
+ {
+ method: 'POST',
+ body: JSON.stringify({ emails, role }),
+ },
+ )
+ if (res.status === 207 && res.errorData) {
+ return {
+ data: {
+ invitations: Array.isArray(res.errorData.invitations) ? res.errorData.invitations as CloudOrgInvitation[] : [],
+ errors: Array.isArray(res.errorData.errors) ? res.errorData.errors as Array<{ email: string; errors: string[] }> : [],
+ },
+ error: null,
+ }
+ }
+ return res.error
+ ? { data: null, error: res.error }
+ : { data: res.data ?? { invitations: [], errors: [] }, error: null }
+ },
resendOrgInvitation: async (organizationId: string, invitationId: number) => {
const res = await fetchApi<{ invitation: CloudOrgInvitation }>(`/api/v1/organizations/${organizationId}/invitations/${invitationId}/resend`, { method: 'POST' })
return res.error ? { data: null, error: res.error } : { data: res.data?.invitation ?? null, error: null }
@@ -241,24 +331,66 @@ export const api = {
return res.error ? { data: null, error: res.error } : { data: res.data?.organization ?? null, error: null }
},
getProjects: async (organizationId?: string | null) => {
- const endpoint = organizationId ? `/api/v1/projects?organization_id=${encodeURIComponent(organizationId)}` : '/api/v1/projects'
- const res = await fetchApi<{ projects: ApiProject[] }>(endpoint)
- return res.error ? { data: null, error: res.error } : { data: res.data?.projects.map(apiProjectToSavedProject) ?? [], error: null }
+ const projects: SavedProject[] = []
+ let page = 1
+ while (true) {
+ const query = new URLSearchParams({ page: String(page), per_page: '100' })
+ if (organizationId) query.set('organization_id', organizationId)
+ const res = await fetchApi<{
+ projects: ApiProject[]
+ pagination: { page: number; total_pages: number }
+ }>(`/api/v1/projects?${query}`)
+ if (res.error) return { data: null, error: res.error }
+ projects.push(...(res.data?.projects.map(apiProjectToSavedProject) ?? []))
+ const totalPages = res.data?.pagination.total_pages ?? 1
+ if (page >= totalPages) break
+ page += 1
+ }
+
+ return { data: projects, error: null }
},
createProject: async (project: SavedProject) => {
const res = await fetchApi<{ project: ApiProject }>('/api/v1/projects', {
method: 'POST',
body: JSON.stringify(savedProjectPayload(project)),
})
- return res.error ? { data: null, error: res.error } : { data: res.data ? apiProjectToSavedProject(res.data.project) : null, error: null }
+ return res.error
+ ? { data: null, error: res.error, status: res.status, code: res.code, conflictProject: null }
+ : { data: res.data ? apiProjectToSavedProject(res.data.project) : null, error: null, status: res.status, code: null, conflictProject: null }
},
updateProject: async (project: SavedProject) => {
const res = await fetchApi<{ project: ApiProject }>(`/api/v1/projects/${project.id}`, {
method: 'PATCH',
body: JSON.stringify(savedProjectPayload(project)),
})
- return res.error ? { data: null, error: res.error } : { data: res.data ? apiProjectToSavedProject(res.data.project) : null, error: null }
+ const conflictProject = res.code === 'project_conflict' && res.errorData?.project
+ ? apiProjectToSavedProject(res.errorData.project as ApiProject)
+ : null
+ return res.error
+ ? { data: null, error: res.error, status: res.status, code: res.code, conflictProject }
+ : { data: res.data ? apiProjectToSavedProject(res.data.project) : null, error: null, status: res.status, code: null, conflictProject: null }
+ },
+ getProjectComments: async (projectId: string) => {
+ const res = await fetchApi<{ comments: CloudProjectComment[]; unread_count: number }>(`/api/v1/projects/${projectId}/comments`)
+ return res.error
+ ? { data: null, error: res.error }
+ : { data: res.data ?? { comments: [], unread_count: 0 }, error: null }
+ },
+ createProjectComment: async (projectId: string, input: { body: string; file_path?: string; line_number?: number }) => {
+ const res = await fetchApi<{ comment: CloudProjectComment }>(`/api/v1/projects/${projectId}/comments`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ })
+ return res.error ? { data: null, error: res.error } : { data: res.data?.comment ?? null, error: null }
+ },
+ resolveProjectComment: async (projectId: string, commentId: number, resolved: boolean) => {
+ const res = await fetchApi<{ comment: CloudProjectComment }>(`/api/v1/projects/${projectId}/comments/${commentId}/resolve`, {
+ method: 'PATCH',
+ body: JSON.stringify({ resolved }),
+ })
+ return res.error ? { data: null, error: res.error } : { data: res.data?.comment ?? null, error: null }
},
+ markProjectCommentsRead: (projectId: string) => fetchApi(`/api/v1/projects/${projectId}/comments/mark_read`, { method: 'POST' }),
archiveProject: async (id: string) => {
const res = await fetchApi<{ project: ApiProject }>(`/api/v1/projects/${id}/archive`, { method: 'PATCH' })
return res.error ? { data: null, error: res.error } : { data: res.data ? apiProjectToSavedProject(res.data.project) : null, error: null }
diff --git a/web/src/lib/cloudSyncStorage.test.ts b/web/src/lib/cloudSyncStorage.test.ts
new file mode 100644
index 0000000..ffe2236
--- /dev/null
+++ b/web/src/lib/cloudSyncStorage.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from 'vitest'
+import {
+ clearProjectPendingCloudSync,
+ markProjectPendingCloudSync,
+ pendingCloudProjectIds,
+ replacePendingCloudProjectId,
+} from './cloudSyncStorage'
+
+describe('pending cloud sync registry', () => {
+ test('persists, replaces, and clears project identities', () => {
+ markProjectPendingCloudSync('local-draft', '2026-07-25T01:00:00.000Z')
+ expect(pendingCloudProjectIds()).toEqual(new Set(['local-draft']))
+
+ replacePendingCloudProjectId('local-draft', '42', '2026-07-25T01:00:01.000Z')
+ expect(pendingCloudProjectIds()).toEqual(new Set(['42']))
+
+ clearProjectPendingCloudSync('42')
+ expect(pendingCloudProjectIds()).toEqual(new Set())
+ })
+
+ test('recovers safely from malformed local storage', () => {
+ localStorage.setItem('hafa-code-pending-cloud-sync-v1', '{not json')
+ expect(pendingCloudProjectIds()).toEqual(new Set())
+ })
+})
diff --git a/web/src/lib/cloudSyncStorage.ts b/web/src/lib/cloudSyncStorage.ts
new file mode 100644
index 0000000..8e2ef89
--- /dev/null
+++ b/web/src/lib/cloudSyncStorage.ts
@@ -0,0 +1,48 @@
+const PENDING_SYNC_STORAGE_KEY = 'hafa-code-pending-cloud-sync-v1'
+
+type PendingSyncMap = Record
+
+function loadPendingSyncMap(): PendingSyncMap {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(PENDING_SYNC_STORAGE_KEY) || '{}') as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+
+ return Object.fromEntries(
+ Object.entries(parsed).filter(([id, updatedAt]) => typeof id === 'string' && typeof updatedAt === 'string'),
+ )
+ } catch {
+ return {}
+ }
+}
+
+function savePendingSyncMap(pending: PendingSyncMap) {
+ if (Object.keys(pending).length === 0) {
+ localStorage.removeItem(PENDING_SYNC_STORAGE_KEY)
+ return
+ }
+
+ localStorage.setItem(PENDING_SYNC_STORAGE_KEY, JSON.stringify(pending))
+}
+
+export function pendingCloudProjectIds() {
+ return new Set(Object.keys(loadPendingSyncMap()))
+}
+
+export function markProjectPendingCloudSync(projectId: string, updatedAt: string) {
+ const pending = loadPendingSyncMap()
+ pending[projectId] = updatedAt
+ savePendingSyncMap(pending)
+}
+
+export function clearProjectPendingCloudSync(projectId: string) {
+ const pending = loadPendingSyncMap()
+ delete pending[projectId]
+ savePendingSyncMap(pending)
+}
+
+export function replacePendingCloudProjectId(previousId: string, nextId: string, updatedAt: string) {
+ const pending = loadPendingSyncMap()
+ delete pending[previousId]
+ pending[nextId] = updatedAt
+ savePendingSyncMap(pending)
+}
diff --git a/web/src/lib/codeRunner.ts b/web/src/lib/codeRunner.ts
index f5f942e..03fd002 100644
--- a/web/src/lib/codeRunner.ts
+++ b/web/src/lib/codeRunner.ts
@@ -29,6 +29,7 @@ export interface SavedProject {
createdAt: string
updatedAt: string
archivedAt?: string | null
+ lockVersion?: number
}
export interface ProjectSnapshot {
diff --git a/web/src/lib/projectStorage.ts b/web/src/lib/projectStorage.ts
index 6a501ab..a61507f 100644
--- a/web/src/lib/projectStorage.ts
+++ b/web/src/lib/projectStorage.ts
@@ -70,6 +70,7 @@ export function normalizeProject(candidate: Partial | null | undef
createdAt: String(candidate.createdAt || now),
updatedAt: String(candidate.updatedAt || now),
archivedAt: candidate.archivedAt ? String(candidate.archivedAt) : null,
+ lockVersion: Number.isInteger(candidate.lockVersion) ? candidate.lockVersion : undefined,
}
}
@@ -199,13 +200,22 @@ export function duplicateProject(project: SavedProject): SavedProject {
id: crypto.randomUUID(),
title: `${project.title} Copy`,
visibility: 'private',
- organizationId: null,
+ organizationId: project.organizationId ?? null,
owner: null,
- organization: null,
+ organization: project.organization ?? null,
files: project.files.map((file) => ({ ...file })),
createdAt: now,
updatedAt: now,
archivedAt: null,
+ lockVersion: undefined,
+ }
+}
+
+export function createConflictCopy(project: SavedProject): SavedProject {
+ const suffix = ' Conflict Copy'
+ return {
+ ...duplicateProject(project),
+ title: `${project.title.slice(0, 120 - suffix.length)}${suffix}`,
}
}
diff --git a/web/src/lib/workspace.test.ts b/web/src/lib/workspace.test.ts
new file mode 100644
index 0000000..bdcf4db
--- /dev/null
+++ b/web/src/lib/workspace.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, test, vi } from 'vitest'
+import type { SavedProject } from './codeRunner'
+import { markProjectPendingCloudSync } from './cloudSyncStorage'
+import { createConflictCopy, duplicateProject, type ProjectLibrary } from './projectStorage'
+import { canViewProjectFeedback, mergeCloudAndLocalProjects } from './workspace'
+
+function project(id: string, updatedAt: string, organizationId: string | null = '10'): SavedProject {
+ return {
+ id,
+ title: `Project ${id}`,
+ kind: 'ruby',
+ visibility: 'private',
+ organizationId,
+ organization: organizationId ? { id: Number(organizationId), name: 'FDMS', slug: 'fdms' } : null,
+ owner: { id: 1, fullName: 'Student One' },
+ entryPath: 'main.rb',
+ files: [{ path: 'main.rb', language: 'ruby', content: `puts ${JSON.stringify(updatedAt)}` }],
+ createdAt: '2026-07-01T00:00:00.000Z',
+ updatedAt,
+ archivedAt: null,
+ lockVersion: 2,
+ }
+}
+
+describe('classroom project reconciliation', () => {
+ test('keeps an unsynced class draft and projects from other workspaces', () => {
+ const draft = project('draft-uuid', '2026-07-25T01:00:00.000Z')
+ const personal = project('personal-uuid', '2026-07-25T02:00:00.000Z', null)
+ const library: ProjectLibrary = { activeProjectId: draft.id, projects: [draft, personal] }
+
+ const merged = mergeCloudAndLocalProjects([], library, '10')
+
+ expect(merged.projects.map((candidate) => candidate.id)).toEqual([draft.id, personal.id])
+ expect(merged.activeProjectId).toBe(draft.id)
+ })
+
+ test('prefers a pending local revision over an older cloud response', () => {
+ const local = project('42', '2026-07-25T02:00:00.000Z')
+ const cloud = project('42', '2026-07-25T01:00:00.000Z')
+ markProjectPendingCloudSync(local.id, local.updatedAt)
+
+ const merged = mergeCloudAndLocalProjects(
+ [cloud],
+ { activeProjectId: local.id, projects: [local] },
+ '10',
+ )
+
+ expect(merged.projects[0].updatedAt).toBe(local.updatedAt)
+ expect(merged.projects[0].files[0].content).toContain(local.updatedAt)
+ })
+
+ test('uses the cloud revision when the local copy is fully synced', () => {
+ const local = project('42', '2026-07-25T01:00:00.000Z')
+ const cloud = project('42', '2026-07-25T02:00:00.000Z')
+
+ const merged = mergeCloudAndLocalProjects(
+ [cloud],
+ { activeProjectId: local.id, projects: [local] },
+ '10',
+ )
+
+ expect(merged.projects[0].updatedAt).toBe(cloud.updatedAt)
+ })
+})
+
+describe('class starter duplication', () => {
+ test('keeps the copy in its class and makes it teacher-only', () => {
+ vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-4000-8000-000000000001')
+ const source = { ...project('42', '2026-07-25T01:00:00.000Z'), visibility: 'organization' as const }
+
+ const copy = duplicateProject(source)
+
+ expect(copy.id).not.toBe(source.id)
+ expect(copy.organizationId).toBe(source.organizationId)
+ expect(copy.organization).toEqual(source.organization)
+ expect(copy.visibility).toBe('private')
+ expect(copy.owner).toBeNull()
+ expect(copy.lockVersion).toBeUndefined()
+ expect(copy.files).not.toBe(source.files)
+ })
+})
+
+describe('save conflict recovery', () => {
+ test('preserves the local work as a private unsynced class copy', () => {
+ vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-4000-8000-000000000002')
+ const source = {
+ ...project('42', '2026-07-25T01:00:00.000Z'),
+ title: 'A'.repeat(120),
+ visibility: 'organization' as const,
+ }
+
+ const copy = createConflictCopy(source)
+
+ expect(copy.id).not.toBe(source.id)
+ expect(copy.title).toHaveLength(120)
+ expect(copy.title).toMatch(/ Conflict Copy$/)
+ expect(copy.visibility).toBe('private')
+ expect(copy.organizationId).toBe(source.organizationId)
+ expect(copy.lockVersion).toBeUndefined()
+ expect(copy.files[0].content).toBe(source.files[0].content)
+ })
+})
+
+describe('archived classroom feedback access', () => {
+ test('keeps feedback visible to the project owner independently of editability', () => {
+ const archived = {
+ ...project('42', '2026-07-25T01:00:00.000Z'),
+ archivedAt: '2026-07-25T03:00:00.000Z',
+ }
+
+ expect(canViewProjectFeedback(archived, true, archived.owner?.id, false)).toBe(true)
+ expect(canViewProjectFeedback(archived, true, 999, false)).toBe(false)
+ expect(canViewProjectFeedback(archived, true, 999, true)).toBe(true)
+ })
+})
diff --git a/web/src/lib/workspace.ts b/web/src/lib/workspace.ts
index bc78e2f..f6c66c4 100644
--- a/web/src/lib/workspace.ts
+++ b/web/src/lib/workspace.ts
@@ -1,5 +1,6 @@
import { inferFileLanguage, type ProjectFile, type ProjectKind, type ProjectVisibility, type SavedProject } from './codeRunner'
import { decodeSharedProject, loadProjectLibrary, type ProjectLibrary } from './projectStorage'
+import { pendingCloudProjectIds } from './cloudSyncStorage'
export type FileDialogMode = 'create' | 'rename' | 'duplicate'
@@ -11,7 +12,7 @@ export interface FileDialogState {
export type ConfirmAction = 'archive' | 'delete' | 'checkpoint' | null
export type MobileTab = 'home' | 'projects' | 'code' | 'output' | 'history'
-export type ClassroomTab = 'people' | 'invitations'
+export type ClassroomTab = 'people' | 'invitations' | 'settings'
export const PROJECT_FILE_LIMIT = 50
@@ -23,14 +24,14 @@ export const kindLabels: Record = {
export const visibilityLabels: Record = {
private: 'Private',
- organization: 'Org',
+ organization: 'Class',
unlisted: 'Unlisted',
public: 'Public',
}
export const visibilityDescriptions: Record = {
- private: 'Only you can edit or list it. In orgs, instructors and owners can view and run it.',
- organization: 'Members of this org can find, view, and run it. Only you can edit it.',
+ private: 'Only you can edit it. In a class, instructors and owners can also view it and give feedback.',
+ organization: 'Everyone in this class can find, view, and run it. Only you can edit it.',
unlisted: 'Anyone with the direct link can view and run it, but it is hidden from org lists.',
public: 'Anyone with access to Hafa Code can view and run it, and org members can find it in lists.',
}
@@ -179,14 +180,21 @@ export async function writeClipboardText(text: string) {
}
export function mergeCloudAndLocalProjects(cloudProjects: SavedProject[], localLibrary: ProjectLibrary, organizationId: string | null): ProjectLibrary {
- const localOnlyProjects = organizationId ? [] : localLibrary.projects.filter((candidate) => !isCloudProjectId(candidate.id))
- const projects = [...cloudProjects, ...localOnlyProjects]
+ const pendingIds = pendingCloudProjectIds()
+ const localContextProjects = localLibrary.projects.filter((candidate) => projectContextMatches(candidate, organizationId))
+ const localById = new Map(localContextProjects.map((candidate) => [candidate.id, candidate]))
+ const mergedCloudProjects = cloudProjects.map((cloudProject) => {
+ const localProject = localById.get(cloudProject.id)
+ return localProject && pendingIds.has(localProject.id) ? localProject : cloudProject
+ })
+ const cloudIds = new Set(cloudProjects.map((candidate) => candidate.id))
+ const unsyncedContextProjects = localContextProjects.filter((candidate) => !cloudIds.has(candidate.id) && (!isCloudProjectId(candidate.id) || pendingIds.has(candidate.id)))
+ const otherContextProjects = localLibrary.projects.filter((candidate) => !projectContextMatches(candidate, organizationId))
+ const projects = [...mergedCloudProjects, ...unsyncedContextProjects, ...otherContextProjects]
if (projects.length === 0) return localLibrary
- const activeProjectId = organizationId && cloudProjects.length > 0
- ? cloudProjects[0].id
- : projects.some((candidate) => candidate.id === localLibrary.activeProjectId)
- ? localLibrary.activeProjectId
- : projects[0].id
+ const activeProjectId = projects.some((candidate) => candidate.id === localLibrary.activeProjectId)
+ ? localLibrary.activeProjectId
+ : (mergedCloudProjects[0] ?? unsyncedContextProjects[0] ?? projects[0]).id
return { activeProjectId, projects }
}
@@ -195,6 +203,18 @@ export function projectContextMatches(project: SavedProject, organizationId: str
return organizationId ? project.organizationId === organizationId : !project.organizationId
}
+export function canViewProjectFeedback(
+ project: SavedProject,
+ isSignedIn: boolean,
+ currentUserId: number | undefined,
+ canUseInstructorPanel: boolean,
+) {
+ if (!isSignedIn || !isCloudProjectId(project.id)) return false
+
+ const ownsProject = !project.owner || project.owner.id === currentUserId
+ return ownsProject || Boolean(project.organizationId && canUseInstructorPanel)
+}
+
export function availableVisibilityOptions(organizationId: string | null): ProjectVisibility[] {
- return organizationId ? ['private', 'organization', 'unlisted', 'public'] : ['private', 'unlisted', 'public']
+ return organizationId ? ['private', 'organization'] : ['private', 'unlisted', 'public']
}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 22d36e1..80f3e46 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -3,11 +3,11 @@ import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/clerk-react'
import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
-import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
-import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
-import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
-import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
-import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
+import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'
+import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker'
+import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker.js?worker'
+import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker.js?worker'
+import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker.js?worker'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from './contexts/AuthContext.tsx'
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
new file mode 100644
index 0000000..4cb1971
--- /dev/null
+++ b/web/src/test/setup.ts
@@ -0,0 +1,6 @@
+import { afterEach } from 'vitest'
+
+afterEach(() => {
+ localStorage.clear()
+ window.history.replaceState(null, '', '/')
+})
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 4482f45..9a066d6 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,10 +1,14 @@
-import { defineConfig, type Plugin } from 'vite'
+import type { Plugin } from 'vite'
+import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
interface BundleEntry {
fileName: string
+ type: 'asset' | 'chunk'
+ isEntry?: boolean
+ imports?: string[]
}
const STATIC_APP_SHELL = [
@@ -26,9 +30,21 @@ function buildServiceWorker(): Plugin {
apply: 'build',
generateBundle(_options, bundle: Record) {
const appShell = new Set(STATIC_APP_SHELL)
+ const entryImports = new Set()
+ const visitEntryImports = (fileName: string) => {
+ if (entryImports.has(fileName)) return
+ entryImports.add(fileName)
+ const item = bundle[fileName]
+ item?.imports?.forEach(visitEntryImports)
+ }
Object.values(bundle).forEach((item) => {
- if (!/\.(css|js|json|png|svg|ttf|wasm)$/i.test(item.fileName)) return
+ if (item.type === 'chunk' && item.isEntry) visitEntryImports(item.fileName)
+ })
+
+ Object.values(bundle).forEach((item) => {
+ const isLightweightAsset = /\.(css|json|png|svg|ttf)$/i.test(item.fileName)
+ if (!isLightweightAsset && !entryImports.has(item.fileName)) return
appShell.add(`/${item.fileName}`)
})
@@ -46,6 +62,10 @@ function buildServiceWorker(): Plugin {
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), buildServiceWorker()],
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ },
optimizeDeps: {
exclude: [
'@jitl/quickjs-wasmfile-release-sync',