From 78d93a08607a084c38d19ce65fc29c866f0f28e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 01:05:46 +0000 Subject: [PATCH 1/3] Sort game and player name columns with the locale-aware collator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The games table and the other name-bearing tables let TanStack Table pick the sorting function for their string columns. Its built-in `text` and `alphanumeric` comparators compare with `>` / `<`, i.e. by UTF-16 code unit, so the Esperanto hat letters (U+0108 and friends) sorted after `z` instead of after their base letters: the whole Ĉ/Ĝ/Ŝ block landed past Zola at the bottom of the list. `compareStrings` and its `Intl.Collator` were already correct — ICU has a real `eo` tailoring — but nothing on these columns ever reached it. The pre-sort in MetaContainer did use it and was then discarded by the table's own sorted row model. Add `stringColumnSortingFn(locale)` next to `compareStrings` and wire it into every game-name and player-name column, matching the existing inline `sortingFn`s that already call `compareStrings`. Where a column memo did not already depend on `i18n.language`, add it so the comparator does not keep a stale locale after a language switch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015FTzbDm2gyDKiek71vA3wE --- CHANGELOG.md | 1 + src/components/Explore/ExploreView.js | 7 +++++- src/components/Me/ChallengeMeRespond.js | 6 +++++- src/components/Me/ChallengeOpen.js | 12 +++++++++-- src/components/Me/ChallengeTheyRespond.js | 6 +++++- src/components/Me/MyTurnTable.js | 6 +++++- src/components/Me/StandingChallengeTable.js | 6 ++++-- src/components/Me/TheirTurnTable.js | 6 +++++- src/components/Me/WatchedGamesTable.js | 6 +++++- src/components/MetaContainer/Table.js | 3 +++ src/components/Players.js | 5 +++-- src/components/Ratings.js | 5 ++++- src/components/RecentCompletedGames.js | 6 ++++-- src/components/StandingChallenges.js | 14 +++++++++++- src/lib/compareStrings.js | 11 ++++++++++ src/lib/compareStrings.test.js | 24 ++++++++++++++++++++- 16 files changed, 107 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1a7369..4cd42dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* Esperanto sorting: Table columns holding game and player names now sort with the locale-aware collator instead of TanStack Table's built-in comparator, which compared by UTF-16 code unit and so listed the hat letters (Ĉ, Ĝ, Ĥ, Ĵ, Ŝ, Ŭ) after Z rather than after their base letters. * Challenges: Fixed bug where new challenges didn't *really* start with "Random" seating selected. * Homeworlds: Fixed bug that stopped the click handler from working properly. * Pikemen: Fixed bug that caused unnecessary reorientation to throw an error instead of being ignored. diff --git a/src/components/Explore/ExploreView.js b/src/components/Explore/ExploreView.js index 067bffd9..478127f7 100644 --- a/src/components/Explore/ExploreView.js +++ b/src/components/Explore/ExploreView.js @@ -12,7 +12,10 @@ import { listPublicCatalogMetas, getGameDisplayName, } from "../../lib/gameOptions"; -import { compareStrings } from "../../lib/compareStrings"; +import { + compareStrings, + stringColumnSortingFn, +} from "../../lib/compareStrings"; import { gameDescription } from "../../lib/gameDescription"; import { useTranslation } from "react-i18next"; import { getUiLocaleBundleKey } from "../../i18n"; @@ -437,6 +440,7 @@ function ExploreView({ config, viewKey, toggleStar, counts, handleChallenge }) { {props.getValue()} ), filterFn: "includesString", + sortingFn: stringColumnSortingFn(i18n.language), }), columnHelper.accessor( (row) => @@ -539,6 +543,7 @@ function ExploreView({ config, viewKey, toggleStar, counts, handleChallenge }) { addTag, multiTagSelect, t, + i18n.language, ] ); diff --git a/src/components/Me/ChallengeMeRespond.js b/src/components/Me/ChallengeMeRespond.js index b4e61063..d615f85a 100644 --- a/src/components/Me/ChallengeMeRespond.js +++ b/src/components/Me/ChallengeMeRespond.js @@ -14,7 +14,10 @@ import { useStorageState } from "react-use-storage-state"; import Spinner from "../Spinner"; import ChallengeResponseModal from "./ChallengeResponseModal"; import { useTranslation } from "react-i18next"; -import { compareStrings } from "../../lib/compareStrings"; +import { + compareStrings, + stringColumnSortingFn, +} from "../../lib/compareStrings"; import { useStore } from "../../stores"; import BotAwareName from "../Bots/BotAwareName"; @@ -56,6 +59,7 @@ function ChallengeMeRespond({ fetching, handleChallengeResponse }) { () => [ columnHelper.accessor("gameName", { header: t("tables.game"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => { if (props.getValue() === "Unknown") { return <>Unknown; diff --git a/src/components/Me/ChallengeOpen.js b/src/components/Me/ChallengeOpen.js index 40e86270..f483301b 100644 --- a/src/components/Me/ChallengeOpen.js +++ b/src/components/Me/ChallengeOpen.js @@ -1,6 +1,7 @@ import { useEffect, useState, useMemo } from "react"; import { Link } from "react-router-dom"; import { getGameDisplayName } from "../../lib/gameOptions"; +import { stringColumnSortingFn } from "../../lib/compareStrings"; import { getCoreRowModel, useReactTable, @@ -27,7 +28,7 @@ function ChallengeOpen({ fetching, handleChallengeRevoke }) { "dashboard-tables-challenges-show", 10 ); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const data = useMemo( () => @@ -54,6 +55,7 @@ function ChallengeOpen({ fetching, handleChallengeRevoke }) { () => [ columnHelper.accessor("gameName", { header: t("tables.game"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => { if (props.getValue() === "Unknown") { return <>Unknown; @@ -122,7 +124,13 @@ function ChallengeOpen({ fetching, handleChallengeRevoke }) { ), }), ], - [columnHelper, activeChallengeModal, handleChallengeRevoke, t] + [ + columnHelper, + activeChallengeModal, + handleChallengeRevoke, + t, + i18n.language, + ] ); const table = useReactTable({ diff --git a/src/components/Me/ChallengeTheyRespond.js b/src/components/Me/ChallengeTheyRespond.js index f6c54661..449bac5c 100644 --- a/src/components/Me/ChallengeTheyRespond.js +++ b/src/components/Me/ChallengeTheyRespond.js @@ -14,7 +14,10 @@ import { useStorageState } from "react-use-storage-state"; import Spinner from "../Spinner"; import ChallengeViewModal from "./ChallengeViewModal"; import { useTranslation } from "react-i18next"; -import { compareStrings } from "../../lib/compareStrings"; +import { + compareStrings, + stringColumnSortingFn, +} from "../../lib/compareStrings"; import { useStore } from "../../stores"; import BotAwareName from "../Bots/BotAwareName"; import { expandVariants } from "../../lib/expandVariants"; @@ -57,6 +60,7 @@ function ChallengeTheyRespond({ challenges, fetching, handleChallengeRevoke }) { () => [ columnHelper.accessor("gameName", { header: t("tables.game"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => { if (props.getValue() === "Unknown") { return <>Unknown; diff --git a/src/components/Me/MyTurnTable.js b/src/components/Me/MyTurnTable.js index 9e999d0c..bb7f8f73 100644 --- a/src/components/Me/MyTurnTable.js +++ b/src/components/Me/MyTurnTable.js @@ -13,7 +13,10 @@ import LocalizedTimeAgo from "../LocalizedTimeAgo"; import { useStorageState } from "react-use-storage-state"; import Spinner from "../Spinner"; import { useTranslation } from "react-i18next"; -import { compareStrings } from "../../lib/compareStrings"; +import { + compareStrings, + stringColumnSortingFn, +} from "../../lib/compareStrings"; import { useStore } from "../../stores"; import BotAwareName from "../Bots/BotAwareName"; @@ -86,6 +89,7 @@ function MyTurnTable({ games, fetching }) { () => [ columnHelper.accessor("gameName", { header: t("tables.game"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => { if (props.getValue() === "Unknown") { return <>Unknown; diff --git a/src/components/Me/StandingChallengeTable.js b/src/components/Me/StandingChallengeTable.js index eb9fd107..3ecc564b 100644 --- a/src/components/Me/StandingChallengeTable.js +++ b/src/components/Me/StandingChallengeTable.js @@ -1,5 +1,6 @@ import { useEffect, useState, useMemo } from "react"; import { getGameDisplayName } from "../../lib/gameOptions"; +import { stringColumnSortingFn } from "../../lib/compareStrings"; import { getCoreRowModel, useReactTable, @@ -26,7 +27,7 @@ function StandingChallengeTable({ fetching, handleSuspend, handleDelete }) { const [nextDate, nextDateSetter] = useState(new Date()); const [showDeleteModal, showDeleteModalSetter] = useState(false); const [standingEntry, standingEntrySetter] = useState(null); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const resetModal = () => { showDeleteModalSetter(false); @@ -105,6 +106,7 @@ function StandingChallengeTable({ fetching, handleSuspend, handleDelete }) { () => [ columnHelper.accessor("gameName", { header: t("tables.game"), + sortingFn: stringColumnSortingFn(i18n.language), }), columnHelper.accessor("variants", { header: t("tables.variants"), @@ -214,7 +216,7 @@ function StandingChallengeTable({ fetching, handleSuspend, handleDelete }) { ), }), ], - [columnHelper, handleSuspend, t] + [columnHelper, handleSuspend, t, i18n.language] ); const table = useReactTable({ diff --git a/src/components/Me/TheirTurnTable.js b/src/components/Me/TheirTurnTable.js index 98b5e148..327fbd56 100644 --- a/src/components/Me/TheirTurnTable.js +++ b/src/components/Me/TheirTurnTable.js @@ -14,7 +14,10 @@ import { useStorageState } from "react-use-storage-state"; import { useStore } from "../../stores"; import BotAwareName from "../Bots/BotAwareName"; import { useTranslation } from "react-i18next"; -import { compareStrings } from "../../lib/compareStrings"; +import { + compareStrings, + stringColumnSortingFn, +} from "../../lib/compareStrings"; function showMilliseconds(ms) { let positive = true; @@ -97,6 +100,7 @@ function TheirTurnTable(props) { () => [ columnHelper.accessor("gameName", { header: t("tables.game"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => { if (props.getValue() === "Unknown") { return <>Unknown; diff --git a/src/components/Me/WatchedGamesTable.js b/src/components/Me/WatchedGamesTable.js index 73bc8d05..2f3d8e4b 100644 --- a/src/components/Me/WatchedGamesTable.js +++ b/src/components/Me/WatchedGamesTable.js @@ -16,7 +16,10 @@ import { useStorageState } from "react-use-storage-state"; import { useStore } from "../../stores"; import BotAwareName from "../Bots/BotAwareName"; import { useTranslation } from "react-i18next"; -import { compareStrings } from "../../lib/compareStrings"; +import { + compareStrings, + stringColumnSortingFn, +} from "../../lib/compareStrings"; import { toast } from "react-toastify"; const allSize = Number.MAX_SAFE_INTEGER; @@ -102,6 +105,7 @@ function WatchedGamesTable(props) { () => [ columnHelper.accessor("gameName", { header: t("tables.game"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => { const cbit = props.row.original.completed ? 1 : 0; if (props.getValue() === "Unknown") { diff --git a/src/components/MetaContainer/Table.js b/src/components/MetaContainer/Table.js index 09c63931..846d940e 100644 --- a/src/components/MetaContainer/Table.js +++ b/src/components/MetaContainer/Table.js @@ -12,6 +12,7 @@ import { compareCategoryTagEntries, getGameDisplayName, } from "../../lib/gameOptions"; +import { stringColumnSortingFn } from "../../lib/compareStrings"; import { tournamentListPath } from "../../lib/tournamentSections"; import { useTranslation } from "react-i18next"; import { getUiLocaleBundleKey } from "../../i18n"; @@ -287,6 +288,7 @@ function Table({ {props.getValue()} ), filterFn: "includesString", + sortingFn: stringColumnSortingFn(i18n.language), }), columnHelper.accessor( (row) => @@ -475,6 +477,7 @@ function Table({ addTag, closeChallengeModal, t, + i18n.language, ] ); diff --git a/src/components/Players.js b/src/components/Players.js index 59ff4002..296e071d 100644 --- a/src/components/Players.js +++ b/src/components/Players.js @@ -13,7 +13,7 @@ import { import { useStorageState } from "react-use-storage-state"; import PageHelmet from "./PageHelmet"; import { isoToCountryCode } from "../lib/isoToCountryCode"; -import { compareStrings } from "../lib/compareStrings"; +import { compareStrings, stringColumnSortingFn } from "../lib/compareStrings"; import Flag from "./Flag"; import ActivityMarker from "./ActivityMarker"; import { useStore } from "../stores"; @@ -251,6 +251,7 @@ function Players() { {formatUserDisplayName(props.row.original, allUsers)} ), + sortingFn: stringColumnSortingFn(i18n.language), }), columnHelper.accessor("lastSeen", { header: t("tables.activity"), @@ -289,7 +290,7 @@ function Players() { ); } return cols; - }, [allUsers, columnHelper, globalMe, toggleBlock, t]); + }, [allUsers, columnHelper, globalMe, toggleBlock, t, i18n.language]); const table = useReactTable({ data, diff --git a/src/components/Ratings.js b/src/components/Ratings.js index 58c3e1cd..e6fc3611 100644 --- a/src/components/Ratings.js +++ b/src/components/Ratings.js @@ -2,6 +2,7 @@ import React, { useState, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router-dom"; import { getGameDisplayName } from "../lib/gameOptions"; +import { stringColumnSortingFn } from "../lib/compareStrings"; import { callAuthApi } from "../lib/api"; import { maybeTrackRecommendationChallenge } from "../lib/recommendationAttribution"; import { @@ -38,7 +39,7 @@ function matchesMetaGame(rec, metaUid) { } function RatingsTable({ metaGame, metaGameName, globalMe, allUsers, summary }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [activeChallengeModal, activeChallengeModalSetter] = useState(""); const [showState, showStateSetter] = useStorageState("ratings-show", 20); const [sorting, setSorting] = useState([{ id: "rank", desc: false }]); @@ -108,6 +109,7 @@ function RatingsTable({ metaGame, metaGameName, globalMe, allUsers, summary }) { }), columnHelper.accessor("player", { header: t("tables.player"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => ( <> @@ -183,6 +185,7 @@ function RatingsTable({ metaGame, metaGameName, globalMe, allUsers, summary }) { metaGame, closeChallengeModal, t, + i18n.language, ] ); diff --git a/src/components/RecentCompletedGames.js b/src/components/RecentCompletedGames.js index daf9137c..88aaa398 100644 --- a/src/components/RecentCompletedGames.js +++ b/src/components/RecentCompletedGames.js @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Link, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { getGameDisplayName } from "../lib/gameOptions"; +import { stringColumnSortingFn } from "../lib/compareStrings"; import { expandVariants as expandVariantsForGame } from "../lib/expandVariants"; import { API_ENDPOINT_OPEN } from "../config"; import { @@ -24,7 +25,7 @@ import { } from "../lib/recentGamesSections"; function RecentCompletedGames() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { metaGame: metaGameParam } = useParams(); const metaGame = isValidRecentGamesMetaGame(metaGameParam) ? metaGameParam @@ -135,6 +136,7 @@ function RecentCompletedGames() { {props.getValue()} ), + sortingFn: stringColumnSortingFn(i18n.language), }) ); } @@ -269,7 +271,7 @@ function RecentCompletedGames() { }) ); return cols; - }, [columnHelper, metaGame, t, allUsers]); + }, [columnHelper, metaGame, t, allUsers, i18n.language]); const table = useReactTable({ data, diff --git a/src/components/StandingChallenges.js b/src/components/StandingChallenges.js index fdd55433..c60f7641 100644 --- a/src/components/StandingChallenges.js +++ b/src/components/StandingChallenges.js @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router-dom"; import { getGameDisplayName } from "../lib/gameOptions"; +import { compareStrings, stringColumnSortingFn } from "../lib/compareStrings"; import { expandVariants as expandVariantsForGame } from "../lib/expandVariants"; import { API_ENDPOINT_OPEN } from "../config"; import { @@ -54,7 +55,7 @@ function partitionStandingChallenges(raw) { } function StandingChallenges(props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { status: authStatus } = useAuthSession(); const [challenges, challengesSetter] = useState(null); const [accepted, acceptedSetter] = useState(null); @@ -374,6 +375,7 @@ function StandingChallenges(props) { cols.push( columnHelper.accessor("metaGameName", { header: t("tables.game"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => ( {props.getValue()} @@ -385,6 +387,7 @@ function StandingChallenges(props) { cols.push( columnHelper.accessor("challenger", { header: t("tables.challenger"), + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => ( <> formatPlayerDisplayName(p, allUsers)) .join(","), + sortingFn: (rowA, rowB, columnID) => { + const names = (row) => + row + .getValue(columnID) + .map((p) => formatPlayerDisplayName(p, allUsers)) + .join(","); + return compareStrings(names(rowA), names(rowB), i18n.language); + }, }), columnHelper.accessor("seating", { header: t("tables.seating"), @@ -509,6 +520,7 @@ function StandingChallenges(props) { showRespond, allUsers, siteWide, + i18n.language, ]); const table = useReactTable({ diff --git a/src/lib/compareStrings.js b/src/lib/compareStrings.js index c43c89c9..579df929 100644 --- a/src/lib/compareStrings.js +++ b/src/lib/compareStrings.js @@ -20,3 +20,14 @@ export function getStringCollator(locale = "en") { export function compareStrings(a, b, locale = "en") { return getStringCollator(locale).compare(String(a ?? ""), String(b ?? "")); } + +/** + * TanStack Table sortingFn for a plain string column. + * The built-in `text` / `alphanumeric` comparators compare with `>` / `<`, i.e. by + * UTF-16 code unit, which strands Esperanto hat letters (ĉ, ĝ, ŝ …) after z. + * @param {string} locale + */ +export function stringColumnSortingFn(locale) { + return (rowA, rowB, columnID) => + compareStrings(rowA.getValue(columnID), rowB.getValue(columnID), locale); +} diff --git a/src/lib/compareStrings.test.js b/src/lib/compareStrings.test.js index 823fa111..3b5a772a 100644 --- a/src/lib/compareStrings.test.js +++ b/src/lib/compareStrings.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { compareStrings } from "./compareStrings"; +import { compareStrings, stringColumnSortingFn } from "./compareStrings"; describe("compareStrings", () => { it("sorts Esperanto words with eo collation rules", () => { @@ -28,3 +28,25 @@ describe("compareStrings", () => { expect(() => compareStrings("a", "b", /** @type {*} */ ({}))).not.toThrow(); }); }); + +describe("stringColumnSortingFn", () => { + /** Minimal stand-in for a TanStack Table row. */ + const row = (value) => ({ getValue: () => value }); + + it("keeps eo hat letters after their base letter, not after z", () => { + const names = ["Ŝiparoj", "Cubeo", "Zola", "Ĉaso", "Symple"]; + const sortingFn = stringColumnSortingFn("eo"); + const sorted = names + .map(row) + .sort((a, b) => sortingFn(a, b, "gameName")) + .map((r) => r.getValue()); + // Ĉ follows C, Ŝ follows S — and Zola stays last. + expect(sorted).toEqual(["Cubeo", "Ĉaso", "Symple", "Ŝiparoj", "Zola"]); + }); + + it("tolerates nullish cell values", () => { + const sortingFn = stringColumnSortingFn("eo"); + expect(sortingFn(row(null), row("a"), "c")).toBeLessThan(0); + expect(sortingFn(row("a"), row(undefined), "c")).toBeGreaterThan(0); + }); +}); From e10fd4b0dc66904897ba5c21666db3b844d67d04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 10:05:26 +0000 Subject: [PATCH 2/3] Sort designer columns by collator; rebuild game list when bundles load Two loose ends from the previous commit. The designers columns in the meta-game table and the explore view hold people's names and had the same code-unit sort as the game-name columns, so a designer whose name carries a hat letter or any other non-ASCII character sorted past Z. Their accessors already return a joined string, so they take the same `stringColumnSortingFn`. MetaContainer's alphabetized meta-game list memoized on `i18n.language` alone. The Esperanto bundles arrive over HTTP after the language tag is already set, so the list was sorted while `getGameDisplayName` still returned English names and never re-sorted once the real names landed. Depend on `getUiLocaleBundleKey` as well, matching how the other translated-label memos in the codebase are keyed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015FTzbDm2gyDKiek71vA3wE --- CHANGELOG.md | 3 ++- src/components/Explore/ExploreView.js | 1 + src/components/MetaContainer.js | 4 +++- src/components/MetaContainer/Table.js | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd42dc3..657d52a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -* Esperanto sorting: Table columns holding game and player names now sort with the locale-aware collator instead of TanStack Table's built-in comparator, which compared by UTF-16 code unit and so listed the hat letters (Ĉ, Ĝ, Ĥ, Ĵ, Ŝ, Ŭ) after Z rather than after their base letters. +* Esperanto sorting: Table columns holding game, player, and designer names now sort with the locale-aware collator instead of TanStack Table's built-in comparator, which compared by UTF-16 code unit and so listed the hat letters (Ĉ, Ĝ, Ĥ, Ĵ, Ŝ, Ŭ) after Z rather than after their base letters. +* Game list: The alphabetized meta-game list is now rebuilt when the translation bundles finish loading, not only when the language tag changes, so it no longer stays in English name order after a locale loads over HTTP. * Challenges: Fixed bug where new challenges didn't *really* start with "Random" seating selected. * Homeworlds: Fixed bug that stopped the click handler from working properly. * Pikemen: Fixed bug that caused unnecessary reorientation to throw an error instead of being ignored. diff --git a/src/components/Explore/ExploreView.js b/src/components/Explore/ExploreView.js index 478127f7..22c194e8 100644 --- a/src/components/Explore/ExploreView.js +++ b/src/components/Explore/ExploreView.js @@ -450,6 +450,7 @@ function ExploreView({ config, viewKey, toggleStar, counts, handleChallenge }) { { header: t("tables.designers"), id: "designers", + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => props.row.original.designers.length === 0 ? "" diff --git a/src/components/MetaContainer.js b/src/components/MetaContainer.js index ac4c106f..277c4f7d 100644 --- a/src/components/MetaContainer.js +++ b/src/components/MetaContainer.js @@ -11,10 +11,12 @@ import Table from "./MetaContainer/Table"; import MetaItem from "./MetaContainer/MetaItem"; import { listPublicCatalogMetas, getGameDisplayName } from "../lib/gameOptions"; import { compareStrings } from "../lib/compareStrings"; +import { getUiLocaleBundleKey } from "../i18n"; import { useStore } from "../stores"; function MetaContainer(props) { const { i18n } = useTranslation(); + const localeBundleKey = getUiLocaleBundleKey(i18n); const globalMe = useStore((state) => state.globalMe); const [counts, countsSetter] = useState(null); const [updateCounter, updateCounterSetter] = useState(0); @@ -40,7 +42,7 @@ function MetaContainer(props) { listPublicCatalogMetas().sort((a, b) => compareStrings(getGameDisplayName(a), getGameDisplayName(b), i18n.language) ), - [i18n.language] + [localeBundleKey, i18n.language] ); const toggleStar = async (game) => { try { diff --git a/src/components/MetaContainer/Table.js b/src/components/MetaContainer/Table.js index 846d940e..526c4052 100644 --- a/src/components/MetaContainer/Table.js +++ b/src/components/MetaContainer/Table.js @@ -298,6 +298,7 @@ function Table({ { header: t("tables.designers"), id: "designers", + sortingFn: stringColumnSortingFn(i18n.language), cell: (props) => props.row.original.designers.length === 0 ? "" From 47cd8fadbdb7670ca14827a154014c1498ca35a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 02:14:38 +0000 Subject: [PATCH 3/3] Compare digit runs by value in the shared collator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `sortingFn` to the name columns moved them off TanStack's built-in `alphanumeric` comparator, which does natural number sorting, onto an `Intl.Collator` constructed with the default `numeric: false`. Nothing regressed in the current data — the only digit-bearing game names are "C1" and "Kunligu 6" — but "Connect 10" would have sorted before "Connect 2" the moment such a name was added. Set `numeric: true` so digit runs compare by value. Verified against the real corpora in all six UI locales: game names, category tag and full labels, and display names are byte-identical before and after. Variant labels and variant groups do change, and change for the better — board sizes now read 8×8, 9×9, 13×13 instead of 13×13, 8×8, 9×9, and piece counts 2, 4, 6, 12 instead of 12, 2, 4, 6. This collator backs every `compareStrings` caller, so the change reaches the variant and challenge pickers too. Every call site is a display sort or a table `sortingFn`; the four places that build an identity key from sorted variants use a bare `.sort()` and are unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015FTzbDm2gyDKiek71vA3wE --- CHANGELOG.md | 1 + src/lib/compareStrings.js | 8 +++++++- src/lib/compareStrings.test.js | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 657d52a3..2d3f9dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Esperanto sorting: Table columns holding game, player, and designer names now sort with the locale-aware collator instead of TanStack Table's built-in comparator, which compared by UTF-16 code unit and so listed the hat letters (Ĉ, Ĝ, Ĥ, Ĵ, Ŝ, Ŭ) after Z rather than after their base letters. * Game list: The alphabetized meta-game list is now rebuilt when the translation bundles finish loading, not only when the language tag changes, so it no longer stays in English name order after a locale loads over HTTP. +* Natural number sorting: Sorted lists containing numbers now order digit runs by value, so board-size and piece-count labels read 8×8, 9×9, 13×13 instead of 13×13, 8×8, 9×9. This restores the natural sort the game tables had before the collator change and extends it to the variant and challenge pickers. * Challenges: Fixed bug where new challenges didn't *really* start with "Random" seating selected. * Homeworlds: Fixed bug that stopped the click handler from working properly. * Pikemen: Fixed bug that caused unnecessary reorientation to throw an error instead of being ignored. diff --git a/src/lib/compareStrings.js b/src/lib/compareStrings.js index 579df929..903ac808 100644 --- a/src/lib/compareStrings.js +++ b/src/lib/compareStrings.js @@ -10,7 +10,13 @@ export function getStringCollator(locale = "en") { const key = resolveSortLocale(locale); let collator = collatorCache.get(key); if (!collator) { - collator = new Intl.Collator(key, { sensitivity: "accent" }); + // numeric: digit runs compare by value, so "Connect 10" follows "Connect 6" + // rather than "Connect 1". Matches the natural sort TanStack's built-in + // `alphanumeric` comparator gave these columns before. + collator = new Intl.Collator(key, { + sensitivity: "accent", + numeric: true, + }); collatorCache.set(key, collator); } return collator; diff --git a/src/lib/compareStrings.test.js b/src/lib/compareStrings.test.js index 3b5a772a..063ebf31 100644 --- a/src/lib/compareStrings.test.js +++ b/src/lib/compareStrings.test.js @@ -27,6 +27,22 @@ describe("compareStrings", () => { it("falls back to en for non-string locale tags", () => { expect(() => compareStrings("a", "b", /** @type {*} */ ({}))).not.toThrow(); }); + + it("compares digit runs by value, not character by character", () => { + const names = ["Connect 10", "Connect 2", "Connect 6"]; + expect([...names].sort((a, b) => compareStrings(a, b, "en"))).toEqual([ + "Connect 2", + "Connect 6", + "Connect 10", + ]); + // Board-size variant labels are the common case in this app. + const boards = ["tabulo (13×13)", "tabulo (8×8)", "tabulo (9×9)"]; + expect([...boards].sort((a, b) => compareStrings(a, b, "eo"))).toEqual([ + "tabulo (8×8)", + "tabulo (9×9)", + "tabulo (13×13)", + ]); + }); }); describe("stringColumnSortingFn", () => {