);
})}
@@ -275,32 +269,25 @@ function ActiveSeasonInfo({
season: SerializeFrom
;
}) {
const { t } = useTranslation(["q"]);
- const { formatDateTime } = useTimeFormat();
- const isHydrated = useHydrated();
- const starts = new Date(season.starts);
- const ends = new Date(season.ends);
-
- const dateToString = (date: Date) =>
- formatDateTime(date, {
- month: "numeric",
- day: "numeric",
- hour: "numeric",
- minute: "numeric",
- });
+ const dateOptions: Intl.DateTimeFormatOptions = {
+ month: "numeric",
+ day: "numeric",
+ hour: "numeric",
+ minute: "numeric",
+ };
return (
-
+
{t("q:front.seasonOpen", { nth: season.nth })}{" "}
- {isHydrated ? (
-
- {dateToString(starts)} - {dateToString(ends)}
-
- ) : null}
+
+
+
);
}
@@ -383,18 +370,11 @@ function UpcomingSeasonInfo({
season: SerializeFrom
;
}) {
const { t } = useTranslation(["q"]);
- const { formatDateTime } = useTimeFormat();
- const isHydrated = useHydrated();
- if (!isHydrated) return null;
-
- const starts = new Date(season.starts);
-
- const dateToString = (date: Date) =>
- formatDateTime(date, {
- month: "numeric",
- day: "numeric",
- hour: "numeric",
- });
+ const { formatter } = useDateTimeFormat({
+ month: "numeric",
+ day: "numeric",
+ hour: "numeric",
+ });
return (
@@ -402,7 +382,7 @@ function UpcomingSeasonInfo({
{t("q:front.upcomingSeason.date", {
nth: season.nth,
- date: dateToString(starts),
+ date: formatter.format(new Date(season.starts)) ?? "",
})}
);
diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts
index b1b7fd6ed..157d7a783 100644
--- a/app/features/settings/actions/settings.server.ts
+++ b/app/features/settings/actions/settings.server.ts
@@ -72,12 +72,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
break;
}
- case "UPDATE_DATE_FORMAT": {
- await UserRepository.updatePreferences(user.id, {
- dateFormat: data.newValue,
- });
- break;
- }
default: {
assertUnreachable(data);
}
diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx
index a2574a1e6..f924f9427 100644
--- a/app/features/settings/routes/settings.tsx
+++ b/app/features/settings/routes/settings.tsx
@@ -30,7 +30,6 @@ import { action } from "../actions/settings.server";
import { loader } from "../loaders/settings.server";
import {
clockFormatSchema,
- dateFormatSchema,
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
@@ -90,18 +89,6 @@ export default function SettingsPage() {
{({ FormField }) => }
) : null}
- {user ? (
-
- {({ FormField }) => }
-
- ) : null}
{user ? (
<>
@@ -201,6 +188,7 @@ function LanguageSelector() {
return (
@@ -45,11 +43,14 @@ export function TeamResultsTable({ results }: TeamResultsTableProps) {
- {formatDate(databaseTimestampToDate(result.startTime), {
- day: "numeric",
- month: "numeric",
- year: "numeric",
- })}
+
|
diff --git a/app/features/top-search/routes/xsearch.tsx b/app/features/top-search/routes/xsearch.tsx
index 0b0e97ec7..abc85dafb 100644
--- a/app/features/top-search/routes/xsearch.tsx
+++ b/app/features/top-search/routes/xsearch.tsx
@@ -4,7 +4,7 @@ import type { MetaFunction } from "react-router";
import { useLoaderData, useSearchParams } from "react-router";
import { Main } from "~/components/Main";
import type { Tables } from "~/db/tables";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
+import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { RankedModeShort } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
@@ -38,7 +38,10 @@ export const meta: MetaFunction = (args) => {
export default function XSearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation(["common", "game-misc"]);
- const { formatDate } = useTimeFormat();
+ const { formatter: monthYearRangeFormatter } = useDateTimeFormat({
+ month: "numeric",
+ year: "numeric",
+ });
const data = useLoaderData ();
const handleSelectChange = (event: React.ChangeEvent) => {
@@ -62,11 +65,11 @@ export default function XSearchPage() {
searchParams.get("mode") ?? "SZ"
}-${searchParams.get("region") ?? "WEST"}`;
- const formatMonthYear = (my: MonthYear) =>
- formatDate(new Date(my.year, my.month - 1), {
- month: "numeric",
- year: "numeric",
- });
+ const formatMonthYearRange = (from: MonthYear, to: MonthYear) =>
+ monthYearRangeFormatter.formatRange(
+ new Date(from.year, from.month - 1),
+ new Date(to.year, to.month - 1),
+ ) ?? "";
return (
@@ -86,8 +89,7 @@ export default function XSearchPage() {
key={option.id}
value={`${option.span.value.month}-${option.span.value.year}-${option.mode}-${option.region}`}
>
- {formatMonthYear(option.span.from)} -{" "}
- {formatMonthYear(option.span.to)} /{" "}
+ {formatMonthYearRange(option.span.from, option.span.to)} /{" "}
{t(`game-misc:MODE_SHORT_${option.mode}`)} /{" "}
{t(`common:divisions.${option.region}`)}
diff --git a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx
index a0e9032ea..e4607df00 100644
--- a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx
+++ b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx
@@ -1,10 +1,10 @@
import clsx from "clsx";
import { differenceInMinutes } from "date-fns";
import * as React from "react";
+import { LocaleTime } from "~/components/LocaleTime";
import type { TournamentRoundMaps } from "~/db/tables";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import type { Unpacked } from "~/utils/types";
import * as Deadline from "../../core/Deadline";
@@ -69,15 +69,17 @@ export function RoundHeader({
}
function LeagueRoundStartDate({ date }: { date: Date }) {
- const { formatDate } = useTimeFormat();
-
return (
- {formatDate(date, {
- month: "numeric",
- day: "numeric",
- })}{" "}
+ {" "}
→
diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx
index 7899a46f8..36d61a6fe 100644
--- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx
+++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx
@@ -20,6 +20,7 @@ import { ModeImage, StageImage } from "~/components/Image";
import { InfoPopover } from "~/components/InfoPopover";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
+import { LocaleTime } from "~/components/LocaleTime";
import { SubmitButton } from "~/components/SubmitButton";
import type { CustomPickBanFlow, TournamentRoundMaps } from "~/db/tables";
import {
@@ -28,12 +29,10 @@ import {
} from "~/features/tournament/routes/to.$id";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { nullFilledArray } from "~/utils/arrays";
-import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { assertUnreachable } from "~/utils/types";
import { calendarEditPage } from "~/utils/urls";
@@ -63,7 +62,6 @@ export function BracketMapListDialog({
isPreparing?: boolean;
}) {
const { t } = useTranslation(["common"]);
- const { formatDateTime } = useTimeFormat();
const fetcher = useFetcher();
const tournament = useTournament();
const untrimmedPreparedMaps = useBracketPreparedMaps(bracketIdx);
@@ -360,19 +358,20 @@ export function BracketMapListDialog({
) : null}
{preparedMaps ? (
-
+
Prepared by{" "}
{authorIdToUsername(tournament, preparedMaps.authorId)} @{" "}
- {formatDateTime(databaseTimestampToDate(preparedMaps.createdAt), {
- day: "numeric",
- month: "numeric",
- year: "numeric",
- hour: "numeric",
- minute: "2-digit",
- })}
+
) : null}
diff --git a/app/features/tournament-bracket/components/TournamentTeamActions.tsx b/app/features/tournament-bracket/components/TournamentTeamActions.tsx
index 76ff048d9..e0bbd0ee4 100644
--- a/app/features/tournament-bracket/components/TournamentTeamActions.tsx
+++ b/app/features/tournament-bracket/components/TournamentTeamActions.tsx
@@ -5,11 +5,11 @@ import * as React from "react";
import { useFetcher } from "react-router";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
+import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { soundEnabled, soundVolume } from "~/features/chat/chat-utils";
import { useTournament } from "~/features/tournament/routes/to.$id";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import { logger } from "~/utils/logger";
import {
soundPath,
@@ -22,7 +22,6 @@ export function TournamentTeamActions() {
const tournament = useTournament();
const user = useUser();
const fetcher = useFetcher();
- const { formatTime } = useTimeFormat();
const status = tournament.teamMemberOfProgressStatus(user);
@@ -104,18 +103,18 @@ export function TournamentTeamActions() {
) : bracket.startTime && bracket.startTime > new Date() ? (
-
+
open{" "}
- {formatTime(sub(bracket.startTime, { hours: 1 }), {
- hour: "numeric",
- minute: "numeric",
- weekday: "short",
- })}{" "}
- -{" "}
- {formatTime(bracket.startTime, {
- hour: "numeric",
- minute: "numeric",
- })}
+
) : bracket.startTime && bracket.startTime < new Date() ? (
over
diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
index e0f8e830b..a065cad67 100644
--- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx
+++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
@@ -23,12 +23,12 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
+import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { useUser } from "~/features/auth/core/user";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import { SENDOU_INK_BASE_URL, tournamentJoinPage } from "~/utils/urls";
import {
@@ -53,7 +53,6 @@ import styles from "../tournament-bracket.module.css";
export default function TournamentBracketsPage() {
const { t } = useTranslation(["common", "tournament"]);
- const { formatDateTime, formatTime } = useTimeFormat();
const visibility = useVisibilityChange();
const { revalidate } = useRevalidator();
const user = useUser();
@@ -287,8 +286,6 @@ export default function TournamentBracketsPage() {
bracketIdx={currentBracketIdx}
waitingForTeamsText={waitingForTeamsText}
teamsSourceText={teamsSourceText}
- formatDateTime={formatDateTime}
- formatTime={formatTime}
/>
)}
@@ -550,15 +547,11 @@ function BracketTabContent({
bracketIdx,
waitingForTeamsText,
teamsSourceText,
- formatDateTime,
- formatTime,
}: {
bracket: BracketType;
bracketIdx: number;
waitingForTeamsText: () => string;
teamsSourceText: () => string | null;
- formatDateTime: (date: Date, options?: Intl.DateTimeFormatOptions) => string;
- formatTime: (date: Date) => string;
}) {
return (
<>
@@ -578,14 +571,19 @@ function BracketTabContent({
Bracket requires check-in{" "}
{bracket.startTime ? (
-
+
(open{" "}
- {formatDateTime(sub(bracket.startTime, { hours: 1 }), {
- hour: "numeric",
- minute: "numeric",
- weekday: "long",
- })}{" "}
- - {formatTime(bracket.startTime)})
+
+ )
) : null}
diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx
index b1760e01b..8bff235fe 100644
--- a/app/features/tournament-match/components/TournamentMatchBanner.tsx
+++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx
@@ -20,8 +20,8 @@ import {
resolveLeagueRoundStartDate,
} from "~/features/tournament/tournament-utils";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
+import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { ModeShort } from "~/modules/in-game-lists/types";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
import { databaseTimestampToDate } from "~/utils/dates";
@@ -34,7 +34,11 @@ export function TournamentMatchBanner({
data: TournamentMatchLoaderData;
}) {
const { t } = useTranslation(["tournament"]);
- const { formatDate } = useTimeFormat();
+ const { formatter: leagueRoundDateFormatter } = useDateTimeFormat({
+ day: "numeric",
+ month: "numeric",
+ year: "numeric",
+ });
const tournament = useTournament();
const {
currentMap,
@@ -85,11 +89,8 @@ export function TournamentMatchBanner({
subtitle={
leagueRoundStartDate
? t("tournament:match.leagueLocked.subtitle", {
- date: formatDate(leagueRoundStartDate, {
- day: "numeric",
- month: "numeric",
- year: "numeric",
- }),
+ date:
+ leagueRoundDateFormatter.format(leagueRoundStartDate) ?? "",
})
: undefined
}
diff --git a/app/features/tournament-organization/components/BannedPlayersList.tsx b/app/features/tournament-organization/components/BannedPlayersList.tsx
index a50980671..f4235d6c1 100644
--- a/app/features/tournament-organization/components/BannedPlayersList.tsx
+++ b/app/features/tournament-organization/components/BannedPlayersList.tsx
@@ -6,10 +6,10 @@ import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
+import { LocaleTime } from "~/components/LocaleTime";
import { Table } from "~/components/Table";
import { BanUserModal } from "~/features/tournament-organization/components/BanUserModal";
import type { OrganizationPageLoaderData } from "~/features/tournament-organization/loaders/org.$slug.server";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { userPage } from "~/utils/urls";
import styles from "../components/BannedPlayersList.module.css";
@@ -22,7 +22,6 @@ export function BannedUsersList({
bannedUsers: NonNullable;
}) {
const { t } = useTranslation(["org"]);
- const { formatDate } = useTimeFormat();
const bannedUsersKey = (bannedUsers ?? [])
.map((u) => [u.id, u.privateNote].join("-"))
@@ -80,23 +79,28 @@ export function BannedUsersList({
|
- {formatDate(databaseTimestampToDate(bannedUser.updatedAt), {
- day: "numeric",
- month: "numeric",
- year: "numeric",
- })}
+
|
- {bannedUser.expiresAt
- ? formatDate(
- databaseTimestampToDate(bannedUser.expiresAt),
- {
- day: "numeric",
- month: "numeric",
- year: "numeric",
- },
- )
- : t("org:banned.permanent")}
+ {bannedUser.expiresAt ? (
+
+ ) : (
+ t("org:banned.permanent")
+ )}
|
]).toString();
function MonthSelector({ month, year }: { month: number; year: number }) {
const date = new Date(Date.UTC(year, month, 15));
- const { formatDate } = useTimeFormat();
return (
@@ -130,10 +129,13 @@ function MonthSelector({ month, year }: { month: number; year: number }) {
{"<"}
- {formatDate(date, {
- year: "numeric",
- month: "numeric",
- })}
+
["series"]>;
}) {
const { t } = useTranslation(["org"]);
- const { formatDate } = useTimeFormat();
return (
@@ -398,10 +395,11 @@ function SeriesHeader({
{series.established ? (
{t("org:events.established.short")}{" "}
- {formatDate(databaseTimestampToDate(series.established), {
- month: "numeric",
- year: "numeric",
- })}
+
) : null}
@@ -502,9 +500,6 @@ function EventInfo({
event: SerializeFrom["events"][number];
showYear?: boolean;
}) {
- const { formatDateTime } = useTimeFormat();
- const isHydrated = useHydrated();
-
return (
{event.name}
-
+
condition ? "completed" : "incomplete";
@@ -439,19 +443,12 @@ function RegistrationProgress({
tournament.registrationClosesAt.getTime() !==
tournament.ctx.startTime.getTime();
- const registrationClosesAtString = isHydrated
- ? formatDate(
- tournament.isLeagueSignup
- ? tournament.ctx.startTime
- : tournament.registrationClosesAt,
- {
- minute: "numeric",
- hour: "numeric",
- day: "2-digit",
- month: "2-digit",
- },
- )
- : "";
+ const registrationClosesAtString =
+ registrationClosesFormatter.format(
+ tournament.isLeagueSignup
+ ? tournament.ctx.startTime
+ : tournament.registrationClosesAt,
+ ) ?? "";
return (
@@ -521,29 +518,19 @@ function CheckIn({
const { t } = useTranslation(["tournament"]);
const isHydrated = useHydrated();
const fetcher = useFetcher();
- const { formatDate } = useTimeFormat();
+ const { formatter: checkInFormatter } = useDateTimeFormat({
+ minute: "numeric",
+ hour: "numeric",
+ day: "2-digit",
+ month: "2-digit",
+ });
const now = useAutoRerender();
const status: "OVER" | "OPEN" | "UPCOMING" =
now > endDate ? "OVER" : now >= startDate ? "OPEN" : "UPCOMING";
- const checkInStartsString = isHydrated
- ? formatDate(startDate, {
- minute: "numeric",
- hour: "numeric",
- day: "2-digit",
- month: "2-digit",
- })
- : "";
-
- const checkInEndsString = isHydrated
- ? formatDate(endDate, {
- minute: "numeric",
- hour: "numeric",
- day: "2-digit",
- month: "2-digit",
- })
- : "";
+ const checkInStartsString = checkInFormatter.format(startDate) ?? "";
+ const checkInEndsString = checkInFormatter.format(endDate) ?? "";
if (status === "UPCOMING") {
return (
diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx
index bf321e767..c0d223fbe 100644
--- a/app/features/user-page/components/UserResultsTable.tsx
+++ b/app/features/user-page/components/UserResultsTable.tsx
@@ -4,11 +4,10 @@ import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
+import { LocaleTime } from "~/components/LocaleTime";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import { TierPill } from "~/components/TierPill";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
-import { databaseTimestampToDate } from "~/utils/dates";
import {
calendarEventPage,
tournamentBracketsPage,
@@ -35,7 +34,6 @@ export function UserResultsTable({
hasHighlightCheckboxes,
}: UserResultsTableProps) {
const { t } = useTranslation("user");
- const { formatDate } = useTimeFormat();
const placementHeaderId = `${id}-th-placement`;
@@ -121,11 +119,14 @@ export function UserResultsTable({
|
- {formatDate(databaseTimestampToDate(result.startTime), {
- day: "numeric",
- month: "numeric",
- year: "2-digit",
- })}
+
|
diff --git a/app/features/user-page/components/Widget.tsx b/app/features/user-page/components/Widget.tsx
index 993164f7b..38d09e98c 100644
--- a/app/features/user-page/components/Widget.tsx
+++ b/app/features/user-page/components/Widget.tsx
@@ -1,6 +1,5 @@
import clsx from "clsx";
import { Link2 as LinkIcon } from "lucide-react";
-import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
@@ -12,6 +11,7 @@ import { BskyIcon } from "~/components/icons/Bsky";
import { DiscordIcon } from "~/components/icons/Discord";
import { TwitchIcon } from "~/components/icons/Twitch";
import { YouTubeIcon } from "~/components/icons/YouTube";
+import { LocaleTime } from "~/components/LocaleTime";
import { Markdown } from "~/components/Markdown";
import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
@@ -19,16 +19,16 @@ import type { Tables } from "~/db/tables";
import { previewUrl } from "~/features/art/art-utils";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { VodListing } from "~/features/vods/components/VodListing";
+import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
+import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { usePagination } from "~/hooks/usePagination";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { GameBadgeId } from "~/modules/in-game-lists/game-badge-ids";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
-import { databaseTimestampToDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
import type { SerializeFrom } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
@@ -62,7 +62,11 @@ export function Widget({
user: Pick;
}) {
const { t } = useTranslation(["user", "badges", "team", "org", "lfg"]);
- const { formatDate } = useTimeFormat();
+ const { formatter: patronSinceFormatter } = useDateTimeFormat({
+ day: "numeric",
+ month: "numeric",
+ year: "numeric",
+ });
const content = () => {
switch (widget.id) {
@@ -166,13 +170,7 @@ export function Widget({
case "patron-since":
if (!widget.data) return null;
return (
-
+
);
case "join-date":
if (!widget.data) return null;
@@ -373,8 +371,6 @@ function HighlightedResults({
}: {
results: Extract["data"];
}) {
- const { formatDate } = useTimeFormat();
-
return (
{results.map((result, i) => (
@@ -414,13 +410,15 @@ function HighlightedResults({
) : null}
-
- {formatDate(databaseTimestampToDate(result.startTime), {
+
+ }}
+ className={styles.resultDate}
+ />
))}
@@ -538,36 +536,31 @@ function XRankPeaks({
}
function TimezoneWidget({ timezone }: { timezone: string }) {
- const { formatTime, formatDate } = useTimeFormat();
- const [currentTime, setCurrentTime] = React.useState(() => new Date());
-
- React.useEffect(() => {
- const interval = setInterval(() => {
- setCurrentTime(new Date());
- }, 1000);
-
- return () => clearInterval(interval);
- }, []);
+ const currentTime = useAutoRerender("second");
try {
return (
-
- {formatTime(currentTime, {
+
-
- {formatDate(currentTime, {
+ }}
+ className={styles.widgetValueMain}
+ />
+
+ }}
+ className={styles.widgetValueFooter}
+ />
);
} catch {
diff --git a/app/features/user-page/routes/u.$identifier.admin.tsx b/app/features/user-page/routes/u.$identifier.admin.tsx
index b8b713ff9..32fa9f3f3 100644
--- a/app/features/user-page/routes/u.$identifier.admin.tsx
+++ b/app/features/user-page/routes/u.$identifier.admin.tsx
@@ -4,11 +4,10 @@ import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
+import { LocaleTime } from "~/components/LocaleTime";
import { useUser } from "~/features/auth/core/user";
import { addModNoteSchema } from "~/features/user-page/user-page-schemas";
import { SendouForm } from "~/form";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
-import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { userPage } from "~/utils/urls";
import { action } from "../actions/u.$identifier.admin.server";
@@ -57,32 +56,39 @@ export default function UserAdminPage() {
function AccountInfos() {
const data = useLoaderData();
- const { formatDateTime } = useTimeFormat();
return (
- User account created at
-
- {data.createdAt
- ? formatDateTime(databaseTimestampToDate(data.createdAt), {
+ {data.createdAt ? (
+
+ ) : (
+ "―"
+ )}
- Discord account created at
-
- {formatDateTime(new Date(data.discordAccountCreatedAt), {
- year: "numeric",
- month: "numeric",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- })}
+
- Discord ID
@@ -113,7 +119,6 @@ function AccountInfos() {
function ModNotes() {
const user = useUser();
const data = useLoaderData();
- const { formatDateTime } = useTimeFormat();
if (!data.modNotes || data.modNotes.length === 0) {
return (
@@ -128,15 +133,17 @@ function ModNotes() {
{data.modNotes.map((note) => (
-
- {formatDateTime(databaseTimestampToDate(note.createdAt), {
+
+ hour: "numeric",
+ minute: "numeric",
+ }}
+ className="font-bold"
+ />
By: {note.username}
Note: {note.text}
{note.discordId === user?.discordId ? (
@@ -184,7 +191,6 @@ function NewModNoteDialog() {
function BanLog() {
const data = useLoaderData();
- const { formatDateTime } = useTimeFormat();
if (!data.banLogs || data.banLogs.length === 0) {
return No bans ;
@@ -194,15 +200,17 @@ function BanLog() {
{data.banLogs.map((ban) => (
-
- {formatDateTime(databaseTimestampToDate(ban.createdAt), {
+
+ hour: "numeric",
+ minute: "numeric",
+ }}
+ className="font-bold"
+ />
{ban.banned === 0 ? (
Unbanned
) : (
@@ -212,15 +220,21 @@ function BanLog() {
{typeof ban.banned === "number" && ban.banned !== 0 ? (
Banned till:{" "}
- {ban.banned !== 1
- ? formatDateTime(databaseTimestampToDate(ban.banned), {
+ {ban.banned !== 1 ? (
+
+ ) : (
+ "No end date set"
+ )}
) : null}
{ban.banned !== 0 ? (
@@ -239,7 +253,6 @@ function BanLog() {
function FriendCodes() {
const data = useLoaderData();
- const { formatDateTime } = useTimeFormat();
if (!data.friendCodes || data.friendCodes.length === 0) {
return No friend codes ;
@@ -252,13 +265,17 @@ function FriendCodes() {
{fc.friendCode}
{index === 0 ? "Current" : "Past"} - Added on{" "}
- {formatDateTime(databaseTimestampToDate(fc.createdAt), {
- year: "numeric",
- month: "numeric",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- })}
+
Submitted by: {fc.submitterUsername}
diff --git a/app/features/user-page/routes/u.$identifier.seasons.tsx b/app/features/user-page/routes/u.$identifier.seasons.tsx
index 58e1ccd6a..b7d2f465c 100644
--- a/app/features/user-page/routes/u.$identifier.seasons.tsx
+++ b/app/features/user-page/routes/u.$identifier.seasons.tsx
@@ -30,6 +30,8 @@ import {
TierImage,
WeaponImage,
} from "~/components/Image";
+import { LocaleTime } from "~/components/LocaleTime";
+import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { mainStyles } from "~/components/Main";
import { Pagination } from "~/components/Pagination";
import { SubNav, SubNavLink } from "~/components/SubNav";
@@ -42,8 +44,6 @@ import type {
SeasonTournamentResult,
} from "~/features/sendouq-match/SQMatchRepository.server";
import { useWeaponUsage } from "~/hooks/swr";
-import { useHydrated } from "~/hooks/useHydrated";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
@@ -205,15 +205,10 @@ function SeasonHeader({
seasonsParticipatedIn: number[];
}) {
const { t } = useTranslation(["user"]);
- const { formatDate } = useTimeFormat();
- const isHydrated = useHydrated();
const { starts, ends } = Seasons.nthToDateRange(seasonViewed);
const navigate = useNavigate();
const options = useSeasonSelectOptions();
- const isDifferentYears =
- new Date(starts).getFullYear() !== new Date(ends).getFullYear();
-
return (
)}
-
- {isHydrated ? (
- <>
- {formatDate(new Date(starts), {
- day: "numeric",
- month: "numeric",
- year: isDifferentYears ? "numeric" : undefined,
- })}{" "}
- -{" "}
- {formatDate(new Date(ends), {
- day: "numeric",
- month: "numeric",
- year: "numeric",
- })}
- >
- ) : (
- "0"
- )}
+
+
);
@@ -702,8 +686,6 @@ function CanceledMatchesDialog({
}: {
canceledMatches: NonNullable ;
}) {
- const { formatDateTime } = useTimeFormat();
-
return (
(
#{match.id}
-
- {formatDateTime(databaseTimestampToDate(match.createdAt))}
-
+
))}
@@ -737,8 +726,6 @@ function Results({
seasonViewed: number;
results: UserSeasonsPageLoaderData["results"];
}) {
- const isHydrated = useHydrated();
- const { formatDate } = useTimeFormat();
const [, setSearchParams] = useSearchParams();
const ref = React.useRef (null);
@@ -766,22 +753,20 @@ function Results({
return (
-
- {isHydrated
- ? formatDate(databaseTimestampToDate(result.createdAt), {
- weekday: "long",
- month: "numeric",
- day: "numeric",
- })
- : "t"}
-
+ />
{result.type === "GROUP_MATCH" ? (
) : (
diff --git a/app/features/vods/routes/vods.$id.tsx b/app/features/vods/routes/vods.$id.tsx
index e15453708..271636838 100644
--- a/app/features/vods/routes/vods.$id.tsx
+++ b/app/features/vods/routes/vods.$id.tsx
@@ -8,14 +8,12 @@ import { LinkButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, WeaponImage } from "~/components/Image";
+import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import { useUser } from "~/features/auth/core/user";
-import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
-import { useTimeFormat } from "~/hooks/useTimeFormat";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
-import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
@@ -80,12 +78,10 @@ export default function VodPage() {
defaultValue: 0,
revive: Number,
});
- const isHydrated = useHydrated();
const [autoplay, setAutoplay] = React.useState(false);
const data = useLoaderData();
const { t } = useTranslation(["common", "vods"]);
const user = useUser();
- const { formatDate } = useTimeFormat();
return (
@@ -100,19 +96,15 @@ export default function VodPage() {
-
+
{canEditVideo({
diff --git a/app/hooks/intl/useDateTimeFormat.ts b/app/hooks/intl/useDateTimeFormat.ts
new file mode 100644
index 000000000..dc026aa76
--- /dev/null
+++ b/app/hooks/intl/useDateTimeFormat.ts
@@ -0,0 +1,45 @@
+import { databaseTimestampToDate } from "~/utils/dates";
+import { useUserIntlPreference } from "./useUserIntlPreference";
+
+const SSR_FORMATTER = {
+ format: (_date: Date | number) => null,
+ formatRange: (_from: Date | number, _to: Date | number) => null,
+};
+
+/**
+ * SSR-safe wrapper around `Intl.DateTimeFormat`.
+ *
+ * Uses the user's locale and hour cycle preferences via `useUserIntlPreference`.
+ * Before hydration the returned formatter's methods return `null` so that
+ * server output matches the initial client render.
+ *
+ * Inputs accept either a `Date` or a database timestamp (`number`); numbers
+ * are converted via `databaseTimestampToDate`.
+ */
+export function useDateTimeFormat(options: Intl.DateTimeFormatOptions) {
+ const { language, hourCycle, isLoaded } = useUserIntlPreference();
+
+ const formatter = new Intl.DateTimeFormat(language, {
+ ...options,
+ ...(options.hour && hourCycle ? { hourCycle } : {}),
+ });
+
+ const realFormatter = {
+ format: (date: Date | number) => {
+ return formatter.format(
+ typeof date === "number" ? databaseTimestampToDate(date) : date,
+ );
+ },
+ formatRange: (from: Date | number, to: Date | number) => {
+ return formatter.formatRange(
+ typeof from === "number" ? databaseTimestampToDate(from) : from,
+ typeof to === "number" ? databaseTimestampToDate(to) : to,
+ );
+ },
+ };
+
+ return {
+ formatter: isLoaded ? realFormatter : SSR_FORMATTER,
+ isLoaded,
+ };
+}
diff --git a/app/hooks/intl/useFormatDistanceToNow.ts b/app/hooks/intl/useFormatDistanceToNow.ts
new file mode 100644
index 000000000..e8f7381ea
--- /dev/null
+++ b/app/hooks/intl/useFormatDistanceToNow.ts
@@ -0,0 +1,35 @@
+import { useTranslation } from "react-i18next";
+import type { LanguageCode } from "~/modules/i18n/config";
+import {
+ databaseTimestampToDate,
+ formatDistanceToNow as formatDistanceToNowUtil,
+} from "~/utils/dates";
+
+/**
+ * Hook that returns a `formatDistanceToNow` function (date-fns) bound to the
+ * current site language, for locale-aware "x ago" / "in x" output.
+ *
+ * Accepts either a `Date` or a database timestamp (`number`); numbers are
+ * converted via `databaseTimestampToDate`.
+ *
+ * Note: this intentionally does NOT honor the user's "always use browser
+ * language" preference (unlike `useDateTimeFormat`). The browser may be set to
+ * a language we have not loaded a date-fns locale for, so we use the
+ * site language to guarantee a translated result.
+ */
+export function useFormatDistanceToNow() {
+ const { i18n } = useTranslation();
+
+ return (
+ date: Date | number,
+ options?: Omit [1], "language">,
+ ) => {
+ return formatDistanceToNowUtil(
+ typeof date === "number" ? databaseTimestampToDate(date) : date,
+ {
+ ...options,
+ language: i18n.language as LanguageCode,
+ },
+ );
+ };
+}
diff --git a/app/hooks/intl/useUserIntlPreference.ts b/app/hooks/intl/useUserIntlPreference.ts
new file mode 100644
index 000000000..541dcf125
--- /dev/null
+++ b/app/hooks/intl/useUserIntlPreference.ts
@@ -0,0 +1,45 @@
+import { useTranslation } from "react-i18next";
+import type { UserPreferences } from "~/db/tables";
+import { useUser } from "~/features/auth/core/user";
+import { useHydrated } from "../useHydrated";
+
+/**
+ * Resolves the language and hour cycle to use when formatting dates and times
+ * for the current user. Prefers a browser language sharing a base tag with the
+ * active i18n language (e.g. `en-GB` over site `en`) for regional formatting.
+ * `isLoaded` is `false` until hydration; gate locale-dependent output on it to
+ * avoid hydration mismatches.
+ */
+export function useUserIntlPreference() {
+ const { i18n } = useTranslation();
+ const user = useUser();
+ const hydrated = useHydrated();
+
+ const browserLanguages = hydrated ? navigator.languages : [];
+
+ // does the user want to use their browser language even if the site is in another language?
+ const language =
+ browserLanguages.find((lang) => compareLanguages(lang, i18n.language)) ??
+ i18n.language;
+
+ return {
+ language,
+ hourCycle: resolveHourCycle(user?.preferences?.clockFormat),
+ isLoaded: hydrated,
+ };
+}
+
+function resolveHourCycle(
+ clockFormat: UserPreferences["clockFormat"],
+): "h12" | "h23" | undefined {
+ if (clockFormat === "12h") return "h12";
+ if (clockFormat === "24h") return "h23";
+ return undefined;
+}
+
+function compareLanguages(a: string, b: string) {
+ const baseA = a.split("-")[0];
+ const baseB = b.split("-")[0];
+
+ return baseA.toUpperCase() === baseB.toUpperCase();
+}
diff --git a/app/hooks/useTimeFormat.ts b/app/hooks/useTimeFormat.ts
deleted file mode 100644
index 5a111b2ae..000000000
--- a/app/hooks/useTimeFormat.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import { useTranslation } from "react-i18next";
-import type { UserPreferences } from "~/db/tables";
-import { useUser } from "~/features/auth/core/user";
-import type { LanguageCode } from "~/modules/i18n/config";
-import { formatDistanceToNow as formatDistanceToNowUtil } from "~/utils/dates";
-
-const H12_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
- hour12: true,
- hourCycle: "h12" as const,
-};
-const H24_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
- hour12: false,
- hourCycle: "h23" as const,
-};
-
-const DATE_FORMAT_LOCALE: Record<
- Exclude, "auto">,
- string
-> = {
- MDY: "en-US",
- DMY: "en-GB",
- YMD: "sv-SE",
-};
-function getClockFormatOptions(
- clockFormat: "auto" | "24h" | "12h" | undefined,
- language: string,
-): Intl.DateTimeFormatOptions {
- if (!clockFormat || clockFormat === "auto") {
- const isEnglish = language === "en";
- if (isEnglish) {
- return H12_TIME_OPTIONS;
- }
- return H24_TIME_OPTIONS;
- }
-
- if (clockFormat === "24h") {
- return H24_TIME_OPTIONS;
- }
-
- return H12_TIME_OPTIONS;
-}
-
-/**
- * Hook for formatting dates, times, durations, and relative times
- * according to user preferences and locale.
- * Respects the user's clock format preference (12h/24h) and current language.
- *
- * @example
- * const { formatDateTime, formatTime, formatDate, formatDuration, formatRelativeTime } = useTimeFormat();
- *
- * // Format full date and time
- * formatDateTime(new Date('2025-01-15T14:30:00'));
- * // => "1/15/2025, 2:30 PM" (12h) or "1/15/2025, 14:30" (24h)
- *
- * // Format time only
- * formatTime(new Date('2025-01-15T14:30:00'));
- * // => "2:30 PM" (12h) or "14:30" (24h)
- *
- * // Format date only
- * formatDate(new Date('2025-01-15'));
- * // => "1/15/2025"
- *
- * // Custom options
- * formatDateTime(new Date(), { dateStyle: 'full', timeStyle: 'short' });
- * // => "Wednesday, January 15, 2025 at 2:30 PM"
- *
- * // Format a duration (hours + minutes)
- * formatDuration(1, 30);
- * // => "1h 30m" (en) or locale-appropriate narrow format
- *
- * // Format relative time (picks the largest significant unit)
- * formatRelativeTime(2, 15);
- * // => "in 2 hr."
- * formatRelativeTime(0, 45);
- * // => "in 45 min."
- */
-export function useTimeFormat() {
- const { i18n } = useTranslation();
- const user = useUser();
- const clockFormat = user?.preferences?.clockFormat;
- const dateFormat = user?.preferences?.dateFormat;
- const clockOptions = getClockFormatOptions(clockFormat, i18n.language);
- const dateLocale = getDateLocale(dateFormat, i18n.language);
-
- const formatDateTime = (date: Date, options?: Intl.DateTimeFormatOptions) => {
- const adjusted = withYearFirstAdjustment(options, dateFormat);
- const useDateLocale = isNumericMonth(adjusted);
- const hasTimePart = Boolean(adjusted?.hour);
-
- // When the user's date-format preference forces a non-language locale (e.g. sv-SE
- // for YMD), applying it to the full date+time would also pull in that locale's
- // time conventions — most visibly Swedish "fm"/"em" instead of "AM"/"PM".
- // Format the date and time portions separately to keep them locale-correct.
- if (hasTimePart && useDateLocale && dateLocale !== i18n.language) {
- const { hour, minute, second, timeZoneName, ...dateOptions } = adjusted!;
- const datePart = date.toLocaleDateString(dateLocale, dateOptions);
- const timePart = formatTime(date, { hour, minute, second, timeZoneName });
- return `${datePart}, ${timePart}`;
- }
-
- const result = date.toLocaleString(
- useDateLocale ? dateLocale : i18n.language,
- hasTimePart
- ? {
- ...adjusted,
- ...clockOptions,
- }
- : {
- ...adjusted,
- },
- );
- return clockOptions.hourCycle === "h23" && hasTimePart
- ? stripLeadingZeroFromHour(result)
- : result;
- };
-
- const formatTime = (
- date: Date,
- options: Intl.DateTimeFormatOptions = {
- hour: "numeric",
- minute: "2-digit",
- },
- ) => {
- const result = date.toLocaleTimeString(i18n.language, {
- ...options,
- ...clockOptions,
- });
- return clockOptions.hourCycle === "h23"
- ? stripLeadingZeroFromHour(result)
- : result;
- };
-
- const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions) => {
- const adjusted = withYearFirstAdjustment(options, dateFormat);
- return date.toLocaleDateString(
- isNumericMonth(adjusted) ? dateLocale : i18n.language,
- adjusted,
- );
- };
-
- const formatDateRange = (
- from: Date,
- to: Date,
- options?: Intl.DateTimeFormatOptions,
- ) => {
- const adjusted = withYearFirstAdjustment(options, dateFormat);
- const locale = isNumericMonth(adjusted) ? dateLocale : i18n.language;
- return new Intl.DateTimeFormat(locale, adjusted)
- .formatRange(from, to)
- .replace(/\s*–\s*/g, " – ");
- };
-
- /** Same as `formatDateTime` but omits minutes when they are zero and AM/PM format is in use */
- const formatDateTimeSmartMinutes = (
- date: Date,
- options?: Intl.DateTimeFormatOptions,
- ) => {
- const showMinutes =
- date.getMinutes() !== 0 ||
- clockFormat === "24h" ||
- i18n.language !== "en";
-
- return formatDateTime(date, {
- ...options,
- minute: showMinutes ? "numeric" : undefined,
- });
- };
-
- const formatDistanceToNow = (
- date: Parameters[0],
- options?: Omit[1], "language">,
- ) => {
- return formatDistanceToNowUtil(date, {
- ...options,
- language: i18n.language as LanguageCode,
- });
- };
-
- const formatDuration = (hours: number, minutes: number) => {
- return new Intl.DurationFormat(i18n.language, { style: "narrow" }).format({
- hours,
- minutes,
- });
- };
-
- const formatRelativeTime = (hours: number, minutes: number) => {
- const rtf = new Intl.RelativeTimeFormat(i18n.language, { style: "short" });
-
- if (hours > 0) {
- return rtf.format(hours, "hour");
- }
-
- return rtf.format(minutes, "minute");
- };
-
- return {
- formatDateTime,
- formatTime,
- formatDate,
- formatDateRange,
- formatDateTimeSmartMinutes,
- formatDistanceToNow,
- formatDuration,
- formatRelativeTime,
- };
-}
-
-// Example: "09:00" -> "9:00"
-function stripLeadingZeroFromHour(timeString: string) {
- return timeString.replace(/\b0(\d:\d{2})/g, "$1");
-}
-
-function getDateLocale(
- dateFormat: UserPreferences["dateFormat"] | undefined,
- language: string,
-) {
- if (!dateFormat || dateFormat === "auto") return language;
- return DATE_FORMAT_LOCALE[dateFormat];
-}
-
-function isNumericMonth(options: Intl.DateTimeFormatOptions | undefined) {
- if (!options?.month) return false;
- return options.month === "numeric" || options.month === "2-digit";
-}
-
-function withYearFirstAdjustment(
- options: Intl.DateTimeFormatOptions | undefined,
- dateFormat: UserPreferences["dateFormat"] | undefined,
-): Intl.DateTimeFormatOptions | undefined {
- if (options?.year !== "2-digit") return options;
- if (dateFormat !== "YMD") return options;
- return { ...options, year: "numeric" };
-}
diff --git a/app/root.tsx b/app/root.tsx
index 5014323a8..51228931f 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -52,6 +52,7 @@ import {
useTheme,
} from "./features/theme/core/provider";
import { getThemeSession } from "./features/theme/core/theme-session.server";
+import { useUserIntlPreference } from "./hooks/intl/useUserIntlPreference";
import { useHydrated } from "./hooks/useHydrated";
import { DEFAULT_LANGUAGE } from "./modules/i18n/config";
import { i18nCookie, i18next } from "./modules/i18n/i18next.server";
@@ -153,6 +154,7 @@ function Document({
}) {
const { htmlThemeClass } = useTheme();
const { i18n } = useTranslation();
+ const { language } = useUserIntlPreference();
const navigate = useNavigate();
const locale = data?.locale ?? DEFAULT_LANGUAGE;
const customThemeStyle = useCustomThemeVars();
@@ -217,7 +219,7 @@ function Document({
{IS_E2E_TEST_RUN && }
-
+
diff --git a/app/styles/utils.css b/app/styles/utils.css
index ac7113a12..dbb683694 100644
--- a/app/styles/utils.css
+++ b/app/styles/utils.css
@@ -380,10 +380,18 @@
display: none;
}
+ .block {
+ display: block;
+ }
+
.invisible {
visibility: hidden;
}
+ .reserve-one-lb {
+ min-height: 1lh;
+ }
+
.whitespace-pre-wrap {
white-space: pre-wrap;
}
diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts
index abd0af2a2..acac94edd 100644
--- a/e2e/calendar.spec.ts
+++ b/e2e/calendar.spec.ts
@@ -1,3 +1,4 @@
+import type { Page } from "@playwright/test";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { calendarPage } from "~/utils/urls";
import {
@@ -102,4 +103,43 @@ test.describe("Calendar", () => {
await page.getByTestId("calendar-navigate-button").nth(1).click();
await expect(page.getByTestId("today-header")).toBeVisible();
});
+
+ test("renders clock header times in the browser locale", async ({
+ browser,
+ workerBaseURL,
+ }) => {
+ const openWith = async (locale: string) => {
+ const context = await browser.newContext({
+ locale,
+ baseURL: workerBaseURL,
+ });
+ const page = await context.newPage();
+ return { context, page };
+ };
+
+ const ca = await openWith("en-CA");
+ const gb = await openWith("en-GB");
+
+ try {
+ await seed(ca.page);
+
+ await navigate({ page: ca.page, url: calendarPage() });
+ await navigate({ page: gb.page, url: calendarPage() });
+
+ const firstClockText = (page: Page) =>
+ page
+ .locator("[class*='clockHeader'] [class*='reserve-one-lb']")
+ .first();
+
+ const caTime = await firstClockText(ca.page).textContent();
+ const gbTime = await firstClockText(gb.page).textContent();
+
+ expect(caTime).toMatch(/AM|PM|a\.m\.|p\.m\./i);
+ expect(gbTime).not.toMatch(/AM|PM|a\.m\.|p\.m\./i);
+ expect(caTime).not.toBe(gbTime);
+ } finally {
+ await ca.context.close();
+ await gb.context.close();
+ }
+ });
});
diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts
index da5bb1746..c22f6d5c7 100644
--- a/e2e/settings.spec.ts
+++ b/e2e/settings.spec.ts
@@ -1,11 +1,9 @@
import type { Page } from "@playwright/test";
import {
- clockFormatSchema,
disableBuildAbilitySortingSchema,
spoilerFreeModeSchema,
} from "~/features/settings/settings-schemas";
import {
- CALENDAR_PAGE,
SETTINGS_PAGE,
tournamentBracketsPage,
tournamentResultsPage,
@@ -57,40 +55,6 @@ test.describe("Settings", () => {
expect(newContents).not.toBe(oldContents);
});
-
- test("updates clock format preference", async ({ page }) => {
- await seed(page);
- await impersonate(page);
-
- await navigate({
- page,
- url: CALENDAR_PAGE,
- });
-
- const clockHeader = page.locator("[class*='clockHeader']").first();
- const initialTime = await clockHeader.locator("span").first().textContent();
-
- expect(initialTime).toMatch(/AM|PM/);
-
- await navigate({
- page,
- url: SETTINGS_PAGE,
- });
-
- const form = createFormHelpers(page, clockFormatSchema);
- await waitForPOSTResponse(page, () => form.select("newValue", "24h"));
-
- await navigate({
- page,
- url: CALENDAR_PAGE,
- });
-
- const newTime = await clockHeader.locator("span").first().textContent();
-
- expect(newTime).not.toMatch(/AM|PM/);
- expect(newTime).not.toBe(initialTime);
- expect(newTime).toContain(":");
- });
});
const enableSpoilerFreeMode = async (page: Page) => {
diff --git a/locales/da/forms.json b/locales/da/forms.json
index 28150d066..253962d86 100644
--- a/locales/da/forms.json
+++ b/locales/da/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/de/forms.json b/locales/de/forms.json
index 9576f3f2f..b462c6654 100644
--- a/locales/de/forms.json
+++ b/locales/de/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/en/forms.json b/locales/en/forms.json
index 481edee16..23f12f4a3 100644
--- a/locales/en/forms.json
+++ b/locales/en/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "Tag",
"labels.teamBsky": "Team Bluesky",
"labels.clockFormat": "Clock format",
- "labels.dateFormat": "Date format",
"labels.disableBuildAbilitySorting": "Builds: Disable automatic ability sorting",
"labels.disallowScrimPickupsFromUntrusted": "Disallow scrim pickups from non-friends",
"labels.noScreen": "[Accessibility] Avoid Splattercolor Screen",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "Only applies if you are in the lobby as group leader. Other group leaders can still pick you up.",
"bottomTexts.noScreen": "Affects tournaments, scrims and SendouQ",
"bottomTexts.spoilerFreeMode": "Hides tournament results from the last week",
+ "bottomTexts.languageClockTimeNote": "Clock and time formats use your browser language setting",
"options.clockFormat.auto": "Automatic",
"options.clockFormat.24h": "24-hour",
"options.clockFormat.12h": "12-hour",
- "options.dateFormat.auto": "Automatic",
- "options.dateFormat.MDY": "MM/DD/YYYY",
- "options.dateFormat.DMY": "DD/MM/YYYY",
- "options.dateFormat.YMD": "YYYY-MM-DD",
"errors.required": "This field is required",
"errors.minLength": "Must be at least {{min}} characters",
"errors.maxLength": "Must be at most {{max}} characters",
diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json
index a6adbc92d..8c62f50d5 100644
--- a/locales/es-ES/forms.json
+++ b/locales/es-ES/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "Etiqueta",
"labels.teamBsky": "Bluesky del equipo",
"labels.clockFormat": "Formato de hora",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "Builds: Desactivar orden automático de potenciadores",
"labels.disallowScrimPickupsFromUntrusted": "No permitir invitaciones de usuarios no verificados",
"labels.noScreen": "[Accesibilidad] Evitar Pantintalla",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "Solo aplica si estás en el lobby como líder de grupo. Otros líderes de grupo aún pueden añadirte.",
"bottomTexts.noScreen": "Afecta a torneos, scrims y SendouQ.",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "Automático",
"options.clockFormat.24h": "24 horas",
"options.clockFormat.12h": "12 horas",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "Este campo es obligatorio",
"errors.minLength": "Debe tener al menos {{min}} caracteres",
"errors.maxLength": "Debe tener como máximo {{max}} caracteres",
diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json
index 6b8f24867..1892162d2 100644
--- a/locales/es-US/forms.json
+++ b/locales/es-US/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json
index e1f3f760c..2a9745d45 100644
--- a/locales/fr-CA/forms.json
+++ b/locales/fr-CA/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json
index 827686623..cf33b2515 100644
--- a/locales/fr-EU/forms.json
+++ b/locales/fr-EU/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "Team Bluesky",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/he/forms.json b/locales/he/forms.json
index e66526d44..7ebccaeb3 100644
--- a/locales/he/forms.json
+++ b/locales/he/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/it/forms.json b/locales/it/forms.json
index 279316f92..4639f98d8 100644
--- a/locales/it/forms.json
+++ b/locales/it/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "Bluesky del team",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/ja/forms.json b/locales/ja/forms.json
index f9b9ff1cb..9d75f36a2 100644
--- a/locales/ja/forms.json
+++ b/locales/ja/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "チームの Bluesky",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/ko/forms.json b/locales/ko/forms.json
index 5a70e3771..ea005f94c 100644
--- a/locales/ko/forms.json
+++ b/locales/ko/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/nl/forms.json b/locales/nl/forms.json
index 37239bb98..12c6231a5 100644
--- a/locales/nl/forms.json
+++ b/locales/nl/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/pl/forms.json b/locales/pl/forms.json
index cc60da373..5888d9bc8 100644
--- a/locales/pl/forms.json
+++ b/locales/pl/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json
index aefb1b092..40b798750 100644
--- a/locales/pt-BR/forms.json
+++ b/locales/pt-BR/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/ru/forms.json b/locales/ru/forms.json
index bb93cffa3..25a8427bb 100644
--- a/locales/ru/forms.json
+++ b/locales/ru/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "Bluesky команды",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
diff --git a/locales/zh/forms.json b/locales/zh/forms.json
index dea5f6563..26ac4d04f 100644
--- a/locales/zh/forms.json
+++ b/locales/zh/forms.json
@@ -5,7 +5,6 @@
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",
- "labels.dateFormat": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
@@ -17,13 +16,10 @@
"bottomTexts.disallowScrimPickupsFromUntrusted": "",
"bottomTexts.noScreen": "",
"bottomTexts.spoilerFreeMode": "",
+ "bottomTexts.languageClockTimeNote": "",
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
- "options.dateFormat.auto": "",
- "options.dateFormat.MDY": "",
- "options.dateFormat.DMY": "",
- "options.dateFormat.YMD": "",
"errors.required": "",
"errors.minLength": "",
"errors.maxLength": "",
|