From 6a72946b4559e5908875e419cf5d450fa57772d4 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 17 May 2026 13:19:25 +0300 Subject: [PATCH] New hooks and components for localized date time (#3065) --- AGENTS.md | 1 + app/components/BuildCard.tsx | 26 +- app/components/Chart.tsx | 26 +- app/components/EventsList.tsx | 20 +- app/components/LocaleTime.tsx | 49 ++++ app/components/LocaleTimeRange.tsx | 53 ++++ app/components/RelativeTime.tsx | 28 +-- app/components/StreamListItems.tsx | 24 +- app/components/TimePopover.tsx | 21 +- app/components/elements/TournamentSearch.tsx | 25 +- app/components/layout/ChatSidebar.tsx | 45 ++-- app/components/layout/index.tsx | 22 +- app/components/match-page/MatchJoinTab.tsx | 4 +- app/components/match-page/MatchTimeline.tsx | 17 +- app/db/tables.ts | 9 - app/features/art/components/ArtGrid.tsx | 17 +- app/features/articles/routes/a.$slug.tsx | 12 +- app/features/articles/routes/a.tsx | 12 +- app/features/ban/routes/suspended.tsx | 23 +- .../builds/components/FilterSection.tsx | 16 +- .../calendar/components/TournamentCard.tsx | 38 +-- app/features/calendar/routes/calendar.$id.tsx | 28 +-- app/features/calendar/routes/calendar.new.tsx | 16 +- app/features/calendar/routes/calendar.tsx | 70 ++++-- app/features/chat/chat-utils.ts | 4 +- app/features/chat/components/Chat.tsx | 22 +- .../friends/components/FriendMenu.tsx | 15 +- .../components/SplatoonRotations.tsx | 105 +++----- app/features/front-page/routes/index.tsx | 24 +- app/features/lfg/components/LFGPost.tsx | 16 +- .../routes/plus.voting.results.tsx | 22 +- app/features/scrims/components/ScrimCard.tsx | 23 +- .../scrims/components/ScrimRequestModal.tsx | 9 +- app/features/scrims/routes/scrims.tsx | 45 ++-- app/features/sendouq/components/GroupCard.tsx | 27 +- app/features/sendouq/routes/q.looking.tsx | 9 +- app/features/sendouq/routes/q.tsx | 122 ++++----- .../settings/actions/settings.server.ts | 6 - app/features/settings/routes/settings.tsx | 14 +- app/features/settings/settings-schemas.ts | 14 -- .../team/components/TeamResultsTable.tsx | 17 +- app/features/top-search/routes/xsearch.tsx | 20 +- .../components/Bracket/RoundHeader.tsx | 16 +- .../components/BracketMapListDialog.tsx | 27 +- .../components/TournamentTeamActions.tsx | 25 +- .../routes/to.$id.brackets.tsx | 28 +-- .../components/TournamentMatchBanner.tsx | 15 +- .../components/BannedPlayersList.tsx | 38 +-- .../components/EventCalendar.tsx | 14 +- .../routes/org.$slug.tsx | 41 ++- .../tournament/routes/to.$id.register.tsx | 55 ++--- .../user-page/components/UserResultsTable.tsx | 17 +- app/features/user-page/components/Widget.tsx | 63 +++-- .../user-page/routes/u.$identifier.admin.tsx | 101 ++++---- .../routes/u.$identifier.seasons.tsx | 79 +++--- app/features/vods/routes/vods.$id.tsx | 28 +-- app/hooks/intl/useDateTimeFormat.ts | 45 ++++ app/hooks/intl/useFormatDistanceToNow.ts | 35 +++ app/hooks/intl/useUserIntlPreference.ts | 45 ++++ app/hooks/useTimeFormat.ts | 233 ------------------ app/root.tsx | 4 +- app/styles/utils.css | 8 + e2e/calendar.spec.ts | 40 +++ e2e/settings.spec.ts | 36 --- locales/da/forms.json | 6 +- locales/de/forms.json | 6 +- locales/en/forms.json | 6 +- locales/es-ES/forms.json | 6 +- locales/es-US/forms.json | 6 +- locales/fr-CA/forms.json | 6 +- locales/fr-EU/forms.json | 6 +- locales/he/forms.json | 6 +- locales/it/forms.json | 6 +- locales/ja/forms.json | 6 +- locales/ko/forms.json | 6 +- locales/nl/forms.json | 6 +- locales/pl/forms.json | 6 +- locales/pt-BR/forms.json | 6 +- locales/ru/forms.json | 6 +- locales/zh/forms.json | 6 +- 80 files changed, 1021 insertions(+), 1184 deletions(-) create mode 100644 app/components/LocaleTime.tsx create mode 100644 app/components/LocaleTimeRange.tsx create mode 100644 app/hooks/intl/useDateTimeFormat.ts create mode 100644 app/hooks/intl/useFormatDistanceToNow.ts create mode 100644 app/hooks/intl/useUserIntlPreference.ts delete mode 100644 app/hooks/useTimeFormat.ts diff --git a/AGENTS.md b/AGENTS.md index e6dfd8792..370ec59d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ - one file can have many components - all texts should be provided translations via the i18next library's `useTranslations` hook's `t` function - instead of `&&` operator for conditional rendering, use the ternary operator +- for localized user-readable time strings use ``, `` or `useFormatDistanceToNow`. If needed use `useDateTimeFormat` directly. NEVER use e.g. `toLocaleString` directly as it does not include users' language selection. ## Remix/React Router diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index 26c9f6ed8..b70079530 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -5,15 +5,12 @@ import { Link } from "react-router"; import type { GearType, Tables, UserWithPlusTier } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types"; -import { useHydrated } from "~/hooks/useHydrated"; -import { useTimeFormat } from "~/hooks/useTimeFormat"; import type { Ability as AbilityType, BuildAbilitiesTuple, ModeShort, } from "~/modules/in-game-lists/types"; import { altWeaponIdToId } from "~/modules/in-game-lists/weapon-ids"; -import { databaseTimestampToDate } from "~/utils/dates"; import { gearTypeToInitial } from "~/utils/strings"; import { analyzerPage, @@ -31,6 +28,7 @@ import { LinkButton, SendouButton } from "./elements/Button"; import { SendouPopover } from "./elements/Popover"; import { FormWithConfirm } from "./FormWithConfirm"; import { Image } from "./Image"; +import { LocaleTime } from "./LocaleTime"; interface BuildProps { build: Pick< @@ -55,8 +53,6 @@ interface BuildProps { export function BuildCard({ build, owner, canEdit = false }: BuildProps) { const user = useUser(); const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]); - const { formatDate } = useTimeFormat(); - const isHydrated = useHydrated(); const { id, @@ -122,17 +118,15 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { {t("common:build.private")} ) : null} - + diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index 3fe9fc542..8dfafc8b4 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -3,8 +3,8 @@ import * as React from "react"; import { type AxisOptions, Chart as ReactChart } from "react-charts"; import type { TooltipRendererProps } from "react-charts/types/components/TooltipRenderer"; import { Theme, useTheme } from "~/features/theme/core/provider"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useHydrated } from "~/hooks/useHydrated"; -import { useTimeFormat } from "~/hooks/useTimeFormat"; import styles from "./Chart.module.css"; export default function Chart({ @@ -24,7 +24,10 @@ export default function Chart({ }) { const theme = useTheme(); const isHydrated = useHydrated(); - const { formatDate } = useTimeFormat(); + const { formatter: scaleFormatter } = useDateTimeFormat({ + day: "numeric", + month: "numeric", + }); const primaryAxis = React.useMemo< AxisOptions<(typeof options)[number]["data"][number]> @@ -37,17 +40,14 @@ export default function Chart({ formatters: { scale: (val: any) => { if (val instanceof Date) { - return formatDate(val, { - day: "numeric", - month: "numeric", - }); + return scaleFormatter.format(val); } return val; }, }, }), - [formatDate, xAxis], + [scaleFormatter, xAxis], ); const secondaryAxes = React.useMemo< @@ -105,7 +105,11 @@ function ChartTooltip({ headerSuffix = "", valueSuffix = "", }: ChartTooltipProps) { - const { formatDate } = useTimeFormat(); + const { formatter: headerFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + month: "numeric", + }); const dataPoints = focusedDatum?.interactiveGroup ?? []; const header = () => { @@ -113,11 +117,7 @@ function ChartTooltip({ if (!primaryValue) return null; if (primaryValue instanceof Date) { - return formatDate(primaryValue, { - weekday: "short", - day: "numeric", - month: "numeric", - }); + return headerFormatter.format(primaryValue); } return primaryValue; diff --git a/app/components/EventsList.tsx b/app/components/EventsList.tsx index a8b57200b..6ada6f1aa 100644 --- a/app/components/EventsList.tsx +++ b/app/components/EventsList.tsx @@ -1,8 +1,8 @@ import { isToday, isTomorrow } from "date-fns"; import { useTranslation } from "react-i18next"; import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useHydrated } from "~/hooks/useHydrated"; -import { useTimeFormat } from "~/hooks/useTimeFormat"; import styles from "./EventsList.module.css"; import { Placeholder } from "./Placeholder"; import { ListLink } from "./SideNav"; @@ -15,7 +15,15 @@ export function EventsList({ onClick?: () => void; }) { const { t, i18n } = useTranslation(["front"]); - const { formatDate, formatTime } = useTimeFormat(); + const { formatter: dateFormatter } = useDateTimeFormat({ + weekday: "long", + month: "numeric", + day: "numeric", + }); + const { formatter: timeFormatter } = useDateTimeFormat({ + hour: "numeric", + minute: "2-digit", + }); const isHydrated = useHydrated(); if (events.length === 0) { @@ -50,11 +58,7 @@ export function EventsList({ const str = rtf.format(1, "day"); return str.charAt(0).toUpperCase() + str.slice(1); } - return formatDate(date, { - weekday: "long", - month: "numeric", - day: "numeric", - }); + return dateFormatter.format(date); }; const groupedEvents = events.reduce>( @@ -85,7 +89,7 @@ export function EventsList({ key={`${event.type}-${event.id}`} to={event.url} imageUrl={event.logoUrl ?? undefined} - subtitle={formatTime(new Date(event.startTime * 1000))} + subtitle={timeFormatter.format(event.startTime)} onClick={onClick} > {event.scrimStatus === "booked" diff --git a/app/components/LocaleTime.tsx b/app/components/LocaleTime.tsx new file mode 100644 index 000000000..4aee47ec6 --- /dev/null +++ b/app/components/LocaleTime.tsx @@ -0,0 +1,49 @@ +import clsx from "clsx"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { databaseTimestampToDate } from "~/utils/dates"; + +interface LocaleTimeProps { + /** The date to render. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */ + date: Date | number; + /** Formatting options forwarded to `Intl.DateTimeFormat`. Combined with the user's locale and hour cycle preferences. */ + options: Intl.DateTimeFormatOptions; + /** Optional extra class names appended to the rendered `