Rebuild the browser pages around shared wordle.css / wordle.js, still served by the unchanged nginx config (same URLs and ?user= / ?board= parameters, data from /wordle/rawstats and /wordle/rawboard): - home: animated terminal demo, copyable ssh command, countdown to the next UTC day, player search with recent searches, and a leaderboard of followed players (kept in the browser) with today's results - stats: stat cards, guess distribution with losses, today's result and the last 14 days - history: calendar heatmap and a card per game, loading more on demand - board: flip-in tiles, hold (or R) to reveal letters, the answer on a loss, copyable spoiler-free emoji result, previous/next game - dark/light/system themes and a high contrast mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
6.2 KiB
JavaScript
139 lines
6.2 KiB
JavaScript
import {
|
|
h, icon, miniBoard, emptyState, skeleton, toast, copyText, countUp, countdown, mountShell,
|
|
profileHeader, normalizeUser, statsPage, boardPage, getStats, getHistory, today, formatDay,
|
|
relativeDay, resultText, describeBoard,
|
|
} from "./wordle.js?v=2";
|
|
|
|
mountShell();
|
|
const main = document.getElementById("main");
|
|
const user = normalizeUser(new URLSearchParams(location.search).get("user"));
|
|
const FORM_DAYS = 14;
|
|
|
|
if (!user) {
|
|
document.title = "Wordle stats";
|
|
main.append(emptyState("Whose stats?", "No player was given in the link.",
|
|
h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find a player")));
|
|
} else {
|
|
document.title = `${user} · Wordle stats`;
|
|
show(user);
|
|
}
|
|
|
|
async function show(user) {
|
|
const copyLink = h("button", { class: "btn btn-sm", type: "button" }, icon("share"), "Copy link");
|
|
copyLink.addEventListener("click", async () => {
|
|
toast(await copyText(location.origin + statsPage(user)) ? "Link copied" : "Couldn't copy, sorry");
|
|
});
|
|
const header = profileHeader(user, "stats", { meta: "Loading stats…", actions: [copyLink] });
|
|
const body = h("div", { class: "stack", style: { "margin-top": "28px" } },
|
|
h("div", { class: "stat-grid" }, Array.from({ length: 5 }, () => skeleton("104px"))),
|
|
h("div", { class: "grid-2" }, skeleton("320px"), skeleton("320px")));
|
|
main.append(header, body);
|
|
|
|
const day = today();
|
|
let stats, recent;
|
|
try {
|
|
[stats, recent] = await Promise.all([getStats(user), getHistory(user, day, FORM_DAYS)]);
|
|
} catch {
|
|
header.setMeta("");
|
|
body.replaceChildren(emptyState("Couldn't load stats", "Check your connection and try again.",
|
|
h("button", { class: "btn btn-primary", type: "button", onclick: () => location.reload() }, "Retry")));
|
|
return;
|
|
}
|
|
|
|
if (!stats) {
|
|
header.setMeta("");
|
|
body.replaceChildren(emptyState(`No stats for ${user} yet`,
|
|
"Stats appear once a player finishes their first daily game. Check the spelling, or go and play!",
|
|
h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find another player"),
|
|
h("a", { class: "btn", href: "/wordle/#how" }, "How to play")));
|
|
return;
|
|
}
|
|
|
|
header.setMeta(stats.lastWin
|
|
? `${stats.played} games · last win in ${stats.lastWin}/6`
|
|
: `${stats.played} games played`);
|
|
|
|
const todayBoard = recent.find(board => board.day === day);
|
|
body.replaceChildren(
|
|
statCards(stats),
|
|
h("div", { class: "grid-2" },
|
|
distribution(stats),
|
|
h("div", { class: "stack" }, todayCard(user, todayBoard, day), formCard(user, recent, day))));
|
|
}
|
|
|
|
function statCards(stats) {
|
|
const card = (value, label, { decimals = 0, suffix = "", fx = "", hot = false } = {}) => {
|
|
const number = h("span", {}, "0");
|
|
countUp(number, value, decimals);
|
|
return h("div", { class: hot ? "stat hot" : "stat" },
|
|
fx ? h("span", { class: "fx", "aria-hidden": "true" }, fx) : null,
|
|
h("div", { class: "value" }, number, suffix),
|
|
h("div", { class: "label" }, label));
|
|
};
|
|
return h("div", { class: "stat-grid" },
|
|
card(stats.played, "Played"),
|
|
card(stats.winRate, "Win rate", { suffix: "%" }),
|
|
card(stats.streak, "Current streak", { fx: stats.streak >= 3 ? "🔥" : "", hot: stats.streak >= 3 }),
|
|
card(stats.maxStreak, "Best streak", { fx: stats.maxStreak > 0 && stats.maxStreak === stats.streak ? "🏆" : "" }),
|
|
stats.average == null
|
|
? h("div", { class: "stat" }, h("div", { class: "value" }, "-"), h("div", { class: "label" }, "Average guesses"))
|
|
: card(stats.average, "Average guesses", { decimals: 2 }));
|
|
}
|
|
|
|
function distribution(stats) {
|
|
const rows = [...stats.hist.map((count, i) => ({ label: String(i + 1), count, last: stats.lastWin === i + 1 })),
|
|
{ label: "X", count: stats.losses, miss: true }];
|
|
const max = Math.max(1, ...rows.map(r => r.count));
|
|
return h("div", { class: "card" },
|
|
h("div", { class: "card-head" },
|
|
h("h2", {}, "Guess distribution"),
|
|
h("span", { class: "sub" }, stats.lastWin ? "Green is the last win" : "")),
|
|
h("div", { class: "dist" }, rows.map((row, i) =>
|
|
h("div", {
|
|
class: `dist-row${row.last ? " last" : ""}${row.miss ? " miss" : ""}`,
|
|
title: row.miss ? `${row.count} lost` : `${row.count} won in ${row.label}`,
|
|
},
|
|
h("span", { class: "n" }, row.label),
|
|
h("div", { class: "dist-bar", style: { "--w": `${(row.count / max) * 100}%`, "--i": i } }, String(row.count))))));
|
|
}
|
|
|
|
function todayCard(user, board, day) {
|
|
const content = board
|
|
? [
|
|
h("a", { href: boardPage(user, day), title: "Open today's board" }, miniBoard(board, { large: true })),
|
|
h("div", { class: "text" },
|
|
h("div", { class: "big" }, board.won ? `Solved ${resultText(board)}` : "Missed it"),
|
|
h("p", { style: { margin: "6px 0 14px", color: "var(--muted)" } }, describeBoard(board)),
|
|
h("a", { class: "btn btn-sm", href: boardPage(user, day) }, icon("grid"), "View board"))]
|
|
: [
|
|
miniBoard(null, { large: true }),
|
|
h("div", { class: "text" },
|
|
h("div", { class: "big" }, "Not played yet"),
|
|
h("p", { style: { margin: "6px 0 0", color: "var(--muted)" } },
|
|
countdown(h("span", { class: "mono", style: { color: "var(--text)", "font-weight": "700" } })),
|
|
" left to play today"))];
|
|
return h("div", { class: "card" },
|
|
h("div", { class: "card-head" }, h("h2", {}, "Today"), h("span", { class: "sub" }, formatDay(day))),
|
|
h("div", { class: "today-card" }, content));
|
|
}
|
|
|
|
function formCard(user, recent, day) {
|
|
const byDay = new Map(recent.map(board => [board.day, board]));
|
|
const days = Array.from({ length: FORM_DAYS }, (_, i) => day - FORM_DAYS + 1 + i);
|
|
return h("div", { class: "card" },
|
|
h("div", { class: "card-head" },
|
|
h("h2", {}, "Recent form"),
|
|
h("span", { class: "sub" }, `${recent.length} of the last ${FORM_DAYS} days`)),
|
|
h("div", { class: "form-strip" }, days.map(d => {
|
|
const board = byDay.get(d);
|
|
const cls = d === day ? "form-day today" : "form-day";
|
|
if (!board) return h("span", { class: cls, title: `${relativeDay(d)}: not played` }, "·");
|
|
return h("a", {
|
|
class: cls,
|
|
href: boardPage(user, d),
|
|
"data-result": board.won ? "win" : "loss",
|
|
title: `${relativeDay(d)}: ${resultText(board)}`,
|
|
}, board.won ? String(board.guesses) : "✗");
|
|
})));
|
|
}
|