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