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 (
+
+ This is taking longer than it should. Your connection may have dropped.
+
+
+ >
+ )}
+
+ )}
+
+ )
+}
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()