srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens/leaderboardScreen.js
blob: 4eb0564eabbe0b5f59a3f00c32ed51999cdb405e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import { escapeHtml } from '../util.js';
import { logoLockup } from '../logo.js';
import { renderCornerRail } from '../cornerRail.js';
import { attachTooltips } from '../tooltip.js';
import { renderTopRail } from '../topRail.js';
import { api, ApiError } from '../api.js';
import { FLAIR_ICONS, CLOSE_ICON } from './icons.js';

const WORD_COUNTS = [10, 25, 50, 100];
const TIME_DURATIONS = [15, 30, 60, 120];

// Scoped to Words/Time only, not the open-ended quote categories - those
// grow with the text pack and don't make a stable, comparable leaderboard
// mode the way a fixed word count or time duration does.
const MODES = [
    ...TIME_DURATIONS.map(d => ({ key: `time-${d}`, label: `Time ${d}s` })),
    ...WORD_COUNTS.map(w => ({ key: `words-${w}`, label: `Words ${w}` })),
];

function formatDate(iso) {
    const t = Date.parse(iso);
    return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString();
}

export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore }) {
    let selectedMode = MODES[1].key; // time-30, matches the app's own default mode
    let desktopOnly = false;

    function rowsMarkup(state) {
        if (state.status === 'loading') return `<div class="stats-empty">Loading...</div>`;
        if (state.status === 'error') return `<div class="stats-empty">Could not load the leaderboard - ${escapeHtml(state.message)}</div>`;
        if (state.entries.length === 0) return `<div class="stats-empty">No results yet for this mode - be the first.</div>`;
        return `
            <div class="leaderboard-list">
                ${state.entries.map((e, i) => `
                    <div class="leaderboard-row">
                        <div class="leaderboard-rank">#${i + 1}</div>
                        <button class="leaderboard-name"${e.is_bot ? ' disabled' : ` data-username="${escapeHtml(e.username)}"`}>${escapeHtml(e.username)}${e.flair && FLAIR_ICONS[e.flair] ? `<span class="leaderboard-flair" data-tooltip="Equipped flair">${FLAIR_ICONS[e.flair]}</span>` : ''}</button>
                        ${e.is_bot ? `<span class="leaderboard-bot-badge" data-tooltip="A practice opponent, not a human result">bot</span>` : ''}
                        ${e.device_type === 'mobile' ? `<span class="leaderboard-device-badge" data-tooltip="Typed on a touchscreen device">mobile</span>` : ''}
                        <div class="leaderboard-wpm">${Math.round(e.wpm)} wpm</div>
                        <div class="leaderboard-acc">${Math.round(e.accuracy)}%</div>
                        <div class="leaderboard-date">${formatDate(e.date)}</div>
                    </div>
                `).join('')}
            </div>
        `;
    }

    root.innerHTML = `
        <div class="stats-screen">
            <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
                ${logoLockup()}
            <h2>Leaderboard</h2>
            <div class="leaderboard-modes">
                ${MODES.map(m => `<button class="menu-button small quiet${m.key === selectedMode ? ' active' : ''}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')}
            </div>
            <button class="menu-button small quiet" data-action="toggle-device-filter" data-tooltip="Mobile results still count for personal stats - this only filters what's shown here.">Devices: All</button>
            <div class="leaderboard-results"></div>
        </div>
    `;

    root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack));
    attachTooltips(root);
    const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });

    const resultsEl = root.querySelector('.leaderboard-results');
    let requestToken = 0;
    async function load() {
        const token = ++requestToken;
        resultsEl.innerHTML = rowsMarkup({ status: 'loading' });
        try {
            const params = new URLSearchParams({ mode: selectedMode });
            if (desktopOnly) params.set('device', 'desktop');
            const entries = await api.get(`/api/leaderboard?${params.toString()}`);
            if (token !== requestToken) return; // a newer mode pick already superseded this request
            resultsEl.innerHTML = rowsMarkup({ status: 'ok', entries });
            // Scoped to resultsEl, not the whole root - attachTooltips
            // already ran once for the rest of the screen at render time,
            // and re-running it there would double-bind those elements'
            // listeners on every reload.
            attachTooltips(resultsEl);
        } catch (err) {
            if (token !== requestToken) return;
            const message = err instanceof ApiError ? err.message : 'network error';
            resultsEl.innerHTML = rowsMarkup({ status: 'error', message });
        }
    }

    root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(btn => {
        btn.addEventListener('click', () => {
            selectedMode = btn.dataset.mode;
            root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(b => b.classList.toggle('active', b === btn));
            load();
        });
    });

    const deviceFilterBtn = root.querySelector('[data-action="toggle-device-filter"]');
    deviceFilterBtn.addEventListener('click', () => {
        desktopOnly = !desktopOnly;
        deviceFilterBtn.textContent = `Devices: ${desktopOnly ? 'Desktop only' : 'All'}`;
        // A filter that is doing something should look like it is.
        deviceFilterBtn.classList.toggle('active', desktopOnly);
        load();
    });

    // Delegated rather than attached per-row - resultsEl's rows get
    // replaced wholesale on every load(), so a listener attached to a
    // specific row would be gone the next time the mode changes.
    resultsEl.addEventListener('click', e => {
        const btn = e.target.closest('[data-username]');
        if (btn) onShowPublicProfile(btn.dataset.username);
    });

    load();

    const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore });

    return () => {
        cleanupTheme();
        cleanupRail();
    };
}