diff --git a/web/.env.example b/web/.env.example index 866d1c7..ea2a39f 100644 --- a/web/.env.example +++ b/web/.env.example @@ -35,3 +35,7 @@ VITE_LOGODEV_TOKEN= # CAPTCHA off is harmless; enabling CAPTCHA in Supabase without this key makes # every auth call fail. VITE_TURNSTILE_SITE_KEY= + +# PostHog error tracking. Unset means errors stay in the console. +VITE_POSTHOG_KEY= +VITE_POSTHOG_HOST=https://us.i.posthog.com diff --git a/web/package-lock.json b/web/package-lock.json index 01f5adb..61eed85 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@supabase/supabase-js": "^2.101.1", "d3-geo": "^3.1.1", "framer-motion": "^12.38.0", + "posthog-js": "^1.434.2", "react": "^19.2.4", "react-dom": "^19.2.4" }, @@ -753,6 +754,31 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@posthog/browser-common": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.0.tgz", + "integrity": "sha512-EWoVzs1vVY7ihpn8yOjj3jyjriHUEEMVSBNL/TW3hk09fTriWRe76vJXrRg0G0yjTYxHmtlSkNn4mOV24ZEzzA==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.54.2", + "@posthog/types": "^1.412.1" + } + }, + "node_modules/@posthog/core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.0.tgz", + "integrity": "sha512-x+0FR+uHsK3uJeofd5X4S2YS7hjaWkzkPN0X1GROHm9SvKw2RJ91kfQ7gT1bPtFwZSHNiRtteB7fX/xEMRN0IQ==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.412.2" + } + }, + "node_modules/@posthog/types": { + "version": "1.412.2", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.412.2.tgz", + "integrity": "sha512-taxTh8o6n7ZVlC8qBypZvF+6w7/18yNkS9XDiGCbutGTKqIljfzYi/7pumgwN3mcnl/n0e9A+JhF05+nLd4Xaw==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -1279,7 +1305,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1295,6 +1321,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2033,6 +2066,20 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2080,7 +2127,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -2191,6 +2238,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2548,6 +2604,12 @@ } } }, + "node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3709,6 +3771,54 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-js": { + "version": "1.434.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.434.2.tgz", + "integrity": "sha512-xyvjj88U2Cp1JfDzmifLvNbdiY/z+YbTT1DaRRKRZ+qzaF9IKqMLumpv8jTKGh6y3m4vc3xz0ku8h5Tw0XU9zQ==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.9.0", + "@posthog/core": "^1.55.0", + "@posthog/types": "^1.412.2", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^6.2.1", + "web-vitals-soft-navs": "npm:web-vitals@6.2.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3759,6 +3869,12 @@ "node": ">=6" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -4384,6 +4500,19 @@ "node": ">=18" } }, + "node_modules/web-vitals": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.2.tgz", + "integrity": "sha512-oto5x6dLEgrRqfcWed+pZEUb2q6ikbFmqF54CRDhI/QGbn+qxC49b4C4OkbH+kb9C3a8shpFD3SgR9kRcs80ZQ==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", + "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/web/package.json b/web/package.json index 1539034..c6fe76c 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@supabase/supabase-js": "^2.101.1", "d3-geo": "^3.1.1", "framer-motion": "^12.38.0", + "posthog-js": "^1.434.2", "react": "^19.2.4", "react-dom": "^19.2.4" }, diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index a8c2240..9ceeee1 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -13,6 +13,7 @@ import { useToast } from '../contexts/ToastContext' import { updateProfile, markIntroSeen } from '../lib/profiles' import { yearGroupOf } from '../data/timelineData' import { useDeadlineEvents } from '../lib/useDeadlineEvents' +import { reportError } from '../lib/errorTracking' import { resolveDeadlinePreferences } from '../lib/preferences' import WeekOverview from './WeekOverview' import DeadlinePanel from './DeadlinePanel' @@ -40,7 +41,10 @@ class ModuleErrorBoundary extends Component< > { state: { error: Error | null } = { error: null } static getDerivedStateFromError(error: Error) { return { error } } - componentDidCatch(error: Error, info: ErrorInfo) { console.error('Module crash:', error, info) } + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('Module crash:', error, info) + reportError(error, { kind: 'module-boundary', componentStack: info.componentStack ?? '' }) + } render() { if (this.state.error) { return ( @@ -174,11 +178,14 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P } await refreshProfile() } catch { - // Re-open editor so user can retry + // Re-open the editor so the student can retry — and say why it reopened. + // On its own the field just springs back open, which reads as the app + // rejecting what they typed rather than failing to save it. setEditingField(field) + toast.error("Couldn't save that — check your connection and try again.") } setSaving(false) - }, [editingField, user, profile, demo, profileDraft, refreshProfile]) + }, [editingField, user, profile, demo, profileDraft, refreshProfile, toast]) const navigateFromDropdown = (p: string) => { setPage(p as typeof page) diff --git a/web/src/components/FinancialAidModule.tsx b/web/src/components/FinancialAidModule.tsx index 57923da..9bb2297 100644 --- a/web/src/components/FinancialAidModule.tsx +++ b/web/src/components/FinancialAidModule.tsx @@ -2505,8 +2505,12 @@ interface Props { export default function FinancialAidModule({ open, onClose, year = 11 }: Props) { const { user, profile, refreshProfile } = useAuth() const toast = useToast() + // Held in a ref so the load effect below re-runs only when the module + // opens — not whenever a context re-render changes this object. + const toastRef = useRef(toast) const tourSeen = profile?.settings?.intros_seen?.includes('fafsa-module-tour') ?? false const [showTour, setShowTour] = useState(false) + useEffect(() => { toastRef.current = toast }, [toast]) useEffect(() => { if (open && !tourSeen) { @@ -2564,21 +2568,32 @@ export default function FinancialAidModule({ open, onClose, year = 11 }: Props) }) }) + // One message for the three of them. Each failing alone leaves a different + // part of the module quietly wrong — a tracked scholarship showing "+ + // Track", a saved college missing from the aid comparison, a cost estimate + // gone — and three separate toasts for one dropped connection is noise. + let loadFailed = false + const noteFailure = () => { + if (cancelled || loadFailed) return + loadFailed = true + toastRef.current.error("Some of your saved work didn't load — reload before adding anything, or you may end up with duplicates.") + } + getTrackerItems() .then((items) => { if (!cancelled) { setTrackerScholarshipIds(new Set(items.filter((i) => i.scholarshipId).map((i) => i.scholarshipId!))) } }) - .catch(() => {}) + .catch(noteFailure) getCollegeList() .then((ids) => { if (!cancelled) setCollegeIds(ids) }) - .catch(() => {}) + .catch(noteFailure) getNpcRuns() .then((runs) => { if (!cancelled) setNpcRuns(runs) }) - .catch(() => {}) + .catch(noteFailure) return () => { cancelled = true diff --git a/web/src/lib/errorTracking.test.ts b/web/src/lib/errorTracking.test.ts new file mode 100644 index 0000000..fac020d --- /dev/null +++ b/web/src/lib/errorTracking.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const H = vi.hoisted(() => ({ + init: vi.fn(), + captureException: vi.fn(), +})) +vi.mock('posthog-js', () => ({ + default: { init: H.init, captureException: H.captureException }, +})) + +/** The module holds `live` at module scope, so each test needs a fresh copy. */ +async function load(env: Record) { + vi.resetModules() + vi.stubEnv('VITE_POSTHOG_KEY', env.VITE_POSTHOG_KEY ?? '') + if (env.VITE_POSTHOG_HOST) vi.stubEnv('VITE_POSTHOG_HOST', env.VITE_POSTHOG_HOST) + return import('./errorTracking') +} + +describe('errorTracking', () => { + beforeEach(() => { H.init.mockReset(); H.captureException.mockReset() }) + afterEach(() => vi.unstubAllEnvs()) + + it('stays inert without a key, and keeps errors on the console', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { initErrorTracking, reportError } = await load({}) + initErrorTracking() + + // Nothing leaves the browser on a deploy that was never configured. + expect(H.init).not.toHaveBeenCalled() + reportError(new Error('boom')) + expect(H.captureException).not.toHaveBeenCalled() + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('never builds a person profile', async () => { + // This app holds a minor's income, race, religion and immigration status. + // An error says a crash happened and where; it must not say who. + const { initErrorTracking } = await load({ VITE_POSTHOG_KEY: 'phc_test' }) + initErrorTracking() + + const opts = H.init.mock.calls[0][1] + expect(opts.person_profiles).toBe('never') + // Nor collect the student's browsing as a side effect of error reporting. + expect(opts.autocapture).toBe(false) + expect(opts.capture_pageview).toBe(false) + expect(opts.capture_pageleave).toBe(false) + expect(opts.disable_session_recording).toBe(true) + }) + + it('reports an error with the context it was given', async () => { + const { initErrorTracking, reportError } = await load({ VITE_POSTHOG_KEY: 'phc_test' }) + initErrorTracking() + + const err = new Error('module exploded') + reportError(err, { kind: 'module-boundary' }) + expect(H.captureException).toHaveBeenCalledWith(err, { kind: 'module-boundary' }) + }) + + it('catches unhandled rejections, which nothing listened for before', async () => { + const { initErrorTracking } = await load({ VITE_POSTHOG_KEY: 'phc_test' }) + initErrorTracking() + + window.dispatchEvent( + Object.assign(new Event('unhandledrejection'), { reason: new Error('dropped') }), + ) + expect(H.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'dropped' }), + { kind: 'unhandledrejection' }, + ) + }) + + it('wraps a non-Error rejection reason rather than dropping it', async () => { + const { initErrorTracking } = await load({ VITE_POSTHOG_KEY: 'phc_test' }) + initErrorTracking() + + window.dispatchEvent( + Object.assign(new Event('unhandledrejection'), { reason: 'just a string' }), + ) + expect(H.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'just a string' }), + { kind: 'unhandledrejection' }, + ) + }) + + it('does not become a second error when the tracker itself fails', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + H.captureException.mockImplementation(() => { throw new Error('posthog down') }) + const { initErrorTracking, reportError } = await load({ VITE_POSTHOG_KEY: 'phc_test' }) + initErrorTracking() + + expect(() => reportError(new Error('boom'))).not.toThrow() + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('initialises once however often it is called', async () => { + const { initErrorTracking } = await load({ VITE_POSTHOG_KEY: 'phc_test' }) + initErrorTracking() + initErrorTracking() + expect(H.init).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/lib/errorTracking.ts b/web/src/lib/errorTracking.ts new file mode 100644 index 0000000..22af941 --- /dev/null +++ b/web/src/lib/errorTracking.ts @@ -0,0 +1,77 @@ +/** + * Error tracking, through PostHog. + * + * Until now a crash reached `console.error` and stopped there, which means it + * reached nobody: the students this is for are sixteen and will not open a + * console, let alone report what it said. An unhandled promise rejection did + * not even get that far — nothing was listening for them at all. + * + * Everything routes through `reportError` so there is one place that decides + * what leaves the browser. + * + * **What is deliberately not sent.** `person_profiles: 'never'` and no + * `identify` call, so PostHog never builds a profile keyed to a student. This + * app holds a minor's zip code, school, GPA, household income, race, religion + * and immigration status; none of that belongs in a third-party error tool, + * and the cheapest way to guarantee that is to never send anything that could + * be joined back to a person. An error tells us a crash happened and where — + * it does not need to say who. + * + * Without VITE_POSTHOG_KEY the whole module is inert: `init` does nothing and + * `reportError` falls back to the console, so development and any deploy + * without the key behave exactly as before. + */ + +import posthog from 'posthog-js' + +let live = false + +/** Start tracking, if this deploy is configured for it. Safe to call twice. */ +export function initErrorTracking(): void { + if (live) return + const key = import.meta.env.VITE_POSTHOG_KEY + if (!key) return + + posthog.init(key, { + api_host: import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com', + // No profile, no identify: see the note above. + person_profiles: 'never', + // This is an error channel, not analytics. Pageviews and autocapture would + // send the student's navigation and clicks, which is not what was asked + // for and is a much larger disclosure than a stack trace. + capture_pageview: false, + capture_pageleave: false, + autocapture: false, + disable_session_recording: true, + }) + live = true + + // Errors that reach the window, which nothing was listening for before. + window.addEventListener('error', (e) => { + reportError(e.error ?? new Error(e.message), { kind: 'window.onerror' }) + }) + window.addEventListener('unhandledrejection', (e) => { + const reason = e.reason + reportError( + reason instanceof Error ? reason : new Error(String(reason)), + { kind: 'unhandledrejection' }, + ) + }) +} + +/** + * Report one error. `context` should say where it came from, never what the + * student typed. + */ +export function reportError(error: Error, context: Record = {}): void { + if (!live) { + console.error('[error]', error, context) + return + } + try { + posthog.captureException(error, context) + } catch { + // The tracker failing must never become a second error on the way out. + console.error('[error]', error, context) + } +} diff --git a/web/src/lib/useModuleState.test.tsx b/web/src/lib/useModuleState.test.tsx new file mode 100644 index 0000000..37859f1 --- /dev/null +++ b/web/src/lib/useModuleState.test.tsx @@ -0,0 +1,70 @@ +/** + * The checklist's two failure paths. Both used to be silent, and both look to + * a student like their work vanished rather than like a dropped connection. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' + +const H = vi.hoisted(() => ({ + getProgress: vi.fn(), setItem: vi.fn(), error: vi.fn(), +})) +vi.mock('./moduleProgress', () => ({ + getModuleChecklistProgress: H.getProgress, + setModuleChecklistItem: H.setItem, + getModuleData: vi.fn().mockResolvedValue(null), + setModuleData: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('../contexts/ToastContext', () => ({ + useToast: () => ({ error: H.error, success: vi.fn(), info: vi.fn() }), +})) + +import { useModuleChecklist } from './useModuleState' + +describe('useModuleChecklist', () => { + beforeEach(() => { + H.getProgress.mockReset().mockResolvedValue({ a: 'available' }) + H.setItem.mockReset().mockResolvedValue(undefined) + H.error.mockReset() + }) + + it('says so when progress will not load', async () => { + // Otherwise the list renders empty and reads as "none of my work saved", + // which invites redoing it. + H.getProgress.mockRejectedValue(new Error('offline')) + renderHook(() => useModuleChecklist('essays', true)) + await waitFor(() => expect(H.error).toHaveBeenCalledWith(expect.stringContaining('progress'))) + }) + + it('stays quiet when the load works', async () => { + const { result } = renderHook(() => useModuleChecklist('essays', true)) + await waitFor(() => expect(result.current.progress).toEqual({ a: 'available' })) + expect(H.error).not.toHaveBeenCalled() + }) + + it('loads nothing, and says nothing, while the module is closed', () => { + renderHook(() => useModuleChecklist('essays', false)) + expect(H.getProgress).not.toHaveBeenCalled() + expect(H.error).not.toHaveBeenCalled() + }) + + it('says so when a tick will not save, and rolls it back', async () => { + H.setItem.mockRejectedValue(new Error('offline')) + const { result } = renderHook(() => useModuleChecklist('essays', true)) + await waitFor(() => expect(result.current.progress.a).toBe('available')) + + await act(async () => { result.current.handleToggle('a') }) + + // Without the message the box simply refuses to stay ticked. + await waitFor(() => expect(H.error).toHaveBeenCalledWith(expect.stringContaining("Couldn't save"))) + expect(result.current.progress.a).toBe('available') + }) + + it('keeps a successful tick without complaining', async () => { + const { result } = renderHook(() => useModuleChecklist('essays', true)) + await waitFor(() => expect(result.current.progress.a).toBe('available')) + + await act(async () => { result.current.handleToggle('a') }) + await waitFor(() => expect(result.current.progress.a).toBe('in-progress')) + expect(H.error).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/lib/useModuleState.ts b/web/src/lib/useModuleState.ts index 67c9529..60d6010 100644 --- a/web/src/lib/useModuleState.ts +++ b/web/src/lib/useModuleState.ts @@ -4,9 +4,15 @@ * * The two loaders both read `profiles.settings` on open; `moduleProgress` * coalesces concurrent reads so a module open is a single round trip. + * + * Both failure paths are told to the student. A failed *load* used to leave a + * checklist looking freshly empty, which reads as "none of my work saved" and + * invites redoing it. A failed *write* rolled the tick back silently, so the + * box just refused to stay ticked with no reason given. */ import { useState, useEffect, useRef, useCallback } from 'react' +import { useToast } from '../contexts/ToastContext' import { getModuleChecklistProgress, setModuleChecklistItem, @@ -25,6 +31,14 @@ const nextStatus = (s: ChecklistItemStatus): ChecklistItemStatus => * `handleMarkComplete` toggles completed ⇄ available. */ export function useModuleChecklist(moduleName: string, open: boolean) { + // Held in a ref, not read as a dependency: this effect should re-run when + // the module opens or changes, and for no other reason. Depending on the + // toast object's identity would tie a data load to a context re-render — + // stable today only because ToastProvider memoises its value, which is not + // a promise this hook should be relying on. + const toast = useToast() + const toastRef = useRef(toast) + useEffect(() => { toastRef.current = toast }, [toast]) const [progress, setProgress] = useState({}) // Ref mirrors state so persist callbacks read the latest value for // rollback / read-modify even under rapid concurrent calls. @@ -34,7 +48,13 @@ export function useModuleChecklist(moduleName: string, open: boolean) { useEffect(() => { if (!open) return let cancelled = false - getModuleChecklistProgress(moduleName).then((p) => { if (!cancelled) setProgress(p) }).catch(() => {}) + getModuleChecklistProgress(moduleName) + .then((p) => { if (!cancelled) setProgress(p) }) + .catch(() => { + if (!cancelled) { + toastRef.current.error("Couldn't load your progress here — it is saved, but this list may look empty until you reload.") + } + }) return () => { cancelled = true } }, [open, moduleName]) @@ -42,7 +62,10 @@ export function useModuleChecklist(moduleName: string, open: boolean) { const before = progressRef.current[itemId] ?? 'available' setProgress((prev) => ({ ...prev, [itemId]: next })) try { await setModuleChecklistItem(moduleName, itemId, next) } - catch { setProgress((prev) => prev[itemId] === next ? { ...prev, [itemId]: before } : prev) } + catch { + setProgress((prev) => prev[itemId] === next ? { ...prev, [itemId]: before } : prev) + toastRef.current.error("Couldn't save that — check your connection and try again.") + } }, [moduleName]) const handleToggle = useCallback((itemId: string) => { diff --git a/web/src/main.tsx b/web/src/main.tsx index 04b6704..57fdaa1 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,6 +4,10 @@ import './index.css' import { AuthProvider } from './contexts/AuthContext' import { ToastProvider } from './contexts/ToastContext' import App from './App.tsx' +import { initErrorTracking } from './lib/errorTracking' + +// Before render, so a crash during the first paint is still caught. +initErrorTracking() createRoot(document.getElementById('root')!).render( diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 796986d..0cabffc 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -7,6 +7,11 @@ interface ImportMetaEnv { readonly VITE_SUPABASE_ANON_KEY: string /** Publishable logo.dev token for school logos. Optional; no logos without it. */ readonly VITE_LOGODEV_TOKEN?: string + /** PostHog project key. Unset means no error reporting leaves the browser; + * errors go to the console as before. */ + readonly VITE_POSTHOG_KEY?: string + /** PostHog ingestion host. Defaults to US cloud. */ + readonly VITE_POSTHOG_HOST?: string /** Cloudflare Turnstile site key. Unset means no captcha widget and no token, * which must match CAPTCHA being disabled in Supabase Auth. */ readonly VITE_TURNSTILE_SITE_KEY?: string