img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+type CardTitleProps = React.ComponentProps<'h2'> & {
+ as: 'h2' | 'h3' | 'h4'
+}
+
+function CardTitle({ as: Heading, className, ...props }: CardTitleProps) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/index.css b/src/index.css
index 5fb3313..4330872 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,18 +1,36 @@
+/*
+ * Tailwind CSS 4. Tailwind puts its styles in CSS layers, so the unlayered CSS
+ * below wins over Tailwind's utility classes. See "Tailwind CSS" in the README.
+ */
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+/*
+ * shadcn/ui setup. The CLI added the imports above, the tokens in :root, the
+ * @theme block, the dark tokens and the base layer. We changed three things:
+ * - Dark mode follows our design: the attribute data-theme="dark" on the
+ * page, as in styles/variables.css, and not a .dark class.
+ * - Typography uses the existing Faustina/Inter tokens from variables.css.
+ * - The tokens --border and --accent are shared with the Vite starter styles
+ * below. shadcn's values win. The starter styles go when the page is built.
+ */
+@custom-variant dark (&:is([data-theme='dark'] *));
+
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
- --border: #e5e4e7;
+ --border: oklch(0.922 0 0);
--code-bg: #f4f3ec;
- --accent: #aa3bff;
+ --accent: oklch(0.97 0 0);
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
+ --sans: var(--font-family-text), sans-serif;
+ --heading: var(--font-family-title);
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
@@ -28,6 +46,66 @@
@media (max-width: 1024px) {
font-size: 16px;
}
+
+ --background: oklch(1 0 0);
+
+ --foreground: oklch(0.145 0 0);
+
+ --card: oklch(1 0 0);
+
+ --card-foreground: oklch(0.145 0 0);
+
+ --popover: oklch(1 0 0);
+
+ --popover-foreground: oklch(0.145 0 0);
+
+ --primary: oklch(0.205 0 0);
+
+ --primary-foreground: oklch(0.985 0 0);
+
+ --secondary: oklch(0.97 0 0);
+
+ --secondary-foreground: oklch(0.205 0 0);
+
+ --muted: oklch(0.97 0 0);
+
+ --muted-foreground: oklch(0.556 0 0);
+
+ --accent-foreground: oklch(0.205 0 0);
+
+ --destructive: oklch(0.577 0.245 27.325);
+
+ --input: oklch(0.922 0 0);
+
+ --ring: oklch(0.708 0 0);
+
+ --chart-1: oklch(0.87 0 0);
+
+ --chart-2: oklch(0.556 0 0);
+
+ --chart-3: oklch(0.439 0 0);
+
+ --chart-4: oklch(0.371 0 0);
+
+ --chart-5: oklch(0.269 0 0);
+
+ --radius: 0.625rem;
+
+ --sidebar: oklch(0.985 0 0);
+
+ --sidebar-foreground: oklch(0.145 0 0);
+
+ --sidebar-primary: oklch(0.205 0 0);
+
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+
+ --sidebar-accent: oklch(0.97 0 0);
+
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+
+ --sidebar-border: oklch(0.922 0 0);
+
+ --sidebar-ring: oklch(0.708 0 0);
}
@media (prefers-color-scheme: dark) {
@@ -70,7 +148,7 @@ h1,
h2 {
font-family: var(--heading);
font-weight: 500;
- color: var(--text-h);
+ color: var(--text-color);
}
h1 {
@@ -109,3 +187,124 @@ code {
padding: 4px 8px;
background: var(--code-bg);
}
+
+@theme inline {
+ --font-heading: var(--font-family-title);
+ --font-sans: var(--font-family-text), sans-serif;
+ --font-title: var(--font-family-title);
+ --font-text: var(--font-family-text), sans-serif;
+ --text-page-title: var(--font-size-page-title);
+ --text-element-title: var(--font-size-element-title);
+ --text-row-title: var(--font-size-row-title);
+ --radius-corner: var(--corner-radius);
+ --shadow-box: var(--box-shadow);
+ --spacing-xs: var(--space-xs);
+ --spacing-sm: var(--space-sm);
+ --spacing-md: var(--space-md);
+ --spacing-lg: var(--space-lg);
+ --spacing-xl: var(--space-xl);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-primary-color: var(--primary-color);
+ --color-secondary-color: var(--secondary-color);
+ --color-accent-color: var(--accent-color);
+ --color-background-color: var(--background-color);
+ --color-background-color-element: var(--background-color-element);
+ --color-text-color: var(--text-color);
+ --color-text-color-dark: var(--text-color-dark);
+ --color-text-color-light: var(--text-color-light);
+ --color-box-color: var(--box-color);
+ --color-box-stroke: var(--box-stroke);
+ --color-border-color: var(--border-color);
+ --color-header-background: var(--header-background);
+ --color-element-background: var(--element-background);
+ --color-surface-selected: var(--surface-selected);
+ --color-text-color-primary: var(--text-color-primary);
+ --color-text-color-secondary: var(--text-color-secondary);
+ --color-text-color-on-dark: var(--text-color-on-dark);
+ --color-border-subtle: var(--border-subtle);
+ --color-badge-d-value-bg: var(--badge-d-value-bg);
+ --color-badge-d-value-text: var(--badge-d-value-text);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+[data-theme='dark'] {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
+}
diff --git a/src/lib/apollo/api.test.ts b/src/lib/apollo/api.test.ts
new file mode 100644
index 0000000..06ecdcf
--- /dev/null
+++ b/src/lib/apollo/api.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from 'vitest'
+import { ApiError, REQUEST_TIMEOUT_MS, refreshRequest } from './api'
+
+const answer = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), { status })
+
+describe('the requests to the API', () => {
+ it('give every request a timeout, so a slow server does not leave a white page', async () => {
+ const fetchFn = vi
+ .fn
()
+ .mockResolvedValue(
+ answer({ data: { refreshSession: { accessToken: 'a' } } }),
+ )
+ const timeout = vi.spyOn(AbortSignal, 'timeout')
+
+ await refreshRequest('/graphql', 'refresh-1', fetchFn)
+
+ expect(timeout).toHaveBeenCalledWith(REQUEST_TIMEOUT_MS)
+ expect(fetchFn.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
+ timeout.mockRestore()
+ })
+
+ it('pass on the code of an error from the server', async () => {
+ const fetchFn = vi.fn().mockImplementation(async () =>
+ answer({
+ errors: [
+ { message: 'Log in again.', extensions: { code: 'UNAUTHENTICATED' } },
+ ],
+ }),
+ )
+ await expect(
+ refreshRequest('/graphql', 'refresh-1', fetchFn),
+ ).rejects.toMatchObject({ code: 'UNAUTHENTICATED' })
+ await expect(
+ refreshRequest('/graphql', 'refresh-1', fetchFn),
+ ).rejects.toBeInstanceOf(ApiError)
+ })
+})
diff --git a/src/lib/apollo/api.ts b/src/lib/apollo/api.ts
new file mode 100644
index 0000000..fb0620e
--- /dev/null
+++ b/src/lib/apollo/api.ts
@@ -0,0 +1,102 @@
+// Plain requests to the API for the login, the refresh and the logout. They do
+// not go through Apollo Client: the authorization link calls the refresh, so
+// the refresh itself must not run through the link.
+
+export type AuthPayload = {
+ accessToken: string
+ refreshToken: string
+ coach: { id: string; name: string; email: string }
+}
+
+/** The server answered with an error. `code` is `extensions.code` (see docs/API.md). */
+export class ApiError extends Error {
+ readonly code: string
+
+ constructor(code: string, message: string) {
+ super(message)
+ this.name = 'ApiError'
+ this.code = code
+ }
+}
+
+type Fetch = typeof fetch
+
+/**
+ * How long the login, refresh and logout requests may take. Without a limit a
+ * slow server leaves a white page, because the app waits for the refresh
+ * before it is shown (see main.tsx). A timeout is not an answer from the
+ * server, so the session keeps the refresh token and a later try can work.
+ */
+export const REQUEST_TIMEOUT_MS = 5000
+
+async function call(
+ url: string,
+ query: string,
+ variables: Record,
+ fetchFn: Fetch,
+): Promise {
+ const response = await fetchFn(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+ if (!response.ok) {
+ throw new ApiError(
+ 'NETWORK_ERROR',
+ `The server answered ${response.status}.`,
+ )
+ }
+ const body = (await response.json()) as {
+ data?: T
+ errors?: { message: string; extensions?: { code?: string } }[]
+ }
+ const first = body.errors?.[0]
+ if (first) {
+ throw new ApiError(first.extensions?.code ?? 'UNKNOWN', first.message)
+ }
+ return body.data as T
+}
+
+const PAYLOAD = 'accessToken refreshToken coach { id name email }'
+
+export async function loginRequest(
+ url: string,
+ googleToken: string,
+ fetchFn: Fetch = fetch,
+): Promise {
+ const data = await call<{ login: AuthPayload }>(
+ url,
+ `mutation Login($googleToken: String!) { login(googleToken: $googleToken) { ${PAYLOAD} } }`,
+ { googleToken },
+ fetchFn,
+ )
+ return data.login
+}
+
+export async function refreshRequest(
+ url: string,
+ refreshToken: string,
+ fetchFn: Fetch = fetch,
+): Promise {
+ const data = await call<{ refreshSession: AuthPayload }>(
+ url,
+ `mutation RefreshSession($refreshToken: String!) { refreshSession(refreshToken: $refreshToken) { ${PAYLOAD} } }`,
+ { refreshToken },
+ fetchFn,
+ )
+ return data.refreshSession
+}
+
+export async function logoutRequest(
+ url: string,
+ refreshToken: string,
+ fetchFn: Fetch = fetch,
+): Promise {
+ await call<{ logout: boolean }>(
+ url,
+ 'mutation Logout($refreshToken: String!) { logout(refreshToken: $refreshToken) }',
+ { refreshToken },
+ fetchFn,
+ )
+}
diff --git a/src/lib/apollo/authLink.test.ts b/src/lib/apollo/authLink.test.ts
new file mode 100644
index 0000000..276871a
--- /dev/null
+++ b/src/lib/apollo/authLink.test.ts
@@ -0,0 +1,192 @@
+import { ApolloLink } from '@apollo/client/link'
+import { gql } from '@apollo/client'
+import { firstValueFrom, of } from 'rxjs'
+import { type Mock, describe, expect, it, vi } from 'vitest'
+import type { AuthPayload } from './api'
+import { createAuthLink } from './authLink'
+import { createSession, type TokenStorage } from './session'
+
+const ME = gql`
+ query Me {
+ me {
+ id
+ }
+ }
+`
+const LOGIN = gql`
+ mutation Login($googleToken: String!) {
+ login(googleToken: $googleToken) {
+ accessToken
+ }
+ }
+`
+
+const unauthenticated = {
+ data: null,
+ errors: [
+ {
+ message: 'You must be logged in.',
+ extensions: { code: 'UNAUTHENTICATED' },
+ },
+ ],
+}
+const ok = { data: { me: { id: 'c1' } } }
+
+const payload = (n: number): AuthPayload => ({
+ accessToken: `access-${n}`,
+ refreshToken: `refresh-${n}`,
+ coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' },
+})
+
+function memoryStorage(): TokenStorage {
+ const values = new Map()
+ return {
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => void values.set(key, value),
+ removeItem: (key) => void values.delete(key),
+ }
+}
+
+/**
+ * A server that only accepts the access token "access-2", and records the
+ * Authorization header of every request.
+ */
+type RefreshRequest = Mock<(refreshToken: string) => Promise>
+
+function setup(options: { refreshRequest?: RefreshRequest } = {}) {
+ const seen: (string | undefined)[] = []
+ const server = new ApolloLink((operation) => {
+ const headers = operation.getContext()['headers'] as
+ Record | undefined
+ seen.push(headers?.['authorization'])
+ return of(
+ headers?.['authorization'] === 'Bearer access-2' ? ok : unauthenticated,
+ )
+ })
+ const refreshRequest =
+ options.refreshRequest ??
+ vi
+ .fn<(refreshToken: string) => Promise>()
+ .mockResolvedValue(payload(2))
+ const session = createSession({ storage: memoryStorage(), refreshRequest })
+ const link = ApolloLink.from([createAuthLink(session), server])
+ const run = (query = ME) =>
+ firstValueFrom(ApolloLink.execute(link, { query }, { client: {} as never }))
+ return { session, refreshRequest, seen, run }
+}
+
+describe('the authorization link', () => {
+ it('adds the access token to the request, and nothing when there is none', async () => {
+ const { session, seen, run } = setup()
+ await run()
+ session.setTokens(payload(2))
+ await run()
+ expect(seen).toEqual([undefined, 'Bearer access-2'])
+ })
+
+ it('refreshes before the first request when there is a refresh token but no access token', async () => {
+ const storage = memoryStorage()
+ storage.setItem('cop.refreshToken', 'refresh-1')
+ const refreshRequest = vi
+ .fn<(refreshToken: string) => Promise>()
+ .mockResolvedValue(payload(2))
+ const seen: (string | undefined)[] = []
+ const server = new ApolloLink((operation) => {
+ const headers = operation.getContext()['headers'] as
+ Record | undefined
+ seen.push(headers?.['authorization'])
+ return of(ok)
+ })
+ const session = createSession({ storage, refreshRequest })
+ const link = ApolloLink.from([createAuthLink(session), server])
+
+ await firstValueFrom(
+ ApolloLink.execute(link, { query: ME }, { client: {} as never }),
+ )
+ expect(refreshRequest).toHaveBeenCalledTimes(1)
+ expect(seen).toEqual(['Bearer access-2']) // no failed first try
+ })
+
+ it('refreshes once and repeats the request when the access token has expired', async () => {
+ const { session, refreshRequest, seen, run } = setup()
+ session.setTokens(payload(1)) // access-1 is expired for this server
+
+ const result = await run()
+ expect(result).toEqual(ok)
+ expect(refreshRequest).toHaveBeenCalledTimes(1)
+ expect(refreshRequest).toHaveBeenCalledWith('refresh-1')
+ expect(seen).toEqual(['Bearer access-1', 'Bearer access-2'])
+ })
+
+ it('refreshes once when several requests expire at the same moment', async () => {
+ const { session, refreshRequest, run } = setup()
+ session.setTokens(payload(1))
+
+ const results = await Promise.all([run(), run(), run()])
+ expect(results).toEqual([ok, ok, ok])
+ expect(refreshRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('passes on the error, and forgets the login, when the refresh token is refused', async () => {
+ const { ApiError } = await import('./api')
+ const refreshRequest = vi
+ .fn<(refreshToken: string) => Promise>()
+ .mockRejectedValue(new ApiError('UNAUTHENTICATED', 'Log in again.'))
+ const { session, seen, run } = setup({ refreshRequest })
+ session.setTokens(payload(1))
+
+ expect(await run()).toEqual(unauthenticated)
+ expect(seen).toHaveLength(1) // not repeated
+ expect(session.hasRefreshToken()).toBe(false)
+ })
+
+ it('does not try more than once', async () => {
+ // The refresh gives a token that the server also refuses.
+ const { session, refreshRequest, seen, run } = setup({
+ refreshRequest: vi
+ .fn<(refreshToken: string) => Promise>()
+ .mockResolvedValue(payload(3)),
+ })
+ session.setTokens(payload(1))
+
+ expect(await run()).toEqual(unauthenticated)
+ expect(refreshRequest).toHaveBeenCalledTimes(1)
+ expect(seen).toEqual(['Bearer access-1', 'Bearer access-3'])
+ })
+
+ it('does not refresh without a refresh token', async () => {
+ const { refreshRequest, seen, run } = setup()
+ expect(await run()).toEqual(unauthenticated)
+ expect(refreshRequest).not.toHaveBeenCalled()
+ expect(seen).toHaveLength(1)
+ })
+
+ it('does not refresh for the login operations themselves', async () => {
+ const { session, refreshRequest, run } = setup()
+ session.setTokens(payload(1))
+ await run(LOGIN)
+ expect(refreshRequest).not.toHaveBeenCalled()
+ })
+
+ it('leaves other errors alone', async () => {
+ const error = {
+ data: null,
+ errors: [
+ { message: 'Bad input', extensions: { code: 'BAD_USER_INPUT' } },
+ ],
+ }
+ const session = createSession({
+ storage: memoryStorage(),
+ refreshRequest: vi.fn(),
+ })
+ session.setTokens(payload(1))
+ const link = ApolloLink.from([
+ createAuthLink(session),
+ new ApolloLink(() => of(error)),
+ ])
+ const result = await firstValueFrom(
+ ApolloLink.execute(link, { query: ME }, { client: {} as never }),
+ )
+ expect(result).toEqual(error)
+ })
+})
diff --git a/src/lib/apollo/authLink.ts b/src/lib/apollo/authLink.ts
new file mode 100644
index 0000000..92eb02e
--- /dev/null
+++ b/src/lib/apollo/authLink.ts
@@ -0,0 +1,60 @@
+// The authorization link: it adds `Authorization: Bearer ` to
+// every request, and renews the session first when the access token has
+// expired or is about to (an expired token makes `me` answer null and not an
+// error, so waiting for an error would show the coach as logged out). When the
+// server answers UNAUTHENTICATED because the access
+// token has expired, it refreshes the session once and repeats the request.
+// If the refresh fails, the answer of the first try is passed on, and the page
+// shows that the coach must log in.
+
+import { ApolloLink } from '@apollo/client/link'
+import { from, mergeMap, of } from 'rxjs'
+import type { Session } from './session'
+
+/** The operations of the login itself must never start a refresh. */
+const AUTH_OPERATIONS = new Set(['Login', 'RefreshSession', 'Logout'])
+
+const isUnauthenticated = (result: ApolloLink.Result): boolean =>
+ 'errors' in result &&
+ (result.errors ?? []).some(
+ (error) => error.extensions?.['code'] === 'UNAUTHENTICATED',
+ )
+
+export function createAuthLink(session: Session): ApolloLink {
+ return new ApolloLink((operation, forward) => {
+ const isAuthOperation = AUTH_OPERATIONS.has(operation.operationName ?? '')
+ const addToken = () => {
+ const token = session.getAccessToken()
+ if (!token) return
+ operation.setContext(
+ ({ headers }: { headers?: Record }) => ({
+ headers: { ...headers, authorization: `Bearer ${token}` },
+ }),
+ )
+ }
+ const send = () => {
+ addToken()
+ return forward(operation).pipe(
+ mergeMap((result) => {
+ if (
+ !isUnauthenticated(result) ||
+ isAuthOperation ||
+ !session.hasRefreshToken()
+ ) {
+ return of(result)
+ }
+ return from(session.refresh()).pipe(
+ mergeMap((refreshed) => {
+ if (!refreshed) return of(result)
+ addToken()
+ return forward(operation) // the second try is not checked again
+ }),
+ )
+ }),
+ )
+ }
+
+ if (isAuthOperation) return send()
+ return from(session.ensureFresh()).pipe(mergeMap(send))
+ })
+}
diff --git a/src/lib/apollo/cache.test.ts b/src/lib/apollo/cache.test.ts
new file mode 100644
index 0000000..b979b79
--- /dev/null
+++ b/src/lib/apollo/cache.test.ts
@@ -0,0 +1,269 @@
+import { gql } from '@apollo/client'
+import { describe, expect, it } from 'vitest'
+import { createCache } from './cache'
+
+const SEARCH = gql`
+ query Search(
+ $search: String
+ $filter: CatalogFilter
+ $first: Int
+ $after: String
+ ) {
+ searchCatalog(
+ search: $search
+ filter: $filter
+ first: $first
+ after: $after
+ ) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ edges {
+ cursor
+ node {
+ __typename
+ id
+ description
+ }
+ }
+ }
+ }
+`
+// The same query without totalCount: it is only asked for with the first page.
+const SEARCH_NEXT = gql`
+ query Search(
+ $search: String
+ $filter: CatalogFilter
+ $first: Int
+ $after: String
+ ) {
+ searchCatalog(
+ search: $search
+ filter: $filter
+ first: $first
+ after: $after
+ ) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ edges {
+ cursor
+ node {
+ __typename
+ id
+ description
+ }
+ }
+ }
+ }
+`
+
+const edge = (n: number, type: 'Element' | 'Vault' = 'Element') => ({
+ __typename: 'CatalogEdge',
+ cursor: `c${n}`,
+ node: { __typename: type, id: `UB-1.${n}-a`, description: `Element ${n}` },
+})
+const connection = (
+ edges: ReturnType[],
+ hasNextPage: boolean,
+ totalCount?: number,
+) => ({
+ searchCatalog: {
+ __typename: 'CatalogConnection',
+ ...(totalCount === undefined ? {} : { totalCount }),
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage,
+ endCursor: edges.at(-1)!.cursor,
+ },
+ edges,
+ },
+})
+
+type Read = {
+ searchCatalog: {
+ totalCount: number
+ edges: { cursor: string }[]
+ pageInfo: { hasNextPage: boolean; endCursor: string }
+ }
+} | null
+
+describe('the cache for searchCatalog', () => {
+ it('puts the next page after the first, so "load more" gives one list', () => {
+ const cache = createCache()
+ const variables = { search: 'kip', first: 2 }
+ cache.writeQuery({
+ query: SEARCH,
+ variables,
+ data: connection([edge(1), edge(2)], true, 5),
+ })
+ cache.writeQuery({
+ query: SEARCH_NEXT,
+ variables: { ...variables, after: 'c2' },
+ data: connection([edge(3), edge(4)], true),
+ })
+ cache.writeQuery({
+ query: SEARCH_NEXT,
+ variables: { ...variables, after: 'c4' },
+ data: connection([edge(5)], false),
+ })
+
+ const read = cache.readQuery({ query: SEARCH, variables })!
+ expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual([
+ 'c1',
+ 'c2',
+ 'c3',
+ 'c4',
+ 'c5',
+ ])
+ expect(read.searchCatalog.pageInfo).toEqual({
+ __typename: 'PageInfo',
+ hasNextPage: false,
+ endCursor: 'c5',
+ })
+ expect(read.searchCatalog.totalCount).toBe(5) // from the first page
+ })
+
+ it('adds nothing when the same page arrives twice', () => {
+ const cache = createCache()
+ const variables = { search: 'kip', first: 2 }
+ cache.writeQuery({
+ query: SEARCH,
+ variables,
+ data: connection([edge(1), edge(2)], true, 4),
+ })
+ const next = {
+ query: SEARCH_NEXT,
+ variables: { ...variables, after: 'c2' },
+ data: connection([edge(3), edge(4)], false),
+ }
+ cache.writeQuery(next)
+ cache.writeQuery(next)
+
+ const read = cache.readQuery({ query: SEARCH, variables })!
+ expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual([
+ 'c1',
+ 'c2',
+ 'c3',
+ 'c4',
+ ])
+ })
+
+ it('starts over when the first page of the same search arrives again', () => {
+ const cache = createCache()
+ const variables = { search: 'kip', first: 2 }
+ cache.writeQuery({
+ query: SEARCH,
+ variables,
+ data: connection([edge(1), edge(2)], true, 4),
+ })
+ cache.writeQuery({
+ query: SEARCH_NEXT,
+ variables: { ...variables, after: 'c2' },
+ data: connection([edge(3), edge(4)], false),
+ })
+ // A reload of the first page (the data may have changed).
+ cache.writeQuery({
+ query: SEARCH,
+ variables,
+ data: connection([edge(1), edge(2)], true, 9),
+ })
+
+ const read = cache.readQuery({ query: SEARCH, variables })!
+ expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual(['c1', 'c2'])
+ expect(read.searchCatalog.totalCount).toBe(9)
+ })
+
+ it('keeps a list for each search, filter and sort', () => {
+ const cache = createCache()
+ cache.writeQuery({
+ query: SEARCH,
+ variables: { search: 'kip', first: 2 },
+ data: connection([edge(1), edge(2)], false, 2),
+ })
+ cache.writeQuery({
+ query: SEARCH,
+ variables: { search: 'hang', first: 2 },
+ data: connection([edge(7)], false, 1),
+ })
+ cache.writeQuery({
+ query: SEARCH,
+ variables: { search: 'kip', filter: { apparatusId: 'UB' }, first: 2 },
+ data: connection([edge(9)], false, 1),
+ })
+
+ const cursors = (variables: object) =>
+ cache
+ .readQuery({ query: SEARCH, variables })!
+ .searchCatalog.edges.map((e) => e.cursor)
+ expect(cursors({ search: 'kip', first: 2 })).toEqual(['c1', 'c2'])
+ expect(cursors({ search: 'hang', first: 2 })).toEqual(['c7'])
+ expect(
+ cursors({ search: 'kip', filter: { apparatusId: 'UB' }, first: 2 }),
+ ).toEqual(['c9'])
+ // The page size is not part of the search.
+ expect(cursors({ search: 'kip', first: 50 })).toEqual(['c1', 'c2'])
+ })
+
+ it('stores an element once, also when it is in two lists', () => {
+ const cache = createCache()
+ cache.writeQuery({
+ query: SEARCH,
+ variables: { search: 'a', first: 2 },
+ data: connection([edge(1), edge(2)], false, 2),
+ })
+ cache.writeQuery({
+ query: SEARCH,
+ variables: { search: 'b', first: 2 },
+ data: connection([edge(2)], false, 1),
+ })
+
+ const elements = Object.keys(cache.extract()).filter((key) =>
+ key.startsWith('Element:'),
+ )
+ expect(elements.sort()).toEqual(['Element:UB-1.1-a', 'Element:UB-1.2-a'])
+ })
+
+ it('lets a fragment on the CatalogItem interface match an element and a vault', () => {
+ const cache = createCache()
+ const query = gql`
+ query Items {
+ catalogItems @client {
+ ...ItemFields
+ }
+ }
+ fragment ItemFields on CatalogItem {
+ id
+ description
+ }
+ `
+ void query
+ cache.writeFragment({
+ id: 'Vault:VT-4.1-a',
+ fragment: gql`
+ fragment V on Vault {
+ id
+ description
+ }
+ `,
+ data: { __typename: 'Vault', id: 'VT-4.1-a', description: 'Handspring' },
+ })
+ const read = cache.readFragment({
+ id: 'Vault:VT-4.1-a',
+ fragment: gql`
+ fragment I on CatalogItem {
+ id
+ description
+ }
+ `,
+ })
+ expect(read).toEqual({
+ __typename: 'Vault',
+ id: 'VT-4.1-a',
+ description: 'Handspring',
+ })
+ })
+})
diff --git a/src/lib/apollo/cache.ts b/src/lib/apollo/cache.ts
new file mode 100644
index 0000000..3e98823
--- /dev/null
+++ b/src/lib/apollo/cache.ts
@@ -0,0 +1,49 @@
+// The cache of Apollo Client. `searchCatalog` is a Relay connection
+// (edges, cursor, pageInfo). The cache keeps all the pages of one search in one
+// list: "load more" (the same query with `after`) adds the next page to it, and
+// a new search, filter or sort starts a new list.
+
+import { InMemoryCache, type TypePolicies } from '@apollo/client'
+
+type Edge = { cursor?: string }
+type Connection = {
+ edges?: readonly Edge[]
+ totalCount?: number | null
+ pageInfo?: unknown
+ [key: string]: unknown
+}
+
+export const typePolicies: TypePolicies = {
+ Query: {
+ fields: {
+ searchCatalog: {
+ // `first` and `after` are left out on purpose: they are the page, not the search.
+ keyArgs: ['search', 'filter', 'sort'],
+ merge(
+ existing: Connection | undefined,
+ incoming: Connection,
+ { args },
+ ): Connection {
+ // The first page of a search (no cursor) replaces the list.
+ if (!existing || !args?.['after']) return incoming
+ const known = new Set(existing.edges?.map((edge) => edge.cursor))
+ const added = (incoming.edges ?? []).filter(
+ (edge) => !known.has(edge.cursor), // the same page twice adds nothing
+ )
+ return {
+ ...incoming,
+ edges: [...(existing.edges ?? []), ...added],
+ // totalCount is only asked for with the first page.
+ totalCount: incoming.totalCount ?? existing.totalCount,
+ }
+ },
+ },
+ },
+ },
+}
+
+/** CatalogItem is an interface: a fragment on it also matches Element and Vault. */
+export const possibleTypes = { CatalogItem: ['Element', 'Vault'] }
+
+export const createCache = () =>
+ new InMemoryCache({ typePolicies, possibleTypes })
diff --git a/src/lib/apollo/client.ts b/src/lib/apollo/client.ts
new file mode 100644
index 0000000..bbbb352
--- /dev/null
+++ b/src/lib/apollo/client.ts
@@ -0,0 +1,87 @@
+// The Apollo Client of the app, and the functions for the session. The address
+// of the API is VITE_API_URL (default "/graphql"). In development Vite sends
+// "/graphql" to the backend on port 3001 (vite.config.ts), so the browser sees
+// one address and there is no CORS to set up.
+
+import { ApolloClient } from '@apollo/client'
+import { HttpLink } from '@apollo/client/link/http'
+import { ApolloLink } from '@apollo/client/link'
+import {
+ type AuthPayload,
+ loginRequest,
+ logoutRequest,
+ refreshRequest,
+} from './api'
+import { createAuthLink } from './authLink'
+import { createCache } from './cache'
+import { type Session, type TokenStorage, createSession } from './session'
+
+export const API_URL: string = import.meta.env.VITE_API_URL ?? '/graphql'
+
+export function createApolloClient(options: {
+ uri: string
+ session: Session
+ fetch?: typeof fetch
+}) {
+ return new ApolloClient({
+ cache: createCache(),
+ link: ApolloLink.from([
+ createAuthLink(options.session),
+ new HttpLink({
+ uri: options.uri,
+ ...(options.fetch ? { fetch: options.fetch } : {}),
+ }),
+ ]),
+ })
+}
+
+function browserStorage(): TokenStorage | null {
+ try {
+ return window.localStorage
+ } catch {
+ return null // blocked by the browser settings
+ }
+}
+
+export const session: Session = createSession({
+ storage: browserStorage(),
+ refreshRequest: (refreshToken) => refreshRequest(API_URL, refreshToken),
+ locks: typeof navigator === 'undefined' ? null : (navigator.locks ?? null),
+})
+
+export const apolloClient = createApolloClient({ uri: API_URL, session })
+
+/**
+ * Logs in with the token from Sign in with Google. Everything the client has
+ * cached is cleared, so nothing from before the login is shown.
+ */
+export async function login(
+ googleToken: string,
+): Promise {
+ const payload = await loginRequest(API_URL, googleToken)
+ session.setTokens(payload)
+ await apolloClient.clearStore()
+ return payload.coach
+}
+
+/** Ends the login on the server and forgets the tokens and everything cached. */
+export async function logout(): Promise {
+ const refreshToken = session.getRefreshToken()
+ session.clear()
+ await apolloClient.clearStore()
+ if (refreshToken) {
+ try {
+ await logoutRequest(API_URL, refreshToken)
+ } catch {
+ // The tokens are gone here either way. The refresh token on the server
+ // expires by itself after 14 days.
+ }
+ }
+}
+
+/**
+ * On page load: if the coach logged in earlier, get an access token with the
+ * refresh token. Returns whether the coach is logged in.
+ */
+export const restoreSession = (): Promise =>
+ session.hasRefreshToken() ? session.refresh() : Promise.resolve(false)
diff --git a/src/lib/apollo/errors.test.ts b/src/lib/apollo/errors.test.ts
new file mode 100644
index 0000000..8f234af
--- /dev/null
+++ b/src/lib/apollo/errors.test.ts
@@ -0,0 +1,89 @@
+import { CombinedGraphQLErrors } from '@apollo/client/errors'
+import { describe, expect, it } from 'vitest'
+import { ApiError } from './api'
+import { describeError } from './errors'
+
+const fromServer = (code: string, message: string) =>
+ new CombinedGraphQLErrors({
+ data: null,
+ errors: [{ message, extensions: { code } }],
+ })
+
+describe('describeError', () => {
+ it('shows the message of the server when it says what to change', () => {
+ for (const code of [
+ 'BAD_USER_INPUT',
+ 'NAME_TAKEN',
+ 'LIMIT_REACHED',
+ 'INVALID_ITEMS',
+ ]) {
+ const described = describeError(
+ fromServer(code, 'The name must be 1-70 characters.'),
+ )
+ expect(described).toEqual({
+ code,
+ message: 'The name must be 1-70 characters.',
+ retryable: false,
+ })
+ }
+ })
+
+ it('uses its own message for the other codes, and never shows server details', () => {
+ expect(
+ describeError(fromServer('UNAUTHENTICATED', 'You must be logged in.')),
+ ).toEqual({
+ code: 'UNAUTHENTICATED',
+ message: 'Log in to continue.',
+ retryable: false,
+ })
+ expect(
+ describeError(fromServer('NOT_FOUND', 'The gymnast was not found.'))
+ .message,
+ ).toBe('We could not find that.')
+ const internal = describeError(
+ fromServer('INTERNAL_SERVER_ERROR', 'password=secret'),
+ )
+ expect(internal.retryable).toBe(true)
+ expect(JSON.stringify(internal)).not.toContain('secret')
+ expect(describeError(fromServer('SOMETHING_NEW', 'x')).retryable).toBe(true)
+ })
+
+ it('understands an ApiError from the login functions', () => {
+ expect(
+ describeError(
+ new ApiError('UNAUTHENTICATED', 'The Google token is not valid.'),
+ ).code,
+ ).toBe('UNAUTHENTICATED')
+ expect(
+ describeError(new ApiError('NETWORK_ERROR', 'The server answered 502.')),
+ ).toMatchObject({
+ code: 'NETWORK_ERROR',
+ retryable: true,
+ })
+ })
+
+ it('says that the server cannot be reached when fetch fails', () => {
+ const described = describeError(new TypeError('Failed to fetch'))
+ expect(described.code).toBe('NETWORK_ERROR')
+ expect(described.retryable).toBe(true)
+ })
+
+ it('handles a server error page (for example 502 from a proxy)', () => {
+ const error = Object.assign(new Error('Response not successful'), {
+ statusCode: 502,
+ })
+ expect(describeError(error)).toMatchObject({
+ code: 'SERVER_ERROR',
+ retryable: true,
+ })
+ })
+
+ it('handles anything else without throwing', () => {
+ for (const value of [null, undefined, 'text', 42, {}, new Error('x')]) {
+ expect(describeError(value)).toMatchObject({
+ code: 'UNKNOWN',
+ retryable: true,
+ })
+ }
+ })
+})
diff --git a/src/lib/apollo/errors.ts b/src/lib/apollo/errors.ts
new file mode 100644
index 0000000..82e729e
--- /dev/null
+++ b/src/lib/apollo/errors.ts
@@ -0,0 +1,94 @@
+// One place that turns an error from the API or the network into a message that
+// a page can show. The codes are the ones in "Error handling" in docs/API.md.
+
+export type DescribedError = {
+ /** The `extensions.code` from the server, or NETWORK_ERROR / UNKNOWN. */
+ code: string
+ /** A message for the user. */
+ message: string
+ /** Whether trying again can help. */
+ retryable: boolean
+}
+
+type GraphQLErrorLike = {
+ message?: string
+ extensions?: Record
+}
+
+/** The GraphQL errors in an Apollo error (CombinedGraphQLErrors), an ApiError or a plain object. */
+function graphQLErrors(error: unknown): GraphQLErrorLike[] {
+ const errors = (error as { errors?: unknown } | null)?.errors
+ return Array.isArray(errors) ? (errors as GraphQLErrorLike[]) : []
+}
+
+/**
+ * Codes where the message from the server is meant for the user, because it
+ * says what to change ("The name of a gymnast must be 1-70 characters.").
+ */
+const SERVER_MESSAGE = new Set([
+ 'BAD_USER_INPUT',
+ 'NAME_TAKEN',
+ 'LIMIT_REACHED',
+ 'INVALID_ITEMS',
+])
+
+const FIXED: Record = {
+ UNAUTHENTICATED: { message: 'Log in to continue.', retryable: false },
+ NOT_FOUND: { message: 'We could not find that.', retryable: false },
+ NOT_IMPLEMENTED: {
+ message: 'This is not available yet.',
+ retryable: false,
+ },
+ GRAPHQL_VALIDATION_FAILED: {
+ message: 'The app sent a request that the server does not understand.',
+ retryable: false,
+ },
+ INTERNAL_SERVER_ERROR: {
+ message: 'Something went wrong on our side. Try again.',
+ retryable: true,
+ },
+}
+
+const NETWORK = {
+ code: 'NETWORK_ERROR',
+ message: 'Could not reach the server. Check your connection and try again.',
+ retryable: true,
+}
+
+export function describeError(error: unknown): DescribedError {
+ const first = graphQLErrors(error)[0]
+ const code =
+ first?.extensions?.['code'] ?? (error as { code?: unknown } | null)?.code
+ if (typeof code === 'string' && code !== 'NETWORK_ERROR') {
+ if (SERVER_MESSAGE.has(code)) {
+ const message =
+ first?.message ?? (error as { message?: string }).message ?? ''
+ return { code, message, retryable: false }
+ }
+ const fixed = FIXED[code]
+ if (fixed) return { code, ...fixed }
+ return {
+ code,
+ message: 'Something went wrong. Try again.',
+ retryable: true,
+ }
+ }
+ // No code from the server: the request did not get an answer (a TypeError
+ // from fetch, a broken connection), or the server answered with a page that
+ // is not GraphQL (a proxy error, 502).
+ if (error instanceof TypeError || code === 'NETWORK_ERROR') return NETWORK
+ if (
+ typeof (error as { statusCode?: unknown } | null)?.statusCode === 'number'
+ ) {
+ return {
+ code: 'SERVER_ERROR',
+ message: 'The server could not answer. Try again.',
+ retryable: true,
+ }
+ }
+ return {
+ code: 'UNKNOWN',
+ message: 'Something went wrong. Try again.',
+ retryable: true,
+ }
+}
diff --git a/src/lib/apollo/session.test.ts b/src/lib/apollo/session.test.ts
new file mode 100644
index 0000000..4b75b84
--- /dev/null
+++ b/src/lib/apollo/session.test.ts
@@ -0,0 +1,259 @@
+import { describe, expect, it, vi } from 'vitest'
+import { ApiError, type AuthPayload } from './api'
+import { REFRESH_TOKEN_KEY, createSession, type TokenStorage } from './session'
+
+const payload = (n: number): AuthPayload => ({
+ accessToken: `access-${n}`,
+ refreshToken: `refresh-${n}`,
+ coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' },
+})
+
+function memoryStorage(): TokenStorage & { values: Map } {
+ const values = new Map()
+ return {
+ values,
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => void values.set(key, value),
+ removeItem: (key) => void values.delete(key),
+ }
+}
+
+const base64url = (value: object) =>
+ btoa(JSON.stringify(value))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '')
+
+/** A token that looks like the server's: three parts, with `exp` in seconds. */
+const jwt = (expSeconds: number) =>
+ `${base64url({ alg: 'HS256' })}.${base64url({ exp: expSeconds })}.signature`
+
+describe('the session', () => {
+ it('keeps the access token in memory and the refresh token in the storage', () => {
+ const storage = memoryStorage()
+ const session = createSession({ storage, refreshRequest: vi.fn() })
+ expect(session.getAccessToken()).toBeNull()
+ expect(session.hasRefreshToken()).toBe(false)
+
+ session.setTokens(payload(1))
+ expect(session.getAccessToken()).toBe('access-1')
+ expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-1')
+ expect([...storage.values.values()]).not.toContain('access-1')
+
+ // A new tab has the refresh token but no access token.
+ const other = createSession({ storage, refreshRequest: vi.fn() })
+ expect(other.getAccessToken()).toBeNull()
+ expect(other.hasRefreshToken()).toBe(true)
+
+ session.clear()
+ expect(session.getAccessToken()).toBeNull()
+ expect(storage.values.size).toBe(0)
+ })
+
+ it('still works when the storage is missing or throws', () => {
+ const broken: TokenStorage = {
+ getItem: () => {
+ throw new Error('blocked')
+ },
+ setItem: () => {
+ throw new Error('blocked')
+ },
+ removeItem: () => {
+ throw new Error('blocked')
+ },
+ }
+ for (const storage of [null, broken]) {
+ const session = createSession({ storage, refreshRequest: vi.fn() })
+ session.setTokens(payload(1))
+ expect(session.getAccessToken()).toBe('access-1')
+ expect(session.getRefreshToken()).toBe('refresh-1')
+ session.clear()
+ expect(session.hasRefreshToken()).toBe(false)
+ }
+ })
+
+ describe('refresh', () => {
+ it('uses the stored refresh token and keeps the new tokens', async () => {
+ const storage = memoryStorage()
+ storage.setItem(REFRESH_TOKEN_KEY, 'refresh-1')
+ const refreshRequest = vi.fn().mockResolvedValue(payload(2))
+ const session = createSession({ storage, refreshRequest })
+
+ expect(await session.refresh()).toBe(true)
+ expect(refreshRequest).toHaveBeenCalledWith('refresh-1')
+ expect(session.getAccessToken()).toBe('access-2')
+ // The new refresh token replaces the old one, which no longer works.
+ expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-2')
+ })
+
+ it('does nothing without a refresh token', async () => {
+ const refreshRequest = vi.fn()
+ const session = createSession({
+ storage: memoryStorage(),
+ refreshRequest,
+ })
+ expect(await session.refresh()).toBe(false)
+ expect(refreshRequest).not.toHaveBeenCalled()
+ })
+
+ it('forgets the login when the server refuses the refresh token', async () => {
+ const storage = memoryStorage()
+ const session = createSession({
+ storage,
+ refreshRequest: vi
+ .fn()
+ .mockRejectedValue(new ApiError('UNAUTHENTICATED', 'Log in again.')),
+ })
+ session.setTokens(payload(1))
+
+ expect(await session.refresh()).toBe(false)
+ expect(session.getAccessToken()).toBeNull()
+ expect(storage.values.size).toBe(0)
+ })
+
+ it('keeps the refresh token when the server cannot be reached, so a later try can work', async () => {
+ const storage = memoryStorage()
+ const refreshRequest = vi
+ .fn()
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
+ .mockRejectedValueOnce(new ApiError('INTERNAL_SERVER_ERROR', 'Oops'))
+ .mockResolvedValueOnce(payload(2))
+ const session = createSession({ storage, refreshRequest })
+ session.setTokens(payload(1))
+
+ expect(await session.refresh()).toBe(false)
+ expect(await session.refresh()).toBe(false)
+ expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-1')
+ expect(await session.refresh()).toBe(true)
+ expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-2')
+ })
+
+ it('sends one request when several parts of the page refresh at the same moment', async () => {
+ let resolve!: (value: AuthPayload) => void
+ const refreshRequest = vi.fn(
+ () => new Promise((r) => (resolve = r)),
+ )
+ const session = createSession({
+ storage: memoryStorage(),
+ refreshRequest,
+ })
+ session.setTokens(payload(1))
+
+ const all = Promise.all([
+ session.refresh(),
+ session.refresh(),
+ session.refresh(),
+ ])
+ resolve(payload(2))
+ expect(await all).toEqual([true, true, true])
+ expect(refreshRequest).toHaveBeenCalledTimes(1)
+
+ // A later refresh is a new request.
+ refreshRequest.mockResolvedValueOnce(payload(3) as never)
+ expect(await session.refresh()).toBe(true)
+ expect(refreshRequest).toHaveBeenCalledTimes(2)
+ })
+
+ it('goes through the lock between tabs, and reads the token inside it', async () => {
+ const storage = memoryStorage()
+ const names: string[] = []
+ // Another tab refreshes while this tab waits for the lock.
+ const locks = {
+ request: ((name: string, callback: () => Promise) => {
+ names.push(name)
+ storage.setItem(REFRESH_TOKEN_KEY, 'refresh-from-other-tab')
+ return callback()
+ }) as unknown as LockManager['request'],
+ }
+ const refreshRequest = vi.fn().mockResolvedValue(payload(5))
+ const session = createSession({ storage, refreshRequest, locks })
+ session.setTokens(payload(1))
+
+ expect(await session.refresh()).toBe(true)
+ expect(names).toEqual(['cop-refresh-session'])
+ expect(refreshRequest).toHaveBeenCalledWith('refresh-from-other-tab')
+ })
+ })
+})
+
+describe('ensureFresh', () => {
+ const NOW = Date.parse('2026-10-21T12:00:00Z')
+ const inSeconds = (n: number) => NOW / 1000 + n
+
+ function setup(accessExpiresInSeconds: number | null) {
+ const refreshed = { ...payload(2), accessToken: jwt(inSeconds(900)) }
+ const refreshRequest = vi
+ .fn<(refreshToken: string) => Promise>()
+ .mockResolvedValue(refreshed)
+ const session = createSession({
+ storage: memoryStorage(),
+ refreshRequest,
+ now: () => NOW,
+ })
+ if (accessExpiresInSeconds !== null) {
+ session.setTokens({
+ accessToken: jwt(inSeconds(accessExpiresInSeconds)),
+ refreshToken: 'refresh-1',
+ })
+ }
+ return { session, refreshRequest }
+ }
+
+ it('does nothing without a refresh token', async () => {
+ const { session, refreshRequest } = setup(null)
+ await session.ensureFresh()
+ expect(refreshRequest).not.toHaveBeenCalled()
+ })
+
+ it('refreshes when there is a refresh token but no access token (a new tab)', async () => {
+ const storage = memoryStorage()
+ createSession({ storage, refreshRequest: vi.fn() }).setTokens({
+ accessToken: jwt(inSeconds(900)),
+ refreshToken: 'refresh-1',
+ })
+ const refreshRequest = vi
+ .fn<(refreshToken: string) => Promise>()
+ .mockResolvedValue(payload(2))
+ const newTab = createSession({ storage, refreshRequest, now: () => NOW })
+ expect(newTab.getAccessToken()).toBeNull()
+
+ await newTab.ensureFresh()
+ expect(refreshRequest).toHaveBeenCalledWith('refresh-1')
+ expect(newTab.getAccessToken()).toBe('access-2')
+ })
+
+ it('does not refresh while the access token is valid for longer than the margin', async () => {
+ const { session, refreshRequest } = setup(600)
+ await session.ensureFresh()
+ expect(refreshRequest).not.toHaveBeenCalled()
+ })
+
+ it('refreshes when the access token has expired or is about to', async () => {
+ for (const seconds of [-60, 0, 20]) {
+ const { session, refreshRequest } = setup(seconds)
+ await session.ensureFresh()
+ expect(refreshRequest).toHaveBeenCalledTimes(1)
+ expect(refreshRequest).toHaveBeenCalledWith('refresh-1')
+ expect(session.getRefreshToken()).toBe('refresh-2')
+ }
+ })
+
+ it('keeps the refresh token when the request times out', async () => {
+ const timeout = new DOMException('The operation timed out.', 'TimeoutError')
+ const refreshRequest = vi
+ .fn<(refreshToken: string) => Promise>()
+ .mockRejectedValue(timeout)
+ const session = createSession({
+ storage: memoryStorage(),
+ refreshRequest,
+ now: () => NOW,
+ })
+ session.setTokens({
+ accessToken: jwt(inSeconds(-1)),
+ refreshToken: 'refresh-1',
+ })
+
+ await expect(session.refresh()).resolves.toBe(false)
+ expect(session.getRefreshToken()).toBe('refresh-1')
+ })
+})
diff --git a/src/lib/apollo/session.ts b/src/lib/apollo/session.ts
new file mode 100644
index 0000000..749ba39
--- /dev/null
+++ b/src/lib/apollo/session.ts
@@ -0,0 +1,154 @@
+// The tokens of the logged-in coach (see "Identifying the user" in docs/API.md).
+// - The access token (15 minutes) is kept in memory only.
+// - The refresh token (14 days) is kept in localStorage, so a reload does not
+// log the coach out. The server replaces it at every use, so the new one is
+// stored before anything else happens.
+// - Refreshing is done from one place at a time. Two tabs that use the same
+// refresh token at the same moment look like a stolen token to the server,
+// and it ends the login. Within a tab the calls share one request, and
+// between tabs the Web Locks API (`navigator.locks`) makes them follow each
+// other. The refresh token is read inside the lock, so a tab that waited
+// uses the newest one.
+
+import { ApiError, type AuthPayload } from './api'
+
+export const REFRESH_TOKEN_KEY = 'cop.refreshToken'
+const LOCK_NAME = 'cop-refresh-session'
+
+/** A token that expires within this time is renewed before the request. */
+export const EXPIRY_MARGIN_MS = 30 * 1000
+
+/**
+ * When the access token expires, in milliseconds, read from its `exp` claim.
+ * Only the client's own planning uses this; the server decides if the token
+ * is valid. Null for a token that cannot be read.
+ */
+function expiryOf(token: string): number | null {
+ try {
+ const part = token.split('.')[1]
+ if (!part) return null
+ const claims = JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')))
+ return typeof claims.exp === 'number' ? claims.exp * 1000 : null
+ } catch {
+ return null
+ }
+}
+
+export type TokenStorage = Pick
+type Locks = Pick
+
+export type Session = {
+ getAccessToken: () => string | null
+ hasRefreshToken: () => boolean
+ getRefreshToken: () => string | null
+ /** Stores the tokens of a login or a refresh. */
+ setTokens: (
+ payload: Pick,
+ ) => void
+ /** Forgets both tokens (logout, or a refresh token that the server refuses). */
+ clear: () => void
+ /**
+ * Gets new tokens with the stored refresh token. True when the coach is
+ * logged in afterwards. False when there is no refresh token, when the server
+ * refuses it (the tokens are cleared), or when the server cannot be reached
+ * (the refresh token is kept, so a later try can work).
+ */
+ refresh: () => Promise
+ /**
+ * Refreshes before a request when the coach has a refresh token and the
+ * access token is missing or about to expire. An expired access token does
+ * not make the server fail the request: `me` answers null, so the page would
+ * show the coach as logged out. Does nothing without a refresh token.
+ */
+ ensureFresh: () => Promise
+}
+
+export function createSession(options: {
+ storage?: TokenStorage | null
+ refreshRequest: (refreshToken: string) => Promise
+ locks?: Locks | null
+ /** The time in milliseconds. The tests set it. */
+ now?: () => number
+}): Session {
+ const { storage = null, locks = null, refreshRequest } = options
+ const now = options.now ?? Date.now
+ let accessToken: string | null = null
+ let accessExpiresAt: number | null = null
+ let inFlight: Promise | null = null
+
+ // Storage can be missing or throw (private windows, blocked site data).
+ const read = (): string | null => {
+ try {
+ return storage?.getItem(REFRESH_TOKEN_KEY) ?? null
+ } catch {
+ return null
+ }
+ }
+ let memoryRefreshToken: string | null = null // when the storage does not work
+ const getRefreshToken = () => read() ?? memoryRefreshToken
+
+ const setTokens: Session['setTokens'] = (payload) => {
+ accessToken = payload.accessToken
+ accessExpiresAt = expiryOf(payload.accessToken)
+ memoryRefreshToken = payload.refreshToken
+ try {
+ storage?.setItem(REFRESH_TOKEN_KEY, payload.refreshToken)
+ } catch {
+ // The memory copy keeps this tab logged in.
+ }
+ }
+
+ const clear = () => {
+ accessToken = null
+ accessExpiresAt = null
+ memoryRefreshToken = null
+ try {
+ storage?.removeItem(REFRESH_TOKEN_KEY)
+ } catch {
+ // Nothing more to do.
+ }
+ }
+
+ const refreshOnce = async (): Promise => {
+ const token = getRefreshToken() // read inside the lock
+ if (!token) return false
+ try {
+ setTokens(await refreshRequest(token))
+ return true
+ } catch (error) {
+ // The server says the login is over: forget it. Any other error (no
+ // network, a server error) keeps the token for a later try.
+ if (error instanceof ApiError && error.code === 'UNAUTHENTICATED') {
+ clear()
+ }
+ return false
+ }
+ }
+
+ const refresh = () => {
+ inFlight ??= (
+ locks ? locks.request(LOCK_NAME, refreshOnce) : refreshOnce()
+ ).finally(() => {
+ inFlight = null
+ })
+ return inFlight
+ }
+
+ const ensureFresh = async () => {
+ if (getRefreshToken() === null) return
+ const expired =
+ accessToken === null ||
+ (accessExpiresAt !== null && now() >= accessExpiresAt - EXPIRY_MARGIN_MS)
+ if (expired) await refresh()
+ }
+
+ return {
+ getAccessToken: () => accessToken,
+ hasRefreshToken: () => getRefreshToken() !== null,
+ getRefreshToken,
+ setTokens,
+ clear,
+ refresh,
+ ensureFresh,
+ }
+}
diff --git a/src/lib/catalog.test.ts b/src/lib/catalog.test.ts
new file mode 100644
index 0000000..13b7d6e
--- /dev/null
+++ b/src/lib/catalog.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { buildImageUrl, getElementDetail } from './catalog'
+
+describe('getElementDetail', () => {
+ it('returns catalog metadata, other variants, and the diagram URL', async () => {
+ const detail = await getElementDetail('UB-1.101-a')
+
+ expect(detail).toMatchObject({
+ id: 'UB-1.101-a',
+ apparatusName: 'Uneven bars',
+ elementGroupName: 'Mounts',
+ variants: [{ id: 'UB-1.101-b' }],
+ })
+ expect(detail?.imageUrl).toContain('UB-1.101-a.webp')
+ })
+
+ it('returns null when the catalog id does not exist', async () => {
+ await expect(getElementDetail('BB-0.000-a')).resolves.toBeNull()
+ })
+})
+
+describe('buildImageUrl', () => {
+ it('has no URL when the record has no image', () => {
+ expect(buildImageUrl('/', 'UB', undefined)).toBeNull()
+ expect(buildImageUrl('/', 'UB', null)).toBeNull()
+ })
+
+ it('does not double the slash after the base URL', () => {
+ expect(buildImageUrl('/project2/', 'UB', 'a.webp')).toBe(
+ '/project2/data/images/UB/a.webp',
+ )
+ expect(buildImageUrl('/', 'UB', 'a.webp')).toBe('/data/images/UB/a.webp')
+ })
+})
diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts
new file mode 100644
index 0000000..e6091e2
--- /dev/null
+++ b/src/lib/catalog.ts
@@ -0,0 +1,95 @@
+import apparatusRecords from '../../data/apparatus.json'
+import elementGroupRecords from '../../data/elementGroups.json'
+import elementRecords from '../../data/elements.json'
+import variantGroupRecords from '../../data/variantGroups.json'
+import vaultRecords from '../../data/vaults.json'
+
+type ElementRecord = (typeof elementRecords)[number]
+type VaultRecord = (typeof vaultRecords)[number]
+type CatalogRecord = ElementRecord | VaultRecord
+
+export type CatalogVariant = {
+ id: string
+ code: string
+ name: string | null
+ description: string
+ value: number
+ isVault: boolean
+}
+
+export type ElementDetail = CatalogVariant & {
+ apparatusId: string
+ apparatusName: string
+ elementGroupName: string
+ imageUrl: string | null
+ variants: CatalogVariant[]
+}
+
+const catalogById = new Map(
+ [...elementRecords, ...vaultRecords].map((record) => [record.id, record]),
+)
+
+const apparatusById = new Map(
+ apparatusRecords.map((apparatus) => [apparatus.id, apparatus]),
+)
+
+const elementGroupById = new Map(
+ elementGroupRecords.map((group) => [group.id, group]),
+)
+
+const variantGroupById = new Map(
+ variantGroupRecords.map((group) => [group.id, group]),
+)
+
+export function buildImageUrl(
+ baseUrl: string,
+ apparatusId: string,
+ image: string | null | undefined,
+): string | null {
+ if (!image) return null
+ return `${baseUrl.replace(/\/+$/, '')}/data/images/${apparatusId}/${image}`
+}
+
+function toCatalogVariant(record: CatalogRecord): CatalogVariant {
+ const isVault = 'dValue' in record
+
+ return {
+ id: record.id,
+ code: record.code,
+ name: record.name,
+ description: record.description,
+ value: isVault ? record.dValue : record.value,
+ isVault,
+ }
+}
+
+export async function getElementDetail(
+ id: string,
+): Promise {
+ const record = catalogById.get(id)
+
+ if (!record) return null
+
+ const apparatusId = id.slice(0, 2)
+ const variantGroupId = `${apparatusId}-${record.code}`
+ const relatedVariantIds =
+ variantGroupById.get(variantGroupId)?.variantIds ?? []
+ const variants = relatedVariantIds
+ .filter((variantId) => variantId !== id)
+ .map((variantId) => catalogById.get(variantId))
+ .filter((variant): variant is CatalogRecord => variant !== undefined)
+ .map(toCatalogVariant)
+ return {
+ ...toCatalogVariant(record),
+ apparatusId,
+ apparatusName: apparatusById.get(apparatusId)?.name ?? apparatusId,
+ elementGroupName:
+ elementGroupById.get(record.elementGroupId)?.name ?? 'Element group',
+ imageUrl: buildImageUrl(
+ import.meta.env.BASE_URL,
+ apparatusId,
+ record.image,
+ ),
+ variants,
+ }
+}
diff --git a/src/lib/difficulty.ts b/src/lib/difficulty.ts
new file mode 100644
index 0000000..3938460
--- /dev/null
+++ b/src/lib/difficulty.ts
@@ -0,0 +1,37 @@
+export const DIFFICULTY_VALUE_BY_LETTER = {
+ A: 0.1,
+ B: 0.2,
+ C: 0.3,
+ D: 0.4,
+ E: 0.5,
+ F: 0.6,
+ G: 0.7,
+ H: 0.8,
+ I: 0.9,
+ J: 1.0,
+} as const
+
+export type DifficultyLetter = keyof typeof DIFFICULTY_VALUE_BY_LETTER
+
+// Keyed by the value in tenths, so that 0.1 and 0.10000000000000002 both find A.
+const letterByTenths = new Map(
+ (
+ Object.entries(DIFFICULTY_VALUE_BY_LETTER) as [DifficultyLetter, number][]
+ ).map(([letter, value]) => [Math.round(value * 10), letter]),
+)
+
+export function getDifficultyValue(letter: string): number | undefined {
+ const normalizedLetter = letter.toUpperCase()
+
+ if (!Object.hasOwn(DIFFICULTY_VALUE_BY_LETTER, normalizedLetter)) {
+ return undefined
+ }
+
+ return DIFFICULTY_VALUE_BY_LETTER[normalizedLetter as DifficultyLetter]
+}
+
+export function getDifficultyLetter(
+ value: number,
+): DifficultyLetter | undefined {
+ return letterByTenths.get(Math.round(value * 10))
+}
diff --git a/src/lib/gymnasts.test.ts b/src/lib/gymnasts.test.ts
new file mode 100644
index 0000000..4104be1
--- /dev/null
+++ b/src/lib/gymnasts.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import {
+ getGymnastInitials,
+ getGymnastNameError,
+ normalizeGymnastName,
+} from "./gymnasts";
+
+describe("gymnast helpers", () => {
+ it("creates initials from the first and last name", () => {
+ expect(getGymnastInitials("Ingrid Haugen")).toBe("IH");
+ });
+
+ it("handles a single name and empty input", () => {
+ expect(getGymnastInitials("Madonna")).toBe("MA");
+ expect(getGymnastInitials(" ")).toBe("?");
+ });
+
+ it("trims and collapses whitespace in names", () => {
+ expect(normalizeGymnastName(" Emma Berg ")).toBe("Emma Berg");
+ });
+
+ it("rejects blank and overlong names", () => {
+ expect(getGymnastNameError(" ")).toBe("Enter a gymnast name.");
+ expect(getGymnastNameError("A".repeat(71))).toBe(
+ "Names must be 70 characters or fewer.",
+ );
+ expect(getGymnastNameError("Sofia Hansen")).toBeUndefined();
+ });
+});
\ No newline at end of file
diff --git a/src/lib/gymnasts.ts b/src/lib/gymnasts.ts
new file mode 100644
index 0000000..ef27b6e
--- /dev/null
+++ b/src/lib/gymnasts.ts
@@ -0,0 +1,33 @@
+export type GymnastSummary = {
+ id: string;
+ name: string;
+};
+
+export const exampleGymnasts: GymnastSummary[] = [
+ { id: "gymnast-1", name: "Ingrid Haugen" },
+ { id: "gymnast-2", name: "Emma Berg" },
+];
+
+export function getGymnastInitials(name: string): string {
+ const words = name.trim().split(/\s+/).filter(Boolean);
+
+ if (words.length === 0) return "?";
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
+
+ return `${words[0][0]}${words.at(-1)?.[0] ?? ""}`.toUpperCase();
+}
+
+export function normalizeGymnastName(name: string): string {
+ return name.trim().replace(/\s+/g, " ");
+}
+
+export function getGymnastNameError(name: string): string | undefined {
+ const normalizedName = normalizeGymnastName(name);
+
+ if (!normalizedName) return "Enter a gymnast name.";
+ if (normalizedName.length > 70) {
+ return "Names must be 70 characters or fewer.";
+ }
+
+ return undefined;
+}
\ No newline at end of file
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..ae9e5a7
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1 @@
+export { cn } from 'cn'
diff --git a/src/main.tsx b/src/main.tsx
index 17d5695..9ba3546 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,12 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
-import './index.css'
-import './styles/variables.css'
-import './styles/global.css'
+import { ApolloProvider } from '@apollo/client/react'
+import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
+import { apolloClient, restoreSession } from './lib/apollo/client'
-createRoot(document.getElementById('root')!).render(
-
-
- ,
-)
+// A coach who logged in earlier gets a new access token before the first
+// request. Without a saved login this answers at once.
+void restoreSession().finally(() => {
+ createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+ ,
+ )
+})
diff --git a/src/styles/global.css b/src/styles/global.css
index e69de29..4db4f32 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -0,0 +1,19 @@
+#root .element-detail-page :is(h1, h2) {
+ color: var(--text-color-dark);
+}
+
+#root .your-gymnasts-page :is(h1, h2) {
+ color: var(--text-color-dark);
+}
+
+#root .your-gymnasts-page h1 {
+ font-size: 42px;
+ line-height: 1.1;
+ margin: 0;
+}
+
+#root .your-gymnasts-page h2 {
+ font-size: 19px;
+ line-height: 1.2;
+ margin: 0;
+}
diff --git a/src/styles/variables.css b/src/styles/variables.css
index 94f575e..f6cac9a 100644
--- a/src/styles/variables.css
+++ b/src/styles/variables.css
@@ -24,8 +24,9 @@
--box-stroke: #888394;
--box-shadow: 0 2px 5px rgba(3, 2, 2, 0.1);
- /* D-value */
- --background-color: #BDE981;
+ /* D-value badge */
+ --badge-d-value-bg: #BDE981;
+ --badge-d-value-text: var(--text-color-dark);
--border-color: var(--text-color);
/* This part was generate with the help of Gemini 2.5 Flash using the prompt:
@@ -37,25 +38,25 @@
--space-md: 1rem; /* 16px - default padding (inputs, row heights) */
--space-lg: 1.5rem; /* 24px - component gaps (between search & filters) */
--space-xl: 2.5rem; /* 40px - page section margins & outer padding */
-
- /* Dark mode colors */
- [data-theme="dark"] {
- /* Main Layout Colors */
- --background-color: #090F2F; /* Dark blue background */
- --header-background: #101844; /* Slightly lighter blue for nav */
- --element-background: #18225C; /* Inputs, dropdowns, table background */
- --surface-selected: #253788; /* Row selection tint */
-
- /* Text Colors */
- --text-color-primary: #EAE0CF; /* Warm light text */
- --text-color-secondary: #A0AEC0; /* Muted text for labels */
- --text-color-on-dark: #090F2F; /* Inverted text for light buttons */
-
- /* UI Components & Borders */
- --border-color: #3B426B;
- --border-subtle: #2A3363;
- --badge-d-value-bg: #82C43C; /* Slightly deeper green for contrast */
- --badge-d-value-text: #090F2F;
-
- }
-}
\ No newline at end of file
+}
+
+/* Dark mode colors */
+[data-theme='dark'] {
+ /* Main Layout Colors */
+ --background-color: #090F2F; /* Dark blue background */
+ --header-background: #101844; /* Slightly lighter blue for nav */
+ --element-background: #18225C; /* Inputs, dropdowns, table background */
+ --surface-selected: #253788; /* Row selection tint */
+
+ /* Text Colors */
+ --text-color: #EAE0CF; /* Headings and body text */
+ --text-color-primary: #EAE0CF; /* Warm light text */
+ --text-color-secondary: #A0AEC0; /* Muted text for labels */
+ --text-color-on-dark: #090F2F; /* Inverted text for light buttons */
+
+ /* UI Components & Borders */
+ --border-color: #3B426B;
+ --border-subtle: #2A3363;
+ --badge-d-value-bg: #82C43C; /* Slightly deeper green for contrast */
+ --badge-d-value-text: #090F2F;
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..d4e233d
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,6 @@
+///
+
+interface ImportMetaEnv {
+ /** The address of the GraphQL API. Default "/graphql" (Vite sends it to the backend in development). */
+ readonly VITE_API_URL?: string
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 6830b6f..099956c 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -7,6 +7,7 @@
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
+ "resolveJsonModule": true,
/* Bundler mode */
"moduleResolution": "bundler",
@@ -20,7 +21,10 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
- "noFallthroughCasesInSwitch": true
+ "noFallthroughCasesInSwitch": true,
+
+ /* Path alias used by shadcn/ui: @/ means src/. No baseUrl, TypeScript 6 deprecates it. */
+ "paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 1ffef60..9e335f7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,16 @@
{
"files": [],
"references": [
- { "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
- ]
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ],
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
}
diff --git a/vite.config.ts b/vite.config.ts
index c7c1ed4..57a10f1 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,8 +1,51 @@
+import tailwindcss from '@tailwindcss/vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
-import { defineConfig } from 'vite'
+import { cpSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vitest/config'
+
+// The drawings in data/images are served by Vite in `npm run dev`, but are not
+// in public/, so a build would leave them out of dist/. Copy them there until
+// the images come from the server (IMAGE_BASE_URL).
+const copyCatalogImages = {
+ name: 'copy-catalog-images',
+ apply: 'build' as const,
+ closeBundle() {
+ cpSync(
+ fileURLToPath(new URL('./data/images', import.meta.url)),
+ fileURLToPath(new URL('./dist/data/images', import.meta.url)),
+ { recursive: true },
+ )
+ },
+}
// https://vite.dev/config/
export default defineConfig({
- plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
+ base: '/project2/',
+ plugins: [
+ react(),
+ babel({ presets: [reactCompilerPreset()] }),
+ tailwindcss(),
+ copyCatalogImages,
+ ],
+ server: {
+ // In development the browser calls the backend through Vite, so the app
+ // and the API have the same address and CORS is not needed. The backend
+ // listens on port 3001 (npm run backend).
+ proxy: {
+ '/graphql': 'http://localhost:3001',
+ '/images': 'http://localhost:3001',
+ },
+ },
+ resolve: {
+ // "@/components/ui/button" means "src/components/ui/button".
+ alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
+ },
+ test: {
+ // The backend tests share one local database, and some of them write to it
+ // (the login tests), while others check that the row counts do not change.
+ // One test file at a time keeps them apart.
+ fileParallelism: false,
+ },
})
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-