diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c1a7369..2d3f9dfd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+* 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/components/Explore/ExploreView.js b/src/components/Explore/ExploreView.js
index cce95df7..0c93df27 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";
@@ -478,6 +481,7 @@ function ExploreView({ config, viewKey, toggleStar, counts, handleChallenge }) {
{props.getValue()}
),
filterFn: "includesString",
+ sortingFn: stringColumnSortingFn(i18n.language),
}),
columnHelper.accessor(
(row) =>
@@ -487,6 +491,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
? ""
@@ -580,6 +585,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 12578081..2227d60e 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";
import { formatUserDisplayName } from "../Bots/botUtils";
@@ -57,6 +60,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 96c6c558..e1d2774d 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);
@@ -106,6 +107,7 @@ function StandingChallengeTable({ fetching, handleSuspend, handleDelete }) {
() => [
columnHelper.accessor("gameName", {
header: t("tables.game"),
+ sortingFn: stringColumnSortingFn(i18n.language),
}),
columnHelper.accessor("variants", {
header: t("tables.variants"),
@@ -215,7 +217,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.js b/src/components/MetaContainer.js
index 38e5f45e..eac95691 100644
--- a/src/components/MetaContainer.js
+++ b/src/components/MetaContainer.js
@@ -11,11 +11,13 @@ 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";
import { rawDirectoryDisplayName } from "./Bots/botUtils";
function MetaContainer(props) {
const { i18n } = useTranslation();
+ const localeBundleKey = getUiLocaleBundleKey(i18n);
const globalMe = useStore((state) => state.globalMe);
const allUsers = useStore((state) => state.users);
const [counts, countsSetter] = useState(null);
@@ -42,7 +44,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 f8a846e0..aec56622 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) =>
@@ -296,6 +298,7 @@ function Table({
{
header: t("tables.designers"),
id: "designers",
+ sortingFn: stringColumnSortingFn(i18n.language),
cell: (props) =>
props.row.original.designers.length === 0
? ""
@@ -463,6 +466,7 @@ function Table({
addTag,
closeImgModal,
t,
+ i18n.language,
]
);
diff --git a/src/components/Players.js b/src/components/Players.js
index 532aad37..beace4a1 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";
@@ -244,6 +244,7 @@ function Players() {
{formatUserDisplayName(props.row.original, allUsers)}
),
+ sortingFn: stringColumnSortingFn(i18n.language),
}),
columnHelper.accessor("lastSeen", {
header: t("tables.activity"),
@@ -282,7 +283,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 b48e9fc7..7d2791a2 100644
--- a/src/components/Ratings.js
+++ b/src/components/Ratings.js
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useCallback } from "react";
import { Trans, 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 {
@@ -40,7 +41,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 }]);
@@ -125,6 +126,7 @@ function RatingsTable({ metaGame, metaGameName, globalMe, allUsers, summary }) {
}),
columnHelper.accessor("player", {
header: t("tables.player"),
+ sortingFn: stringColumnSortingFn(i18n.language),
cell: (props) => (
<>
@@ -178,7 +180,7 @@ function RatingsTable({ metaGame, metaGameName, globalMe, allUsers, summary }) {
},
}),
],
- [globalMe, t]
+ [globalMe, t, i18n.language]
);
const table = useReactTable({
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 f3d0f8b1..598cf9fd 100644
--- a/src/components/StandingChallenges.js
+++ b/src/components/StandingChallenges.js
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router-dom";
import { getGameDisplayName } from "../lib/gameOptions";
import { formatChallengeTablePlayerCount } from "../lib/challengeTablePlayerCount";
+import { compareStrings, stringColumnSortingFn } from "../lib/compareStrings";
import { expandVariants as expandVariantsForGame } from "../lib/expandVariants";
import { API_ENDPOINT_OPEN } from "../config";
import {
@@ -66,7 +67,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);
@@ -454,6 +455,7 @@ function StandingChallenges(props) {
cols.push(
columnHelper.accessor("metaGameName", {
header: t("tables.game"),
+ sortingFn: stringColumnSortingFn(i18n.language),
cell: (props) => (
{props.getValue()}
@@ -465,6 +467,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"),
@@ -622,6 +633,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..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;
@@ -20,3 +26,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..063ebf31 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", () => {
@@ -27,4 +27,42 @@ 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", () => {
+ /** 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);
+ });
});