Skip to content

fix: normalize all-caps athlete names at render time#233

Merged
rootulp merged 1 commit into
mainfrom
fix/224-normalize-name-casing
Jun 15, 2026
Merged

fix: normalize all-caps athlete names at render time#233
rootulp merged 1 commit into
mainfrom
fix/224-normalize-name-casing

Conversation

@rootulp

@rootulp rootulp commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Closes #224

Summary

Athlete names scraped from ironman.com have inconsistent casing, so search results showed "MULLER Nicolas" next to "Muller Alain", and the all-caps entries carried through to profile, result, and race pages.

This is a purely render-time fix (no data files, index scripts, or search/matching logic touched, to avoid conflicts with #218):

  • New pure helper formatAthleteName in app/src/lib/format.ts:
    • Only transforms tokens that are fully uppercase with 2+ letters ("MULLER" → "Muller"); mixed-case tokens like "McDonald" and "van der Berg" are left untouched
    • Handles hyphenated parts ("SMITH-JONES" → "Smith-Jones"), apostrophes ("O'BRIEN" → "O'Brien"), "Mc" prefixes ("MCDONALD" → "McDonald"; "Mac" intentionally not special-cased as it's ambiguous), and accented uppercase ("MÜLLER" → "Müller")
    • Single-letter initials are preserved
  • Applied at every athlete-name render site:
    • GlobalSearchBar search results (display only)
    • CommandPalette search results
    • Athlete profile page heading (/athlete/[slug])
    • Result page heading (/race/[slug]/result/[id]) and its OG share image
    • Top-finisher tables on the race page (/race/[slug])
    • Stats page ("Athlete with Most Races")

Test notes

  • Red/green TDD: added app/src/lib/__tests__/format.test.ts (13 cases: all-caps surname, already-correct names unchanged, hyphen/apostrophe/Mc/Mac, accented caps, initials, whitespace preservation) — written first and confirmed failing, then implemented
  • npx vitest run: 10 files, 98 tests, all passing
  • npm run lint: clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements
    • Athlete names are now displayed with consistent, readable title casing across athlete profiles, race pages (top finishers), race result pages, OpenGraph previews, statistics, and search (including the command palette and global search).
    • Proper handling of complex name formats (e.g., hyphens, apostrophes, “Mc” capitalization, accented characters, and whitespace) ensures more accurate display.
  • Tests
    • Added automated coverage for the name-formatting behavior across common and edge-case inputs.

@rootulp rootulp self-assigned this Jun 12, 2026
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tritimes Ready Ready Preview, Comment Jun 15, 2026 10:14pm

@rootulp rootulp enabled auto-merge (squash) June 12, 2026 02:55
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ed388695-a29f-4a28-9e79-0dc6b28adc42

📥 Commits

Reviewing files that changed from the base of the PR and between e39e76d and 1145ceb.

📒 Files selected for processing (9)
  • app/src/app/athlete/[slug]/page.tsx
  • app/src/app/race/[slug]/page.tsx
  • app/src/app/race/[slug]/result/[id]/opengraph-image.tsx
  • app/src/app/race/[slug]/result/[id]/page.tsx
  • app/src/app/stats/page.tsx
  • app/src/components/CommandPalette.tsx
  • app/src/components/GlobalSearchBar.tsx
  • app/src/lib/__tests__/format.test.ts
  • app/src/lib/format.ts
✅ Files skipped from review due to trivial changes (2)
  • app/src/app/race/[slug]/result/[id]/opengraph-image.tsx
  • app/src/app/race/[slug]/page.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • app/src/components/CommandPalette.tsx
  • app/src/lib/tests/format.test.ts
  • app/src/app/stats/page.tsx
  • app/src/app/race/[slug]/result/[id]/page.tsx
  • app/src/lib/format.ts

📝 Walkthrough

Walkthrough

The PR introduces a new formatAthleteName utility function that normalizes athlete name display casing by title-casing only fully-uppercase tokens, with special handling for Mc prefixes and accented characters. The function is then applied consistently across seven UI surfaces where athlete names are rendered.

Changes

Athlete Name Formatting Utility

Layer / File(s) Summary
formatAthleteName implementation and tests
app/src/lib/format.ts, app/src/lib/__tests__/format.test.ts
New formatAthleteName(name: string) export detects all-caps tokens using Unicode property matching and applies title-casing selectively. Internal helpers isAllCaps, titleCaseToken, and titleCasePart handle hyphen/apostrophe splitting and Mc prefix restoration. Comprehensive test suite validates casing normalization, accent support, initial preservation, empty strings, and whitespace exactness.
Integration across UI surfaces
app/src/app/athlete/[slug]/page.tsx, app/src/app/race/[slug]/page.tsx, app/src/app/race/[slug]/result/[id]/opengraph-image.tsx, app/src/app/race/[slug]/result/[id]/page.tsx, app/src/app/stats/page.tsx, app/src/components/CommandPalette.tsx, app/src/components/GlobalSearchBar.tsx
formatAthleteName is imported and applied to athlete name display across athlete profile headings, race leaderboard links, result OpenGraph image headers, result page headings, stats page links, and search result text in CommandPalette and GlobalSearchBar.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • rootulp/tritimes#230: Both PRs modify athlete name rendering in race result pages with different approaches to display formatting.

Poem

🐰 A name's just a string, sometimes ALL IN CAPS,
But now they'll display with the right overlap!
McDonald and O'Reilly shine bright and true,
Across every page, the formatting's new. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately describes the main change: adding a helper function to normalize athlete name casing at render time rather than at the data source.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/224-normalize-name-casing

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/app/stats/page.tsx (1)

1-18: ⚡ Quick win

Use the shared time formatter here instead of the local copy.

app/src/lib/format.ts already exports formatTime, and this local formatSeconds has already drifted (seconds % 60 here vs Math.round(seconds % 60) there). Reusing the shared helper keeps time formatting consistent across pages and avoids another copy to maintain.

♻️ Proposed cleanup
 import Link from "next/link";
 import { getStatsPageData } from "`@/lib/data`";
 import { getCountryFlagISO } from "`@/lib/flags`";
-import { formatAthleteName } from "`@/lib/format`";
+import { formatAthleteName, formatTime } from "`@/lib/format`";
@@
-function formatSeconds(seconds: number): string {
-  const h = Math.floor(seconds / 3600);
-  const m = Math.floor((seconds % 3600) / 60);
-  const s = seconds % 60;
-  if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
-  return `${m}:${String(s).padStart(2, "0")}`;
-}
-
@@
-          <BigNumber value={formatSeconds(agg.averageHalfFinishSeconds)} />
+          <BigNumber value={formatTime(agg.averageHalfFinishSeconds)} />
@@
-          <BigNumber value={formatSeconds(agg.averageFullFinishSeconds)} />
+          <BigNumber value={formatTime(agg.averageFullFinishSeconds)} />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/app/stats/page.tsx` around lines 1 - 18, The local formatSeconds
function in page.tsx should be removed and the shared formatter imported from
app/src/lib/format.ts; replace all uses of formatSeconds with formatTime and add
an import for formatTime from "`@/lib/format`", relying on formatTime's Math.round
behavior to keep formatting consistent (update any references to function name
formatSeconds to formatTime and delete the local formatSeconds implementation).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/src/app/stats/page.tsx`:
- Around line 1-18: The local formatSeconds function in page.tsx should be
removed and the shared formatter imported from app/src/lib/format.ts; replace
all uses of formatSeconds with formatTime and add an import for formatTime from
"`@/lib/format`", relying on formatTime's Math.round behavior to keep formatting
consistent (update any references to function name formatSeconds to formatTime
and delete the local formatSeconds implementation).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: be87f6d8-923b-44ce-b841-d80f9015c485

📥 Commits

Reviewing files that changed from the base of the PR and between eca33ed and e39e76d.

📒 Files selected for processing (9)
  • app/src/app/athlete/[slug]/page.tsx
  • app/src/app/race/[slug]/page.tsx
  • app/src/app/race/[slug]/result/[id]/opengraph-image.tsx
  • app/src/app/race/[slug]/result/[id]/page.tsx
  • app/src/app/stats/page.tsx
  • app/src/components/CommandPalette.tsx
  • app/src/components/GlobalSearchBar.tsx
  • app/src/lib/__tests__/format.test.ts
  • app/src/lib/format.ts

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a formatAthleteName helper that render-time normalises all-caps athlete names scraped from ironman.com, and applies it at every display site (search bars, profile page, race/result pages, OG image, and stats page) without touching data files, indexing scripts, or search/matching logic.

  • New formatAthleteName in app/src/lib/format.ts: only transforms tokens where every letter is uppercase (>= 2 letters); mixed-case tokens, whitespace, and single-letter initials pass through unchanged; hyphen/apostrophe splits, Mc-prefix restoration, and accented-uppercase letters are handled correctly.
  • Applied at 7 render sites (GlobalSearchBar, CommandPalette, athlete profile page, result page + OG image, race top-finisher table, stats page) with 13 vitest cases (all passing).

Confidence Score: 5/5

Pure render-time change with no data mutations; safe to merge.

All changes are isolated to display formatting: a single pure helper function applied at render sites, no data files or indexing touched, and 13 passing unit tests covering the documented edge cases. The known limitation around multi-letter period-separated initials was already surfaced in a prior review thread.

No files require special attention.

Important Files Changed

Filename Overview
app/src/lib/format.ts New formatAthleteName implementation; logic is correct for all documented cases, with the pre-existing limitation of multi-letter period-separated initials (e.g. "J.R.") already flagged in review threads.
app/src/lib/tests/format.test.ts 13 test cases covering the main scenarios (all-caps, mixed-case passthrough, hyphen, apostrophe, Mc, Mac, accented, initials, empty string, whitespace); consistent with implementation behaviour.
app/src/app/athlete/[slug]/page.tsx formatAthleteName applied to heading; raw fullName correctly passed to AthleteRaceList for CSV filename generation.
app/src/app/race/[slug]/result/[id]/opengraph-image.tsx OG social-share image now uses formatAthleteName for the athlete name display; straightforward one-line change.
app/src/app/race/[slug]/result/[id]/page.tsx Both the linked and fallback athlete name heading paths updated; downloadFilename intentionally retains raw fullName for file system use.
app/src/app/race/[slug]/page.tsx Top-finisher table entries now use formatAthleteName; one-line change, correct placement.
app/src/app/stats/page.tsx Athlete-with-most-races display name normalised; only athlete name render site in this file.
app/src/components/CommandPalette.tsx Search result display name updated; formatAthleteName applied only to the visible label, not the underlying search data.
app/src/components/GlobalSearchBar.tsx GlobalSearchBar result display name updated consistently with CommandPalette; correct render-time-only application.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["formatAthleteName(name)"] --> B["Split by /(\s+)/ capturing whitespace"]
    B --> C{For each token}
    C --> D["isAllCaps(token)?"]
    D -->|No| E["Leave token unchanged"]
    D -->|Yes| F["titleCaseToken(token)"]
    F --> G["Split by /([-''])/ capturing separators"]
    G --> H["titleCasePart(part) for each part"]
    H --> I{Starts with Mc + lowercase?}
    I -->|Yes| J["Restore: Mc + uppercase 3rd char + rest"]
    I -->|No| K["lowercase all, uppercase first char"]
    J --> L["Join parts back"]
    K --> L
    L --> M["Join tokens back"]
    E --> M
    M --> N["Return formatted name"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["formatAthleteName(name)"] --> B["Split by /(\s+)/ capturing whitespace"]
    B --> C{For each token}
    C --> D["isAllCaps(token)?"]
    D -->|No| E["Leave token unchanged"]
    D -->|Yes| F["titleCaseToken(token)"]
    F --> G["Split by /([-''])/ capturing separators"]
    G --> H["titleCasePart(part) for each part"]
    H --> I{Starts with Mc + lowercase?}
    I -->|Yes| J["Restore: Mc + uppercase 3rd char + rest"]
    I -->|No| K["lowercase all, uppercase first char"]
    J --> L["Join parts back"]
    K --> L
    L --> M["Join tokens back"]
    E --> M
    M --> N["Return formatted name"]
Loading

Reviews (2): Last reviewed commit: "fix: normalize all-caps athlete names at..." | Re-trigger Greptile

Comment thread app/src/lib/format.ts
Comment on lines +21 to +25
function isAllCaps(token: string): boolean {
const letters = token.match(/\p{L}/gu);
if (!letters || letters.length < 2) return false;
return letters.every((c) => c !== c.toLowerCase() && c === c.toUpperCase());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Multi-letter period-separated initials are mangled

isAllCaps counts only Unicode letter code points, so a token like "J.R." has two uppercase letters and passes the >= 2 guard — titleCasePart then lowercases everything after the first character, turning it into "J.r.". The single-initial test works because "J." has only one letter (below the threshold). Any athlete stored as e.g. "SMITH J.R." would display as "Smith J.r.". A guard that treats tokens containing non-letter non-separator characters as non-transformable would cover this.

Athlete names scraped from ironman.com arrive with inconsistent casing,
so search results show "MULLER Nicolas" next to "Muller Alain". Add a
pure formatAthleteName helper that title-cases only fully-uppercase
tokens (handling hyphens, apostrophes, Mc prefixes, and accented
letters) while leaving correctly mixed-case names like "McDonald" and
"van der Berg" untouched, and apply it at every render site: global
search results, command palette, athlete profile heading, result page
heading, race page top-finisher tables, stats page, and the OG share
image. No data files or index-build scripts are modified.

Closes #224

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rootulp rootulp force-pushed the fix/224-normalize-name-casing branch from e39e76d to 1145ceb Compare June 15, 2026 22:07
@rootulp rootulp merged commit 6c384d0 into main Jun 15, 2026
7 checks passed
@rootulp rootulp deleted the fix/224-normalize-name-casing branch June 15, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

data: athlete name casing is inconsistent in search results ("MULLER Nicolas" vs "Muller Alain")

1 participant