From 1cebd06bfd54f1ecf811358275aa9062edfe07e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:37:35 +0000 Subject: [PATCH] Keep code and plain-text speeds apart Words and passages type far faster than code, so mixing them inflated the profile best, the division and the Velocity badges. The profile now shows speed stats for Code or Words & passages (a toggle when you have both), the division is always ranked on code, the shared profile card leads with the best code run, Velocity badges (and the Pixel shades they unlock) count code runs only, and the friends board shows best code speed. Personal bests were already kept per language. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018xNjY7412Rp5bFuw5h69yu --- api/_lib/profile-card.ts | 9 ++++-- api/_lib/social.ts | 4 ++- src/components/social/ProfileFollow.tsx | 2 +- src/lib/achievements.test.ts | 12 ++++++- src/lib/achievements.ts | 7 +++-- src/lib/kap-wardrobe.test.ts | 2 +- src/lib/kap-wardrobe.ts | 2 +- src/routes/Profile.tsx | 42 ++++++++++++++++++++----- src/utils/ranking.test.ts | 22 +++++++++++++ src/utils/ranking.ts | 15 +++++++++ 10 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 src/utils/ranking.test.ts diff --git a/api/_lib/profile-card.ts b/api/_lib/profile-card.ts index f160905..2dc74c3 100644 --- a/api/_lib/profile-card.ts +++ b/api/_lib/profile-card.ts @@ -4,6 +4,7 @@ import { languageLabel, metaPageHtml, RUN_ID_PATTERN } from './share-card.js'; import { CARD_VERSION } from '../../src/utils/share-card-version.js'; import { average, languageSummary, streakFromRuns } from '../../src/lib/run-stats.js'; import { calculateCodeIndex, getDivisionInfo } from '../../src/utils/division.js'; +import { bestByKind, runKind } from '../../src/utils/ranking.js'; import { evaluate, TIER_NAMES, type SnapshotRun, type Tier } from '../../src/lib/achievements.js'; /** @@ -95,11 +96,15 @@ export async function loadSharedProfile(userId: string, db: ProfileDb = adminDat verified: doc.verified === true, })) .sort((a, b) => a.timestamp - b.timestamp); - const best = runs.reduce<(typeof runs)[number] | null>((top, run) => (!top || run.wpm > top.wpm ? run : top), null); + // Code and plain text are kept apart: the card leads with coding speed, and + // only falls back to words for someone who has only typed words. + const split = bestByKind(runs); + const best = split.code ?? split.text; const avgAccuracy = average(runs.map((run) => run.accuracy)); + const codeAccuracy = average(runs.filter((run) => runKind(run.language) === 'code').map((run) => run.accuracy)); const username = text(profile.githubUsername, 100) || null; const byLanguage = languageSummary(runs); - const division = best ? getDivisionInfo(calculateCodeIndex(best.wpm, avgAccuracy)) : null; + const division = split.code ? getDivisionInfo(calculateCodeIndex(split.code.wpm, codeAccuracy)) : null; const bestStreak = Math.max(Number(profile.bestStreak) || 0, streakFromRuns(runs).best); const snapshotRuns: SnapshotRun[] = runs.map(({ timestamp, wpm, accuracy, language, duration, charsTyped }) => ({ timestamp, wpm, accuracy, language, duration, charsTyped })); const evaluation = evaluate({ diff --git a/api/_lib/social.ts b/api/_lib/social.ts index 032a154..a542ec5 100644 --- a/api/_lib/social.ts +++ b/api/_lib/social.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import { Query } from 'node-appwrite'; import { APPWRITE } from './appwrite-admin.js'; +import { runKind } from '../../src/utils/ranking.js'; import { DEMOTE, DIVISIONS, GROUP_SIZE, LEAGUE, listGroup, loadPlayer, memberId, MIN_SIZE_TO_DEMOTE, PROMOTE, TOP_DIVISION, weekEndsAt, weekKey, type LeagueDb, type MemberProfile } from './league.js'; /** @@ -149,7 +150,8 @@ export async function friendsBoard(database: SocialDb, userId: string, now = new for (const run of runs) { const id = String(run.userId); const wpm = Number(run.wpm) || 0; - if (wpm > (best.get(id)?.wpm ?? -1)) best.set(id, { wpm, language: text(run.language, 40) }); + // Best coding speed; words and passages are a different race. + if (runKind(text(run.language, 40)) === 'code' && wpm > (best.get(id)?.wpm ?? -1)) best.set(id, { wpm, language: text(run.language, 40) }); } const rows = ids.map((id) => ({ ...profiles.get(id)!, diff --git a/src/components/social/ProfileFollow.tsx b/src/components/social/ProfileFollow.tsx index f3467ff..547ede8 100644 --- a/src/components/social/ProfileFollow.tsx +++ b/src/components/social/ProfileFollow.tsx @@ -19,7 +19,7 @@ export function ProfileFollow({ userId, own }: { userId: string; own: boolean }) return (
- {relation && ( + {typeof relation?.followers === "number" && ( {relation.followers} followers {relation.following} following diff --git a/src/lib/achievements.test.ts b/src/lib/achievements.test.ts index 9929807..bf1e2e1 100644 --- a/src/lib/achievements.test.ts +++ b/src/lib/achievements.test.ts @@ -85,7 +85,7 @@ describe("evaluate", () => { describe("describe", () => { it("names tiers and singles", () => { - expect(describeBadge("speed-3")).toMatchObject({ name: "Velocity III", goal: "Hit 100 WPM in a run", tier: 3 }); + expect(describeBadge("speed-3")).toMatchObject({ name: "Velocity III", goal: "Hit 100 WPM on code", tier: 3 }); expect(describeBadge("night-owl")).toMatchObject({ name: "Night Owl", single: "night-owl" }); expect(describeBadge("speed-9")).toBeNull(); expect(describeBadge("nope-1")).toBeNull(); @@ -152,3 +152,13 @@ describe("account record", async () => { expect(record.partyWins).toEqual(["d2"]); }); }); + +describe("speed badges", () => { + it("count code runs only, not words or passages", () => { + const run = (wpm: number, language: string) => ({ timestamp: 0, wpm, accuracy: 98, language, duration: 30_000, charsTyped: 200 }); + const snapshot = { runs: [run(130, "English"), run(80, "TypeScript")], bestStreak: 0, keystrokes: 0, duelWins: 0, partyWins: 0, dailyCompleted: 0, rankedVerified: 0 }; + const speed = evaluate(snapshot).families.find((family) => family.family.id === "speed"); + expect(speed?.value).toBe(80); + expect(speed?.tier).toBe(2); + }); +}); diff --git a/src/lib/achievements.ts b/src/lib/achievements.ts index f9c1de1..3f649e5 100644 --- a/src/lib/achievements.ts +++ b/src/lib/achievements.ts @@ -1,3 +1,5 @@ +import { isTextLanguage } from "../utils/ranking.js"; + /** * Achievements: tiered families (Bronze → Diamond) plus a few one-off feats. * Everything is derived from data the app already keeps; the only extra @@ -36,7 +38,7 @@ export type SingleId = "flawless" | "party" | "night-owl" | "early-bird"; export interface Family { id: FamilyId; name: string; - /** Sentence for one tier's goal, e.g. "Hit 75 WPM in a run". */ + /** Sentence for one tier's goal, e.g. "Hit 75 WPM on code". */ goal: (target: number) => string; unit: string; tiers: [number, number, number, number]; @@ -54,7 +56,8 @@ const hourOf = (timestamp: number) => new Date(timestamp).getHours(); const compact = (value: number) => (value >= 1_000_000 ? `${value / 1_000_000}M` : value >= 1000 ? `${value / 1000}k` : `${value}`); export const FAMILIES: Family[] = [ - { id: "speed", name: "Velocity", unit: "wpm", tiers: [50, 75, 100, 125], goal: (t) => `Hit ${t} WPM in a run`, metric: (s) => Math.max(0, ...s.runs.map((run) => run.wpm)) }, + // Words and passages are much faster than code, so speed badges count code runs only. + { id: "speed", name: "Velocity", unit: "wpm", tiers: [50, 75, 100, 125], goal: (t) => `Hit ${t} WPM on code`, metric: (s) => Math.max(0, ...s.runs.filter((run) => !isTextLanguage(run.language)).map((run) => run.wpm)) }, { id: "precision", name: "Precision", unit: "runs", tiers: [5, 25, 75, 200], goal: (t) => `Finish ${t} runs at 98% accuracy or better`, metric: (s) => s.runs.filter((run) => run.accuracy >= 98).length }, { id: "streak", name: "On Fire", unit: "days", tiers: [3, 7, 14, 30], goal: (t) => `Practice ${t} days in a row`, metric: (s) => s.bestStreak }, { id: "runs", name: "Dedicated", unit: "runs", tiers: [10, 50, 200, 500], goal: (t) => `Complete ${t} runs`, metric: (s) => s.runs.length }, diff --git a/src/lib/kap-wardrobe.test.ts b/src/lib/kap-wardrobe.test.ts index 3f6c6b9..271f3d4 100644 --- a/src/lib/kap-wardrobe.test.ts +++ b/src/lib/kap-wardrobe.test.ts @@ -12,7 +12,7 @@ describe("kap wardrobe", () => { it("every requirement is a real achievement with a goal", () => { for (const entry of WARDROBE) if (entry.requires) expect(unlockHint(entry)).not.toBe("Keep practicing"); - expect(unlockHint(item("shades"))).toBe("Hit 100 WPM in a run"); + expect(unlockHint(item("shades"))).toBe("Hit 100 WPM on code"); }); it("drops unknown and locked items from a stored look", () => { diff --git a/src/lib/kap-wardrobe.ts b/src/lib/kap-wardrobe.ts index 7341339..dcd7482 100644 --- a/src/lib/kap-wardrobe.ts +++ b/src/lib/kap-wardrobe.ts @@ -10,7 +10,7 @@ export { isUnlocked, sanitizeLook, WARDROBE, type WardrobeItem, type WardrobeSlo export const SLOT_NAMES: Record = { color: "Keycap", head: "Head", eyes: "Eyes", wear: "Wear" }; -/** What it takes, from the badge's own goal text, e.g. "Hit 100 WPM in a run". */ +/** What it takes, from the badge's own goal text, e.g. "Hit 100 WPM on code". */ export function unlockHint(item: WardrobeItem): string { if (!item.requires) return "Free"; return describeBadge(item.requires)?.goal ?? "Keep practicing"; diff --git a/src/routes/Profile.tsx b/src/routes/Profile.tsx index cfa6e9d..d73059e 100644 --- a/src/routes/Profile.tsx +++ b/src/routes/Profile.tsx @@ -38,6 +38,7 @@ import { average, dailyBuckets, formatMinutes, formatRelative, languageSummary, import { drillCharFor, rankKeys } from "@/lib/key-metrics"; import { keyLabel } from "@/lib/keyboard-layout"; import { cn } from "@/lib/utils"; +import { bestByKind, runKind, type RunKind } from "@/utils/ranking"; import { AchievementBadge } from "@/components/achievements/Badge"; import { TIER_NAMES } from "@/lib/achievements"; import { useAchievements } from "@/hooks/useAchievements"; @@ -120,21 +121,31 @@ export default function Profile() { const [avatarFailed, setAvatarFailed] = useState(false); // Oldest first for charts; cloud returns newest first. - const timeline = useMemo(() => runs.map(asRunLike).sort((a, b) => a.timestamp - b.timestamp), [runs]); + const allRuns = useMemo(() => runs.map(asRunLike).sort((a, b) => a.timestamp - b.timestamp), [runs]); + // Code and plain text are different sports: speed stats show one at a time. + const hasText = allRuns.some((run) => runKind(run.language) === "text"); + const hasCode = allRuns.some((run) => runKind(run.language) === "code"); + const [kindChoice, setKind] = useState("code"); + const kind: RunKind = kindChoice === "text" ? (hasText ? "text" : "code") : hasCode || !hasText ? "code" : "text"; + const timeline = useMemo(() => allRuns.filter((run) => runKind(run.language) === kind), [allRuns, kind]); const wpms = timeline.map((run) => run.wpm); const accs = timeline.map((run) => run.accuracy); const best = timeline.reduce<(typeof timeline)[number] | null>((top, run) => (!top || run.wpm > top.wpm ? run : top), null); const avgAccuracy = average(accs); + // Divisions rank coding speed, whichever view is showing. + const codeRuns = useMemo(() => allRuns.filter((run) => runKind(run.language) === "code"), [allRuns]); + const codeBest = bestByKind(codeRuns).code; + const codeAccuracy = average(codeRuns.map((run) => run.accuracy)); const recentAvg = average(wpms.slice(-10)); const deltaSize = Math.min(10, Math.floor(timeline.length / 2)); - const minutes = timeline.reduce((sum, run) => sum + run.duration / 60_000, 0); - const cloudStreak = useMemo(() => streakFromRuns(timeline), [timeline]); + const minutes = allRuns.reduce((sum, run) => sum + run.duration / 60_000, 0); + const cloudStreak = useMemo(() => streakFromRuns(allRuns), [allRuns]); // A stored streak only counts while its last day is today or yesterday. const storedStreak = profile ? streakStatus({ current: profile.currentStreak ?? 0, best: profile.bestStreak ?? 0, lastDate: profile.lastActiveDate ? dateKey(new Date(profile.lastActiveDate)) : "" }, []).current : 0; const currentStreak = Math.max(storedStreak, cloudStreak.current, isOwnProfile ? ownStreak.current : 0); const bestStreak = Math.max(profile?.bestStreak ?? 0, cloudStreak.best, isOwnProfile ? localStreak.best : 0); const byLanguage = useMemo(() => languageSummary(timeline), [timeline]); - const calendar = useMemo(() => dailyBuckets(timeline, 26 * 7), [timeline]); + const calendar = useMemo(() => dailyBuckets(allRuns, 26 * 7), [allRuns]); const trend = timeline.slice(-80).map((run) => ({ t: run.timestamp, value: run.wpm, detail: `${run.language} · ${formatLabel(run.source)}` })); const joined = profile?.$createdAt ? new Date(profile.$createdAt).toLocaleDateString(undefined, { month: "short", year: "numeric" }) : null; @@ -245,7 +256,7 @@ export default function Profile() { )}
- + + + {hasCode && hasText && ( +
+ {([["code", "Code"], ["text", "Words & passages"]] as const).map(([id, label]) => ( + + ))} +
+ )} {dataLoading && runs.length === 0 ? (
@@ -265,7 +293,7 @@ export default function Profile() { ) : ( <>
- } label="Best run" value={best ? best.wpm.toFixed(1) : "–"} unit="wpm" sub={best ? `${best.language} · ${formatRelative(best.timestamp)}` : undefined} /> + } label={kind === "text" ? "Best text run" : "Best code run"} value={best ? best.wpm.toFixed(1) : "–"} unit="wpm" sub={best ? `${best.language} · ${formatRelative(best.timestamp)}` : undefined} /> } label="Recent speed" diff --git a/src/utils/ranking.test.ts b/src/utils/ranking.test.ts new file mode 100644 index 0000000..225a71d --- /dev/null +++ b/src/utils/ranking.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { bestByKind, runKind } from "./ranking"; + +describe("code vs plain text", () => { + it("tells words and passages apart from code", () => { + expect(runKind("English")).toBe("text"); + expect(runKind("Passages")).toBe("text"); + expect(runKind("TypeScript")).toBe("code"); + }); + + it("keeps a separate best for each", () => { + const best = bestByKind([ + { wpm: 130, language: "English" }, + { wpm: 88, language: "Go" }, + { wpm: 95, language: "Rust" }, + { wpm: 110, language: "Indonesian" }, + ]); + expect(best.code?.language).toBe("Rust"); + expect(best.text?.wpm).toBe(130); + expect(bestByKind([{ wpm: 50, language: "English" }]).code).toBeNull(); + }); +}); diff --git a/src/utils/ranking.ts b/src/utils/ranking.ts index 4519dce..3ee5f8a 100644 --- a/src/utils/ranking.ts +++ b/src/utils/ranking.ts @@ -70,3 +70,18 @@ export function describeRankRejection(reason: RankRejection): string { return 'This snippet fell outside the length its category guarantees.'; } } + +export type RunKind = 'code' | 'text'; + +/** Plain-text practice (words, passages) is far faster than code, so bests and divisions keep them apart. */ +export const runKind = (language: string): RunKind => (isTextLanguage(language) ? 'text' : 'code'); + +/** The fastest run of each kind. */ +export function bestByKind(runs: T[]): Record { + const best: Record = { code: null, text: null }; + for (const run of runs) { + const kind = runKind(run.language); + if (!best[kind] || run.wpm > best[kind]!.wpm) best[kind] = run; + } + return best; +}