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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .cursor/setup-worktree-unix.sh
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion .cursor/worktrees.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
21 changes: 21 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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
17 changes: 5 additions & 12 deletions app/src/hooks/dataQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 21 additions & 53 deletions app/src/routes/_layout/tabelle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand All @@ -62,7 +38,7 @@ export const Route = createFileRoute("/_layout/tabelle")({
teams: teams.items,
lastResultCap: 6,
rankingsByLeagueUuid: {} satisfies Record<string, RankingResponse>,
matches: undefined,
matchesQueryOptions: undefined,
};
}

Expand All @@ -78,49 +54,41 @@ export const Route = createFileRoute("/_layout/tabelle")({
const lastResultCap = calculateLastResultCap(samsTeams.teams.length, GAMES_PER_TEAM);

let rankingsByLeagueUuid: Record<string, RankingResponse> = {};
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 });
Expand Down
45 changes: 23 additions & 22 deletions app/src/routes/_layout/teams.$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
});
Expand Down Expand Up @@ -127,7 +131,10 @@ function RouteComponent() {
<TeamCalendar slug={slug} loaderSamsTeam={loaderData.samsTeam} />
</Suspense>
<Suspense fallback={<CenteredLoader text="Lade Spielplan..." />}>
<TeamMatches loaderSamsTeam={loaderData.samsTeam} loaderMatches={loaderData.matches} />
<TeamMatches
loaderSamsTeam={loaderData.samsTeam}
matchesQueryOptions={loaderData.matchesQueryOptions}
/>
</Suspense>
<Center>
<Button component={Link} to="/#mannschaften">
Expand Down Expand Up @@ -168,20 +175,14 @@ function TeamCalendar({

function TeamMatches({
loaderSamsTeam,
loaderMatches,
matchesQueryOptions,
}: {
loaderSamsTeam: ReturnType<typeof Route.useLoaderData>["samsTeam"];
loaderMatches?: LeagueMatchesResponse;
matchesQueryOptions: SamsMatchesHookOptions | undefined;
}) {
const matchesInitialDataUpdatedAt = loaderMatches?.timestamp
? new Date(loaderMatches.timestamp).getTime()
: undefined;

const { data: matches, isLoading: isLoadingMatches } = useSamsMatches({
team: loaderSamsTeam?.uuid,
initialData: loaderMatches,
initialDataUpdatedAt: matchesInitialDataUpdatedAt,
});
const { data: matches, isLoading: isLoadingMatches } = useSamsMatches(
matchesQueryOptions ?? { team: loaderSamsTeam?.uuid },
);

const currentMonth = dayjs().month() + 1;
const isOffSeason = currentMonth >= 5 && currentMonth <= 9;
Expand Down
37 changes: 17 additions & 20 deletions app/src/routes/_layout/termine.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,33 @@ import PageWithHeading from "@webapp/components/layout/PageWithHeading";
import Matches from "@webapp/components/Matches";
import { useSamsMatches } from "@webapp/hooks/dataQueries";
import { getUpcomingEventsFn } from "@webapp/server/functions/events";
import { peekSamsMatchesCacheFn } from "@webapp/server/functions/sams";
import { loadSamsMatchesForSsrFn } from "@webapp/server/functions/sams";
import { createWebcalLink } from "@webapp/utils/webcal";
import dayjs from "dayjs";
import { Fragment } from "react";
import { FaBullhorn as IconSubscribe } from "react-icons/fa6";
import type { LeagueMatchesResponse } from "@/lambda/sams/types";
import type { SamsMatchesHookOptions } from "@webapp/utils/sams-ssr";

export const Route = createFileRoute("/_layout/termine/")({
loader: async () => {
const [eventsResult, cachedMatchesResult] = await Promise.allSettled([
const [eventsResult, matchesSsr] = await Promise.allSettled([
getUpcomingEventsFn(),
peekSamsMatchesCacheFn({ data: { range: "future" } }),
loadSamsMatchesForSsrFn({ data: { range: "future" } }),
]);

const events = eventsResult.status === "fulfilled" ? eventsResult.value.items : [];
const matches =
cachedMatchesResult.status === "fulfilled"
? (cachedMatchesResult.value ?? undefined)
: undefined;
const matchesQueryOptions =
matchesSsr.status === "fulfilled"
? matchesSsr.value.hookOptions
: ({ range: "future" } satisfies SamsMatchesHookOptions);

return { events, matches };
return { events, matchesQueryOptions };
},
component: RouteComponent,
});

function RouteComponent() {
const { events, matches: loaderMatches } = Route.useLoaderData();
const { events, matchesQueryOptions } = Route.useLoaderData();
const webcalLink = createWebcalLink("/ics/all.ics");

return (
Expand All @@ -57,7 +57,7 @@ function RouteComponent() {
</Stack>
</Card>
<EventsContent events={events} />
<MatchesContent loaderMatches={loaderMatches} />
<MatchesContent matchesQueryOptions={matchesQueryOptions} />
</Stack>
</PageWithHeading>
);
Expand Down Expand Up @@ -86,19 +86,16 @@ function EventsContent({
);
}

function MatchesContent({ loaderMatches }: { loaderMatches: LeagueMatchesResponse | undefined }) {
const matchesInitialDataUpdatedAt = loaderMatches?.timestamp
? new Date(loaderMatches.timestamp).getTime()
: undefined;
function MatchesContent({
matchesQueryOptions,
}: {
matchesQueryOptions: SamsMatchesHookOptions | undefined;
}) {
const {
data: matchesData,
isLoading,
isError,
} = useSamsMatches({
range: "future",
initialData: loaderMatches,
initialDataUpdatedAt: matchesInitialDataUpdatedAt,
});
} = useSamsMatches(matchesQueryOptions ?? { range: "future" });

const currentMonth = dayjs().month() + 1;
const isOffSeason = currentMonth >= 5 && currentMonth <= 9;
Expand Down
Loading
Loading