1
0
Fork 0
tsgames/src/games/horde/components/modals/leaderboard-modal.tsx

149 lines
6.1 KiB
TypeScript

import clsx from "clsx";
import { useEffect, useState } from "preact/hooks";
import { useHordeState } from "../../contexts/state";
import { fetchLeaderboard, type LeaderboardEntry } from "../../utils/api";
import { formatNumber, formatTime } from "@common/utils";
import { Modal } from "@common/components/modal/Modal";
import modalStyles from "../../assets/modal.module.css";
import styles from "../../assets/leaderboard-modal.module.css";
interface Props {
open: boolean;
onClose: () => void;
}
const PAGE_SIZE = 25;
export const LeaderboardModal = ({ open, onClose }: Props) => {
const { state } = useHordeState();
const { user, workers } = state;
const ownWorkerIds = new Set(user?.worker_ids ?? []);
const ownWorkers = workers.filter(w => ownWorkerIds.has(w.id));
const totalUptime = ownWorkers.reduce((s, w) => s + w.uptime, 0);
const totalGenerated = ownWorkers.reduce((s, w) => s + (w.kudos_details?.generated ?? 0), 0);
const kudosPerHour = totalUptime > 0 ? (totalGenerated / totalUptime) * 3600 : 0;
const [rows, setRows] = useState<(LeaderboardEntry & { rank: number; diff?: number })[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
const load = async () => {
setLoading(true);
setError(null);
try {
if (!user) {
const page = await fetchLeaderboard(1);
if (!cancelled) {
setRows(page.slice(0, 10).map((e, i) => ({ ...e, rank: i + 1 })));
}
} else {
let userRank = -1;
let page = 1;
outer: while (true) {
const entries = await fetchLeaderboard(page);
if (entries.length === 0) break;
for (let i = 0; i < entries.length; i++) {
if (entries[i].username === user.username) {
userRank = (page - 1) * PAGE_SIZE + i + 1;
break outer;
}
}
page++;
if (page > 20) break;
}
if (cancelled) return;
const startRank = Math.max(1, userRank - 10);
const startPage = Math.ceil(startRank / PAGE_SIZE);
const endPage = Math.ceil(userRank / PAGE_SIZE);
const collected: LeaderboardEntry[] = [];
for (let p = startPage; p <= endPage; p++) {
const entries = await fetchLeaderboard(p);
collected.push(...entries);
}
if (cancelled) return;
const userKudos = user.kudos;
const result = collected
.slice(startRank - (startPage - 1) * PAGE_SIZE - 1)
.slice(0, userRank - startRank + 1)
.map((e, i) => ({
...e,
rank: startRank + i,
diff: e.username === user.username ? undefined : e.kudos - userKudos,
}));
setRows(result);
}
} catch (e) {
if (!cancelled) setError(String(e));
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [open, user?.username]);
return (
<Modal open={open} onClose={onClose} class={clsx(modalStyles.modal, styles.modal)}>
<div class={modalStyles.header}>
<span class={modalStyles.title}>Leaderboard</span>
</div>
<div class={modalStyles.body}>
{loading && <p class={styles.loading}>Loading</p>}
{error && <p class={styles.error}>{error}</p>}
{!loading && !error && rows.length > 0 && (
<table class={styles.table}>
<thead>
<tr>
<th>#</th>
<th>Username</th>
<th>Kudos</th>
{user && <th>Diff</th>}
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.rank} class={r.username === user?.username ? styles.selfRow : undefined}>
<td>{r.rank}</td>
<td>{r.username}</td>
<td>{formatNumber(r.kudos)}</td>
{user && (
<td class={clsx(r.diff !== undefined && (r.diff > 0 ? styles.above : styles.below))}>
{r.diff !== undefined ? `+${formatNumber(r.diff)}` : '—'}
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
{(() => {
if (!user || rows.length < 2 || kudosPerHour <= 0) return null;
const nextRow = rows[rows.length - 2];
if (!nextRow.diff || nextRow.diff <= 0) return null;
const secs = Math.ceil((nextRow.diff / kudosPerHour) * 3600);
return (
<div class={modalStyles.footer}>
<span class={styles.nextPlace}>
Time to #{nextRow.rank}: <strong>{formatTime(secs)}</strong>
<span class={styles.nextPlaceMeta}> ({formatNumber(Math.round(kudosPerHour))} kudos/h)</span>
</span>
</div>
);
})()}
</Modal>
);
};