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
6 changes: 4 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,13 @@ function LeagueHomeRoute() {
}

function LeagueStandingsRoute() {
return <LeagueStandingsPage season={useLeagueAuthState().season} />;
const authState = useLeagueAuthState();
return <LeagueStandingsPage currentUser={authState.currentUser} season={authState.season} />;
}

function LeagueFixturesRoute() {
return <LeagueFixturesPage season={useLeagueAuthState().season} />;
const authState = useLeagueAuthState();
return <LeagueFixturesPage currentUser={authState.currentUser} season={authState.season} />;
}

function LeaguePlayerStatsRoute() {
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ export type LeagueSeasonCoverageResponse = {
seasons: LeagueSeasonCoverage[];
};

export type SyncStatusState = "NEVER_SYNCED" | "OK" | "STALE" | "FAILED" | "RETRY_PENDING";

export type SyncStatus = {
task: string;
label: string;
lastSyncedAt: string | null;
lastAttemptAt: string | null;
lastSuccessAt: string | null;
lastFailureAt: string | null;
failureCount: number | null;
lastErrorMessage: string | null;
status: SyncStatusState | string | null;
};

export type SyncStatusResponse = {
statuses: SyncStatus[];
};

export type LeaguePlayerRankings = {
leagueId: number;
season: number;
Expand Down Expand Up @@ -954,6 +972,17 @@ export async function fetchLeagueSeasons(leagueId = 39): Promise<LeagueSeasonCov
);
}

export async function fetchSyncStatuses(season: number): Promise<SyncStatusResponse> {
return fetchJson<SyncStatusResponse>(
`/api/v1/sync/statuses?season=${normalizeSeason(season)}`,
"Failed to load sync status.",
);
}

export async function requestPublicSync(task: "standings" | "fixtures", season: number): Promise<void> {
await postJson(`/api/v1/sync/${task}?league=39&season=${normalizeSeason(season)}`, {});
}

