diff --git a/docs/stats-api.md b/docs/stats-api.md index 010880f..9eba035 100644 --- a/docs/stats-api.md +++ b/docs/stats-api.md @@ -8,6 +8,7 @@ Mini-Doku für die API-Endpunkte, die das Frontend unter `/statistiken` nutzt. - `/api/metrics` (Kategorien/Definitionen) - `/api/leaderboard?metric=...&limit=...&cursor=...` (Ranglisten mit Cursor-Paging) - `/api/leaderboards?limit=...` (Top-Listen je Metrik) +- `/api/world-state` (globaler Weltzustand, aktuell Weltalter) - `/api/players?q=...&limit=...` (Autocomplete) - `/api/player?uuid=...` (Spieler-Detail) - `/api/ban-status?query=...` (Bann-Status per exaktem Name oder UUID) @@ -81,6 +82,7 @@ Edge-Cache (`caches.default`) + `Cache-Control`: - `metrics`: `max-age=3600` - `summary`: `max-age=60` +- `world-state`: `max-age=60` - `leaderboard` / `leaderboards`: `max-age=60` - `players`: `max-age=30` - `player`: `max-age=60` @@ -98,6 +100,40 @@ Mojang (`cape` / `profile`): - Stale bei Upstream-Fehler: `30s` - Header zusätzlich: `stale-while-revalidate=30`, `stale-if-error=86400` +## Weltzustand + +`GET /api/world-state` + +Liefert den globalen Zustand des aktiven Import-Snapshots. Aktuell nutzt der +Endpunkt die View `v_world_state` und gibt das vorberechnete Weltalter aus. +Das Frontend soll `world.ageDays` direkt anzeigen und keine eigene Umrechnung +von Ticks in Minecraft-Tage vornehmen. + +Antwort: + +```json +{ + "world": { + "name": "world", + "ageTicks": 123456789, + "ageDays": 5144, + "importedAt": "2026-05-09T12:34:56+02:00" + }, + "__generated": "2026-05-09T12:34:56+02:00", + "__generated_timezone": "Europe/Berlin" +} +``` + +Felder: + +- `world.name`: Name der Minecraft-Welt, z. B. `world` +- `world.ageTicks`: Rohwert aus Bukkit/Paper `World#getFullTime()` +- `world.ageDays`: fertig berechnete Minecraft-Tage aus der Datenbank +- `world.importedAt`: Import-Zeitpunkt des Weltzustands + +Wenn kein Weltzustand im aktiven Snapshot vorhanden ist, wird `world: null` +zurueckgegeben. + ## Lokale Entwicklung - `npm run dev` für Astro + Cloudflare-Worker-Runtime diff --git a/src/features/stats/StatsApp.tsx b/src/features/stats/StatsApp.tsx index e8f8214..e79579c 100644 --- a/src/features/stats/StatsApp.tsx +++ b/src/features/stats/StatsApp.tsx @@ -136,6 +136,10 @@ export default function StatsApp() { generatedIso, setGeneratedIso, totals, + worldState, + worldStateLoaded, + worldStateLoading, + worldStateError, summaryLoaded, summaryLoading, summaryError, @@ -457,6 +461,10 @@ export default function StatsApp() { onOpenRankings={handleOpenRankingsFromOverview} navigationDisabled={tabsDisabled} totals={totals} + worldState={worldState} + worldStateLoaded={worldStateLoaded} + worldStateLoading={worldStateLoading} + worldStateError={worldStateError} summaryLoaded={summaryLoaded} summaryLoading={summaryLoading} summaryError={summaryError} diff --git a/src/features/stats/api.ts b/src/features/stats/api.ts index cb2db91..468f8f1 100644 --- a/src/features/stats/api.ts +++ b/src/features/stats/api.ts @@ -3,6 +3,7 @@ import type { MetricsResponse, PlayersSearchResponse, SummaryResponse, + WorldStateResponse, } from './types'; import { fetchJsonOrThrow } from '../../lib/http/fetchJson'; import { toApiUrl } from '../../lib/http/apiUrl'; @@ -21,6 +22,10 @@ export function getSummary(metrics: string[], signal?: AbortSignal) { ); } +export function getWorldState(signal?: AbortSignal) { + return fetchJsonOrThrow(toApiUrl('/api/world-state'), { signal }); +} + export function getLeaderboard( metricId: string, limit: number, diff --git a/src/features/stats/components/sections/OverviewSection.test.tsx b/src/features/stats/components/sections/OverviewSection.test.tsx index 0770685..2cb13b7 100644 --- a/src/features/stats/components/sections/OverviewSection.test.tsx +++ b/src/features/stats/components/sections/OverviewSection.test.tsx @@ -22,8 +22,16 @@ async function mountOverview(overrides: OverviewOverrides = {}) { hours: 123, distance: 456, mob_kills: 789, - creeper: 321, }, + worldState: { + name: 'world', + ageTicks: 102_880_000, + ageDays: 5_144, + importedAt: '2026-02-19T12:00:00.000Z', + }, + worldStateLoaded: true, + worldStateLoading: false, + worldStateError: null, summaryLoaded: true, summaryLoading: false, summaryError: null, @@ -71,6 +79,10 @@ describe('OverviewSection', () => { const onOpenRankings = vi.fn(); const view = await mountOverview({ onOpenRankings }); + expect(view.container.textContent).toContain('Weltalter'); + expect(view.container.textContent).toContain('5.144'); + expect(view.container.textContent).toContain('Minecraft-Tage seit Weltstart.'); + const leadCard = view.container.querySelector( '[aria-label="Spielzeit Rangliste \u00f6ffnen"]', ) as HTMLElement | null; diff --git a/src/features/stats/components/sections/OverviewSection.tsx b/src/features/stats/components/sections/OverviewSection.tsx index ebb96d7..85dcf45 100644 --- a/src/features/stats/components/sections/OverviewSection.tsx +++ b/src/features/stats/components/sections/OverviewSection.tsx @@ -1,107 +1,413 @@ import { useMemo, type KeyboardEvent, type ReactNode } from 'react'; -import { ArrowRight, Clock, Map as MapIcon, Skull, Sparkles, Swords, X } from 'lucide-react'; +import { ArrowRight, CalendarDays, Clock, Map as MapIcon, Sparkles, Swords, X } from 'lucide-react'; import { KPI_FALLBACK_DEFS, KPI_METRICS } from '../../constants'; -import { formatMetricValue } from '../../format'; +import { fmtNumber, formatMetricValue } from '../../format'; import { StatsLayoutGrid, StatsLayoutMain, StatsLayoutRail } from '../../layout/StatsLayout'; import { StatValue, type StatValueState } from '../StatValue'; import { SectionTitle } from '../StatsPrimitives'; import { resolveLiveDataStatus } from '../../../../lib/live/types'; import { LIVE_COPY_DE } from '../../../../lib/live/copy.de'; +import type { WorldState } from '../../types'; -export function OverviewSection({ - showWelcome, - onDismissWelcome, - onOpenRankings, - navigationDisabled, +type OverviewSectionProps = { + showWelcome: boolean; + onDismissWelcome: () => void; + onOpenRankings: (metricId?: string | string[]) => void; + navigationDisabled: boolean; + totals: Record | null; + worldState: WorldState | null; + worldStateLoaded: boolean; + worldStateLoading: boolean; + worldStateError: string | null; + summaryLoaded: boolean; + summaryLoading: boolean; + summaryError: string | null; + onRetrySummary: () => void; + summaryRetryDisabled: boolean; + summaryRetryInSeconds: number; +}; + +type OverviewItemState = { + state: StatValueState; + hint?: string; + onRetry?: () => void; + retryDisabled?: boolean; + retryDisabledHint?: string; +}; + +type OverviewItem = OverviewItemState & { + id: string; + icon: ReactNode; + label: string; + value?: string; +}; + +type RankingQuicklink = { + label: string; + metricIds: string[]; +}; + +const ICON_BY_KPI_ID: Record = { + hours: , + distance: , + mob_kills: , +}; + +const RANKING_QUICKLINKS: RankingQuicklink[] = [ + { + label: 'Diamanterz abgebaut', + metricIds: ['diamond_ore', 'minecraft:diamond_ore', 'diamond'], + }, + { + label: 'Truhen ge\u00f6ffnet', + metricIds: ['open_chest', 'minecraft:open_chest', 'stat:minecraft:open_chest'], + }, + { + label: 'Im Bett geschlafen', + metricIds: ['sleep_in_bed', 'minecraft:sleep_in_bed', 'stat:minecraft:sleep_in_bed'], + }, +]; + +function getRetryWaitText(summaryRetryDisabled: boolean, summaryRetryInSeconds: number) { + if (!summaryRetryDisabled || summaryRetryInSeconds <= 0) return null; + return LIVE_COPY_DE.retry_wait(summaryRetryInSeconds); +} + +function getSummaryItemState({ + value, + label, totals, summaryLoaded, summaryLoading, summaryError, onRetrySummary, summaryRetryDisabled, - summaryRetryInSeconds, + retryWaitText, }: { - showWelcome: boolean; - onDismissWelcome: () => void; - onOpenRankings: (metricId?: string | string[]) => void; - navigationDisabled: boolean; + value: number | undefined; + label: string; totals: Record | null; summaryLoaded: boolean; summaryLoading: boolean; summaryError: string | null; onRetrySummary: () => void; summaryRetryDisabled: boolean; - summaryRetryInSeconds: number; -}) { - const retryWaitText = - summaryRetryDisabled && summaryRetryInSeconds > 0 - ? LIVE_COPY_DE.retry_wait(summaryRetryInSeconds) - : null; + retryWaitText: string | null; +}): OverviewItemState { + const state = resolveLiveDataStatus({ + loading: summaryLoading, + loaded: summaryLoaded, + hasData: typeof value === 'number', + hasSnapshot: Boolean(totals), + error: summaryError ? { kind: 'unknown', message: summaryError } : null, + }); - const resolveItemState = useMemo( - () => - ( - value: number | undefined, - label: string, - ): { - state: StatValueState; - hint?: string; - onRetry?: () => void; - retryDisabled?: boolean; - retryDisabledHint?: string; - } => { - const hasValue = typeof value === 'number'; - const state = resolveLiveDataStatus({ - loading: summaryLoading, - loaded: summaryLoaded, - hasData: hasValue, - hasSnapshot: Boolean(totals), - error: summaryError - ? { - kind: 'unknown', - message: summaryError, - } - : null, - }); + if (state === 'loading') return { state }; + + if (state === 'error') { + return { + state, + hint: retryWaitText || LIVE_COPY_DE.summary_error_hint, + onRetry: onRetrySummary, + retryDisabled: summaryRetryDisabled, + retryDisabledHint: retryWaitText || undefined, + }; + } + + if (state === 'stale' && summaryLoading) { + return { state, hint: LIVE_COPY_DE.summary_stale_refreshing }; + } + + if (state === 'stale' && summaryError) { + return { state, hint: LIVE_COPY_DE.summary_stale_failed }; + } + + if (state === 'empty') { + return { state, hint: LIVE_COPY_DE.summary_missing_metric(label) }; + } + + return { state: 'ok' }; +} + +function getWorldAgeDetails({ + worldState, + worldStateLoaded, + worldStateLoading, + worldStateError, +}: Pick< + OverviewSectionProps, + 'worldState' | 'worldStateLoaded' | 'worldStateLoading' | 'worldStateError' +>) { + const days = + typeof worldState?.ageDays === 'number' && Number.isFinite(worldState.ageDays) + ? worldState.ageDays + : undefined; + const state = resolveLiveDataStatus({ + loading: worldStateLoading, + loaded: worldStateLoaded, + hasData: typeof days === 'number', + hasSnapshot: Boolean(worldState), + error: worldStateError ? { kind: 'unknown', message: worldStateError } : null, + }); + + return { + state, + value: typeof days === 'number' ? fmtNumber(days) : undefined, + hint: getWorldAgeHint(state, days, worldStateLoading, worldStateError), + }; +} + +function getWorldAgeHint( + state: StatValueState, + days: number | undefined, + worldStateLoading: boolean, + worldStateError: string | null, +) { + if (state === 'empty') return 'Das Weltalter wurde vom Server noch nicht geliefert.'; + if (state === 'stale' && worldStateLoading) return LIVE_COPY_DE.summary_stale_refreshing; + if (state === 'stale' && worldStateError) return LIVE_COPY_DE.summary_stale_failed; + + return worldStateError || `${days === 1 ? 'Minecraft-Tag' : 'Minecraft-Tage'} seit Weltstart.`; +} + +function WelcomePanel({ + showWelcome, + onDismissWelcome, +}: Pick) { + if (!showWelcome) { + return ( +
+ + + Willkommen-Hinweis ausgeblendet. Du kannst direkt mit den Kennzahlen arbeiten. + +
+ ); + } + + return ( +
+
+ +
+
+

Willkommen auf der Statistik-Seite!

+

+ Nutze die Suche oben, um direkt zur Spielerstatistik zu springen. In den Ranglisten + findest du die Top-Werte je Kategorie, von Spielzeit über Distanz bis zu Kreaturen. +

+
+ +
+ ); +} + +function QuicklinksPanel({ + navigationDisabled, + onOpenRankings, +}: Pick) { + return ( +
+

Quicklinks

+
+ {RANKING_QUICKLINKS.map((quicklink) => ( + + ))} +
+
+ ); +} - if (state === 'loading') { - return { state }; - } +function WorldAgePanel({ + state, + value, + hint, + onRetrySummary, + summaryRetryDisabled, + retryWaitText, +}: { + state: StatValueState; + value?: string; + hint: string; + onRetrySummary: () => void; + summaryRetryDisabled: boolean; + retryWaitText: string | null; +}) { + return ( +
+
+ +
+

Leitwert

+

Weltalter

+ +
+ ); +} - if (state === 'error') { - return { - state, - hint: retryWaitText || LIVE_COPY_DE.summary_error_hint, - onRetry: onRetrySummary, - retryDisabled: summaryRetryDisabled, - retryDisabledHint: retryWaitText || undefined, - }; - } +function TotalsPanel({ + rows, + navigationDisabled, + onActivate, +}: { + rows: OverviewItem[]; + navigationDisabled: boolean; + onActivate: (metricId: string) => void; +}) { + return ( +
+
+

Serverweite Gesamtwerte

+

Alle Spieler zusammengezählt.

+
+
    + {rows.map((item) => ( + + ))} +
+
+ ); +} - if (state === 'stale' && summaryLoading) { - return { - state, - hint: LIVE_COPY_DE.summary_stale_refreshing, - }; - } +function OverviewTotalRow({ + item, + navigationDisabled, + onActivate, +}: { + item: OverviewItem; + navigationDisabled: boolean; + onActivate: (metricId: string) => void; +}) { + const handleKeyDown = (event: KeyboardEvent): void => { + if (navigationDisabled) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onActivate(item.id); + }; - if (state === 'stale' && summaryError) { - return { - state, - hint: LIVE_COPY_DE.summary_stale_failed, - }; - } + return ( +
  • { + if (!navigationDisabled) onActivate(item.id); + }} + onKeyDown={handleKeyDown} + > + + + {item.icon} + + {item.label} + + +
  • + ); +} - if (state === 'empty') { - return { - state, - hint: LIVE_COPY_DE.summary_missing_metric(label), - }; - } +export function OverviewSection({ + showWelcome, + onDismissWelcome, + onOpenRankings, + navigationDisabled, + totals, + worldState, + worldStateLoaded, + worldStateLoading, + worldStateError, + summaryLoaded, + summaryLoading, + summaryError, + onRetrySummary, + summaryRetryDisabled, + summaryRetryInSeconds, +}: OverviewSectionProps) { + const retryWaitText = getRetryWaitText(summaryRetryDisabled, summaryRetryInSeconds); + const overviewItems = useMemo( + () => + KPI_METRICS.map((id) => { + const def = KPI_FALLBACK_DEFS[id]; + const value = totals?.[id]; + const valueState = getSummaryItemState({ + value, + label: def.label, + totals, + summaryLoaded, + summaryLoading, + summaryError, + onRetrySummary, + summaryRetryDisabled, + retryWaitText, + }); - return { state: 'ok' }; - }, + return { + id, + icon: ICON_BY_KPI_ID[id], + label: def.label, + value: typeof value === 'number' ? formatMetricValue(value, def) : undefined, + ...valueState, + }; + }), [ onRetrySummary, retryWaitText, @@ -112,134 +418,26 @@ export function OverviewSection({ totals, ], ); - - const overviewItems = useMemo< - Array<{ - id: string; - icon: ReactNode; - label: string; - value?: string; - state: StatValueState; - hint?: string; - onRetry?: () => void; - retryDisabled?: boolean; - retryDisabledHint?: string; - }> - >(() => { - const iconById: Record = { - hours: , - distance: , - mob_kills: , - creeper: , - }; - return KPI_METRICS.map((id) => { - const def = KPI_FALLBACK_DEFS[id]; - const value = totals?.[id]; - const valueState = resolveItemState(value, def.label); - return { - id, - icon: iconById[id], - label: def.label, - value: typeof value === 'number' ? formatMetricValue(value, def) : undefined, - ...valueState, - }; - }); - }, [resolveItemState, totals]); + const worldAge = getWorldAgeDetails({ + worldState, + worldStateLoaded, + worldStateLoading, + worldStateError, + }); const handleCardActivate = (metricId: string): void => { if (navigationDisabled) return; onOpenRankings(metricId); }; - const handleCardKeyDown = (event: KeyboardEvent, metricId: string): void => { - if (navigationDisabled) return; - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - onOpenRankings(metricId); - }; - - const highlightItem = overviewItems[0]; - const rows = overviewItems.slice(1, 4); - const rankingQuicklinks: Array<{ label: string; metricIds: string[] }> = [ - { - label: 'Diamanterz abgebaut', - metricIds: ['diamond_ore', 'minecraft:diamond_ore', 'diamond'], - }, - { - label: 'Truhen ge\u00f6ffnet', - metricIds: ['open_chest', 'minecraft:open_chest', 'stat:minecraft:open_chest'], - }, - { - label: 'Im Bett geschlafen', - metricIds: ['sleep_in_bed', 'minecraft:sleep_in_bed', 'stat:minecraft:sleep_in_bed'], - }, - ]; - return (
    - {showWelcome ? ( -
    -
    - -
    -
    -

    Willkommen auf der Statistik-Seite!

    -

    - Nutze die Suche oben, um direkt zur Spielerstatistik zu springen. In den - Ranglisten findest du die Top-Werte je Kategorie, von Spielzeit über Distanz - bis zu Kreaturen. -

    -
    - -
    - ) : ( -
    - - - Willkommen-Hinweis ausgeblendet. Du kannst direkt mit den Kennzahlen arbeiten. - -
    - )} +
    -
    -

    Quicklinks

    -
    - {rankingQuicklinks.map((quicklink) => ( - - ))} -
    -
    +
    - {highlightItem ? ( -
    handleCardActivate(highlightItem.id)} - onKeyDown={(event) => handleCardKeyDown(event, highlightItem.id)} - > -
    - {highlightItem.icon} -
    -

    - Leitwert -

    -

    - {highlightItem.label} -

    - -

    - Zur Rangliste - -

    -
    - ) : null} - -
    -
      - {rows.map((item) => ( -
    • handleCardActivate(item.id)} - onKeyDown={(event) => handleCardKeyDown(event, item.id)} - > - - - {item.icon} - - {item.label} - - -
    • - ))} -
    -
    + +
    diff --git a/src/features/stats/constants.ts b/src/features/stats/constants.ts index 14f8bb2..fa6479e 100644 --- a/src/features/stats/constants.ts +++ b/src/features/stats/constants.ts @@ -2,7 +2,7 @@ import type { MetricDef, MetricId } from './types'; export const STATS_PAGE_SIZES = [5, 10, 20, 30, 40] as const; export const STATS_DEFAULT_PAGE_SIZE = 20; -export const KPI_METRICS: MetricId[] = ['hours', 'distance', 'mob_kills', 'creeper']; +export const KPI_METRICS: MetricId[] = ['hours', 'distance', 'mob_kills']; export const VERSUS_MAX_METRICS = 12; export const RANKINGS_TOP_CATEGORY_KEYS = [ 'hours', @@ -24,5 +24,4 @@ export const KPI_FALLBACK_DEFS: Record = { decimals: 2, }, mob_kills: { label: 'Mobs get\u00f6tet', category: '\u00dcbersicht' }, - creeper: { label: 'Creeper get\u00f6tet', category: '\u00dcbersicht' }, }; diff --git a/src/features/stats/hooks/useStatsData.test.tsx b/src/features/stats/hooks/useStatsData.test.tsx index 24df2a7..700dcf0 100644 --- a/src/features/stats/hooks/useStatsData.test.tsx +++ b/src/features/stats/hooks/useStatsData.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LIVE_COPY_DE } from '../../../lib/live/copy.de'; import { getLiveResource } from '../../../lib/live/cache'; import type { LiveDataState } from '../../../lib/live/types'; -import { getLeaderboard, getMetrics, getSummary } from '../api'; +import { getLeaderboard, getMetrics, getSummary, getWorldState } from '../api'; import type { SummaryResponse } from '../types'; import type { TabKey } from '../types-ui'; import { useStatsData } from './useStatsData'; @@ -16,6 +16,7 @@ vi.mock('../api', () => ({ getLeaderboard: vi.fn(), getMetrics: vi.fn(), getSummary: vi.fn(), + getWorldState: vi.fn(), })); vi.mock('../usePlayerAutocomplete', () => ({ @@ -137,6 +138,7 @@ describe('useStatsData rate limit', () => { vi.mocked(getLeaderboard).mockReset(); vi.mocked(getMetrics).mockReset(); vi.mocked(getSummary).mockReset(); + vi.mocked(getWorldState).mockReset(); vi.mocked(getLiveResource).mockClear(); vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -191,7 +193,7 @@ describe('useStatsData rate limit', () => { await hook.unmount(); }); - it('loads only summary on initial overview tab', async () => { + it('loads overview live resources on initial overview tab', async () => { const hook = await mountHook({ activeTab: 'uebersicht', pageSize: 10, @@ -201,7 +203,7 @@ describe('useStatsData rate limit', () => { await flushEffects(3); - expect(getLiveResource).toHaveBeenCalledTimes(1); + expect(getLiveResource).toHaveBeenCalledTimes(2); expect(getMetrics).not.toHaveBeenCalled(); expect(getLeaderboard).not.toHaveBeenCalled(); diff --git a/src/features/stats/hooks/useStatsData.ts b/src/features/stats/hooks/useStatsData.ts index 07f0c18..277a5bd 100644 --- a/src/features/stats/hooks/useStatsData.ts +++ b/src/features/stats/hooks/useStatsData.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getLeaderboard, getMetrics, getSummary } from '../api'; +import { getLeaderboard, getMetrics, getSummary, getWorldState } from '../api'; import { KPI_METRICS } from '../constants'; import { filterMetricIds, groupMetricIds, pickDefaultRankMetricId } from '../metric-utils'; import { normalizeUmlauts } from '../normalizeUmlauts'; -import type { MetricDef, SummaryResponse } from '../types'; +import type { MetricDef, SummaryResponse, WorldState, WorldStateResponse } from '../types'; import type { LeaderboardState, TabKey } from '../types-ui'; import { usePlayerAutocomplete } from '../usePlayerAutocomplete'; import type { GroupedMetrics } from '../components/MetricPicker'; @@ -64,6 +64,7 @@ function resolveLeaderboardErrorKind(error: unknown): LiveDataErrorKind { return 'unknown'; } const SUMMARY_CACHE_KEY = 'stats-kpi-summary'; +const WORLD_STATE_CACHE_KEY = 'stats-world-state'; const SUMMARY_MIN_REVALIDATE_INTERVAL_MS = 15_000; function makeEmptyLeaderboardState(): LeaderboardState { @@ -145,11 +146,16 @@ export function useStatsData({ const [playerCount, setPlayerCount] = useState(null); const [metrics, setMetrics] = useState | null>(null); const [totals, setTotals] = useState | null>(null); + const [worldState, setWorldState] = useState(null); + const [worldStateLoaded, setWorldStateLoaded] = useState(false); + const [worldStateLoading, setWorldStateLoading] = useState(false); + const [worldStateError, setWorldStateError] = useState(null); const [summaryLoaded, setSummaryLoaded] = useState(false); const [summaryLoading, setSummaryLoading] = useState(false); const [summaryError, setSummaryError] = useState(null); const [summaryLastUpdatedAt, setSummaryLastUpdatedAt] = useState(null); const [summaryReloadTrigger, setSummaryReloadTrigger] = useState(0); + const [worldStateReloadTrigger, setWorldStateReloadTrigger] = useState(0); const [apiError, setApiError] = useState(null); const [apiErrorKind, setApiErrorKind] = useState(null); const [nextAllowedFetchAt, setNextAllowedFetchAt] = useState(null); @@ -464,6 +470,7 @@ export function useStatsData({ const retrySummary = useCallback(() => { if (isRateLimitBlocked) return; setSummaryReloadTrigger((prev) => prev + 1); + setWorldStateReloadTrigger((prev) => prev + 1); }, [isRateLimitBlocked]); const goToPlayer = useCallback((uuid: string) => { @@ -636,6 +643,143 @@ export function useStatsData({ }; }, [activeTab, registerRateLimit, setApiErrorWithKind, summaryReloadTrigger]); + useEffect(() => { + if (activeTab !== 'uebersicht') return; + + const ac = new AbortController(); + const thresholds = LIVE_WIDGET_THRESHOLDS['stats-kpi']; + + const applyWorldStatePayload = (data: WorldStateResponse): void => { + if (typeof data.__generated === 'string') setGeneratedIso(data.__generated); + if ('world' in data) { + setWorldState(data.world ?? null); + } + }; + + const applyWorldState = ( + state: LiveDataState, + options: { initial: boolean; hasRevalidate: boolean }, + ): void => { + if (ac.signal.aborted) return; + + if (state.data) { + applyWorldStatePayload(state.data); + } + + if (state.status === 'loading') { + setWorldStateLoading(true); + setWorldStateError(null); + return; + } + + const shouldTreatInitialStateAsLoading = + options.initial && + options.hasRevalidate && + (state.status === 'stale' || state.status === 'error'); + + if (shouldTreatInitialStateAsLoading) { + if (state.status === 'stale') { + setWorldStateLoaded(true); + } + setWorldStateLoading(true); + setWorldStateError(null); + return; + } + + setWorldStateLoaded(true); + setWorldStateLoading(false); + + if (state.status === 'error' || state.status === 'stale') { + const errorKind = state.error?.kind === 'rate_limit' ? 'rate_limit' : 'unknown'; + const message = + getLiveMessage({ status: 'error', errorKind }) ?? + (errorKind === 'rate_limit' ? API_RATE_LIMIT_MESSAGE : API_ERROR_MESSAGE); + setWorldStateError(message); + return; + } + + setWorldStateError(null); + }; + + (async () => { + const resource = getLiveResource( + WORLD_STATE_CACHE_KEY, + async (): Promise> => { + try { + const data = await getWorldState(ac.signal); + const fetchedAt = Date.now(); + return { + status: 'ok', + data, + updatedAt: fetchedAt, + fetchedAt, + }; + } catch (error) { + if ((error as Error)?.name === 'AbortError') { + return { + status: 'error', + fetchedAt: Date.now(), + error: { + kind: 'network', + message: 'Anfrage wurde abgebrochen.', + }, + }; + } + + const errorKind = resolveLeaderboardErrorKind(error); + const retryAfterMs = resolveRetryAfterMs(error); + if (errorKind === 'rate_limit') { + registerRateLimit(retryAfterMs); + } + + return { + status: 'error', + fetchedAt: Date.now(), + error: { + kind: errorKind, + message: + getLiveMessage({ status: 'error', errorKind }) ?? + (errorKind === 'rate_limit' ? API_RATE_LIMIT_MESSAGE : API_ERROR_MESSAGE), + retryAfterMs: retryAfterMs ?? undefined, + }, + }; + } + }, + { + staleAfterMs: thresholds.staleAfterMs, + maxCacheAgeMs: thresholds.maxCacheAgeMs, + persist: true, + minRevalidateIntervalMs: SUMMARY_MIN_REVALIDATE_INTERVAL_MS, + }, + ); + + applyWorldState(resource.state, { + initial: true, + hasRevalidate: resource.revalidate != null, + }); + + if (!resource.revalidate) return; + + const latest = await resource.revalidate; + applyWorldState(latest, { + initial: false, + hasRevalidate: true, + }); + + if ( + !ac.signal.aborted && + (latest.status === 'error' || (latest.status === 'stale' && latest.error)) + ) { + console.warn('Weltzustand Fehler', latest.error ?? latest); + } + })(); + + return () => { + ac.abort(); + setWorldStateLoading(false); + }; + }, [activeTab, registerRateLimit, worldStateReloadTrigger]); + useEffect(() => { if (activeTab !== 'uebersicht') return; @@ -656,6 +800,7 @@ export function useStatsData({ summaryVisibilityRevalidateAtRef.current = now; setSummaryReloadTrigger((prev) => prev + 1); + setWorldStateReloadTrigger((prev) => prev + 1); }; document.addEventListener('visibilitychange', onVisibilityChange); @@ -771,6 +916,10 @@ export function useStatsData({ setGeneratedIso, playerCount, totals, + worldState, + worldStateLoaded, + worldStateLoading, + worldStateError, summaryLoaded, summaryLoading, summaryError, diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 9b0fccb..552362a 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -22,6 +22,19 @@ export interface SummaryResponse { __generated_timezone?: string | null; } +export interface WorldState { + name: string; + ageTicks: number; + ageDays: number; + importedAt: string | null; +} + +export interface WorldStateResponse { + world?: WorldState | null; + __generated?: string; + __generated_timezone?: string | null; +} + export interface LeaderboardRow { uuid: string; value: number; diff --git a/src/lib/http/server/statsApiProxy.ts b/src/lib/http/server/statsApiProxy.ts index 21b5317..92bd9a4 100644 --- a/src/lib/http/server/statsApiProxy.ts +++ b/src/lib/http/server/statsApiProxy.ts @@ -107,6 +107,11 @@ const CACHE_PROFILES: Record = { staleWhileRevalidateSeconds: 30, staleIfErrorSeconds: 300, }, + 'world-state': { + maxAgeSeconds: 60, + staleWhileRevalidateSeconds: 30, + staleIfErrorSeconds: 300, + }, leaderboard: { maxAgeSeconds: 60, staleWhileRevalidateSeconds: 30, @@ -148,6 +153,7 @@ const ALLOWED_ENDPOINTS = new Set([ 'metrics', 'summary', 'leaderboards', + 'world-state', 'leaderboard', 'players', 'player', @@ -1270,6 +1276,14 @@ type BanStatusKnownResult = { ban: BanStatusBan | null; }; +type WorldStateResult = { + name: string; + ageTicks: number; + ageDays: number; + importedAt: string | null; + importedAtDate: Date | null; +}; + function readEndpointUuidParams( requestUrl: URL, primaryParam: string, @@ -1438,6 +1452,67 @@ async function handleSummaryEndpoint(route: DataRouteContext): Promise ); } +async function fetchWorldState(route: DataRouteContext): Promise { + const rows = await queryRows( + route.db, + `SELECT world_name, + world_age_ticks, + world_age_days, + DATE_FORMAT(imported_at, '%Y-%m-%dT%H:%i:%s') AS imported_at, + TIMESTAMPDIFF(MINUTE, UTC_TIMESTAMP(), NOW()) AS db_utc_offset_minutes + FROM v_world_state + ORDER BY CASE WHEN world_name = 'world' THEN 0 ELSE 1 END, world_name ASC + LIMIT 1`, + ); + + const row = rows[0]; + if (!row) return null; + + const detectedOffsetMinutes = parseIntegerOrNull(row.db_utc_offset_minutes); + const importedTimeZone = resolveStatsDbTimeZone(asRuntimeEnv(), detectedOffsetMinutes); + const importedAt = toOffsetIsoOrNull(row.imported_at, importedTimeZone); + + return { + name: String(row.world_name ?? ''), + ageTicks: parseInteger(row.world_age_ticks, 0), + ageDays: parseInteger(row.world_age_days, 0), + importedAt, + importedAtDate: toDateOrNull(importedAt), + }; +} + +async function handleWorldStateEndpoint(route: DataRouteContext): Promise { + const worldState = await fetchWorldState(route); + const etagParts = worldState + ? [ + route.active.runId, + worldState.name, + worldState.ageTicks, + worldState.ageDays, + worldState.importedAt ?? 'no-imported-at', + ] + : [route.active.runId, 'empty']; + const lastModified = route.active.generatedAt ?? worldState?.importedAtDate ?? null; + const headers = etagHeaders('world-state', `world-state:${etagParts.join(':')}`, lastModified); + + return jsonResponse( + withGenerated( + { + world: worldState + ? { + name: worldState.name, + ageTicks: worldState.ageTicks, + ageDays: worldState.ageDays, + importedAt: worldState.importedAt, + } + : null, + }, + route.active, + ), + { headers }, + ); +} + async function handleLeaderboardsEndpoint(route: DataRouteContext): Promise { const limit = clampLimit(parseInteger(route.requestUrl.searchParams.get('limit'), 50)); const limitPlus = Math.min(limit + 1, API_MAX_LIMIT + 1); @@ -1922,6 +1997,8 @@ async function routeRequest(context: APIContext, endpoint: string): Promise