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; +}