diff --git a/web/src/App.tsx b/web/src/App.tsx index e513e03..d4214c4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react' -import { motion, AnimatePresence } from 'framer-motion' +import { AnimatePresence } from 'framer-motion' import { useAuth } from './contexts/AuthContext' import { saveOnboardingData } from './lib/profiles' import AuthScreen from './components/AuthScreen' @@ -14,6 +14,7 @@ import { signOut } from './lib/auth' import { currentGradeStartIdx } from './lib/currentGrade' import { useToast } from './contexts/ToastContext' import ResetPasswordScreen from './components/ResetPasswordScreen' +import LoadingScreen from './components/LoadingScreen' import { resolvePreferences } from './lib/preferences' import { useThemePref } from './lib/theme' import { ApplicationsProvider } from './contexts/ApplicationsContext' @@ -133,7 +134,7 @@ export default function App() { if (recovering && user) return switch (screen) { case 'loading': - return
+ return case 'auth': return case 'welcome-back': diff --git a/web/src/components/LoadingScreen.test.tsx b/web/src/components/LoadingScreen.test.tsx new file mode 100644 index 0000000..b3ed364 --- /dev/null +++ b/web/src/components/LoadingScreen.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, act } from '@testing-library/react' +import LoadingScreen from './LoadingScreen' + +const advance = (ms: number) => act(() => { vi.advanceTimersByTime(ms) }) + +describe('LoadingScreen', () => { + afterEach(() => vi.useRealTimers()) + + it('shows nothing at all for a load that resolves quickly', () => { + vi.useFakeTimers() + render() + // A spinner flashed for 200ms reads as jank, not as speed. + advance(300) + expect(screen.queryByText(/Loading your dashboard/)).not.toBeInTheDocument() + }) + + it('says it is working once the wait is noticeable', () => { + vi.useFakeTimers() + render() + advance(500) + expect(screen.getByText('Loading your dashboard…')).toBeInTheDocument() + }) + + it('admits something is wrong, and offers the reload people were already doing', () => { + vi.useFakeTimers() + render() + advance(9500) + expect(screen.getByText('Still loading…')).toBeInTheDocument() + expect(screen.getByText(/connection may have dropped/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument() + }) + + it('is announced, so it is not silence to a screen reader either', () => { + vi.useFakeTimers() + render() + expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite') + }) +}) diff --git a/web/src/components/LoadingScreen.tsx b/web/src/components/LoadingScreen.tsx new file mode 100644 index 0000000..d7203a7 --- /dev/null +++ b/web/src/components/LoadingScreen.tsx @@ -0,0 +1,60 @@ +/** + * LoadingScreen — what the app shows before it knows who you are. + * + * This used to be an empty parchment rectangle: the right background, the + * grain overlay, and nothing else. On a cold load the profile fetch can take + * several seconds (its first attempt races Supabase's own token rotation and + * times out, and only the retry succeeds), and for all of it the page looked + * broken rather than busy. People refreshed, which worked — the second load + * has a warm token — and so the app taught them to refresh it. + * + * Two rules here: + * + * The spinner waits a moment before appearing. A load that resolves in 200ms + * should not flash a spinner at anyone; that reads as jank, not speed. + * + * A load that is taking far too long says so, and offers the reload that + * people were doing anyway. Staring at a spinner with no end is the same + * failure as staring at a blank page, one step along. + */ + +import { useEffect, useState } from 'react' + +/** Long enough that a fast load shows nothing at all. */ +const SPINNER_AFTER_MS = 400 +/** By here something is wrong: the profile fetch has had a timeout and a retry. */ +const STUCK_AFTER_MS = 9000 + +export default function LoadingScreen() { + const [phase, setPhase] = useState<'quiet' | 'busy' | 'stuck'>('quiet') + + useEffect(() => { + const busy = setTimeout(() => setPhase('busy'), SPINNER_AFTER_MS) + const stuck = setTimeout(() => setPhase('stuck'), STUCK_AFTER_MS) + return () => { clearTimeout(busy); clearTimeout(stuck) } + }, []) + + return ( +
+
+ {phase !== 'quiet' && ( +
+
+ )} +
+ ) +} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index d4688d7..e1862a3 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react' import type { User } from '@supabase/supabase-js' import { supabase } from '../lib/supabase' +import { markBoot } from '../lib/bootTiming' import { getProfile } from '../lib/profiles' import type { UserProfile } from '../types/user' @@ -37,6 +38,12 @@ export function useAuth() { return useContext(AuthContext) } +/** + * How long each profile attempt gets. Short first, then patient — see + * fetchProfile. + */ +const ATTEMPT_TIMEOUTS_MS = [2500, 4000, 6000, 6000, 6000] + /** Reject after `ms` so a stalled promise can't hang an awaited caller. */ function withTimeout(p: Promise, ms: number): Promise { return new Promise((resolve, reject) => { @@ -95,9 +102,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { try { // Bound each attempt: this fetch gates the loading screen, so a stalled // Supabase call must not hang it. null = a genuine "no row". - const p = await withTimeout(getProfile(uid), 6000) + // + // Short first, then patient. A transient failure is far more likely + // than a genuinely slow query, and a flat 6s made the first one cost + // nearly seven seconds before the retry even began. + // + // Note this is NOT where a cold load spends its time: auth-js refreshes + // an expired session inside __loadSession and holds the init lock until + // it has, so INITIAL_SESSION only reaches us with a valid token and + // this fetch never races the rotation. The cold-start wait is that + // refresh round-trip, which happens before any of our code runs and + // which nothing here can shorten. + const p = await withTimeout(getProfile(uid), ATTEMPT_TIMEOUTS_MS[attempt]) setProfile(p) setProfileReady(true) + markBoot('profile-ready', attempt === 0 ? 'first try' : `try ${attempt + 1}`) return true } catch { // The profile row always exists (created by a trigger on signup), so a @@ -123,6 +142,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { data: { subscription } } = supabase.auth.onAuthStateChange( async (event, session) => { listenerFired.current = true + // Everything before this point is auth-js: the client booting and, on + // a cold load, swapping the refresh token for a live one. + markBoot('auth-ready') const u = session?.user ?? null // PASSWORD_RECOVERY fires once, but the session it accompanies is // persisted. Holding the flag in memory alone meant a single reload diff --git a/web/src/index.css b/web/src/index.css index a6b3cdd..dfe54ac 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -979,6 +979,37 @@ button { to { transform: rotate(360deg); } } +/* ---- the screen before we know who you are ---- + It was the background and nothing else, so a slow profile fetch looked like + a broken page rather than a loading one. */ +.ld-box { + position: relative; z-index: 1; + display: flex; flex-direction: column; align-items: center; gap: 12px; + padding: 0 24px; max-width: 30rem; text-align: center; +} +.ld-spinner { + width: 26px; height: 26px; border-radius: 50%; + border: 2.5px solid var(--border-str); border-top-color: var(--c-go); + animation: module-spin 0.7s linear infinite; +} +.ld-text { + margin: 0; font-family: var(--font-body); + font-size: 14px; color: var(--text-muted); +} +.ld-note { + margin: 0; font-family: var(--font-body); + font-size: 13px; line-height: 1.5; color: var(--text-muted); +} +.ld-retry { + margin-top: 2px; padding: 8px 18px; border-radius: 9px; cursor: pointer; + border: 1px solid var(--border-str); background: var(--card-bg); + font-family: var(--font-body); font-size: 13px; font-weight: 600; color: var(--text); +} +.ld-retry:hover { border-color: var(--text-muted); } +@media (prefers-reduced-motion: reduce) { + .ld-spinner { animation-duration: 2.4s; } +} + /* Application Status overview — school rows, shared-task cards, timeline. */ .ast-grid { display: grid; diff --git a/web/src/lib/bootTiming.ts b/web/src/lib/bootTiming.ts new file mode 100644 index 0000000..e16f51c --- /dev/null +++ b/web/src/lib/bootTiming.ts @@ -0,0 +1,47 @@ +/** + * Where a cold load actually spends its time. + * + * The app shows a loading screen until sign-in resolves, and on a first visit + * that takes seconds. Reading auth-js says the bulk of it is the refresh + * round-trip it does before handing us a session — but that is inference from + * its source, not a measurement of this app on a real connection. + * + * This measures it. Off unless asked for: `?timing` on the URL, or any dev + * server. Nothing is sent anywhere; it prints one line to the console. + */ + +type Mark = 'app-start' | 'auth-ready' | 'profile-ready' + +const marks = new Map() +let reported = false + +function enabled(): boolean { + if (typeof window === 'undefined') return false + if (import.meta.env.DEV) return true + try { return new URLSearchParams(window.location.search).has('timing') } + catch { return false } +} + +export function markBoot(name: Mark, detail?: string) { + if (!enabled() || marks.has(name)) return + marks.set(name, performance.now()) + if (name === 'profile-ready') report(detail) +} + +function report(detail?: string) { + if (reported) return + reported = true + const start = marks.get('app-start') ?? 0 + const auth = marks.get('auth-ready') + const profile = marks.get('profile-ready') + if (auth == null || profile == null) return + const ms = (n: number) => `${Math.round(n)}ms` + // The first figure is everything before our code runs: the client booting + // and, on a cold load, swapping the refresh token for a live one. The second + // is our own profile query. If the wait is the round-trip, the first number + // is nearly all of it. + console.info( + `[edvifi boot] session ${ms(auth - start)} · profile ${ms(profile - auth)}` + + `${detail ? ` (${detail})` : ''} · total ${ms(profile - start)}`, + ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 57fdaa1..05b699a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,6 +6,11 @@ import { ToastProvider } from './contexts/ToastContext' import App from './App.tsx' import { initErrorTracking } from './lib/errorTracking' +import { markBoot } from './lib/bootTiming' + +// Before anything renders, so the session figure includes the client booting. +markBoot('app-start') + // Before render, so a crash during the first paint is still caught. initErrorTracking()