diff options
Diffstat (limited to 'web/src/screens')
| -rw-r--r-- | web/src/screens/endScreen.js | 20 | ||||
| -rw-r--r-- | web/src/screens/leaderboardScreen.js | 9 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 25 | ||||
| -rw-r--r-- | web/src/screens/multiplayerScreen.js | 3 | ||||
| -rw-r--r-- | web/src/screens/placeholderScreen.js | 2 | ||||
| -rw-r--r-- | web/src/screens/statsScreen.js | 30 |
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 : '·'}</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>` |