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