srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/screens')
-rw-r--r--web/src/screens/endScreen.js20
-rw-r--r--web/src/screens/leaderboardScreen.js9
-rw-r--r--web/src/screens/mainMenu.js25
-rw-r--r--web/src/screens/multiplayerScreen.js3
-rw-r--r--web/src/screens/placeholderScreen.js2
-rw-r--r--web/src/screens/statsScreen.js30
6 files changed, 69 insertions, 20 deletions
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js
index 2b859bb..086407c 100644
--- a/web/src/screens/endScreen.js
+++ b/web/src/screens/endScreen.js
@@ -2,6 +2,7 @@ import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLin
import { escapeHtml } from '../util.js';
import { attachTooltips } from '../tooltip.js';
import { onThemeChange } from '../theme.js';
+import { renderSiteFooter } from '../siteFooter.js';
import { recordResult } from '../pb.js';
import { recordTest } from '../profileStats.js';
import { renderCornerRail } from '../cornerRail.js';
@@ -9,7 +10,7 @@ import { renderTopRail } from '../topRail.js';
import { submitTestResult } from '../auth.js';
import { detectDeviceType } from '../deviceDetect.js';
-export function renderEndScreen(root, { stats, text, attribution, explanation, standings, onStandingsUpdate, onLeaveRace, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
+export function renderEndScreen(root, { stats, text, attribution, explanation, standings, onShowPrivacy, onStandingsUpdate, onLeaveRace, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
// Monkeytype's four-way split. "Extra" is anything typed past the end of
// the passage, "missed" is passage left untyped - neither is visible in
// a plain correct/incorrect pair, and the two mean very different things
@@ -82,7 +83,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
<div class="mp-standings-rows"></div>
</div>` : ''}
<div class="end-screen-buttons">
- <button class="end-screen-button" data-action="again">Play Again</button>
+ <button class="end-screen-button primary" data-action="again">Play Again</button>
</div>
</div>
`;
@@ -93,12 +94,20 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
const standingsRows = root.querySelector('.mp-standings-rows');
function paintStandings(list) {
if (!standingsRows) return;
- standingsRows.innerHTML = list.map(r => `
+ // Place, who, speed, accuracy, time - the columns TypeRacer and
+ // 10FastFingers both show after a race. Anyone still typing has no
+ // place or time yet, so their row carries how far along they are.
+ standingsRows.innerHTML = `
+ <div class="mp-standings-row mp-standings-head">
+ <span></span><span>Racer</span><span>WPM</span><span>Acc</span><span>Time</span>
+ </div>
+ ` + list.map(r => `
<div class="mp-standings-row${r.me ? ' me' : ''}${r.place ? '' : ' racing'}" style="--racer-color: ${r.color || 'var(--primary-color)'}">
<span class="mp-standings-place">${r.place ? r.place : '&middot;'}</span>
<span class="mp-standings-name">${escapeHtml(r.name)}${r.me ? ' (you)' : ''}</span>
- <span class="mp-standings-wpm">${Math.round(r.wpm)} wpm</span>
+ <span class="mp-standings-wpm">${Math.round(r.wpm)}</span>
<span class="mp-standings-acc">${r.place ? `${Math.round(r.accuracy)}%` : `${Math.round(r.percent)}%`}</span>
+ <span class="mp-standings-time">${r.place && r.time != null ? `${r.time.toFixed(1)}s` : 'racing'}</span>
</div>
`).join('');
}
@@ -129,6 +138,8 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
attachTooltips(root.querySelector('.end-screen-stat-row'));
const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+ // Share here carries the result just achieved, not just the site.
+ const cleanupFooter = renderSiteFooter(root, { result: stats, onShowPrivacy });
root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onMainMenu));
root.querySelector('[data-action="again"]').addEventListener('click', onPlayAgain);
@@ -247,6 +258,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s
offStandings?.();
onLeaveRace?.();
cleanupTheme();
+ cleanupFooter();
window.removeEventListener('resize', redraw);
offTheme();
canvas.removeEventListener('mousemove', handleMove);
diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js
index 2288400..5d56a97 100644
--- a/web/src/screens/leaderboardScreen.js
+++ b/web/src/screens/leaderboardScreen.js
@@ -34,7 +34,8 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS
${state.entries.map((e, i) => `
<div class="leaderboard-row">
<div class="leaderboard-rank">#${i + 1}</div>
- <button class="leaderboard-name" 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>
+ <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>
@@ -51,9 +52,9 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS
<div class="logo" data-action="menu">TyperPunk</div>
<h2>Leaderboard</h2>
<div class="leaderboard-modes">
- ${MODES.map(m => `<button class="menu-button small${m.key === selectedMode ? '' : ' ghost'}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')}
+ ${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 ghost" data-action="toggle-device-filter" data-tooltip="Mobile results still count for personal stats - this only filters what's shown here.">Devices: All</button>
+ <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>
`;
@@ -97,6 +98,8 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS
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);
deviceFilterBtn.classList.toggle('ghost', !desktopOnly);
load();
});
diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js
index d0553c3..cc30bfa 100644
--- a/web/src/screens/mainMenu.js
+++ b/web/src/screens/mainMenu.js
@@ -6,6 +6,7 @@ import { getProfileStats } from '../profileStats.js';
import { attachTooltips } from '../tooltip.js';
import { wordListTiers } from '../wordGenerator.js';
import { renderTopRail } from '../topRail.js';
+import { renderSiteFooter } from '../siteFooter.js';
function label(m) {
if (m === 'random') return 'Random';
@@ -67,7 +68,7 @@ function wordListTierLabel(tier) {
}
export function renderMainMenu(root, props) {
- const { onStartGame, onPickAndStart, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props;
+ const { onStartGame, onPickAndStart, onShowPrivacy, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props;
const modes = ['random', 'words', 'time', 'zen', 'practice', ...categories, 'custom'];
// The picker used to be one flat list of 18 entries mixing two unrelated
// things: how a test is generated (Random/Words/Timed/Zen/Practice/Custom)
@@ -140,7 +141,7 @@ export function renderMainMenu(root, props) {
<input type="checkbox" class="settings-skip-menu"${settings.skipMenu ? ' checked' : ''} />
Start directly into a game, skip this menu
</label>
- <button class="menu-button small" data-action="toggle-favorite">
+ <button class="menu-button small quiet" data-action="toggle-favorite">
${isFavorite ? `★ Favorite (${escapeHtml(label(currentMode))})` : `☆ Set "${escapeHtml(label(currentMode))}" as favorite`}
</button>
<div class="settings-columns">
@@ -148,20 +149,20 @@ export function renderMainMenu(root, props) {
<div class="settings-column">
<div class="settings-hint">Rules</div>
<div class="settings-row-group">
- ${isWords ? `<button class="menu-button small" data-action="cycle-word-count" data-tooltip="How many words the Words mode generates. Click to cycle.">Words: ${wordCount}</button>` : ''}
- ${isTime ? `<button class="menu-button small" data-action="cycle-time-duration" data-tooltip="How long the clock runs in Timed mode. Click to cycle.">Time: ${timeDuration}s</button>` : ''}
- <button class="menu-button small" data-action="toggle-punctuation" data-tooltip="Include punctuation (commas, periods) in generated text.">Punctuation: ${settings.wordsPunctuation ? 'On' : 'Off'}</button>
- <button class="menu-button small" data-action="toggle-numbers" data-tooltip="Include numbers in generated text.">Numbers: ${settings.wordsNumbers ? 'On' : 'Off'}</button>
- <button class="menu-button small" data-action="cycle-word-list-tier" data-tooltip="Common (short, everyday words), Extended (adds longer/less frequent words), or Hard (technical and irregularly-spelled words). Click to cycle.">Word List: ${wordListTierLabel(settings.wordListTier)}</button>
+ ${isWords ? `<button class="menu-button small quiet" data-action="cycle-word-count" data-tooltip="How many words the Words mode generates. Click to cycle.">Words: ${wordCount}</button>` : ''}
+ ${isTime ? `<button class="menu-button small quiet" data-action="cycle-time-duration" data-tooltip="How long the clock runs in Timed mode. Click to cycle.">Time: ${timeDuration}s</button>` : ''}
+ <button class="menu-button small quiet" data-action="toggle-punctuation" data-tooltip="Include punctuation (commas, periods) in generated text.">Punctuation: ${settings.wordsPunctuation ? 'On' : 'Off'}</button>
+ <button class="menu-button small quiet" data-action="toggle-numbers" data-tooltip="Include numbers in generated text.">Numbers: ${settings.wordsNumbers ? 'On' : 'Off'}</button>
+ <button class="menu-button small quiet" data-action="cycle-word-list-tier" data-tooltip="Common (short, everyday words), Extended (adds longer/less frequent words), or Hard (technical and irregularly-spelled words). Click to cycle.">Word List: ${wordListTierLabel(settings.wordListTier)}</button>
</div>
</div>` : ''}
<div class="settings-column">
<div class="settings-hint">Typing</div>
<div class="settings-row-group">
- <button class="menu-button small" data-action="toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button>
- <button class="menu-button small" data-action="toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button>
- <button class="menu-button small" data-action="toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button>
- <button class="menu-button small" data-action="cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${escapeHtml(soundThemeLabel(settings.soundTheme))}</button>
+ <button class="menu-button small quiet" data-action="toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button>
+ <button class="menu-button small quiet" data-action="toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button>
+ <button class="menu-button small quiet" data-action="toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button>
+ <button class="menu-button small quiet" data-action="cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${escapeHtml(soundThemeLabel(settings.soundTheme))}</button>
</div>
</div>
</div>
@@ -211,6 +212,7 @@ export function renderMainMenu(root, props) {
attachTooltips(root);
const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
+ const cleanupFooter = renderSiteFooter(root, { onShowPrivacy });
// Friends-online lives in the top rail beside the Friends control.
@@ -514,6 +516,7 @@ export function renderMainMenu(root, props) {
return () => {
cleanupTheme();
+ cleanupFooter();
document.removeEventListener('keydown', handleEnterToStart);
outsideClickHandlers.forEach(h => document.removeEventListener('click', h));
};
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
index 59c4b3f..ba051c6 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -63,7 +63,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
<div class="account-panel">
<input class="account-input" type="text" id="mp-name" placeholder="Your name" value="${escapeHtml(getUser()?.username || '')}">
<button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Who you get matched with, and the setting any room you open uses. Joining by code always uses that room's setting.">Match: Everyone</button>
- <button class="menu-button" data-action="quick" data-tooltip="Drops you straight into a race with whoever else is looking. No code to share.">Find a Race</button>
+ <button class="menu-button primary" data-action="quick" data-tooltip="Drops you straight into a race with whoever else is looking. No code to share.">Find a Race</button>
<div class="mp-divider"><span>or race friends</span></div>
@@ -283,6 +283,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
place: done ? done.place : null,
wpm: done ? done.wpm : (progressById[p.id]?.wpm || 0),
accuracy: done ? done.accuracy : null,
+ time: done ? done.time : null,
percent: done ? 100 : (progressById[p.id]?.percent || 0),
};
}).sort((a, b) => {
diff --git a/web/src/screens/placeholderScreen.js b/web/src/screens/placeholderScreen.js
index 4375b8d..e03dabf 100644
--- a/web/src/screens/placeholderScreen.js
+++ b/web/src/screens/placeholderScreen.js
@@ -15,7 +15,7 @@ export function renderPlaceholderScreen(root, { title, description, onBack, onSh
<button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
<div class="logo" data-action="menu">TyperPunk</div>
<h2>${escapeHtml(title)}</h2>
- <div class="stats-placeholder">${escapeHtml(description)}</div>
+ <div class="stats-placeholder prose">${escapeHtml(description)}</div>
</div>
`;
diff --git a/web/src/screens/statsScreen.js b/web/src/screens/statsScreen.js
index a76cbca..9b00181 100644
--- a/web/src/screens/statsScreen.js
+++ b/web/src/screens/statsScreen.js
@@ -1,6 +1,7 @@
import { CLOSE_ICON } from './icons.js';
import { getProfileStats } from '../profileStats.js';
import { getAllPersonalBests } from '../pb.js';
+import { getWeakCharDetails } from '../keyStats.js';
import { escapeHtml } from '../util.js';
import { drawProgressChart } from '../chart.js';
import { renderCornerRail } from '../cornerRail.js';
@@ -24,6 +25,19 @@ function formatDate(ts) {
export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) {
const profile = getProfileStats();
const bests = getAllPersonalBests();
+ const weakKeys = getWeakCharDetails(8);
+
+ // Derived from the stored history rather than kept as separate counters,
+ // so these stay correct for anyone who already has a history recorded.
+ const history = profile.history || [];
+ const recent = history.slice(-10);
+ const recentAvg = recent.length ? Math.round(recent.reduce((a, h) => a + h.wpm, 0) / recent.length) : 0;
+ const bestAcc = history.length ? Math.round(Math.max(...history.map(h => h.accuracy))) : 0;
+ const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
+ const thisWeek = history.filter(h => h.date && new Date(h.date).getTime() >= weekAgo).length;
+ // Recent form against the lifetime average: the single most useful thing
+ // a stats page can tell you is whether you are getting better.
+ const trend = recentAvg && profile.averageWpm ? recentAvg - profile.averageWpm : 0;
root.innerHTML = `
<div class="stats-screen">
@@ -39,6 +53,9 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou
<div class="menu-stat"><div class="stat-label">Avg Acc</div><div class="stat-value">${profile.averageAccuracy}%</div></div>
<div class="menu-stat"><div class="stat-label">Streak</div><div class="stat-value">${profile.streakDays}d</div></div>
<div class="menu-stat"><div class="stat-label">Time Typed</div><div class="stat-value">${Math.round(profile.totalTimeSeconds / 60)}m</div></div>
+ <div class="menu-stat" data-tooltip="Average of your last 10 tests, and how that compares with your lifetime average"><div class="stat-label">Last 10</div><div class="stat-value">${recentAvg}${trend ? `<span class="stat-trend ${trend > 0 ? 'up' : 'down'}">${trend > 0 ? '+' : ''}${trend}</span>` : ''}</div></div>
+ <div class="menu-stat"><div class="stat-label">Best Acc</div><div class="stat-value">${bestAcc}%</div></div>
+ <div class="menu-stat"><div class="stat-label">This Week</div><div class="stat-value">${thisWeek}</div></div>
</div>
` : `<div class="stats-empty">No tests completed yet - finish a test to start tracking stats.</div>`}
@@ -55,6 +72,19 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou
</div>
` : `<div class="stats-empty">No personal bests recorded yet.</div>`}
+ ${weakKeys.length ? `
+ <h3>Weakest keys</h3>
+ <div class="settings-hint">What Practice mode targets. Ranked by how often you mistype a key and how long you pause before it.</div>
+ <div class="weak-key-list">
+ ${weakKeys.map(k => `
+ <div class="weak-key" data-tooltip="${k.errors} wrong out of ${k.attempts} attempts">
+ <span class="weak-key-char">${escapeHtml(k.char)}</span>
+ <span class="weak-key-err">${Math.round(k.errorRate * 100)}%</span>
+ <span class="weak-key-lat">${Math.round(k.avgLatency)}ms</span>
+ </div>
+ `).join('')}
+ </div>` : ''}
+
<h3>Progress</h3>
${profile.history.length > 1
? `<div class="stats-chart"><canvas></canvas></div>`