149 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
};
|