diff --git a/.github/workflows/build-and-push-docker-image.yaml b/.github/workflows/build-and-push-docker-image.yaml index d04d4a18..a51609ec 100644 --- a/.github/workflows/build-and-push-docker-image.yaml +++ b/.github/workflows/build-and-push-docker-image.yaml @@ -17,21 +17,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: submodules: recursive # https://github.com/docker/setup-qemu-action - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -39,12 +39,12 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image - uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64 diff --git a/.github/workflows/status_checks.yaml b/.github/workflows/status_checks.yaml index 61da5505..04c3ac14 100644 --- a/.github/workflows/status_checks.yaml +++ b/.github/workflows/status_checks.yaml @@ -11,13 +11,13 @@ jobs: name: Prettier Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: - version: 10 - - uses: actions/setup-node@v4 + version: 11 + - uses: actions/setup-node@v6 with: - node-version: "22" + node-version: "24" cache: pnpm - name: Install dependencies run: pnpm install @@ -28,13 +28,13 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: - version: 9 - - uses: actions/setup-node@v4 + version: 11 + - uses: actions/setup-node@v6 with: - node-version: "22" + node-version: "24" cache: pnpm - name: Install dependencies run: pnpm install @@ -45,13 +45,13 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 with: - version: 9 - - uses: actions/setup-node@v4 + version: 11 + - uses: actions/setup-node@v6 with: - node-version: "22" + node-version: "24" cache: pnpm - name: Install dependencies run: pnpm install diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e4db2889 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,136 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +`rezervo-web` is the Next.js (App Router) web client for [`rezervo`](https://github.com/mathiazom/rezervo), +a service for automatic booking of group fitness classes. It renders booking schedules per gym **chain** +and lets authenticated users manage recurring-booking preferences. All domain data comes from the separate +`rezervo` backend, which owns the database (PostgreSQL via SQLAlchemy + Alembic). This web repo is stateless — +it has no database of its own; it is a presentation + light API-proxy layer over the backend. + +## Commands + +Package manager is **pnpm only** (`preinstall` runs `only-allow pnpm`; npm/yarn are rejected). + +```shell +pnpm dev # dev server with Turbopack +pnpm build # production build +pnpm prod # build + start +pnpm check # prettier:check + lint + typecheck, run in parallel (CI gate) +pnpm fix # prettier + eslint --fix +pnpm typecheck # tsc --noEmit +pnpm lint # eslint +``` + +There is **no test runner** in this project. `pnpm check` is the verification gate — run it before considering +work complete. Type config is `@tsconfig/strictest`, so expect strict null/optional handling. + +Requires a `.env.local` (copy from `.env.local.example`) and a running `rezervo` backend. Node version is pinned +in `.nvmrc`. + +Dependencies with build scripts must be explicitly approved in `pnpm-workspace.yaml` under `allowBuilds` (pnpm +blocks postinstall scripts by default) — currently `sharp` and `unrs-resolver`. Add new ones there if a package's +native build is silently skipped. + +## Environment & backend wiring + +The backend host is referenced two ways (see `src/lib/helpers/requests.ts`): + +- `NEXT_PUBLIC_CONFIG_HOST` — used for **client-side** (browser) requests (`mode: "client"`). +- `INTERNAL_CONFIG_HOST` — used for **server-side** requests from Server Components / API routes (`mode: "server"`), + falling back to `NEXT_PUBLIC_CONFIG_HOST`. This split exists so server-side calls can use an internal network + address (e.g. `host.docker.internal`) while the browser uses the public one. + +Auth is **FusionAuth** via OAuth2 Authorization Code + PKCE (`react-oauth2-code-pkce`). The token exchange is +proxied through `src/app/api/auth/token/route.ts` so the `FUSIONAUTH_CLIENT_SECRET` stays server-side. Server-only +env vars are read through `requireServerEnv` / `requireServerAuthConfig` in `src/lib/helpers/env.ts`. + +## Architecture + +### Multi-chain routing (the central concept) + +The app is multi-tenant by gym chain. The main route is `src/app/[chain]/page.tsx`: + +- `generateStaticParams` statically generates one page per active chain (`dynamicParams = false` — unknown chains 404). +- Pages are ISR with `revalidate = 300`; on-demand revalidation is available via `src/app/api/revalidate/route.ts` + (guarded by `REVALIDATION_SECRET_TOKEN`). +- `/` redirects to `/:chain` based on the `rezervo.selectedChain` cookie (see `next.config.ts` redirects + + `src/app/[chain]/storeSelectedChain.ts`). + +### Server → client data flow + +Data fetching/caching uses **TanStack Query** (`@tanstack/react-query`). The client is created in +`src/lib/queryProvider.tsx` (a fresh `QueryClient` per request on the server to avoid cross-request state leaks; +a reused singleton in the browser) and wraps the app in `src/app/layout.tsx`. Devtools are included. + +`page.tsx` (Server Component) fetches chain metadata via `src/lib/helpers/fetchers.ts`, then **prefetches** the +current week's schedule into a server-side `QueryClient` and ships it to the client via `dehydrate` + +``, so the first client render is instant: + +1. `page.tsx` calls `queryClient.prefetchQuery({ queryKey: scheduleQueryKey(chain, weekParam), ... })` using + `fetchScheduleWeekDTOServer` (server-side fetch). +2. `` hands that cache to the client. +3. Client hooks (`useScheduleWeek`, etc.) read the hydrated cache; week navigation fetches more weeks on demand. + +**Query keys do not include locations** — `scheduleQueryKey(chain, week)` = `["schedule", chain, week]`. The cached +schedule always holds **all** locations (server prefetch uses the chain's default location ids; client fetches all), +so components must still **filter classes down to the currently selected locations** when displaying. Schedule key / +URL / fetch helpers live in `src/lib/helpers/schedule.ts` (`scheduleQueryKey`, `constructScheduleUrl`, +`fetchScheduleWeekDTO`, `offsetWeekParam`). + +**The React Query cache stores the serializable `*DTO` form, not the deserialized domain form**, so it survives +server→client dehydration without losing Luxon `DateTime`s. Hooks pass `select:` (e.g. `deserializeWeekSchedule`) +to deserialize/transform on read — use **module-level stable `select` functions** so React Query can memoize the +(expensive) result. `useScheduleWeek` uses `keepPreviousData` for smooth week navigation; +`usePrefetchAdjacentWeeks` background-prefetches offsets `[-1, 1, 2, 3]`. `staleTime` defaults to 60s globally, +but schedules use `SCHEDULE_STALE_TIME` (1h, matching the server-side cache window). + +### Serialization (DTO ↔ domain) + +Luxon `DateTime` objects cannot cross the Server→Client boundary. The backend/server layer works with `*DTO` +types (dates as ISO strings, see `src/types/serialization.ts`); the client works with `Rezervo*` domain types +(dates as `DateTime`). Conversions live in `src/lib/serialization/{serializers,deserializers}.ts`. When adding +fields that include dates/times, update both the DTO type and the serializer/deserializer pair. + +### Data hooks + +Client data fetching is centralized in `src/lib/hooks/use*.ts` using TanStack Query's `useQuery` / `useMutation`, +e.g. `useScheduleWeek`, `useUserConfig`, `useUserSessions`, `useUserChainConfigs`. Authenticated hooks build a +fetcher with `authedFetcher(token)` from `src/lib/utils/fetchUtils.ts` and gate the request on `isAuthenticated` +via `enabled:`. The shared `fetcher` throws a `FetchError` (`{ status, +statusText }`) on non-OK responses — use it as the `useQuery` error type so consumers can branch on `error.status` +(e.g. treating 404 as "no config yet" in `useUserConfig`). Mutations (e.g. `useUserConfig.putUserConfig`) update +the cache in `onSuccess` with `queryClient.setQueryData(...)` and/or `invalidateQueries(...)`, and trigger dependent +hooks' `mutate*` helpers (which wrap `invalidateQueries`) to keep sessions/configs in sync. + +### Recurrent class identity + +Recurring bookings are matched to concrete scheduled classes by a derived **recurrent id** +(`activityId_weekday_hour_minute`) — see `src/lib/helpers/recurrentId.ts`. Note `classRecurrentId` shifts weekday +by `(weekday + 6) % 7` to align Luxon's 1–7 (Mon=1) with the backend's 0-based weekday. "Ghost" configs (recurring +bookings whose class isn't in the current schedule) are merged in `Chain.tsx` so they remain visible/removable. + +### State + +Lightweight global state uses **Zustand** (`src/stores/userStore.ts`: current user id/name, avatar cache-bust +timestamp). Most state is local component state or the TanStack Query cache. UI-persisted filters (selected locations/categories, +excluded class times) are stored in browser storage via `src/lib/helpers/storage.ts`, keyed per chain. + +### PWA / service worker + +PWA support via **Serwist** (`@serwist/next`). The service worker source is `src/serviceworker/index.ts`, +compiled to `public/sw.js` through `withSerwist` in `next.config.ts` (do not edit `public/sw.js` by hand; it is +generated and lint-ignored). Push-notification subscription logic is in `src/lib/hooks/usePushNotificationSubscription.ts`. + +## Conventions + +- **Imports**: use the `@/*` path alias (maps to `src/*`); relative import paths are forbidden by ESLint + (`no-relative-import-paths`). Import order is enforced and auto-fixable — run `pnpm fix`. +- **React Compiler**: `eslint-plugin-react-compiler` runs as an error-level rule; avoid patterns it flags. +- **UI**: MUI v6 (`@mui/material`) with Emotion; theme in `src/lib/theme.ts`, applied in `src/app/layout.tsx`. + Dates/times use Luxon (`src/lib/helpers/date.ts`), with `@mui/x-date-pickers` localized in + `src/lib/datePickerLocalizationProvider.tsx`. Locale/UI language is Norwegian (`lang="no"`). +- Components live in `src/components/` (modals under `modals/`, schedule grid under `schedule/`); shared logic in + `src/lib/` (`helpers/`, `hooks/`, `utils/`, `serialization/`); types in `src/types/`. diff --git a/package.json b/package.json index ea166244..ba8e034a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@mui/material-nextjs": "^6.4.2", "@mui/x-date-pickers": "^7.28.3", "@serwist/next": "^9.1.1", + "@tanstack/react-query": "^5.101.0", "js-cookie": "^3.0.5", "jwt-decode": "^4.0.0", "luxon": "^3.7.1", @@ -38,12 +39,12 @@ "react-oauth2-code-pkce": "^1.23.1", "react-snowfall": "^2.3.0", "sharp": "^0.34.3", - "swr": "^2.3.6", "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.34.0", "@next/eslint-plugin-next": "^15.5.2", + "@tanstack/react-query-devtools": "^5.101.0", "@tsconfig/next": "^2.0.3", "@tsconfig/strictest": "^2.0.5", "@types/js-cookie": "^3.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cce085f4..49b292dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: "@serwist/next": specifier: ^9.1.1 version: 9.1.1(next@15.5.7(@babel/core@7.28.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.9.2) + "@tanstack/react-query": + specifier: ^5.101.0 + version: 5.101.0(react@19.1.1) js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -67,9 +70,6 @@ importers: sharp: specifier: ^0.34.3 version: 0.34.3 - swr: - specifier: ^2.3.6 - version: 2.3.6(react@19.1.1) zustand: specifier: ^5.0.8 version: 5.0.8(@types/react@19.1.12)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)) @@ -80,6 +80,9 @@ importers: "@next/eslint-plugin-next": specifier: ^15.5.2 version: 15.5.2 + "@tanstack/react-query-devtools": + specifier: ^5.101.0 + version: 5.101.0(@tanstack/react-query@5.101.0(react@19.1.1))(react@19.1.1) "@tsconfig/next": specifier: ^2.0.3 version: 2.0.3 @@ -663,6 +666,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-arm@1.2.0": resolution: @@ -671,6 +675,7 @@ packages: } cpu: [arm] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-ppc64@1.2.0": resolution: @@ -679,6 +684,7 @@ packages: } cpu: [ppc64] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-s390x@1.2.0": resolution: @@ -687,6 +693,7 @@ packages: } cpu: [s390x] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-x64@1.2.0": resolution: @@ -695,6 +702,7 @@ packages: } cpu: [x64] os: [linux] + libc: [glibc] "@img/sharp-libvips-linuxmusl-arm64@1.2.0": resolution: @@ -703,6 +711,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [musl] "@img/sharp-libvips-linuxmusl-x64@1.2.0": resolution: @@ -711,6 +720,7 @@ packages: } cpu: [x64] os: [linux] + libc: [musl] "@img/sharp-linux-arm64@0.34.3": resolution: @@ -720,6 +730,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [linux] + libc: [glibc] "@img/sharp-linux-arm@0.34.3": resolution: @@ -729,6 +740,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm] os: [linux] + libc: [glibc] "@img/sharp-linux-ppc64@0.34.3": resolution: @@ -738,6 +750,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [ppc64] os: [linux] + libc: [glibc] "@img/sharp-linux-s390x@0.34.3": resolution: @@ -747,6 +760,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [s390x] os: [linux] + libc: [glibc] "@img/sharp-linux-x64@0.34.3": resolution: @@ -756,6 +770,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [linux] + libc: [glibc] "@img/sharp-linuxmusl-arm64@0.34.3": resolution: @@ -765,6 +780,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [linux] + libc: [musl] "@img/sharp-linuxmusl-x64@0.34.3": resolution: @@ -774,6 +790,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [linux] + libc: [musl] "@img/sharp-wasm32@0.34.3": resolution: @@ -1224,6 +1241,7 @@ packages: engines: { node: ">= 10" } cpu: [arm64] os: [linux] + libc: [glibc] "@next/swc-linux-arm64-musl@15.5.7": resolution: @@ -1233,6 +1251,7 @@ packages: engines: { node: ">= 10" } cpu: [arm64] os: [linux] + libc: [musl] "@next/swc-linux-x64-gnu@15.5.7": resolution: @@ -1242,6 +1261,7 @@ packages: engines: { node: ">= 10" } cpu: [x64] os: [linux] + libc: [glibc] "@next/swc-linux-x64-musl@15.5.7": resolution: @@ -1251,6 +1271,7 @@ packages: engines: { node: ">= 10" } cpu: [x64] os: [linux] + libc: [musl] "@next/swc-win32-arm64-msvc@15.5.7": resolution: @@ -1361,6 +1382,35 @@ packages: integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==, } + "@tanstack/query-core@5.101.0": + resolution: + { + integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==, + } + + "@tanstack/query-devtools@5.101.0": + resolution: + { + integrity: sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==, + } + + "@tanstack/react-query-devtools@5.101.0": + resolution: + { + integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==, + } + peerDependencies: + "@tanstack/react-query": ^5.101.0 + react: ^18 || ^19 + + "@tanstack/react-query@5.101.0": + resolution: + { + integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==, + } + peerDependencies: + react: ^18 || ^19 + "@tsconfig/next@2.0.3": resolution: { @@ -1632,6 +1682,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [glibc] "@unrs/resolver-binding-linux-arm64-musl@1.11.1": resolution: @@ -1640,6 +1691,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [musl] "@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": resolution: @@ -1648,6 +1700,7 @@ packages: } cpu: [ppc64] os: [linux] + libc: [glibc] "@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": resolution: @@ -1656,6 +1709,7 @@ packages: } cpu: [riscv64] os: [linux] + libc: [glibc] "@unrs/resolver-binding-linux-riscv64-musl@1.11.1": resolution: @@ -1664,6 +1718,7 @@ packages: } cpu: [riscv64] os: [linux] + libc: [musl] "@unrs/resolver-binding-linux-s390x-gnu@1.11.1": resolution: @@ -1672,6 +1727,7 @@ packages: } cpu: [s390x] os: [linux] + libc: [glibc] "@unrs/resolver-binding-linux-x64-gnu@1.11.1": resolution: @@ -1680,6 +1736,7 @@ packages: } cpu: [x64] os: [linux] + libc: [glibc] "@unrs/resolver-binding-linux-x64-musl@1.11.1": resolution: @@ -1688,6 +1745,7 @@ packages: } cpu: [x64] os: [linux] + libc: [musl] "@unrs/resolver-binding-wasm32-wasi@1.11.1": resolution: @@ -2174,13 +2232,6 @@ packages: } engines: { node: ">= 0.4" } - dequal@2.0.3: - resolution: - { - integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==, - } - engines: { node: ">=6" } - detect-libc@2.0.4: resolution: { @@ -2668,6 +2719,7 @@ packages: { integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==, } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@11.12.0: @@ -3312,6 +3364,7 @@ packages: integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==, } engines: { node: ^18.18.0 || ^19.8.0 || >= 20.0.0 } + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: "@opentelemetry/api": ^1.1.0 @@ -4099,14 +4152,6 @@ packages: } engines: { node: ">= 0.4" } - swr@2.3.6: - resolution: - { - integrity: sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==, - } - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - tinyglobby@0.2.14: resolution: { @@ -5198,6 +5243,21 @@ snapshots: dependencies: tslib: 2.8.1 + "@tanstack/query-core@5.101.0": {} + + "@tanstack/query-devtools@5.101.0": {} + + "@tanstack/react-query-devtools@5.101.0(@tanstack/react-query@5.101.0(react@19.1.1))(react@19.1.1)": + dependencies: + "@tanstack/query-devtools": 5.101.0 + "@tanstack/react-query": 5.101.0(react@19.1.1) + react: 19.1.1 + + "@tanstack/react-query@5.101.0(react@19.1.1)": + dependencies: + "@tanstack/query-core": 5.101.0 + react: 19.1.1 + "@tsconfig/next@2.0.3": {} "@tsconfig/strictest@2.0.5": {} @@ -5672,8 +5732,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - dequal@2.0.3: {} - detect-libc@2.0.4: {} doctrine@2.1.0: @@ -6964,12 +7022,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swr@2.3.6(react@19.1.1): - dependencies: - dequal: 2.0.3 - react: 19.1.1 - use-sync-external-store: 1.5.0(react@19.1.1) - tinyglobby@0.2.14: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -7087,6 +7139,7 @@ snapshots: use-sync-external-store@1.5.0(react@19.1.1): dependencies: react: 19.1.1 + optional: true validate-npm-package-license@3.0.4: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..ada97ee1 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + sharp: true + unrs-resolver: true diff --git a/src/app/[chain]/SWRCacheInjector.tsx b/src/app/[chain]/SWRCacheInjector.tsx deleted file mode 100644 index 9fad3f60..00000000 --- a/src/app/[chain]/SWRCacheInjector.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useSWRConfig } from "swr"; - -export const SWRCacheInjector = ({ cacheData }: { cacheData: Record }) => { - const { mutate } = useSWRConfig(); - - useEffect(() => { - for (const [key, value] of Object.entries(cacheData)) { - mutate(key, value); - } - }, [mutate, cacheData]); - - return null; -}; diff --git a/src/app/[chain]/SWRConfigurator.tsx b/src/app/[chain]/SWRConfigurator.tsx deleted file mode 100644 index 767d7338..00000000 --- a/src/app/[chain]/SWRConfigurator.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { PropsWithChildren } from "react"; -import { Middleware, SWRConfig, useSWRConfig } from "swr"; - -import { SWRCacheInjector } from "@/app/[chain]/SWRCacheInjector"; -import { RezervoWeekScheduleDTO } from "@/types/serialization"; - -export default function SWRConfigurator({ - scheduleCache, - children, -}: { scheduleCache: Record | undefined } & PropsWithChildren) { - return ( - - {scheduleCache && } - {children} - - ); -} - -/** - * Middleware to reduce cache misses by allowing schedule data from a superset of the requested locations. - * Classes from excess locations must therefore be filtered out when displayed. - */ -const scheduleFetchMiddleware: Middleware = (useSWRNext) => (key, fetcher, config) => { - // ignore non-schedule keys - if (!(typeof key === "string" && key.match(/^[a-z0-9-]+\/schedule/))) { - return useSWRNext(key, fetcher, config); - } - const { cache } = useSWRConfig(); - // quick check for existing keys - if (cache.get(key) !== undefined) { - return useSWRNext(key, fetcher, config); - } - const [baseUrl, queryParamsRaw] = key.split("?"); - if (baseUrl == undefined || queryParamsRaw == undefined) { - return useSWRNext(key, fetcher, config); - } - const queryParams = new URLSearchParams(queryParamsRaw); - const compactISOWeek = queryParams.get("w"); - if (compactISOWeek == undefined) { - return useSWRNext(key, fetcher, config); - } - const locationIds = queryParams.getAll("location"); - for (const candidateKey of Array.from(cache.keys())) { - if (!candidateKey.startsWith(baseUrl)) { - continue; - } - const [, candidateQueryParamsRaw] = candidateKey.split("?"); - if (candidateQueryParamsRaw == undefined) { - continue; - } - const candidateQueryParams = new URLSearchParams(candidateQueryParamsRaw); - if (candidateQueryParams.get("w") !== compactISOWeek) { - continue; - } - const candidateLocationIds = candidateQueryParams.getAll("location"); - // check if cache value represents a superset of the requested locationIds - // (possibly returning more data than requested) - if (locationIds.every((id) => candidateLocationIds.includes(id))) { - return useSWRNext(candidateKey, fetcher, config); - } - } - return useSWRNext(key, fetcher, config); -}; diff --git a/src/app/[chain]/page.tsx b/src/app/[chain]/page.tsx index 27f9e0f2..5a689bac 100644 --- a/src/app/[chain]/page.tsx +++ b/src/app/[chain]/page.tsx @@ -1,8 +1,15 @@ +import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; + import StoreSelectedChain from "@/app/[chain]/storeSelectedChain"; -import SWRConfigurator from "@/app/[chain]/SWRConfigurator"; import Chain from "@/components/Chain"; -import { CLASS_ID_QUERY_PARAM, ISO_WEEK_QUERY_PARAM, SCROLL_TO_NOW_QUERY_PARAM } from "@/lib/consts"; -import { fetchActiveChains, fetchChain, fetchChainPageStaticProps } from "@/lib/helpers/fetchers"; +import { CLASS_ID_QUERY_PARAM, ISO_WEEK_QUERY_PARAM } from "@/lib/consts"; +import { + fetchActiveChains, + fetchChain, + fetchChainPageStaticProps, + fetchScheduleWeekDTOServer, +} from "@/lib/helpers/fetchers"; +import { scheduleQueryKey } from "@/lib/helpers/schedule"; export const dynamicParams = false; @@ -16,32 +23,30 @@ export async function generateStaticParams() { export default async function Page({ params, searchParams }: PageProps<"/[chain]">) { const chainIdentifier = (await params).chain; - const { - [ISO_WEEK_QUERY_PARAM]: rawWeekParam, - [SCROLL_TO_NOW_QUERY_PARAM]: scrollToNowParam, - [CLASS_ID_QUERY_PARAM]: showClassId, - } = await searchParams; - const { chain, weekParam, chainProfiles, scheduleCache, activityCategories, classPopularityIndex, error } = - await fetchChain(chainIdentifier).then((c) => - fetchChainPageStaticProps(c, Array.isArray(rawWeekParam) ? rawWeekParam[0] : rawWeekParam), - ); + const { [ISO_WEEK_QUERY_PARAM]: rawWeekParam, [CLASS_ID_QUERY_PARAM]: showClassId } = await searchParams; + const { chain, weekParam, chainProfiles, activityCategories } = await fetchChain(chainIdentifier).then((c) => + fetchChainPageStaticProps(c, Array.isArray(rawWeekParam) ? rawWeekParam[0] : rawWeekParam), + ); const defaultLocationIds = chain.branches.flatMap((branch) => branch.locations.map(({ identifier }) => identifier)); + const queryClient = new QueryClient(); + await queryClient.prefetchQuery({ + queryKey: scheduleQueryKey(chain.profile.identifier, weekParam), + queryFn: () => fetchScheduleWeekDTOServer(chain.profile.identifier, weekParam, defaultLocationIds), + }); + return ( - + - + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dc744199..c03cbc2a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { Roboto } from "next/font/google"; import AuthProvider from "@/lib/authProvider"; import DatePickerLocalizationProvider from "@/lib/datePickerLocalizationProvider"; import { requireServerAuthConfig } from "@/lib/helpers/env"; +import QueryProvider from "@/lib/queryProvider"; import SnowfallProvider from "@/lib/snowfallProvider"; import theme from "@/lib/theme"; @@ -70,15 +71,17 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - - - -
- {children} -
-
-
+ + + + + +
+ {children} +
+
+
+
diff --git a/src/components/Chain.tsx b/src/components/Chain.tsx index 4c501098..a617b858 100644 --- a/src/components/Chain.tsx +++ b/src/components/Chain.tsx @@ -13,20 +13,21 @@ import ProfileModal from "@/components/modals/Profile/ProfileModal"; import SettingsModal from "@/components/modals/Settings/SettingsModal"; import WeekNavigator from "@/components/schedule/WeekNavigator"; import WeekSchedule from "@/components/schedule/WeekSchedule"; +import WeekScheduleSkeleton from "@/components/schedule/WeekScheduleSkeleton"; import AppBar from "@/components/utils/AppBar"; import ChainSwitcher from "@/components/utils/ChainSwitcher"; import CheckIn from "@/components/utils/CheckIn"; import ErrorMessage from "@/components/utils/ErrorMessage"; import PWAInstallPrompt from "@/components/utils/PWAInstallPrompt"; -import { CLASS_ID_QUERY_PARAM, SCROLL_TO_NOW_QUERY_PARAM } from "@/lib/consts"; -import { compactISOWeekString, LocalizedDateTime } from "@/lib/helpers/date"; +import { CLASS_ID_QUERY_PARAM, ISO_WEEK_QUERY_PARAM } from "@/lib/consts"; +import { compactISOWeekString, fromCompactISOWeekString, LocalizedDateTime } from "@/lib/helpers/date"; import { classConfigRecurrentId, classRecurrentId } from "@/lib/helpers/recurrentId"; import { getStoredExcludeClassTimeFilters, getStoredSelectedCategories, getStoredSelectedLocations, } from "@/lib/helpers/storage"; -import { useSchedule } from "@/lib/hooks/useSchedule"; +import { useClassPopularityIndex, usePrefetchAdjacentWeeks, useScheduleWeek } from "@/lib/hooks/useSchedule"; import { useUserChainConfigs } from "@/lib/hooks/useUserChainConfigs"; import { useUserConfig } from "@/lib/hooks/useUserConfig"; import { useUserSessions } from "@/lib/hooks/useUserSessions"; @@ -45,7 +46,6 @@ import { } from "@/types/chain"; import { ClassConfig } from "@/types/config"; import { RezervoError } from "@/types/errors"; -import { ClassPopularityIndex } from "@/types/popularity"; import { SessionStatus } from "@/types/userSessions"; // Memoize to avoid redundant schedule re-render on class selection change @@ -53,24 +53,18 @@ const WeekScheduleMemo = memo(WeekSchedule); function Chain({ weekParam, - scrollToNow, showClassId, - classPopularityIndex, chain, chainProfiles, initialLocationIds, activityCategories, - error, }: { weekParam: string; - scrollToNow: boolean; showClassId: string | undefined; - classPopularityIndex: ClassPopularityIndex; chain: RezervoChain; chainProfiles: ChainProfile[]; initialLocationIds: string[]; activityCategories: ActivityCategory[]; - error: RezervoError | undefined; }) { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -84,6 +78,8 @@ function Chain({ const [userConfigActive, setUserConfigActive] = useState(true); + const [currentWeek, setCurrentWeek] = useState(weekParam); + const [selectedClassIds, setSelectedClassIds] = useState(null); const deferredSelectedClassIds = useDeferredValue(selectedClassIds); @@ -93,6 +89,11 @@ function Chain({ const [isProfileOpen, setIsProfileOpen] = useState(false); const [bookingPopupState, setBookingPopupState] = useState(null); + const allLocationIds = useMemo( + () => chain.branches.flatMap((branch) => branch.locations.map(({ identifier }) => identifier)), + [chain.branches], + ); + const defaultLocationIds = useMemo(() => { const firstBranch = chain.branches[0]; return firstBranch ? firstBranch.locations.map(({ identifier }) => identifier) : []; @@ -106,7 +107,6 @@ function Chain({ enabled: true, filters: [], }); - const [selectedChain, setSelectedChain] = useState(null); const [classInfoClass, setClassInfoClass] = useState(null); useEffect(() => { @@ -116,18 +116,19 @@ function Chain({ getStoredSelectedCategories(chain.profile.identifier) ?? activityCategories.map((ac) => ac.name), ); setExcludeClassTimeFilters(getStoredExcludeClassTimeFilters() ?? { enabled: true, filters: [] }); - setSelectedChain(chain.profile.identifier); }, [chain.profile.identifier, defaultLocationIds, activityCategories]); const { + weekSchedule: currentWeekSchedule, + weekScheduleError, + isLoadingInitial, isLoadingPreviousWeek, isLoadingNextWeek, - weekSchedule: currentWeekSchedule, - } = useSchedule( - selectedChain, - weekParam ?? compactISOWeekString(LocalizedDateTime.now()), - deferredSelectedLocationIds, - ); + } = useScheduleWeek(chain.profile.identifier, currentWeek, allLocationIds); + + usePrefetchAdjacentWeeks(chain.profile.identifier, currentWeek, allLocationIds, currentWeekSchedule != null); + + const classPopularityIndex = useClassPopularityIndex(chain.profile.identifier, currentWeek, allLocationIds); const classes = useMemo( () => currentWeekSchedule?.days.flatMap((daySchedule) => daySchedule.classes) ?? [], @@ -176,70 +177,102 @@ function Chain({ return { ...classesConfigMap, ...ghostClassesConfigs }; }, [classesConfigMap, userConfig?.recurringBookings]); - const onUpdateConfig = async (classId: string, selected: boolean) => { - const selectedClass = classes.find((c) => classRecurrentId(c) === classId); - if (selectedClass?.isBookable) { - const isBooked = - userSessionsIndex?.[selectedClass.id]?.some( - (userSession) => userSession.isSelf && userSession.status === SessionStatus.BOOKED, - ) ?? false; - if (selected && !isBooked) { - setBookingPopupState({ - chain: chain.profile.identifier, - _class: selectedClass, - action: BookingPopupAction.BOOK, - }); - } else if (!selected && isBooked) { - setBookingPopupState({ - chain: chain.profile.identifier, - _class: selectedClass, - action: BookingPopupAction.CANCEL, - }); + const onUpdateConfig = useCallback( + async (classId: string, selected: boolean) => { + const selectedClass = classes.find((c) => classRecurrentId(c) === classId); + if (selectedClass?.isBookable) { + const isBooked = + userSessionsIndex?.[selectedClass.id]?.some( + (userSession) => userSession.isSelf && userSession.status === SessionStatus.BOOKED, + ) ?? false; + if (selected && !isBooked) { + setBookingPopupState({ + chain: chain.profile.identifier, + _class: selectedClass, + action: BookingPopupAction.BOOK, + }); + } else if (!selected && isBooked) { + setBookingPopupState({ + chain: chain.profile.identifier, + _class: selectedClass, + action: BookingPopupAction.CANCEL, + }); + } } - } - if (deferredSelectedClassIds == null) return; - const newSelectedClassIds = updateValueSelection(deferredSelectedClassIds, classId, selected); - setSelectedClassIds(newSelectedClassIds); - return await putUserConfig({ - active: userConfigActive, - recurringBookings: newSelectedClassIds?.flatMap((id) => allClassesConfigMap[id] ?? []) ?? [], - }); - }; + if (deferredSelectedClassIds == null) return; + const newSelectedClassIds = updateValueSelection(deferredSelectedClassIds, classId, selected); + setSelectedClassIds(newSelectedClassIds); + return await putUserConfig({ + active: userConfigActive, + recurringBookings: newSelectedClassIds?.flatMap((id) => allClassesConfigMap[id] ?? []) ?? [], + }); + }, + [ + classes, + userSessionsIndex, + chain.profile.identifier, + deferredSelectedClassIds, + putUserConfig, + userConfigActive, + allClassesConfigMap, + ], + ); const scrollToTodayRef = useRef(null); + const scrollToToday = useCallback(() => { + scrollToTodayRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + }, []); + + const [scrollPending, setScrollPending] = useState(true); + useEffect(() => { + // stay pending until the today column has actually rendered (it may be absent during a placeholder week) + if (!scrollPending || currentWeekSchedule == null || scrollToTodayRef.current == null) return; + const handle = requestAnimationFrame(() => scrollToToday()); + setScrollPending(false); + return () => cancelAnimationFrame(handle); + }, [scrollPending, currentWeekSchedule, scrollToToday]); + useEffect(() => { setSelectedClassIds(userConfig?.recurringBookings?.map(classConfigRecurrentId) ?? null); setUserConfigActive(userConfig?.active ?? false); }, [userConfig?.active, userConfig?.recurringBookings]); - useEffect(() => { - scrollToToday(); - }, [scrollToTodayRef]); + const syncWeekUrl = useCallback( + (week: string) => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set(ISO_WEEK_QUERY_PARAM, week); + window.history.replaceState(null, "", `${pathname}?${newSearchParams.toString()}`); + }, + [pathname, searchParams], + ); - useEffect(() => { - if (!scrollToNow) return; - scrollToToday(); - const newSearchParams = new URLSearchParams(searchParams); - newSearchParams.delete(SCROLL_TO_NOW_QUERY_PARAM); - // @ts-expect-error TODO: bad route type - router.replace(pathname + "?" + newSearchParams.toString()); - }, [pathname, router, scrollToNow, searchParams]); - - function scrollToToday() { - const target = scrollToTodayRef.current; - if (target != null) { - target.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "center", - }); + const goToWeek = useCallback( + (week: string) => { + setCurrentWeek(week); + syncWeekUrl(week); + }, + [syncWeekUrl], + ); + + const goToToday = useCallback(() => { + const today = compactISOWeekString(LocalizedDateTime.now()); + if (today != null) { + setCurrentWeek(today); + syncWeekUrl(today); } - } + setScrollPending(true); + }, [syncWeekUrl]); - const refetchConfig = useCallback(async () => { - await mutateUserConfig(); - }, [mutateUserConfig]); + const weekNumber = useMemo(() => { + if (currentWeekSchedule != null) return getWeekNumber(currentWeekSchedule); + const date = fromCompactISOWeekString(currentWeek); + return date.isValid ? date.weekNumber : LocalizedDateTime.now().weekNumber; + }, [currentWeekSchedule, currentWeek]); const [showPWAInstall, setShowPWAInstall] = useState(false); const [isPWAInstalled, setIsPWAInstalled] = useState(false); @@ -257,8 +290,8 @@ function Chain({ chainConfigs={userChainConfigs} userSessions={userSessions} isLoadingConfig={userConfigLoading} - isConfigError={userConfigError} - onRefetchConfig={refetchConfig} + isConfigError={userConfigError != null} + onRefetchConfig={mutateUserConfig} onCommunityOpen={() => setIsCommunityOpen(true)} onSettingsOpen={() => setIsSettingsOpen(true)} onAgendaOpen={() => setIsAgendaOpen(true)} @@ -266,13 +299,15 @@ function Chain({ /> } /> - {error === undefined && currentWeekSchedule != null && ( + {weekScheduleError == null && ( - {error === undefined ? ( - currentWeekSchedule != null && ( - - ) + {weekScheduleError != null ? ( + + ) : currentWeekSchedule != null ? ( + ) : ( - + isLoadingInitial && )} diff --git a/src/components/modals/ClassInfo/ClassInfo.tsx b/src/components/modals/ClassInfo/ClassInfo.tsx index ac7b2a79..62e96e8d 100644 --- a/src/components/modals/ClassInfo/ClassInfo.tsx +++ b/src/components/modals/ClassInfo/ClassInfo.tsx @@ -58,7 +58,7 @@ export default function ClassInfo({ const { userSessionsIndex, userSessionsIndexLoading, userSessionsIndexError, mutateSessionsIndex } = useUserSessionsIndex(chain); const { mutateUserSessions } = useUserSessions(); - const userSessionsLoading = userSessionsIndexLoading || userSessionsIndexError; + const userSessionsLoading = userSessionsIndexLoading || userSessionsIndexError != null; const userSessions = userSessionsIndex?.[_class.id] ?? []; const color = (dark: boolean) => hexWithOpacityToRgb(_class.activity.color, 0.6, dark ? 0 : 255); diff --git a/src/components/schedule/WeekNavigator.tsx b/src/components/schedule/WeekNavigator.tsx index 953f4e6e..bd2a3e6e 100644 --- a/src/components/schedule/WeekNavigator.tsx +++ b/src/components/schedule/WeekNavigator.tsx @@ -1,7 +1,6 @@ import { ArrowBack, ArrowForward } from "@mui/icons-material"; import FilterAltRoundedIcon from "@mui/icons-material/FilterAltRounded"; import { Avatar, AvatarGroup, Box, Button, Stack, Typography } from "@mui/material"; -import Link from "next/link"; import { Dispatch, SetStateAction, useMemo, useState } from "react"; import ScheduleFiltersDialog, { @@ -9,7 +8,6 @@ import ScheduleFiltersDialog, { EXCLUDE_CLASS_TIME_COLOR, LOCATIONS_COLOR, } from "@/components/modals/ScheduleFiltersDialog"; -import { ISO_WEEK_QUERY_PARAM, SCROLL_TO_NOW_QUERY_PARAM } from "@/lib/consts"; import { compactISOWeekString, fromCompactISOWeekString, LocalizedDateTime } from "@/lib/helpers/date"; import { ActivityCategory, ExcludeClassTimeFiltersType, RezervoChain } from "@/types/chain"; @@ -19,6 +17,8 @@ export default function WeekNavigator({ isLoadingPreviousWeek, isLoadingNextWeek, weekNumber, + onChangeWeek, + onToday, selectedLocationIds, setSelectedLocationIds, allCategories, @@ -32,6 +32,8 @@ export default function WeekNavigator({ isLoadingPreviousWeek: boolean; isLoadingNextWeek: boolean; weekNumber: number; + onChangeWeek: (weekParam: string) => void; + onToday: () => void; selectedLocationIds: string[]; setSelectedLocationIds: Dispatch>; allCategories: ActivityCategory[]; @@ -65,8 +67,9 @@ export default function WeekNavigator({ return compactISOWeekString(firstDayOfWeek.plus({ weeks: offset })); } - function nowSearchParams() { - return `${ISO_WEEK_QUERY_PARAM}=${compactISOWeekString(LocalizedDateTime.now())}&${SCROLL_TO_NOW_QUERY_PARAM}`; + function changeWeekByOffset(offset: number) { + const week = offsetWeekParam(offset); + if (week != null) onChangeWeek(week); } return ( @@ -161,17 +164,15 @@ export default function WeekNavigator({ excludeClassTimeFilters={excludeClassTimeFilters} setExcludeClassTimeFilters={setExcludeClassTimeFilters} /> - - - + {`UKE ${weekNumber}`} - - - + - - - + ); diff --git a/src/components/schedule/WeekScheduleSkeleton.tsx b/src/components/schedule/WeekScheduleSkeleton.tsx new file mode 100644 index 00000000..73ce9894 --- /dev/null +++ b/src/components/schedule/WeekScheduleSkeleton.tsx @@ -0,0 +1,120 @@ +import { alpha, Box, Chip, Divider, Stack, Typography, useTheme } from "@mui/material"; +import { DateTime } from "luxon"; +import { useMemo } from "react"; + +import ClassCardSkeleton from "@/components/schedule/class/ClassCardSkeleton"; +import { + firstDateOfWeekByOffset, + fromCompactISOWeekString, + getCapitalizedWeekday, + isDayPassed, + isToday, + LocalizedDateTime, +} from "@/lib/helpers/date"; +import { hexWithOpacityToRgb } from "@/lib/utils/colorUtils"; + +// Plausible per-day card counts so the skeleton grid does not look uniform while loading. +const SKELETON_CARD_COUNTS = [5, 6, 5, 6, 5, 3, 2]; + +function weekDates(weekParam: string): DateTime[] { + const reference = fromCompactISOWeekString(weekParam); + const monday = reference.isValid + ? firstDateOfWeekByOffset(reference, 0) + : firstDateOfWeekByOffset(LocalizedDateTime.now(), 0); + return Array.from({ length: 7 }, (_, i) => monday.plus({ days: i })); +} + +function DayScheduleSkeleton({ date, cardCount }: { date: DateTime; cardCount: number }) { + const theme = useTheme(); + const dayIsToday = isToday(date); + + return ( + + + + + {getCapitalizedWeekday(date)}{" "} + {dayIsToday && ( + + )} + + + {date.toFormat("yyyy-MM-dd")} + + + + + + {Array.from({ length: cardCount }, (_, i) => ( + + + + ))} + + + ); +} + +export default function WeekScheduleSkeleton({ weekParam }: { weekParam: string }) { + const theme = useTheme(); + const days = useMemo(() => weekDates(weekParam), [weekParam]); + + return ( + + + + {days.map((date, i) => { + const dayIsToday = isToday(date); + return ( + + + + ); + })} + + + + ); +} diff --git a/src/components/schedule/class/ClassCard.tsx b/src/components/schedule/class/ClassCard.tsx index 8407d552..00e0e7ba 100644 --- a/src/components/schedule/class/ClassCard.tsx +++ b/src/components/schedule/class/ClassCard.tsx @@ -39,7 +39,7 @@ const ClassCard = ({ onInfo: () => void; }) => { const { userSessionsIndex, userSessionsIndexLoading, userSessionsIndexError } = useUserSessionsIndex(chain); - const userSessionsLoading = userSessionsIndexLoading || userSessionsIndexError; + const userSessionsLoading = userSessionsIndexLoading || userSessionsIndexError != null; const userSessions = userSessionsIndex?.[_class.id]?.sort(userNameWithIsSelfComparator) ?? []; const { allConfigsIndex } = useUserConfig(chain); const configUsers = allConfigsIndex ? (allConfigsIndex[classRecurrentId(_class)] ?? []) : []; diff --git a/src/components/schedule/class/ClassCardSkeleton.tsx b/src/components/schedule/class/ClassCardSkeleton.tsx new file mode 100644 index 00000000..dd221065 --- /dev/null +++ b/src/components/schedule/class/ClassCardSkeleton.tsx @@ -0,0 +1,40 @@ +import { Box, Card, CardContent, Skeleton } from "@mui/material"; + +export default function ClassCardSkeleton() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/src/lib/helpers/fetchers.ts b/src/lib/helpers/fetchers.ts index f5e769ac..daafc7f8 100644 --- a/src/lib/helpers/fetchers.ts +++ b/src/lib/helpers/fetchers.ts @@ -4,74 +4,40 @@ import { fromCompactISOWeekString, LocalizedDateTime, } from "@/lib/helpers/date"; -import { createClassPopularityIndex } from "@/lib/helpers/popularity"; import { get } from "@/lib/helpers/requests"; -import { deserializeWeekSchedule } from "@/lib/serialization/deserializers"; -import { serializeWeekSchedule } from "@/lib/serialization/serializers"; -import { ActivityCategory, ChainIdentifier, RezervoChain, RezervoSchedule, RezervoWeekSchedule } from "@/types/chain"; -import { RezervoError } from "@/types/errors"; -import { ChainPageProps, RezervoWeekScheduleDTO, SWRPrefetchedCacheData } from "@/types/serialization"; +import { constructScheduleUrl } from "@/lib/helpers/schedule"; +import { ActivityCategory, ChainIdentifier, RezervoChain } from "@/types/chain"; +import { ChainPageProps, RezervoWeekScheduleDTO } from "@/types/serialization"; -export function constructScheduleUrl(chainIdentifier: string, compactISOWeek: string, locationIds: string[]) { - if (locationIds == undefined) { - // make sure conditional fetching check fails - return null; +export async function fetchScheduleWeekDTOServer( + chainIdentifier: string, + weekParam: string, + locationIds: string[], + revalidate: number = 60 * 60, +): Promise { + const url = constructScheduleUrl(chainIdentifier, weekParam, locationIds); + const res = await get(url, { mode: "server", revalidate }); + if (!res.ok) { + throw new Error(`Failed to fetch schedule for ${chainIdentifier} ${weekParam}: ${res.statusText}`); } - const searchParams = new URLSearchParams([...locationIds.map((locationId) => ["location", locationId])]); - return `schedule/${chainIdentifier}/${compactISOWeek}?${searchParams.toString()}`; + return { ...(await res.json()), locationIds }; } export async function fetchChainPageStaticProps( chain: RezervoChain, weekParam: string | undefined, ): Promise { - // TODO: consider not fetching all locations - const locationIdentifiers = chain.branches.flatMap((branch) => - branch.locations.map((location) => location.identifier), - ); let referenceDateTime = weekParam ? fromCompactISOWeekString(weekParam) : null; if (!referenceDateTime || !referenceDateTime.isValid) referenceDateTime = LocalizedDateTime.now(); const currentCompactIsoWeek = compactISOWeekString(firstDateOfWeekByOffset(referenceDateTime, 0)); - const previousCompactIsoWeek = compactISOWeekString(firstDateOfWeekByOffset(referenceDateTime, 1)); - const compactISOWeeks = [previousCompactIsoWeek, currentCompactIsoWeek].concat( - [1, 2, 3].map((weekOffset) => compactISOWeekString(firstDateOfWeekByOffset(referenceDateTime, weekOffset))), - ); - const chainProfiles = await fetchActiveChains().then((chains) => chains.map((chain) => chain.profile)); + const chainProfiles = await fetchActiveChains().then((chains) => chains.map((c) => c.profile)); const activityCategories = await fetchActivityCategories(); - const scheduleResult = await tryFetchRezervoSchedule( - chain.profile.identifier, - compactISOWeeks, - locationIdentifiers, - ); - if (!scheduleResult.ok) { - return { - chain: chain, - weekParam: currentCompactIsoWeek, - chainProfiles: chainProfiles, - activityCategories: activityCategories, - error: RezervoError.CHAIN_SCHEDULE_UNAVAILABLE, - }; - } - const schedule = scheduleResult.value; - const previousWeekSchedule = schedule[previousCompactIsoWeek]; - const classPopularityIndex = previousWeekSchedule ? createClassPopularityIndex(previousWeekSchedule) : {}; - - const scheduleCache: SWRPrefetchedCacheData = Object.entries(schedule).reduce( - (acc, [compactISOWeek, weekSchedule]) => { - const key = scheduleUrlKey(chain.profile.identifier, compactISOWeek, locationIdentifiers); - return key === null ? acc : { ...acc, [key]: serializeWeekSchedule(weekSchedule) }; - }, - {}, - ); return { chain: chain, weekParam: currentCompactIsoWeek, - defaultLocationIds: locationIdentifiers, chainProfiles: chainProfiles, - scheduleCache: scheduleCache, activityCategories: activityCategories, - classPopularityIndex: classPopularityIndex, }; } @@ -96,66 +62,6 @@ export async function fetchActiveChains(revalidate: number = 60 * 60 * 24): Prom }); } -export async function fetchRezervoWeekSchedule( - chainIdentifier: string, - compactISOWeek: string, - locationIdentifiers: string[], - revalidate: number = 5 * 60, -): Promise { - return deserializeWeekSchedule({ - locationIds: locationIdentifiers, - ...(await ( - await get( - `schedule/${chainIdentifier}/${compactISOWeek}${ - locationIdentifiers.length > 0 ? `?location=${locationIdentifiers.join("&location=")}` : "" - }`, - { mode: "server", revalidate }, - ) - ).json()), - }) as RezervoWeekSchedule; -} - -export async function fetchRezervoSchedule( - chainIdentifier: string, - compactISOWeeks: string[], - locationIdentifiers: string[] = [], -): Promise { - return ( - await Promise.all( - compactISOWeeks.map( - async (compactISOWeek: string): Promise => ({ - [compactISOWeek]: await fetchRezervoWeekSchedule( - chainIdentifier, - compactISOWeek, - locationIdentifiers, - ), - }), - ), - ) - ).reduce((acc, next) => ({ ...acc, ...next }), {}); -} - -type RezervoScheduleResult = { ok: true; value: RezervoSchedule } | { ok: false }; - -async function tryFetchRezervoSchedule( - chainIdentifier: string, - compactISOWeeks: string[], - locationIdentifiers: string[] = [], -): Promise { - try { - const schedule = await fetchRezervoSchedule(chainIdentifier, compactISOWeeks, locationIdentifiers); - return { - ok: true, - value: schedule, - }; - } catch (e) { - console.error(e); - return { - ok: false, - }; - } -} - export async function fetchActivityCategories(revalidate: number = 60 * 60 * 24): Promise { return get("categories", { mode: "server", revalidate }).then((res) => { if (!res.ok) { @@ -164,11 +70,3 @@ export async function fetchActivityCategories(revalidate: number = 60 * 60 * 24) return res.json(); }); } - -export function scheduleUrlKey(chainIdentifier: string, compactISOWeek: string, locationIds: string[]) { - return constructScheduleUrl( - chainIdentifier, - compactISOWeek, - [...locationIds].sort(), // ensure cache hit with consistent ordering - ); -} diff --git a/src/lib/helpers/schedule.ts b/src/lib/helpers/schedule.ts new file mode 100644 index 00000000..2a65b070 --- /dev/null +++ b/src/lib/helpers/schedule.ts @@ -0,0 +1,40 @@ +import { compactISOWeekString, firstDateOfWeekByOffset, fromCompactISOWeekString } from "@/lib/helpers/date"; +import { fetcher } from "@/lib/utils/fetchUtils"; +import { RezervoWeekScheduleDTO } from "@/types/serialization"; + +// Schedule data is cached for an hour server-side; client queries reuse that window and silently refresh on load. +export const SCHEDULE_STALE_TIME_MS = 60 * 60 * 1000; + +// Background-prefetched weeks relative to the current week: previous week + next 3 weeks. +export const ADJACENT_WEEK_OFFSETS = [-1, 1, 2, 3]; + +export function constructScheduleUrl(chainIdentifier: string, compactISOWeek: string, locationIds: string[]) { + const searchParams = new URLSearchParams([...locationIds.map((locationId) => ["location", locationId])]); + return `schedule/${chainIdentifier}/${compactISOWeek}?${searchParams.toString()}`; +} + +export function scheduleQueryKey(chainIdentifier: string, weekParam: string) { + return ["schedule", chainIdentifier, weekParam] as const; +} + +export function offsetWeekParam(weekParam: string, offset: number): string | null { + const reference = fromCompactISOWeekString(weekParam); + if (!reference.isValid) return null; + return compactISOWeekString(firstDateOfWeekByOffset(reference, offset)); +} + +/** + * Fetches a week's schedule (all locations) from the browser as a serializable DTO. + * The DTO (rather than the deserialized form) is what lives in the React Query cache, so it can be + * dehydrated from the server and hydrated on the client without losing Luxon DateTime objects. + */ +export async function fetchScheduleWeekDTO( + chainIdentifier: string, + weekParam: string, + locationIds: string[], +): Promise { + const url = constructScheduleUrl(chainIdentifier, weekParam, locationIds); + // The backend response does not include the requested locationIds, so inject them for deserialization. + const dto = await fetcher>(url); + return { ...dto, locationIds }; +} diff --git a/src/lib/hooks/useAllConfigs.ts b/src/lib/hooks/useAllConfigs.ts index 76fd5f17..b55a4564 100644 --- a/src/lib/hooks/useAllConfigs.ts +++ b/src/lib/hooks/useAllConfigs.ts @@ -1,24 +1,27 @@ -import useSWR from "swr"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useUser } from "@/lib/hooks/useUser"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { ChainIdentifier } from "@/types/chain"; import { AllConfigsIndex } from "@/types/config"; export function useAllConfigs(chain: ChainIdentifier) { const { isAuthenticated, token } = useUser(); + const queryClient = useQueryClient(); const allConfigsApiUrl = `${chain}/all-configs`; + const queryKey = [allConfigsApiUrl]; - const { data, error, isLoading, mutate } = useSWR( - isAuthenticated && chain ? allConfigsApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data, error, isLoading } = useQuery({ + queryKey, + queryFn: () => authedFetcher(token ?? "")(allConfigsApiUrl), + enabled: isAuthenticated && !!chain, + }); return { allConfigsIndex: data, allConfigsError: error, allConfigsLoading: isLoading, - mutateAllConfigs: mutate, + mutateAllConfigs: () => queryClient.invalidateQueries({ queryKey }), }; } diff --git a/src/lib/hooks/useChainUser.ts b/src/lib/hooks/useChainUser.ts index e72076b2..46dedf4e 100644 --- a/src/lib/hooks/useChainUser.ts +++ b/src/lib/hooks/useChainUser.ts @@ -1,11 +1,10 @@ -import useSWR from "swr"; -import useSWRMutation from "swr/mutation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { destroy, put } from "@/lib/helpers/requests"; import { useUser } from "@/lib/hooks/useUser"; import { useUserChainConfigs } from "@/lib/hooks/useUserChainConfigs"; import { useUserConfig } from "@/lib/hooks/useUserConfig"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { ChainIdentifier } from "@/types/chain"; import { ChainUser, ChainUserPayload, ChainUserProfile, ChainUserTotpPayload } from "@/types/config"; @@ -21,104 +20,90 @@ interface ChainUserTotpFlowInitiatedResponse { type ChainUserMutationResponse = ChainUserUpdatedResponse | ChainUserTotpFlowInitiatedResponse; -async function putChainUser( - url: string, - token: string | null, - chainUser: ChainUserPayload, - dependantMutations: () => Promise, -) { - if (token == null) { - throw new Error("Not authenticated"); - } - const res = await put(url, { body: JSON.stringify(chainUser, null, 2), mode: "client", accessToken: token }); - const data = await res.json(); - if (!res.ok) { - throw new Error("An error occurred while updating chain user"); - } - await dependantMutations(); - return data; -} - -async function destroyChainUser(url: string, token: string | null, dependantMutations: () => Promise) { - if (token == null) { - throw new Error("Not authenticated"); - } - const res = await destroy(url, { mode: "client", accessToken: token }); - const data = await res.json(); - if (!res.ok) { - throw new Error("An error occurred while destroying chain user"); - } - await dependantMutations(); - return data; -} - -async function putChainUserTotp( - url: string, - token: string, - totp: ChainUserTotpPayload, - dependantMutations: () => Promise, -) { - if (token == null) { - throw new Error("Not authenticated"); - } - const res = await put(url, { body: JSON.stringify(totp, null, 2), mode: "client", accessToken: token }); - const data = await res.json(); - if (!res.ok) { - throw new Error("An error occurred while updating chain user TOTP"); - } - await dependantMutations(); - return data; -} - export function useChainUser(chain: ChainIdentifier) { const { isAuthenticated, token } = useUser(); + const queryClient = useQueryClient(); const chainUserApiUrl = `${chain}/user`; const chainUserTotpApiUrl = `${chain}/user/totp`; + const queryKey = [chainUserApiUrl]; - const { data, error, isLoading, mutate } = useSWR( - isAuthenticated && chain ? chainUserApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data, error, isLoading } = useQuery({ + queryKey, + queryFn: () => authedFetcher(token ?? "")(chainUserApiUrl), + enabled: isAuthenticated && !!chain, + }); const { mutateUserConfig } = useUserConfig(chain); const { mutateUserChainConfigs } = useUserChainConfigs(); const dependantMutations = async () => { - await mutate(); + await queryClient.invalidateQueries({ queryKey }); await mutateUserConfig(); await mutateUserChainConfigs(); }; - const putChainUserMutation = useSWRMutation( - chainUserApiUrl, - (url: string, { arg: chainUser }: { arg: ChainUserPayload }) => - putChainUser(url, token, chainUser, dependantMutations), - ); + const putChainUserMutation = useMutation({ + mutationFn: async (chainUser) => { + if (token == null) { + throw new Error("Not authenticated"); + } + const res = await put(chainUserApiUrl, { + body: JSON.stringify(chainUser, null, 2), + mode: "client", + accessToken: token, + }); + const responseData = await res.json(); + if (!res.ok) { + throw new Error("An error occurred while updating chain user"); + } + return responseData; + }, + onSuccess: dependantMutations, + }); - const destroyChainUserMutation = useSWRMutation( - chainUserApiUrl, - (url: string) => destroyChainUser(url, token, dependantMutations), - ); + const destroyChainUserMutation = useMutation({ + mutationFn: async () => { + if (token == null) { + throw new Error("Not authenticated"); + } + const res = await destroy(chainUserApiUrl, { mode: "client", accessToken: token }); + const responseData = await res.json(); + if (!res.ok) { + throw new Error("An error occurred while destroying chain user"); + } + return responseData; + }, + onSuccess: dependantMutations, + }); - const putChainUserTotpMutation = useSWRMutation( - chainUserTotpApiUrl, - (url: string, { arg: totp }: { arg: ChainUserTotpPayload }) => { + const putChainUserTotpMutation = useMutation({ + mutationFn: async (totp) => { if (!token) { throw new Error("Not authenticated"); } - return putChainUserTotp(url, token, totp, dependantMutations); + const res = await put(chainUserTotpApiUrl, { + body: JSON.stringify(totp, null, 2), + mode: "client", + accessToken: token, + }); + const responseData = await res.json(); + if (!res.ok) { + throw new Error("An error occurred while updating chain user TOTP"); + } + return responseData; }, - ); + onSuccess: dependantMutations, + }); return { chainUser: data, chainUserError: error, - chainUserMissing: error && error.status === 404, - chainUserLoading: isLoading || putChainUserMutation.isMutating, - putChainUser: putChainUserMutation.trigger, - destroyChainUser: destroyChainUserMutation.trigger, - putChainUserIsMutating: putChainUserMutation.isMutating, - putChainUserTotp: putChainUserTotpMutation.trigger, - putChainUserTotpIsMutating: putChainUserTotpMutation.isMutating, + chainUserMissing: error != null && error.status === 404, + chainUserLoading: isLoading || putChainUserMutation.isPending, + putChainUser: putChainUserMutation.mutateAsync, + destroyChainUser: destroyChainUserMutation.mutateAsync, + putChainUserIsMutating: putChainUserMutation.isPending, + putChainUserTotp: putChainUserTotpMutation.mutateAsync, + putChainUserTotpIsMutating: putChainUserTotpMutation.isPending, }; } diff --git a/src/lib/hooks/useCommunity.ts b/src/lib/hooks/useCommunity.ts index 9c3d7f35..c6f891cc 100644 --- a/src/lib/hooks/useCommunity.ts +++ b/src/lib/hooks/useCommunity.ts @@ -1,62 +1,45 @@ -import { useState } from "react"; -import useSWR from "swr"; -import useSWRMutation from "swr/mutation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { put } from "@/lib/helpers/requests"; import { useUser } from "@/lib/hooks/useUser"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { RezervoCommunity, UserRelationship, UserRelationshipAction } from "@/types/community"; -function putRelationship( - url: string, - token: string, - { arg: relationship }: { arg: { userId: string; action: UserRelationshipAction } }, -) { - return put(url, { - body: JSON.stringify(relationship, null, 2), - accessToken: token, - }).then((r) => r.json()); -} - export function useCommunity() { const { isAuthenticated, token } = useUser(); + const queryClient = useQueryClient(); const communityApiUrl = `community`; const relationshipApiUrl = `community/relationship`; + const queryKey = [communityApiUrl]; - const { data, error, isLoading, mutate } = useSWR( - isAuthenticated ? communityApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data, error, isLoading } = useQuery({ + queryKey, + queryFn: () => authedFetcher(token ?? "")(communityApiUrl), + enabled: isAuthenticated, + }); - const [isMutatingRelationship, setIsMutatingRelationship] = useState(false); - - const { trigger: updateRelationship } = useSWRMutation< - UserRelationship, - unknown, - string | null, + const { mutateAsync: updateRelationship, isPending: isUpdatingRelationship } = useMutation< + UserRelationship | undefined, + FetchError, { userId: string; action: UserRelationshipAction } - >( - isAuthenticated ? relationshipApiUrl : null, - async (url, { arg: relationship }) => { - if (token == null) return; - setIsMutatingRelationship(true); - const res = await putRelationship(url, token, { arg: relationship }); - await mutate(); - setIsMutatingRelationship(false); - return res; - }, - { - revalidate: false, + >({ + mutationFn: (relationship) => { + if (token == null) return Promise.resolve(undefined); + return put(relationshipApiUrl, { + body: JSON.stringify(relationship, null, 2), + accessToken: token, + }).then((r) => r.json()); }, - ); + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + }); return { community: data, communityError: error, communityLoading: isLoading, - mutateCommunity: mutate, - updateRelationship: updateRelationship, - isUpdatingRelationship: isMutatingRelationship, + mutateCommunity: () => queryClient.invalidateQueries({ queryKey }), + updateRelationship, + isUpdatingRelationship, }; } diff --git a/src/lib/hooks/useFeatures.ts b/src/lib/hooks/useFeatures.ts index 84e20eaf..47b56e8f 100644 --- a/src/lib/hooks/useFeatures.ts +++ b/src/lib/hooks/useFeatures.ts @@ -1,18 +1,17 @@ -import useSWR from "swr"; +import { useQuery } from "@tanstack/react-query"; import { useUser } from "@/lib/hooks/useUser"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { Features } from "@/types/features"; export function useFeatures() { const { isAuthenticated, token } = useUser(); - const featuresApiUrl = `features`; - - const { data, error, isLoading } = useSWR( - isAuthenticated ? featuresApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data, error, isLoading } = useQuery({ + queryKey: ["features"], + queryFn: () => authedFetcher(token ?? "")("features"), + enabled: isAuthenticated, + }); return { features: data, diff --git a/src/lib/hooks/useLiveClassData.ts b/src/lib/hooks/useLiveClassData.ts index bf9f9024..2185a081 100644 --- a/src/lib/hooks/useLiveClassData.ts +++ b/src/lib/hooks/useLiveClassData.ts @@ -1,23 +1,21 @@ -import useSWR from "swr"; +import { useQuery } from "@tanstack/react-query"; import { deserializeClass } from "@/lib/serialization/deserializers"; -import { fetcher } from "@/lib/utils/fetchUtils"; +import { fetcher, FetchError } from "@/lib/utils/fetchUtils"; import { deepMerge } from "@/lib/utils/objectUtils"; import { RezervoClass } from "@/types/chain"; import { RezervoClassDTO } from "@/types/serialization"; export function useLiveClassData(chainIdentifier: string, _class: RezervoClass) { - const { data, error, isLoading } = useSWR( - `classes/${chainIdentifier}/${_class.id}`, - async (url, opts) => { - const dto = await fetcher(url, opts); - return dto ? deserializeClass(dto) : dto; + const { data, error, isLoading } = useQuery({ + queryKey: [`classes/${chainIdentifier}/${_class.id}`], + queryFn: async () => { + const dto = await fetcher(`classes/${chainIdentifier}/${_class.id}`); + return deserializeClass(dto); }, - { - refreshInterval: 10 * 1000, - fallbackData: _class, - }, - ); + refetchInterval: 10 * 1000, + placeholderData: _class, + }); const liveClassData = data ? deepMerge(_class, data) : _class; diff --git a/src/lib/hooks/usePreferences.ts b/src/lib/hooks/usePreferences.ts index 1b937f84..29b35902 100644 --- a/src/lib/hooks/usePreferences.ts +++ b/src/lib/hooks/usePreferences.ts @@ -1,45 +1,44 @@ -import useSWR from "swr"; -import useSWRMutation from "swr/mutation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { put } from "@/lib/helpers/requests"; import { useUser } from "@/lib/hooks/useUser"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { Preferences, PreferencesPayload } from "@/types/config"; -function putPreferences(url: string, token: string, { arg: preferences }: { arg: PreferencesPayload }) { - return put(url, { body: JSON.stringify(preferences, null, 2), mode: "client", accessToken: token }).then((r) => - r.json(), - ); -} - export function usePreferences() { const { isAuthenticated, token } = useUser(); + const queryClient = useQueryClient(); const preferencesApiUrl = `preferences`; + const queryKey = [preferencesApiUrl]; - const { data, error, isLoading } = useSWR( - isAuthenticated ? preferencesApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data, error, isLoading } = useQuery({ + queryKey, + queryFn: () => authedFetcher(token ?? "")(preferencesApiUrl), + enabled: isAuthenticated, + }); - const { trigger, isMutating } = useSWRMutation( - preferencesApiUrl, - (url: string, { arg: preferences }: { arg: PreferencesPayload }) => { + const { mutateAsync, isPending } = useMutation({ + mutationFn: (preferences) => { if (!token) { throw new Error("Not authenticated"); } - return putPreferences(url, token, { arg: preferences }); + return put(preferencesApiUrl, { + body: JSON.stringify(preferences, null, 2), + mode: "client", + accessToken: token, + }).then((r) => r.json()); }, - { - populateCache: true, // use updated data from mutate response - revalidate: false, + onSuccess: (updated) => { + // use the updated data from the response instead of revalidating + queryClient.setQueryData(queryKey, updated); }, - ); + }); return { preferences: data, preferencesError: error, - preferencesLoading: isLoading || isMutating, - putPreferences: trigger, + preferencesLoading: isLoading || isPending, + putPreferences: mutateAsync, }; } diff --git a/src/lib/hooks/usePushNotificationSubscription.ts b/src/lib/hooks/usePushNotificationSubscription.ts index 844ba473..50d93f09 100644 --- a/src/lib/hooks/usePushNotificationSubscription.ts +++ b/src/lib/hooks/usePushNotificationSubscription.ts @@ -1,8 +1,8 @@ -import useSWR from "swr"; -import useSWRMutation from "swr/mutation"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { destroy, get, post, put } from "@/lib/helpers/requests"; import { useUser } from "@/lib/hooks/useUser"; +import { FetchError } from "@/lib/utils/fetchUtils"; export function usePushNotificationSubscription() { const { token } = useUser(); @@ -15,75 +15,74 @@ export function usePushNotificationSubscription() { data: publicKey, error: publicKeyError, isLoading: publicKeyLoading, - } = useSWR(token ? publicKeyApiUrl : null, async () => { - if (!token) { - throw new Error("Not authenticated"); - } - return await ( - await get(publicKeyApiUrl, { mode: "client", accessToken: token, revalidate: 60 * 60 * 24 }) - ).json(); + } = useQuery({ + queryKey: [publicKeyApiUrl], + queryFn: async () => { + if (!token) { + throw new Error("Not authenticated"); + } + return await ( + await get(publicKeyApiUrl, { mode: "client", accessToken: token, revalidate: 60 * 60 * 24 }) + ).json(); + }, + enabled: !!token, }); - function subscribe(url: string, token: string, { arg: subscription }: { arg: PushSubscription }) { - return put(url, { body: JSON.stringify(subscription, null, 2), mode: "client", accessToken: token }).then((r) => - r.json(), - ); - } - - const { trigger: triggerSubscribe, isMutating: isSubscribing } = useSWRMutation< + const { mutateAsync: subscribeToPush, isPending: isSubscribing } = useMutation< PushSubscription, - unknown, - string, + FetchError, PushSubscription - >(subscriptionApiUrl, (url: string, { arg: subscription }: { arg: PushSubscription }) => { - if (!token) { - throw new Error("Not authenticated"); - } - return subscribe(url, token, { arg: subscription }); + >({ + mutationFn: (subscription) => { + if (!token) { + throw new Error("Not authenticated"); + } + return put(subscriptionApiUrl, { + body: JSON.stringify(subscription, null, 2), + mode: "client", + accessToken: token, + }).then((r) => r.json()); + }, }); - function unsubscribe(url: string, token: string, { arg: subscription }: { arg: PushSubscription }) { - return destroy(url, { body: JSON.stringify(subscription, null, 2), mode: "client", accessToken: token }).then( - (r) => r.ok, - ); - } - - const { trigger: triggerUnsubscribe, isMutating: isUnsubscribing } = useSWRMutation< + const { mutateAsync: unsubscribeFromPush, isPending: isUnsubscribing } = useMutation< boolean, - unknown, - string, + FetchError, PushSubscription - >(subscriptionApiUrl, (url, { arg: subscription }) => { - if (!token) { - throw new Error("Not authenticated"); - } - return unsubscribe(url, token, { arg: subscription }); + >({ + mutationFn: (subscription) => { + if (!token) { + throw new Error("Not authenticated"); + } + return destroy(subscriptionApiUrl, { + body: JSON.stringify(subscription, null, 2), + mode: "client", + accessToken: token, + }).then((r) => r.ok); + }, }); - function verify(url: string, token: string, { arg: subscription }: { arg: PushSubscription }) { - return post(url, { body: JSON.stringify(subscription, null, 2), mode: "client", accessToken: token }).then( - (r) => r.json(), - ); - } - - const { trigger: triggerVerify } = useSWRMutation( - subscriptionVerifyApiUrl, - (url, { arg: subscription }) => { + const { mutateAsync: verifySubscription } = useMutation({ + mutationFn: (subscription) => { if (!token) { throw new Error("Not authenticated"); } - return verify(url, token, { arg: subscription }); + return post(subscriptionVerifyApiUrl, { + body: JSON.stringify(subscription, null, 2), + mode: "client", + accessToken: token, + }).then((r) => r.json()); }, - ); + }); return { pushNotificationPublicKey: publicKey, pushNotificationPublicKeyError: publicKeyError, pushNotificationPublicKeyLoading: publicKeyLoading, - subscribeToPush: triggerSubscribe, - unsubscribeFromPush: triggerUnsubscribe, + subscribeToPush, + unsubscribeFromPush, isSubscribingToPush: isSubscribing, isUnsubscribingFromPush: isUnsubscribing, - verifySubscription: triggerVerify, + verifySubscription, }; } diff --git a/src/lib/hooks/useSchedule.ts b/src/lib/hooks/useSchedule.ts index 1f580f95..3c9b4cda 100644 --- a/src/lib/hooks/useSchedule.ts +++ b/src/lib/hooks/useSchedule.ts @@ -1,39 +1,107 @@ -import { DateTime } from "luxon"; -import { useMemo, useState } from "react"; -import useSWR from "swr"; +import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; import { fromCompactISOWeekString, LocalizedDateTime } from "@/lib/helpers/date"; -import { scheduleUrlKey } from "@/lib/helpers/fetchers"; +import { createClassPopularityIndex } from "@/lib/helpers/popularity"; +import { + ADJACENT_WEEK_OFFSETS, + fetchScheduleWeekDTO, + offsetWeekParam, + SCHEDULE_STALE_TIME_MS, + scheduleQueryKey, +} from "@/lib/helpers/schedule"; import { deserializeWeekSchedule } from "@/lib/serialization/deserializers"; -import { fetcher } from "@/lib/utils/fetchUtils"; +import { FetchError } from "@/lib/utils/fetchUtils"; +import { RezervoWeekSchedule } from "@/types/chain"; +import { ClassPopularityIndex } from "@/types/popularity"; import { RezervoWeekScheduleDTO } from "@/types/serialization"; -export function useSchedule(chainIdentifier: string | null, weekParam: string | null, locationIds: string[] | null) { - const [latestLoadedWeekDate, setLatestLoadedWeekDate] = useState(null); +// Stable references so React Query memoizes the (expensive) select result and consumers keep a stable empty value. +const EMPTY_POPULARITY_INDEX: ClassPopularityIndex = {}; +const selectClassPopularityIndex = (dto: RezervoWeekScheduleDTO): ClassPopularityIndex => + createClassPopularityIndex(deserializeWeekSchedule(dto)); + +export function useScheduleWeek( + chainIdentifier: string | null, + weekParam: string | null, + locationIds: string[] | null, +) { + const enabled = chainIdentifier != null && weekParam != null && locationIds != null; + const dateFromWeekParam = weekParam ? fromCompactISOWeekString(weekParam) : null; const currentWeekDate = dateFromWeekParam !== null && dateFromWeekParam.isValid ? dateFromWeekParam : LocalizedDateTime.now(); - const { data, error, isLoading } = useSWR( - locationIds == null || weekParam == null || chainIdentifier == null - ? null - : scheduleUrlKey(chainIdentifier, weekParam, locationIds), - fetcher, - { - onSuccess: () => setLatestLoadedWeekDate(currentWeekDate), - keepPreviousData: true, - revalidateIfStale: false, - }, - ); - - const weekSchedule = useMemo(() => { - return data ? deserializeWeekSchedule(data) : null; - }, [data]); + const { data, error, isLoading, isFetching, isPlaceholderData, isSuccess, dataUpdatedAt } = useQuery< + RezervoWeekScheduleDTO, + FetchError, + RezervoWeekSchedule + >({ + queryKey: scheduleQueryKey(chainIdentifier ?? "", weekParam ?? ""), + queryFn: () => fetchScheduleWeekDTO(chainIdentifier ?? "", weekParam ?? "", locationIds ?? []), + enabled, + select: deserializeWeekSchedule, + placeholderData: keepPreviousData, + staleTime: SCHEDULE_STALE_TIME_MS, + }); + + const [latestLoadedWeekParam, setLatestLoadedWeekParam] = useState(null); + useEffect(() => { + if (isSuccess && !isPlaceholderData && weekParam != null) { + setLatestLoadedWeekParam(weekParam); + } + }, [isSuccess, isPlaceholderData, weekParam, dataUpdatedAt]); + + const latestLoadedDate = latestLoadedWeekParam ? fromCompactISOWeekString(latestLoadedWeekParam) : null; + const isNavigating = isFetching && isPlaceholderData; return { - isLoadingPreviousWeek: isLoading && latestLoadedWeekDate != null && latestLoadedWeekDate > currentWeekDate, - isLoadingNextWeek: isLoading && latestLoadedWeekDate != null && latestLoadedWeekDate < currentWeekDate, - weekSchedule: weekSchedule, + weekSchedule: data ?? null, weekScheduleError: error, + isLoadingInitial: enabled && isLoading, + isLoadingPreviousWeek: isNavigating && latestLoadedDate != null && latestLoadedDate > currentWeekDate, + isLoadingNextWeek: isNavigating && latestLoadedDate != null && latestLoadedDate < currentWeekDate, }; } + +export function usePrefetchAdjacentWeeks( + chainIdentifier: string | null, + weekParam: string | null, + locationIds: string[] | null, + ready: boolean, +) { + const queryClient = useQueryClient(); + useEffect(() => { + if (!ready || chainIdentifier == null || weekParam == null || locationIds == null) { + return; + } + for (const offset of ADJACENT_WEEK_OFFSETS) { + const week = offsetWeekParam(weekParam, offset); + if (week == null) continue; + void queryClient.prefetchQuery({ + queryKey: scheduleQueryKey(chainIdentifier, week), + queryFn: () => fetchScheduleWeekDTO(chainIdentifier, week, locationIds), + staleTime: SCHEDULE_STALE_TIME_MS, + }); + } + }, [queryClient, chainIdentifier, weekParam, locationIds, ready]); +} + +export function useClassPopularityIndex( + chainIdentifier: string | null, + weekParam: string | null, + locationIds: string[] | null, +): ClassPopularityIndex { + const previousWeekParam = weekParam ? offsetWeekParam(weekParam, -1) : null; + const enabled = chainIdentifier != null && previousWeekParam != null && locationIds != null; + + const { data } = useQuery({ + queryKey: scheduleQueryKey(chainIdentifier ?? "", previousWeekParam ?? ""), + queryFn: () => fetchScheduleWeekDTO(chainIdentifier ?? "", previousWeekParam ?? "", locationIds ?? []), + enabled, + select: selectClassPopularityIndex, + staleTime: SCHEDULE_STALE_TIME_MS, + }); + + return data ?? EMPTY_POPULARITY_INDEX; +} diff --git a/src/lib/hooks/useUserCalendarFeedUrl.ts b/src/lib/hooks/useUserCalendarFeedUrl.ts index bc4eeec4..033c4c3f 100644 --- a/src/lib/hooks/useUserCalendarFeedUrl.ts +++ b/src/lib/hooks/useUserCalendarFeedUrl.ts @@ -1,7 +1,7 @@ -import useSWR from "swr"; +import { useQuery } from "@tanstack/react-query"; import { useUser } from "@/lib/hooks/useUser"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; function calendarFeedUrlWithToken(token: string, includePast: boolean) { const calendarFeedUrl = new URL(`${process.env["NEXT_PUBLIC_CONFIG_HOST"]}/cal`); @@ -13,13 +13,15 @@ function calendarFeedUrlWithToken(token: string, includePast: boolean) { export function useUserCalendarFeedUrl(includePast: boolean) { const { isAuthenticated, token } = useUser(); - const userCalendarFeedToken = `cal-token`; - const { data: calendarToken, error: urlError, isLoading, - } = useSWR(isAuthenticated ? userCalendarFeedToken : null, authedFetcher(token ?? "")); + } = useQuery({ + queryKey: ["cal-token"], + queryFn: () => authedFetcher(token ?? "")("cal-token"), + enabled: isAuthenticated, + }); return calendarToken != undefined && urlError == null ? { diff --git a/src/lib/hooks/useUserChainConfigs.ts b/src/lib/hooks/useUserChainConfigs.ts index 32634483..63c6ea13 100644 --- a/src/lib/hooks/useUserChainConfigs.ts +++ b/src/lib/hooks/useUserChainConfigs.ts @@ -1,24 +1,26 @@ -import useSWR from "swr"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useUser } from "@/lib/hooks/useUser"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { ChainIdentifier } from "@/types/chain"; import { ChainConfig } from "@/types/config"; export function useUserChainConfigs() { const { isAuthenticated, token } = useUser(); + const queryClient = useQueryClient(); - const userChainConfigsApiUrl = `user/chain-configs`; + const queryKey = ["user/chain-configs"]; - const { data, error, isLoading, mutate } = useSWR>( - isAuthenticated ? userChainConfigsApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data, error, isLoading } = useQuery, FetchError>({ + queryKey, + queryFn: () => authedFetcher(token ?? "")>("user/chain-configs"), + enabled: isAuthenticated, + }); return { userChainConfigs: data ?? null, userChainConfigsError: error, userChainConfigsLoading: isLoading, - mutateUserChainConfigs: mutate, + mutateUserChainConfigs: () => queryClient.invalidateQueries({ queryKey }), }; } diff --git a/src/lib/hooks/useUserConfig.ts b/src/lib/hooks/useUserConfig.ts index 25c8ab6e..c9cd4d44 100644 --- a/src/lib/hooks/useUserConfig.ts +++ b/src/lib/hooks/useUserConfig.ts @@ -1,63 +1,56 @@ -import useSWR from "swr"; -import useSWRMutation from "swr/mutation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { put } from "@/lib/helpers/requests"; import { useAllConfigs } from "@/lib/hooks/useAllConfigs"; import { useUser } from "@/lib/hooks/useUser"; import { useUserChainConfigs } from "@/lib/hooks/useUserChainConfigs"; import { useUserSessions } from "@/lib/hooks/useUserSessions"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { ChainIdentifier } from "@/types/chain"; -import { ChainConfigPayload, ChainConfig } from "@/types/config"; - -async function putConfig( - url: string, - token: string, - { arg: config }: { arg: ChainConfigPayload }, - dependantMutations: () => Promise, -) { - const r = await put(url, { body: JSON.stringify(config, null, 2), mode: "client", accessToken: token }); - await dependantMutations(); - return await r.json(); -} +import { ChainConfig, ChainConfigPayload } from "@/types/config"; export function useUserConfig(chain: ChainIdentifier) { const { isAuthenticated, token } = useUser(); + const queryClient = useQueryClient(); const configApiUrl = `${chain}/config`; + const queryKey = [configApiUrl]; const { allConfigsIndex, mutateAllConfigs } = useAllConfigs(chain); const { mutateUserSessions } = useUserSessions(); const { mutateUserChainConfigs } = useUserChainConfigs(); - const dependantMutations = async () => Promise.all([mutateUserSessions(), mutateUserChainConfigs()]); - - const { data, error, isLoading, mutate } = useSWR( - isAuthenticated && chain ? configApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data, error, isLoading } = useQuery({ + queryKey, + queryFn: () => authedFetcher(token ?? "")(configApiUrl), + enabled: isAuthenticated && !!chain, + }); - const { trigger, isMutating } = useSWRMutation( - configApiUrl, - (url: string, { arg: config }: { arg: ChainConfigPayload }) => { + const { mutateAsync, isPending } = useMutation({ + mutationFn: (config) => { if (!token) { throw new Error("Not authenticated"); } - return putConfig(url, token, { arg: config }, dependantMutations); + return put(configApiUrl, { + body: JSON.stringify(config, null, 2), + mode: "client", + accessToken: token, + }).then((r) => r.json()); }, - { - populateCache: true, // use updated data from mutate response - revalidate: false, - onSuccess: () => mutateAllConfigs(), + onSuccess: async (updatedConfig) => { + // populate the cache with the response instead of revalidating the config itself + queryClient.setQueryData(queryKey, updatedConfig); + await Promise.all([mutateUserSessions(), mutateUserChainConfigs()]); + await mutateAllConfigs(); }, - ); + }); return { userConfig: data, - userConfigError: !isLoading && !isMutating && error && error.status !== 404 ? error : null, - userConfigLoading: isLoading || isMutating, - mutateUserConfig: mutate, - putUserConfig: trigger, + userConfigError: !isLoading && !isPending && error && error.status !== 404 ? error : null, + userConfigLoading: isLoading || isPending, + mutateUserConfig: () => queryClient.invalidateQueries({ queryKey }), + putUserConfig: mutateAsync, allConfigsIndex: allConfigsIndex, }; } diff --git a/src/lib/hooks/useUserSessions.ts b/src/lib/hooks/useUserSessions.ts index 72aecb36..9941983e 100644 --- a/src/lib/hooks/useUserSessions.ts +++ b/src/lib/hooks/useUserSessions.ts @@ -1,22 +1,24 @@ -import useSWR from "swr"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useUser } from "@/lib/hooks/useUser"; import { deserializeUserSessions } from "@/lib/serialization/deserializers"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { BaseUserSessionDTO } from "@/types/serialization"; export function useUserSessions() { const { isAuthenticated, token } = useUser(); + const queryClient = useQueryClient(); - const userSessionsApiUrl = `user/sessions`; + const queryKey = ["user/sessions"]; - const { data, mutate } = useSWR( - isAuthenticated ? userSessionsApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data } = useQuery({ + queryKey, + queryFn: () => authedFetcher(token ?? "")("user/sessions"), + enabled: isAuthenticated, + }); return { userSessions: data ? deserializeUserSessions(data) : null, - mutateUserSessions: mutate, + mutateUserSessions: () => queryClient.invalidateQueries({ queryKey }), }; } diff --git a/src/lib/hooks/useUserSessionsIndex.ts b/src/lib/hooks/useUserSessionsIndex.ts index 11510504..1d143137 100644 --- a/src/lib/hooks/useUserSessionsIndex.ts +++ b/src/lib/hooks/useUserSessionsIndex.ts @@ -1,24 +1,27 @@ -import useSWR from "swr"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useUser } from "@/lib/hooks/useUser"; -import { authedFetcher } from "@/lib/utils/fetchUtils"; +import { authedFetcher, FetchError } from "@/lib/utils/fetchUtils"; import { ChainIdentifier } from "@/types/chain"; import { UserSessionsIndex } from "@/types/userSessions"; export function useUserSessionsIndex(chain: ChainIdentifier) { const { isAuthenticated, token } = useUser(); + const queryClient = useQueryClient(); const userSessionsIndexApiUrl = `${chain}/sessions-index`; + const queryKey = [userSessionsIndexApiUrl]; - const { data, error, isLoading, mutate } = useSWR( - isAuthenticated && chain ? userSessionsIndexApiUrl : null, - authedFetcher(token ?? ""), - ); + const { data, error, isLoading } = useQuery({ + queryKey, + queryFn: () => authedFetcher(token ?? "")(userSessionsIndexApiUrl), + enabled: isAuthenticated && !!chain, + }); return { userSessionsIndex: data, userSessionsIndexError: error, userSessionsIndexLoading: isLoading, - mutateSessionsIndex: mutate, + mutateSessionsIndex: () => queryClient.invalidateQueries({ queryKey }), }; } diff --git a/src/lib/queryProvider.tsx b/src/lib/queryProvider.tsx new file mode 100644 index 00000000..16f65b1f --- /dev/null +++ b/src/lib/queryProvider.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { isServer, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode, useState } from "react"; + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + }, + }); +} + +let browserQueryClient: QueryClient | undefined = undefined; + +function getQueryClient() { + if (isServer) { + // Always make a new client on the server to avoid leaking state between requests. + return makeQueryClient(); + } + // Reuse a single client in the browser across re-renders/Suspense. + browserQueryClient ??= makeQueryClient(); + return browserQueryClient; +} + +export default function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(getQueryClient); + + return ( + + {children} + + + ); +} diff --git a/src/lib/utils/fetchUtils.ts b/src/lib/utils/fetchUtils.ts index 0b8bd10b..0836d807 100644 --- a/src/lib/utils/fetchUtils.ts +++ b/src/lib/utils/fetchUtils.ts @@ -1,5 +1,10 @@ import { createRequest } from "@/lib/helpers/requests"; +export interface FetchError { + status: number; + statusText: string; +} + export const fetcher = async (path: string, init?: RequestInit): Promise => { const r = await createRequest(path, init, { mode: "client" }); if (r.ok) { diff --git a/src/types/serialization.ts b/src/types/serialization.ts index 92df167b..0df97868 100644 --- a/src/types/serialization.ts +++ b/src/types/serialization.ts @@ -1,6 +1,4 @@ import { ActivityCategory, ChainIdentifier, ChainProfile, RezervoChain, RezervoClassBase } from "@/types/chain"; -import { RezervoError } from "@/types/errors"; -import { ClassPopularityIndex } from "@/types/popularity"; import { SessionStatus } from "@/types/userSessions"; export interface RezervoWeekScheduleDTO { @@ -18,27 +16,12 @@ export type RezervoClassDTO = RezervoClassBase & { endTime: string; }; -export type SWRPrefetchedCacheData = Record; - -export type ChainPageProps = { +export interface ChainPageProps { chain: RezervoChain; weekParam: string; chainProfiles: ChainProfile[]; activityCategories: ActivityCategory[]; -} & ( - | { - defaultLocationIds: string[]; - scheduleCache: SWRPrefetchedCacheData; - classPopularityIndex: ClassPopularityIndex; - error?: never; - } - | { - defaultLocationIds?: never; - scheduleCache?: never; - classPopularityIndex?: never; - error: RezervoError.CHAIN_SCHEDULE_UNAVAILABLE; - } -); +} export interface IndexPageProps { chainProfiles: ChainProfile[];