diff --git a/.cursor/setup-worktree-unix.sh b/.cursor/setup-worktree-unix.sh new file mode 100755 index 000000000..09c1d8c92 --- /dev/null +++ b/.cursor/setup-worktree-unix.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Shared git dir: fetch once, then sync local main and merge into this worktree's branch. +git fetch origin main + +root="${ROOT_WORKTREE_PATH:?ROOT_WORKTREE_PATH is not set}" +if ref="$(git -C "$root" symbolic-ref -q HEAD 2>/dev/null)" && [ "$ref" = "refs/heads/main" ]; then + git -C "$root" merge --ff-only origin/main +else + git update-ref refs/heads/main origin/main +fi + +git merge origin/main --no-edit + +vp install diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json index 1e9fc58b6..ae877dc2d 100644 --- a/.cursor/worktrees.json +++ b/.cursor/worktrees.json @@ -1,3 +1,9 @@ { - "setup-worktree": ["git fetch origin main", "git merge origin/main --no-edit", "vp install"] + "setup-worktree-unix": "setup-worktree-unix.sh", + "setup-worktree": [ + "git fetch origin main", + "git update-ref refs/heads/main origin/main", + "git merge origin/main --no-edit", + "vp install" + ] } diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..8ee47d0cc --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,21 @@ +# VC Müllheim Website + +Public website and admin app for VC Müllheim, including SAMS league data (matches, rankings, teams). + +## Language + +**Synced season**: +The season UUID stored on team records by the teams sync, read from DynamoDB when loading matches. +_Avoid_: Current season, live season + +**Effective match input**: +The resolved query parameters (clubs, season, team, range, limit) used for cache keys and React Query after server-side resolution. +_Avoid_: Hook options (implementation term), query params + +**Cache peek**: +A DynamoDB-only read of cached SAMS data that never calls the external SAMS API during SSR navigation. +_Avoid_: SSR fetch, preload + +**Configured club**: +A sportsclub listed in project config whose `sportsclubUuid` is resolved from the SAMS clubs sync in DynamoDB. +_Avoid_: Target club, default club diff --git a/app/src/hooks/dataQueries.ts b/app/src/hooks/dataQueries.ts index dcae8d6cd..a7a2d76df 100644 --- a/app/src/hooks/dataQueries.ts +++ b/app/src/hooks/dataQueries.ts @@ -5,8 +5,10 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { z } from "zod"; -import type { LeagueMatchesResponse, RankingResponse } from "@/lambda/sams/types"; +import type { RankingResponse } from "@/lambda/sams/types"; import type { PaginationCursor } from "@/lib/db/types"; +import type { SamsMatchesHookOptions } from "@webapp/utils/sams-ssr"; +import { SAMS_MATCHES_CACHE_TTL_MS } from "@utils/sams-api"; import { getEventByIdFn, getUpcomingEventsFn } from "../server/functions/events"; import { listLocationsFn } from "../server/functions/locations"; import { listMembersFn } from "../server/functions/members"; @@ -234,21 +236,12 @@ export const useSamsMatches = ({ range, initialData, initialDataUpdatedAt, -}: { - league?: string; - season?: string; - sportsclub?: string; - team?: string; - limit?: number; - range?: "past" | "future"; - initialData?: LeagueMatchesResponse; - initialDataUpdatedAt?: number; -} = {}) => { +}: SamsMatchesHookOptions = {}) => { return useQuery({ queryKey: ["samsMatches", league, season, sportsclub, team, limit, range], queryFn: () => getSamsMatchesFn({ data: { league, season, sportsclub, team, limit, range } }), retry: 1, - staleTime: 1000 * 60 * 2, + staleTime: SAMS_MATCHES_CACHE_TTL_MS, placeholderData: (previousData) => previousData, refetchOnWindowFocus: false, initialData, diff --git a/app/src/routes/_layout/tabelle.tsx b/app/src/routes/_layout/tabelle.tsx index ac9f724e4..f69679c46 100644 --- a/app/src/routes/_layout/tabelle.tsx +++ b/app/src/routes/_layout/tabelle.tsx @@ -7,7 +7,7 @@ import RankingTable from "@webapp/components/RankingTable"; import { useSamsMatches } from "@webapp/hooks/dataQueries"; import { listSamsTeamsFn, - peekSamsMatchesCacheFn, + loadSamsMatchesForSsrFn, peekSamsRankingsCacheFn, } from "@webapp/server/functions/sams"; import { listTeamsFn } from "@webapp/server/functions/teams"; @@ -17,39 +17,15 @@ import { sortLeagueUuidsByLevels, } from "@webapp/utils/ranking"; import { numToWord } from "num-words-de"; -import type { LeagueMatchesResponse, RankingResponse } from "@/lambda/sams/types"; +import type { RankingResponse } from "@/lambda/sams/types"; +import type { SamsMatchesHookOptions } from "@webapp/utils/sams-ssr"; const GAMES_PER_TEAM: number = 2.3; // maximum number of games per team to shown below the rankings export const Route = createFileRoute("/_layout/tabelle")({ /** - * LOADING STRATEGY — do not change without understanding the full picture. - * - * Goal: instant navigation (no skeleton), with a small spinner showing when data - * is being refreshed in the background. - * - * How it works: - * 1. Loader runs server-side before navigation completes. It must be FAST — any - * async call that hits an external API blocks the browser from showing the page. - * → Use only DDB cache-peek functions (peekSamsRankingsCacheFn, peekSamsMatchesCacheFn). - * → These read DynamoDB only, never call the SAMS API, and use Infinity TTL so they - * always return whatever is cached regardless of age. - * - * 2. The loader passes the cached data as `initialData` + `initialDataUpdatedAt` to - * React Query hooks. React Query compares `initialDataUpdatedAt` against its - * `staleTime` (10 min). If the data is stale, it starts a background refetch - * immediately after render → `isFetching: true` → small spinner in RankingTable. - * - * 3. The React Query `queryFn` (getSamsRankingsByLeagueUuidsFn) has its own 5-min - * DDB cache check and falls back to the SAMS API on miss — this is the only place - * the SAMS API is called. - * - * Result: users always see cached data instantly. The spinner appears when React Query - * decides fresh data is needed. A loading skeleton only appears when the DDB cache is - * completely empty (first-ever visit or after a full cache eviction). - * - * PITFALL: Do NOT replace peek functions with getSamsRankingsByLeagueUuidsFn in the - * loader. That function calls the SAMS API on cache miss, blocking navigation for 2-3s. + * SSR uses cache-peek only (loadSamsMatchesForSsrFn) — never getSamsMatchesFn in loaders. + * See docs/adr/0001-sams-match-loading.md. */ loader: async () => { // Main data comes from DynamoDB; only a batched SAMS metadata lookup is used for league ordering. @@ -62,7 +38,7 @@ export const Route = createFileRoute("/_layout/tabelle")({ teams: teams.items, lastResultCap: 6, rankingsByLeagueUuid: {} satisfies Record, - matches: undefined, + matchesQueryOptions: undefined, }; } @@ -78,49 +54,41 @@ export const Route = createFileRoute("/_layout/tabelle")({ const lastResultCap = calculateLastResultCap(samsTeams.teams.length, GAMES_PER_TEAM); let rankingsByLeagueUuid: Record = {}; - let matches: LeagueMatchesResponse | undefined; + let matchesQueryOptions: SamsMatchesHookOptions | undefined; if (sortedLeagueUuids.length > 0) { - const [rankingsResult, matchesResult] = await Promise.all([ + const matchesInput = { range: "past" as const, limit: lastResultCap }; + const [rankingsResult, matchesSsr] = await Promise.allSettled([ peekSamsRankingsCacheFn({ data: { leagueUuids: sortedLeagueUuids } }), - peekSamsMatchesCacheFn({ data: { range: "past", limit: lastResultCap } }), + loadSamsMatchesForSsrFn({ data: matchesInput }), ]); - rankingsByLeagueUuid = Object.fromEntries(rankingsResult.map((r) => [r.leagueUuid, r])); - matches = matchesResult ?? undefined; + if (rankingsResult.status === "fulfilled") { + rankingsByLeagueUuid = Object.fromEntries( + rankingsResult.value.map((r) => [r.leagueUuid, r]), + ); + } + matchesQueryOptions = + matchesSsr.status === "fulfilled" ? matchesSsr.value.hookOptions : matchesInput; } return { leagueUuids: sortedLeagueUuids, teams: teams.items, lastResultCap, rankingsByLeagueUuid, - matches, + matchesQueryOptions, }; }, component: RouteComponent, }); function RouteComponent() { - const { - leagueUuids, - teams, - lastResultCap, - rankingsByLeagueUuid, - matches: loaderMatches, - } = Route.useLoaderData(); - - const matchesInitialDataUpdatedAt = loaderMatches?.timestamp - ? new Date(loaderMatches.timestamp).getTime() - : undefined; + const { leagueUuids, teams, lastResultCap, rankingsByLeagueUuid, matchesQueryOptions } = + Route.useLoaderData(); const { data: matchesData, isLoading: isLoadingMatches, isError: isMatchesError, - } = useSamsMatches({ - range: "past", - limit: lastResultCap, - initialData: loaderMatches, - initialDataUpdatedAt: matchesInitialDataUpdatedAt, - }); + } = useSamsMatches(matchesQueryOptions ?? { range: "past", limit: lastResultCap }); const recentMatches = matchesData?.matches ?? []; const lastResultWord = recentMatches.length > 1 && numToWord(recentMatches.length, { uppercase: false }); diff --git a/app/src/routes/_layout/teams.$slug.tsx b/app/src/routes/_layout/teams.$slug.tsx index 06b79c39e..016b7c670 100644 --- a/app/src/routes/_layout/teams.$slug.tsx +++ b/app/src/routes/_layout/teams.$slug.tsx @@ -34,14 +34,14 @@ import { useSamsMatches, useSamsRoster, useTeamBySlug, -} from "@/app/src/hooks/dataQueries"; +} from "@webapp/hooks/dataQueries"; import { listSamsTeamsFn, - peekSamsMatchesCacheFn, + loadSamsMatchesForSsrFn, peekSamsRankingsCacheFn, -} from "@/app/src/server/functions/sams"; -import { getTeamBySlugFn } from "@/app/src/server/functions/teams"; -import type { LeagueMatchesResponse } from "@/lambda/sams/types"; +} from "@webapp/server/functions/sams"; +import { getTeamBySlugFn } from "@webapp/server/functions/teams"; +import type { SamsMatchesHookOptions } from "@webapp/utils/sams-ssr"; dayjs.locale(de); dayjs.extend(weekday); @@ -55,23 +55,27 @@ export const Route = createFileRoute("/_layout/teams/$slug")({ ]); if (!team) { - return { team: null, rankings: undefined, matches: undefined }; + return { team: null, rankings: undefined, matchesQueryOptions: undefined }; } const samsTeam = samsTeamsResult.teams.find((t) => t.uuid === team.sbvvTeamId); if (!samsTeam) { - return { team, samsTeam: undefined, rankings: undefined, matches: undefined }; + return { team, samsTeam: undefined, rankings: undefined, matchesQueryOptions: undefined }; } - const [rankings, matches] = await Promise.all([ + const [rankingsResult, matchesSsr] = await Promise.allSettled([ samsTeam.leagueUuid ? peekSamsRankingsCacheFn({ data: { leagueUuids: [samsTeam.leagueUuid] } }) : Promise.resolve(undefined), - peekSamsMatchesCacheFn({ data: { team: samsTeam.uuid } }).then((m) => m ?? undefined), + loadSamsMatchesForSsrFn({ data: { team: samsTeam.uuid } }), ]); - return { team, samsTeam, rankings, matches }; + const rankings = rankingsResult.status === "fulfilled" ? rankingsResult.value : undefined; + const matchesQueryOptions: SamsMatchesHookOptions = + matchesSsr.status === "fulfilled" ? matchesSsr.value.hookOptions : { team: samsTeam.uuid }; + + return { team, samsTeam, rankings, matchesQueryOptions }; }, component: RouteComponent, }); @@ -127,7 +131,10 @@ function RouteComponent() { }> - +