export async function fetchLeaguePlayerRankings(
season: number,
leagueId = 39,
Expand Down
37 changes: 36 additions & 1 deletion frontend/src/pages/AdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ type SyncStatus = {
task: string;
label: string;
lastSyncedAt: string | null;
lastAttemptAt: string | null;
lastSuccessAt: string | null;
lastFailureAt: string | null;
failureCount: number | null;
lastErrorMessage: string | null;
status: string | null;
};

type CoverageStatus = "loading" | "ready" | "error";
Expand Down Expand Up @@ -1158,6 +1164,7 @@ export function AdminPage({ authState }: AdminPageProps) {
}

const syncStatusByTask = new Map(syncStatuses.map((status) => [status.task, status]));
const apiFootballStatus = syncStatusByTask.get("api-football");
const activeSyncTasks = new Set(syncJobs.filter((job) => job.active).map((job) => job.task));
const runningSyncJobs = syncJobs.filter((job) => job.status === "RUNNING" || job.status === "CANCEL_REQUESTED");
const queuedSyncJobs = syncJobs.filter((job) => job.status === "QUEUED");
Expand Down Expand Up @@ -1452,7 +1459,14 @@ export function AdminPage({ authState }: AdminPageProps) {
{isSyncing ? <LoaderCircle className="admin-loading-icon" aria-hidden="true" /> : null}
{item.label}
</button>
<span>Last sync: {formatDateTime(status?.lastSyncedAt ?? null)}</span>
<span className={`status-pill sync-status-${(status?.status ?? "NEVER_SYNCED").toLowerCase()}`}>
{syncStatusLabel(status?.status)}
</span>
<span>Last success: {formatDateTime(lastSuccessfulSyncTime(status))}</span>
<span>Last attempt: {formatDateTime(status?.lastAttemptAt ?? null)}</span>
{status?.lastFailureAt ? <span>Last failure: {formatDateTime(status.lastFailureAt)}</span> : null}
{(status?.failureCount ?? 0) > 0 ? <span className="admin-sync-warning">Failures: {status?.failureCount}</span> : null}
{status?.lastErrorMessage ? <span className="admin-sync-warning">{status.lastErrorMessage}</span> : null}
{cooldownSeconds > 0 ? <span className="admin-sync-warning">{cooldownSeconds}초 ν›„ μž¬μš”μ²­ κ°€λŠ₯</span> : null}
{!availability.enabled ? <span className="admin-sync-warning">{availability.message}</span> : null}
</div>
Expand Down Expand Up @@ -2398,6 +2412,27 @@ function syncTaskLabel(task: string) {
return syncTasks.find((item) => item.task === task)?.label ?? task;
}

function syncStatusLabel(status?: string | null) {
const labels: Record<string, string> = {
OK: "정상",
STALE: "μ§€μ—°",
FAILED: "μ‹€νŒ¨",
RETRY_PENDING: "μž¬μ‹œλ„ λŒ€κΈ°μ€‘",
NEVER_SYNCED: "기둝 μ—†μŒ",
};
return labels[status ?? "NEVER_SYNCED"] ?? status ?? "기둝 μ—†μŒ";
}

function lastSuccessfulSyncTime(status?: SyncStatus | null) {
if (!status) {
return null;
}
if (status.lastSuccessAt) {
return status.lastSuccessAt;
}
return status.status === "OK" || status.status === "STALE" ? status.lastSyncedAt : null;
}

function syncJobStatusLabel(status: SyncJobStatus) {
const labels: Record<SyncJobStatus, string> = {
QUEUED: "λŒ€κΈ° 쀑",
Expand Down
91 changes: 90 additions & 1 deletion frontend/src/pages/LeagueFixturesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import {
fetchFixtureMeta,
fetchFixtures,
fetchStandings,
fetchSyncStatuses,
requestPublicSync,
type CurrentUser,
type FixtureMeta,
type FixtureSummary,
type SyncStatus,
type TeamStanding,
} from "../api";
import { formatFixtureDateKey, parseKoreaDateTime } from "../dateUtils";
Expand All @@ -29,7 +33,7 @@ const fixtureModes: Array<{ label: string; value: FixtureMode }> = [
{ label: "νŒ€λ³„", value: "team" },
];

export function LeagueFixturesPage({ season }: { season: number }) {
export function LeagueFixturesPage({ currentUser, season }: { currentUser: CurrentUser | null; season: number }) {
const [searchParams, setSearchParams] = useSearchParams();
const mode = fixtureMode(searchParams.get("mode")) ?? "date";
const urlSeason = positiveInteger(searchParams.get("season"));
Expand All @@ -47,6 +51,9 @@ export function LeagueFixturesPage({ season }: { season: number }) {
const [teams, setTeams] = useState<FixtureTeamOption[]>([]);
const selectedTeamId = positiveInteger(searchParams.get("teamId"));
const [fixtures, setFixtures] = useState<FixtureSummary[]>([]);
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [syncMessage, setSyncMessage] = useState("");
const [syncCooldownUntil, setSyncCooldownUntil] = useState(0);
const teamPage = Math.max(0, (positiveInteger(searchParams.get("page")) ?? 1) - 1);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingTeams, setIsLoadingTeams] = useState(true);
Expand Down Expand Up @@ -84,6 +91,7 @@ export function LeagueFixturesPage({ season }: { season: number }) {
setMetaSeason(null);
void loadFixtureMeta();
void loadTeamOptions();
void loadSyncStatus(season);
}, [season]);

useEffect(() => {
Expand Down Expand Up @@ -225,6 +233,31 @@ export function LeagueFixturesPage({ season }: { season: number }) {
}
}

async function loadSyncStatus(targetSeason = season) {
try {
const response = await fetchSyncStatuses(targetSeason);
setSyncStatus(response.statuses.find((status) => status.task === "fixtures") ?? null);
} catch {
setSyncStatus(null);
}
}

async function refreshFixtureSync() {
if (!currentUser || Date.now() < syncCooldownUntil) {
return;
}
setSyncMessage("");
setSyncCooldownUntil(Date.now() + 30_000);
try {
await requestPublicSync("fixtures", season);
setSyncMessage("Latest fixture data was requested.");
await Promise.all([loadFixtures(), loadFixtureMeta(), loadSyncStatus(season)]);
} catch (error) {
setSyncMessage(error instanceof Error ? error.message : "Fixture sync request failed.");
await loadSyncStatus(season);
}
}

function moveWeek(dayDelta: number) {
updateQuery({ week: addDaysToDateKey(weekStart, dayDelta) });
}
Expand Down Expand Up @@ -255,6 +288,9 @@ export function LeagueFixturesPage({ season }: { season: number }) {
}, { replace });
}

const isStale = isSyncStatusStale(syncStatus);
const cooldownSeconds = Math.max(0, Math.ceil((syncCooldownUntil - Date.now()) / 1000));

return (
<section className="league-content fixtures-page">
<div className="fixtures-toolbar">
Expand Down Expand Up @@ -332,6 +368,16 @@ export function LeagueFixturesPage({ season }: { season: number }) {
) : null}
</div>

<div className={`data-freshness ${isStale ? "stale" : ""}`}>
<span>{syncFreshnessText(syncStatus, "Fixtures")}</span>
{isStale && currentUser ? (
<button type="button" onClick={() => void refreshFixtureSync()} disabled={cooldownSeconds > 0}>
{cooldownSeconds > 0 ? `${cooldownSeconds}s` : "Refresh data"}
</button>
) : null}
</div>
{syncMessage ? <div className="notice">{syncMessage}</div> : null}

{mode === "team" ? (
<TeamFixtureHeader
selectedTeam={selectedTeam}
Expand Down Expand Up @@ -375,6 +421,49 @@ export function LeagueFixturesPage({ season }: { season: number }) {
);
}

function isSyncStatusStale(status: SyncStatus | null) {
if (!status) {
return false;
}
if (status.status === "FAILED" || status.status === "RETRY_PENDING") {
return true;
}
const lastSuccess = lastSuccessfulSyncTime(status);
return lastSuccess ? Date.now() - Date.parse(lastSuccess) > 24 * 60 * 60 * 1000 : false;
}

function syncFreshnessText(status: SyncStatus | null, label: string) {
const lastSuccess = lastSuccessfulSyncTime(status);
if (!lastSuccess) {
return `${label}: no sync timestamp`;
}
return `${label}: data as of ${formatDateTime(lastSuccess)}`;
}

function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}

function lastSuccessfulSyncTime(status: SyncStatus | null) {
if (!status) {
return null;
}
if (status.lastSuccessAt) {
return status.lastSuccessAt;
}
return status.status === "OK" || status.status === "STALE" ? status.lastSyncedAt : null;
}

function TeamFixtureHeader({
selectedTeam,
teamRangeStart,
Expand Down
Loading
Loading