Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions api/_lib/profile-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 3 additions & 1 deletion api/_lib/social.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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)!,
Expand Down
2 changes: 1 addition & 1 deletion src/components/social/ProfileFollow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function ProfileFollow({ userId, own }: { userId: string; own: boolean })

return (
<div className="flex items-center gap-2">
{relation && (
{typeof relation?.followers === "number" && (
<Link to="/league#friends" className="flex h-8 items-center gap-2 rounded-lg border px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<span><b className="font-mono text-foreground">{relation.followers}</b> followers</span>
<span><b className="font-mono text-foreground">{relation.following}</b> following</span>
Expand Down
12 changes: 11 additions & 1 deletion src/lib/achievements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
7 changes: 5 additions & 2 deletions src/lib/achievements.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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];
Expand All @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion src/lib/kap-wardrobe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/kap-wardrobe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export { isUnlocked, sanitizeLook, WARDROBE, type WardrobeItem, type WardrobeSlo

export const SLOT_NAMES: Record<WardrobeSlot, string> = { 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";
Expand Down
42 changes: 35 additions & 7 deletions src/routes/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<RunKind>("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;

Expand Down Expand Up @@ -245,7 +256,7 @@ export default function Profile() {
)}
<button
type="button"
onClick={() => setProfileShare({ userId: viewedUserId, name: displayName, username: githubUsername, bestWpm: best?.wpm ?? 0, runs: timeline.length, own: isOwnProfile })}
onClick={() => setProfileShare({ userId: viewedUserId, name: displayName, username: githubUsername, bestWpm: (codeBest ?? best)?.wpm ?? 0, runs: allRuns.length, own: isOwnProfile })}
className="flex h-8 items-center gap-1.5 rounded-lg bg-foreground px-3 text-xs font-semibold text-background transition-opacity hover:opacity-90 cursor-pointer"
>
<Share2 className="size-3.5" /> Share profile
Expand All @@ -254,7 +265,24 @@ export default function Profile() {
</div>
</section>

<DivisionBadge bestWpm={best?.wpm ?? 0} avgAccuracy={avgAccuracy} size="lg" showProgress />
<DivisionBadge bestWpm={codeBest?.wpm ?? 0} avgAccuracy={codeAccuracy} size="lg" showProgress />

{hasCode && hasText && (
<div className="inline-flex self-start rounded-xl border bg-card/80 p-1" role="tablist" aria-label="Speed stats for">
{([["code", "Code"], ["text", "Words & passages"]] as const).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={kind === id}
onClick={() => setKind(id)}
className={cn("rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors cursor-pointer", kind === id ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground")}
>
{label}
</button>
))}
</div>
)}

{dataLoading && runs.length === 0 ? (
<div className="grid h-48 place-items-center rounded-2xl border bg-card/60"><LoaderCircle className="size-5 animate-spin text-muted-foreground" /></div>
Expand All @@ -265,7 +293,7 @@ export default function Profile() {
) : (
<>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={<Trophy className="size-3.5" />} label="Best run" value={best ? best.wpm.toFixed(1) : "–"} unit="wpm" sub={best ? `${best.language} · ${formatRelative(best.timestamp)}` : undefined} />
<StatTile icon={<Trophy className="size-3.5" />} 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} />
<StatTile
icon={<Gauge className="size-3.5" />}
label="Recent speed"
Expand Down
22 changes: 22 additions & 0 deletions src/utils/ranking.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 15 additions & 0 deletions src/utils/ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { wpm: number; language: string }>(runs: T[]): Record<RunKind, T | null> {
const best: Record<RunKind, T | null> = { 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;
}
Loading