Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -133,7 +134,7 @@ export default function App() {
if (recovering && user) return <ResetPasswordScreen key="reset" />
switch (screen) {
case 'loading':
return <motion.div key="loading" className="wb-screen" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}><div className="wb-grain" /></motion.div>
return <LoadingScreen key="loading" />
case 'auth':
return <AuthScreen key="auth" />
case 'welcome-back':
Expand Down
39 changes: 39 additions & 0 deletions web/src/components/LoadingScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LoadingScreen />)
// 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(<LoadingScreen />)
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(<LoadingScreen />)
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(<LoadingScreen />)
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite')
})
})
60 changes: 60 additions & 0 deletions web/src/components/LoadingScreen.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="wb-screen" role="status" aria-live="polite">
<div className="wb-grain" />
{phase !== 'quiet' && (
<div className="ld-box">
<span className="ld-spinner" aria-hidden="true" />
<p className="ld-text">
{phase === 'stuck' ? 'Still loading…' : 'Loading your dashboard…'}
</p>
{phase === 'stuck' && (
<>
<p className="ld-note">
This is taking longer than it should. Your connection may have dropped.
</p>
<button type="button" className="ld-retry" onClick={() => window.location.reload()}>
Reload
</button>
</>
)}
</div>
)}
</div>
)
}
24 changes: 23 additions & 1 deletion web/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<T>(p: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions web/src/lib/bootTiming.ts
Original file line number Diff line number Diff line change
@@ -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<Mark, number>()
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)}`,
)
}
5 changes: 5 additions & 0 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading