Skip to content

Commit c2868fd

Browse files
committed
Adds i18n support and event stats UI concepts
Introduces client-side internationalization with locale detection, manual overrides, and a translation system for navigation and UI labels. Updates navigation to support translated text and adds a language selector. Enhances event and team stats pages with new figures, comparison charts, and UI mockups for stats and results concepts. Improves error handling for GraphQL queries and extends event tabs to include new "Figures" visualizations.
1 parent 51e250c commit c2868fd

39 files changed

Lines changed: 3819 additions & 67 deletions

File tree

packages/web/src/app.d.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,19 @@
33
declare global {
44
namespace App {
55
// interface Error {}
6-
// interface Locals {}
7-
// interface PageData {}
6+
interface Locals {
7+
locale: string;
8+
localePreference: "auto" | "manual";
9+
manualLocale: string | null;
10+
detectedLocale: string;
11+
}
12+
13+
interface PageData {
14+
locale?: string;
15+
localePreference?: "auto" | "manual";
16+
manualLocale?: string | null;
17+
detectedLocale?: string;
18+
}
819
// interface Platform {}
920
}
1021
}

packages/web/src/app.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!DOCTYPE html>
2-
<html lang="en">
2+
<html lang="%lang%">
33
<head>
44
<meta charset="utf-8" />
55
<meta name="viewport" content="width=device-width" />

packages/web/src/hooks.server.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,46 @@
11
import type { Handle } from "@sveltejs/kit";
2-
import { THEME_COOKIE_NAME } from "./lib/constants";
2+
import {
3+
DEFAULT_LOCALE,
4+
LOCALE_COOKIE_NAME,
5+
SUPPORTED_LOCALES,
6+
THEME_COOKIE_NAME,
7+
} from "./lib/constants";
8+
9+
type LocaleCookie = {
10+
preference?: "auto" | "manual";
11+
locale?: string;
12+
};
13+
14+
function resolveSupportedLocale(locale: string): string {
15+
let normalized = locale.replace("_", "-").trim();
16+
let lower = normalized.toLowerCase();
17+
let exact = SUPPORTED_LOCALES.find((supported) => supported.toLowerCase() === lower);
18+
if (exact) return exact;
19+
let base = lower.split("-")[0];
20+
let baseMatch = SUPPORTED_LOCALES.find((supported) => supported.toLowerCase() === base);
21+
if (baseMatch) return baseMatch;
22+
if (base === "zh") return "zh-Hans";
23+
return DEFAULT_LOCALE;
24+
}
25+
26+
function normalizeLocale(locale: string | undefined): string {
27+
if (!locale) return DEFAULT_LOCALE;
28+
return resolveSupportedLocale(locale);
29+
}
30+
31+
function parseAcceptLanguage(value: string | null): string | undefined {
32+
if (!value) return undefined;
33+
let candidates = value
34+
.split(",")
35+
.map((part) => {
36+
let [tag, qValue] = part.trim().split(";q=");
37+
let q = qValue ? Number.parseFloat(qValue) : 1;
38+
return { tag, q: Number.isNaN(q) ? 0 : q };
39+
})
40+
.filter((entry) => entry.tag);
41+
candidates.sort((a, b) => b.q - a.q);
42+
return candidates[0]?.tag;
43+
}
344

445
export const handle: Handle = async ({ event, resolve }) => {
546
let theme = "system";
@@ -8,9 +49,34 @@ export const handle: Handle = async ({ event, resolve }) => {
849
theme = JSON.parse(cookieVal ?? "").preference ?? "system";
950
} catch {}
1051

52+
let detectedLocale = normalizeLocale(
53+
parseAcceptLanguage(event.request.headers.get("accept-language"))
54+
);
55+
let localePreference: "auto" | "manual" = "auto";
56+
let manualLocale: string | null = null;
57+
let locale = detectedLocale;
58+
59+
try {
60+
let cookieVal = event.cookies.get(LOCALE_COOKIE_NAME);
61+
let parsed = JSON.parse(cookieVal ?? "") as LocaleCookie;
62+
if (parsed?.locale) {
63+
manualLocale = normalizeLocale(parsed.locale);
64+
}
65+
if (parsed?.preference === "manual" && manualLocale) {
66+
localePreference = "manual";
67+
locale = manualLocale;
68+
}
69+
} catch {}
70+
71+
event.locals.locale = locale;
72+
event.locals.localePreference = localePreference;
73+
event.locals.manualLocale = manualLocale;
74+
event.locals.detectedLocale = detectedLocale;
75+
1176
let response = await resolve(event, {
1277
filterSerializedResponseHeaders: (name) => ["content-type"].indexOf(name) != -1,
13-
transformPageChunk: ({ html }) => html.replace("%theme%", `class="${theme}"`),
78+
transformPageChunk: ({ html }) =>
79+
html.replace("%theme%", `class="${theme}"`).replace("%lang%", locale),
1480
});
1581

1682
return response;

packages/web/src/lib/components/charts/BubbleChart.svelte

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
<script lang="ts">
22
import { onMount } from "svelte";
3-
import Highcharts from "highcharts";
4-
import HighchartsMore from "highcharts/highcharts-more";
5-
6-
// Initialize Highcharts More module for bubble charts
7-
if (typeof Highcharts === "object") {
8-
HighchartsMore(Highcharts);
9-
}
3+
import { browser } from "$app/environment";
4+
import type * as HighchartsType from "highcharts";
105
116
export let teams: Array<{
127
teamNumber: number;
@@ -21,10 +16,28 @@
2116
export let title = "Team Performance Comparison";
2217
2318
let chartContainer: HTMLDivElement;
24-
let chart: Highcharts.Chart | null = null;
19+
type HighchartsModule = typeof import("highcharts");
20+
let chart: HighchartsType.Chart | null = null;
21+
22+
async function loadHighcharts(): Promise<HighchartsModule | null> {
23+
if (!browser) return null;
24+
const highchartsModule = await import("highcharts");
25+
const Highcharts =
26+
(highchartsModule as { default?: HighchartsModule }).default ?? highchartsModule;
27+
const highchartsMoreModule = await import("highcharts/highcharts-more");
28+
const HighchartsMore =
29+
(highchartsMoreModule as { default?: (h: HighchartsModule) => void }).default ??
30+
(highchartsMoreModule as (h: HighchartsModule) => void);
31+
if (typeof HighchartsMore === "function") {
32+
HighchartsMore(Highcharts);
33+
}
34+
return Highcharts;
35+
}
2536
26-
onMount(() => {
37+
onMount(async () => {
2738
if (!chartContainer || teams.length === 0) return;
39+
const Highcharts = await loadHighcharts();
40+
if (!Highcharts) return;
2841
2942
// Prepare bubble data
3043
const bubbleData = teams.map((team) => ({

packages/web/src/lib/components/charts/OPRTrendChart.svelte

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { onMount } from "svelte";
3-
import Highcharts from "highcharts";
3+
import { browser } from "$app/environment";
4+
import type * as HighchartsType from "highcharts";
45
56
export let teamData: Array<{
67
teamNumber: number;
@@ -12,8 +13,9 @@
1213
}>;
1314
}>;
1415
16+
type HighchartsModule = typeof import("highcharts");
1517
let chartContainer: HTMLDivElement;
16-
let chart: Highcharts.Chart | null = null;
18+
let chart: HighchartsType.Chart | null = null;
1719
1820
// Generate colors for teams (using theme colors)
1921
const teamColors = [
@@ -25,8 +27,16 @@
2527
"rgb(255, 152, 0)", // Amber
2628
];
2729
28-
onMount(() => {
30+
async function loadHighcharts(): Promise<HighchartsModule | null> {
31+
if (!browser) return null;
32+
const highchartsModule = await import("highcharts");
33+
return (highchartsModule as { default?: HighchartsModule }).default ?? highchartsModule;
34+
}
35+
36+
onMount(async () => {
2937
if (!chartContainer || teamData.length === 0) return;
38+
const Highcharts = await loadHighcharts();
39+
if (!Highcharts) return;
3040
3141
// Prepare series data
3242
const series = teamData.map((team, idx) => ({
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script lang="ts">
2+
import { browser } from "$app/environment";
3+
import { locale, translateText } from "$lib/i18n";
4+
5+
export let text: string;
6+
export let msgKey: string | undefined = undefined;
7+
8+
let translated = text;
9+
let requestId = 0;
10+
11+
$: void (async () => {
12+
let currentLocale = $locale;
13+
let currentKey = msgKey ?? text;
14+
if (!browser || !currentLocale) {
15+
translated = text;
16+
return;
17+
}
18+
if (currentLocale.toLowerCase().startsWith("en")) {
19+
translated = text;
20+
return;
21+
}
22+
translated = text;
23+
let id = ++requestId;
24+
let result = await translateText(currentLocale, currentKey, text);
25+
if (id === requestId) translated = result;
26+
})();
27+
</script>
28+
29+
{translated}

packages/web/src/lib/components/matches/MatchScore.svelte

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@
3838
import { matchTimeTip } from "../../util/tippy";
3939
import { getContext } from "svelte";
4040
import { SHOW_MATCH_SCORE, type ShowMatchFn } from "./MatchTable.svelte";
41+
import MatchPrediction from "./MatchPrediction.svelte";
4142
4243
export let match: FullMatchFragment;
4344
export let timeZone: string;
45+
export let teamOprMap: Record<number, number> | null = null;
4446
4547
$: winner = computeWinner(match.scores);
4648
@@ -62,6 +64,27 @@
6264
$: tip = matchTimeTip(match, timeZone, $tippyTheme);
6365
6466
let show: ShowMatchFn = getContext(SHOW_MATCH_SCORE);
67+
68+
function getTeamNumber(team: FullMatchFragment["teams"][number]): number | null {
69+
return team.teamNumber ?? team.team?.number ?? null;
70+
}
71+
72+
$: redTeams = match.teams.filter((t) => t.alliance == Alliance.Red);
73+
$: blueTeams = match.teams.filter((t) => t.alliance == Alliance.Blue);
74+
$: redTeamNums = redTeams.map(getTeamNumber).filter((n): n is number => n != null);
75+
$: blueTeamNums = blueTeams.map(getTeamNumber).filter((n): n is number => n != null);
76+
77+
$: redOPR1 = teamOprMap?.[redTeamNums[0] ?? -1] ?? null;
78+
$: redOPR2 = teamOprMap?.[redTeamNums[1] ?? -1] ?? null;
79+
$: blueOPR1 = teamOprMap?.[blueTeamNums[0] ?? -1] ?? null;
80+
$: blueOPR2 = teamOprMap?.[blueTeamNums[1] ?? -1] ?? null;
81+
82+
$: showPrediction =
83+
!match.scores &&
84+
!!teamOprMap &&
85+
[redOPR1, redOPR2, blueOPR1, blueOPR2].every(
86+
(val) => typeof val == "number" && !Number.isNaN(val)
87+
);
6588
</script>
6689

6790
<td
@@ -80,7 +103,12 @@
80103
</div>
81104
<div class="score">
82105
{#if match.scores == undefined}
83-
{prettyPrintTimeString(match.scheduledStartTime, timeZone)}
106+
<div class="upcoming">
107+
<span class="time">{prettyPrintTimeString(match.scheduledStartTime, timeZone)}</span>
108+
{#if showPrediction}
109+
<MatchPrediction {redOPR1} {redOPR2} {blueOPR1} {blueOPR2} />
110+
{/if}
111+
</div>
84112
{:else if "red" in match.scores}
85113
<div class="left" class:winner={winner == Alliance.Red} class:tie={winner == "Tie"}>
86114
<!-- // Help: Season Specific -->
@@ -200,6 +228,8 @@
200228
display: flex;
201229
justify-content: space-around;
202230
gap: var(--sm-gap);
231+
align-items: center;
232+
flex-wrap: wrap;
203233
}
204234
205235
.score .left {
@@ -226,4 +256,15 @@
226256
font-weight: bold;
227257
color: var(--neutral-team-text-color);
228258
}
259+
260+
.upcoming {
261+
display: flex;
262+
flex-direction: column;
263+
align-items: center;
264+
gap: var(--sm-gap);
265+
}
266+
267+
.time {
268+
white-space: nowrap;
269+
}
229270
</style>

packages/web/src/lib/components/matches/MatchTable.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
remote: boolean;
3434
};
3535
export let focusedTeam: number | null = null;
36+
export let teamOprMap: Record<number, number> | null = null;
3637
3738
$: timeZone = event.timezone;
3839
$: remote = event.remote;
@@ -118,6 +119,7 @@
118119
{focusedTeam}
119120
zebraStripe={i % 2 == 1}
120121
{teamCount}
122+
{teamOprMap}
121123
/>
122124
{/each}
123125
{#if finals.length}
@@ -131,6 +133,7 @@
131133
{timeZone}
132134
{focusedTeam}
133135
zebraStripe={i % 2 == 1}
136+
{teamOprMap}
134137
/>
135138
{/each}
136139
{#if semis.length}
@@ -144,6 +147,7 @@
144147
{timeZone}
145148
{focusedTeam}
146149
zebraStripe={i % 2 == 1}
150+
{teamOprMap}
147151
/>
148152
{/each}
149153
{#if quals.length && (finals.length || semis.length || doubleElim.length)}
@@ -157,6 +161,7 @@
157161
{timeZone}
158162
{focusedTeam}
159163
zebraStripe={i % 2 == 1}
164+
{teamOprMap}
160165
/>
161166
{/each}
162167
{/if}

packages/web/src/lib/components/matches/TradMatchRow.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
export let focusedTeam: number | null;
1818
export let zebraStripe: boolean;
1919
export let teamCount = 0;
20+
export let teamOprMap: Record<number, number> | null = null;
2021
2122
$: teams = match.teams;
2223
$: redTeams = teams.filter((t) => t.alliance == Alliance.Red);
@@ -83,7 +84,7 @@
8384
</script>
8485

8586
<tr class:zebraStripe class:isDoubleElim class:new-round={isNewRound}>
86-
<MatchScore {match} {timeZone} />
87+
<MatchScore {match} {timeZone} {teamOprMap} />
8788

8889
{#if isDoubleElim}
8990
<DeLives

0 commit comments

Comments
 (0)