New hooks and components for localized date time (#3065)

This commit is contained in:
Kalle
2026-05-17 13:19:25 +03:00
committed by GitHub
parent 948975cb00
commit 6a72946b45
80 changed files with 1021 additions and 1184 deletions

View File

@@ -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 `<LocaleTime />`, `<LocaleTimeRange>` or `useFormatDistanceToNow`. If needed use `useDateTimeFormat` directly. NEVER use e.g. `toLocaleString` directly as it does not include users' language selection.
## Remix/React Router

View File

@@ -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) {
<Lock size={16} /> {t("common:build.private")}
</div>
) : null}
<time
className={clsx("whitespace-nowrap", { invisible: !isHydrated })}
>
{isHydrated
? formatDate(databaseTimestampToDate(updatedAt), {
day: "numeric",
month: "numeric",
year: "numeric",
})
: "t"}
</time>
<LocaleTime
date={updatedAt}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
className="whitespace-nowrap"
/>
</div>
</div>
</div>

View File

@@ -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;

View File

@@ -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<Record<string, typeof events>>(
@@ -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"

View File

@@ -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 `<time>` element. */
className?: string;
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
inline?: boolean;
}
/**
* Renders a `<time>` element with the given date formatted according to the user's locale preferences.
*
* During SSR and before the user's locale preference has loaded the formatted text is hidden
* (via `invisible`) while still reserving one line of height to avoid layout shift on hydration.
* The `dateTime` attribute is always set to the ISO string for machine readability and a11y.
*/
export function LocaleTime({
date,
options,
className,
inline,
}: LocaleTimeProps) {
const { formatter, isLoaded } = useDateTimeFormat(options);
const dateObject =
typeof date === "number" ? databaseTimestampToDate(date) : date;
return (
<time
dateTime={dateObject.toISOString()}
className={clsx(
"reserve-one-lb",
{
block: !inline,
invisible: !isLoaded,
},
className,
)}
>
{formatter.format(dateObject)}
</time>
);
}

View File

@@ -0,0 +1,53 @@
import clsx from "clsx";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
interface LocaleTimeRangeProps {
/** Start of the range. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
from: Date | number;
/** End of the range. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
to: 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 element. */
className?: string;
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
inline?: boolean;
}
/**
* Renders the given date range formatted according to the user's locale preferences,
* using `Intl.DateTimeFormat.prototype.formatRange` for locale-aware separators and
* collapsing of shared parts (e.g. the year when both bounds share it).
*
* During SSR and before the user's locale preference has loaded the formatted text is hidden
* (via `invisible`) while still reserving one line of height to avoid layout shift on hydration.
*/
export function LocaleTimeRange({
from,
to,
options,
className,
inline,
}: LocaleTimeRangeProps) {
const { formatter, isLoaded } = useDateTimeFormat(options);
const fromDate =
typeof from === "number" ? databaseTimestampToDate(from) : from;
const toDate = typeof to === "number" ? databaseTimestampToDate(to) : to;
return (
<span
className={clsx(
"reserve-one-lb",
{
block: !inline,
invisible: !isLoaded,
},
className,
)}
>
{formatter.formatRange(fromDate, toDate)}
</span>
);
}

View File

@@ -1,6 +1,5 @@
import type * as React from "react";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
export function RelativeTime({
children,
@@ -9,24 +8,15 @@ export function RelativeTime({
children: React.ReactNode;
timestamp: number;
}) {
const isHydrated = useHydrated();
const { formatDateTime } = useTimeFormat();
const { formatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
timeZoneName: "short",
});
return (
<abbr
title={
isHydrated
? formatDateTime(new Date(timestamp), {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
timeZoneName: "short",
})
: undefined
}
>
{children}
</abbr>
<abbr title={formatter.format(timestamp) ?? undefined}>{children}</abbr>
);
}

View File

@@ -4,8 +4,9 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import type { SidebarStream } from "~/features/core/streams/streams.server";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl, tournamentRegisterPage } from "~/utils/urls";
import { Image } from "./Image";
@@ -25,12 +26,22 @@ export function StreamListItems({
savedTournamentIds?: number[];
}) {
const { t, i18n } = useTranslation(["front"]);
const { formatDateTime, formatTime, formatDistanceToNow } = useTimeFormat();
const formatDistanceToNow = useFormatDistanceToNow();
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const { formatter: dateTimeFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const isHydrated = useHydrated();
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const timeStr = formatTime(date);
const timeStr = timeFormatter.format(date);
if (isToday(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
@@ -47,12 +58,7 @@ export function StreamListItems({
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
}
return formatDateTime(date, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return dateTimeFormatter.format(date);
};
return (

View File

@@ -5,9 +5,9 @@ import { useRef, useState } from "react";
import { Dialog, Popover } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { SendouButton } from "./elements/Button";
import popoverStyles from "./elements/Popover.module.css";
import { LocaleTime } from "./LocaleTime";
import styles from "./TimePopover.module.css";
export default function TimePopover({
@@ -28,8 +28,6 @@ export default function TimePopover({
className?: string;
footerText?: string;
}) {
const { formatDateTimeSmartMinutes, formatTime } = useTimeFormat();
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
@@ -63,7 +61,7 @@ export default function TimePopover({
setOpen(true);
}}
>
{formatDateTimeSmartMinutes(time, options)}
<LocaleTime date={time} options={options} inline />
</button>
<Popover
isOpen={open}
@@ -73,12 +71,15 @@ export default function TimePopover({
>
<Dialog className={popoverStyles.dialog}>
<div className="stack sm">
<div className="text-center" suppressHydrationWarning>
{formatTime(time, {
timeZoneName: "long",
hour: "numeric",
minute: "2-digit",
})}
<div className="text-center">
<LocaleTime
date={time}
options={{
timeZoneName: "long",
hour: "numeric",
minute: "numeric",
}}
/>
</div>
<SendouButton
size="miniscule"

View File

@@ -21,8 +21,7 @@ import { useDebounce } from "react-use";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouLabel } from "~/components/elements/Label";
import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { LocaleTime } from "../LocaleTime";
import selectStyles from "./Select.module.css";
import tournamentSearchStyles from "./TournamentSearch.module.css";
@@ -151,7 +150,6 @@ function TournamentItem({
};
}) {
const { t } = useTranslation(["common"]);
const { formatDate } = useTimeFormat();
if (typeof item.id === "string") {
return (
@@ -167,15 +165,6 @@ function TournamentItem({
);
}
const additionalText = () => {
const date = databaseTimestampToDate(item.startTime);
return formatDate(date, {
day: "numeric",
month: "numeric",
year: "numeric",
});
};
return (
<ListBoxItem
id={item.id}
@@ -191,9 +180,15 @@ function TournamentItem({
<img src={item.logoUrl} alt="" className={tournamentSearchStyles.logo} />
<div className={tournamentSearchStyles.itemTextsContainer}>
<span>{item.name}</span>
<div className={tournamentSearchStyles.itemAdditionalText}>
{additionalText()}
</div>
<LocaleTime
date={item.startTime}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
className={tournamentSearchStyles.itemAdditionalText}
/>
</div>
</ListBoxItem>
);

View File

@@ -11,7 +11,7 @@ import {
import { resolveDatePlaceholders } from "~/features/chat/chat-utils";
import { Chat } from "~/features/chat/components/Chat";
import { useChatContext } from "~/features/chat/useChatContext";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import sideNavStyles from "../SideNav.module.css";
import styles from "./ChatSidebar.module.css";
@@ -65,7 +65,16 @@ function LoadingState({ onClose }: { onClose?: () => void }) {
function RoomList({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
const chatContext = useChatContext()!;
const { formatDateTime } = useTimeFormat();
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const { formatter: timestampFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const rawRouteChatCode = useCurrentRouteChatCode();
const routeChatCodes = rawRouteChatCode
@@ -127,13 +136,9 @@ function RoomList({ onClose }: { onClose?: () => void }) {
room.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(room.header, (d) =>
formatDateTime(d, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}),
{resolveDatePlaceholders(
room.header,
(d) => headerFormatter.format(d) ?? "",
)}
</span>
<span className={sideNavStyles.listLinkSubtitle}>
@@ -144,10 +149,9 @@ function RoomList({ onClose }: { onClose?: () => void }) {
<span className={styles.unreadBadge}>{unread}</span>
) : room.lastMessageTimestamp > 0 ? (
<span className={styles.roomTimestamp}>
{formatDateTime(new Date(room.lastMessageTimestamp), {
hour: "numeric",
minute: "numeric",
})}
{timestampFormatter.format(
new Date(room.lastMessageTimestamp),
)}
</span>
) : null}
</Button>
@@ -163,7 +167,12 @@ function ChatView({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
const chatContext = useChatContext()!;
const activeRoom = chatContext.activeRoom!;
const { formatDateTime } = useTimeFormat();
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const otherRoomsUnreadCount = Object.entries(chatContext.unreadCounts)
.filter(([code]) => code !== activeRoom)
@@ -234,13 +243,7 @@ function ChatView({ onClose }: { onClose?: () => void }) {
>
{resolveDatePlaceholders(
room?.header ?? t("common:chat.sidebar.title"),
(d) =>
formatDateTime(d, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}),
(d) => headerFormatter.format(d) ?? "",
)}
</span>
{room?.subtitle ? (

View File

@@ -24,8 +24,8 @@ import { Link, useFetcher, useLocation, useMatches } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -58,7 +58,16 @@ const MAX_DESKTOP_FRIENDS = 4;
function useRelativeDayFormat() {
const { i18n } = useTranslation();
const { formatTime, formatDateTime } = useTimeFormat();
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const { formatter: dateTimeFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const formatRelativeDay = (daysFromToday: number) => {
const rtf = new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" });
@@ -68,7 +77,7 @@ function useRelativeDayFormat() {
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const timeStr = formatTime(date);
const timeStr = timeFormatter.format(date);
if (isToday(date)) {
return `${formatRelativeDay(0)}, ${timeStr}`;
@@ -77,12 +86,7 @@ function useRelativeDayFormat() {
return `${formatRelativeDay(1)}, ${timeStr}`;
}
return formatDateTime(date, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return dateTimeFormatter.format(date);
};
return { formatRelativeDate };

View File

@@ -2,7 +2,7 @@ import clsx from "clsx";
import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { Alert } from "~/components/Alert";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { SendouButton } from "../elements/Button";
import { SendouTabPanel } from "../elements/Tabs";
import styles from "./MatchJoinTab.module.css";
@@ -34,7 +34,7 @@ export function MatchJoinTab({
isConfirming,
}: MatchJoinTabProps) {
const { t } = useTranslation(["q"]);
const { formatDistanceToNow } = useTimeFormat();
const formatDistanceToNow = useFormatDistanceToNow();
return (
<SendouTabPanel id={TAB_KEYS.JOIN}>

View File

@@ -8,9 +8,8 @@ import {
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { LocaleTime } from "~/components/LocaleTime";
import type { GroupSkillDifference, UserSkillDifference } from "~/db/tables";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type {
MainWeaponId,
@@ -208,8 +207,6 @@ function TimelineHeader({
function TimelineMapRow({ map }: { map: TimelineMap }) {
const { t } = useTranslation(["game-misc"]);
const isHydrated = useHydrated();
const { formatTime } = useTimeFormat();
const alphaPoints = map.points?.[0];
const bravoPoints = map.points?.[1];
@@ -225,13 +222,11 @@ function TimelineMapRow({ map }: { map: TimelineMap }) {
/>
</div>
<div className={styles.mapCenter}>
<time className={styles.mapTimestamp}>
{isHydrated ? (
formatTime(new Date(map.timestamp))
) : (
<div className="invisible">X</div>
)}
</time>
<LocaleTime
date={new Date(map.timestamp)}
options={{ hour: "numeric", minute: "numeric" }}
className={styles.mapTimestamp}
/>
<StageImage
stageId={map.stageId}
width={80}

View File

@@ -1001,15 +1001,6 @@ export interface UserPreferences {
* "12h" = 12 hour format (e.g. 2:00 PM)
* */
clockFormat?: "24h" | "12h" | "auto";
/**
* What numeric date format the user prefers?
*
* "auto" = use the format the active language defaults to (default value)
* "MDY" = month/day/year (e.g. 4/27/2026)
* "DMY" = day/month/year (e.g. 27/04/2026)
* "YMD" = ISO year-month-day (e.g. 2026-04-27)
* */
dateFormat?: "auto" | "MDY" | "DMY" | "YMD";
/** Is the new widget based user page enabled? (Supporter early preview) */
newProfileEnabled?: boolean;
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */

View File

@@ -8,10 +8,11 @@ import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Pagination } from "~/components/Pagination";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import { usePagination } from "~/hooks/usePagination";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { artPage, newArtPage, userArtPage, userPage } from "~/utils/urls";
import { ResponsiveMasonry } from "../../../modules/responsive-masonry/components/ResponsiveMasonry";
@@ -89,15 +90,15 @@ export function ArtGrid({
function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
const [imageLoaded, setImageLoaded] = React.useState(false);
const { formatDate } = useTimeFormat();
const { formatter } = useDateTimeFormat({
year: "numeric",
month: "numeric",
day: "numeric",
});
return (
<SendouDialog
heading={formatDate(databaseTimestampToDate(art.createdAt), {
year: "numeric",
month: "numeric",
day: "numeric",
})}
heading={formatter.format(databaseTimestampToDate(art.createdAt)) ?? ""}
onClose={close}
isFullScreen
>
@@ -168,7 +169,7 @@ function ImagePreview({
}) {
const [imageLoaded, setImageLoaded] = React.useState(false);
const { t } = useTranslation(["common", "art"]);
const { formatDistanceToNow } = useTimeFormat();
const formatDistanceToNow = useFormatDistanceToNow();
const img = (
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration

View File

@@ -1,9 +1,9 @@
import * as React from "react";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { Markdown } from "~/components/Markdown";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -53,20 +53,20 @@ export const meta: MetaFunction = (args) => {
export default function ArticlePage() {
const data = useLoaderData<typeof loader>();
const { formatDate } = useTimeFormat();
return (
<Main>
<article className="article">
<h1>{data.title}</h1>
<div className="text-sm text-lighter">
by <Author /> {" "}
<time>
{formatDate(new Date(data.date), {
<LocaleTime
date={new Date(data.date)}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
})}
</time>
}}
/>
</div>
<Markdown>
{contentWithoutLeadingTitle(data.content, data.title)}

View File

@@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
@@ -31,7 +31,6 @@ export const meta: MetaFunction = (args) => {
export default function ArticlesMainPage() {
const { t, i18n } = useTranslation(["common"]);
const { formatDate } = useTimeFormat();
const data = useLoaderData<typeof loader>();
return (
@@ -49,13 +48,14 @@ export default function ArticlesMainPage() {
}).format(article.authors.map((a) => a.name)),
})}{" "}
{" "}
<time>
{formatDate(new Date(article.date), {
<LocaleTime
date={new Date(article.date)}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
})}
</time>
}}
/>
</div>
</li>
))}

View File

@@ -1,6 +1,6 @@
import { useLoaderData } from "react-router";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { loader } from "../loaders/suspended.server";
@@ -9,7 +9,6 @@ export { loader };
export default function SuspendedPage() {
const data = useLoaderData<typeof loader>();
const { formatDateTime } = useTimeFormat();
const ends = (() => {
if (!data.banned || data.banned === 1) return null;
@@ -22,15 +21,19 @@ export default function SuspendedPage() {
<h2>Account suspended</h2>
{data.reason ? <div>Reason: {data.reason}</div> : null}
{ends ? (
<div suppressHydrationWarning>
<div>
Ends:{" "}
{formatDateTime(ends, {
month: "numeric",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
})}
<LocaleTime
date={ends}
options={{
month: "numeric",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
}}
inline
/>
</div>
) : (
<div>

View File

@@ -5,7 +5,7 @@ import { Ability } from "~/components/Ability";
import { SendouButton } from "~/components/elements/Button";
import { ModeImage } from "~/components/Image";
import { possibleApValues } from "~/features/build-analyzer/core/utils";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { abilities } from "~/modules/in-game-lists/abilities";
import { modesShort } from "~/modules/in-game-lists/modes";
import type {
@@ -196,7 +196,11 @@ function DateFilter({
onChange: (filter: Partial<DateBuildFilter>) => void;
}) {
const { t } = useTranslation(["builds"]);
const { formatDate } = useTimeFormat();
const { formatter: patchDateFormatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
year: "numeric",
});
const selectValue = () =>
PATCHES.some(({ date }) => date === filter.date) ? filter.date : "CUSTOM";
@@ -233,13 +237,7 @@ function DateFilter({
return (
<option key={patch} value={dateString}>
{patch} (
{formatDate(date, {
day: "numeric",
month: "numeric",
year: "numeric",
})}
)
{patch} ({patchDateFormatter.format(date) ?? ""})
</option>
);
})}

View File

@@ -8,9 +8,9 @@ import { Flag } from "~/components/Flag";
import { Image, ModeImage } from "~/components/Image";
import { TierPill } from "~/components/TierPill";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl } from "~/utils/urls";
import type { CalendarEvent, ShowcaseCalendarEvent } from "../calendar-types";
@@ -20,39 +20,21 @@ import styles from "./TournamentCard.module.css";
export function TournamentCard({
tournament,
className,
withRelativeTime = false,
}: {
tournament: CalendarEvent | ShowcaseCalendarEvent;
className?: string;
withRelativeTime?: boolean;
}) {
const isHydrated = useHydrated();
const { formatDateTimeSmartMinutes, formatDistanceToNow } = useTimeFormat();
const formatDistanceToNow = useFormatDistanceToNow();
const { isCensored, reveal } = useSpoilerFree();
const isShowcase = tournament.type === "showcase";
const isCalendar = tournament.type === "calendar";
const isHostedOnSendouInk = typeof tournament.isRanked === "boolean";
const time = () => {
if (!isShowcase) return null;
if (!isHydrated) return "Placeholder";
const date = databaseTimestampToDate(tournament.startTime);
if (withRelativeTime) {
return formatDistanceToNow(date, {
addSuffix: true,
});
}
return formatDateTimeSmartMinutes(date, {
month: "numeric",
day: "numeric",
hour: "numeric",
weekday: "short",
});
};
const startDate = isShowcase
? databaseTimestampToDate(tournament.startTime)
: null;
return (
<div
@@ -101,16 +83,16 @@ export function TournamentCard({
<TierPill tier={tournament.tentativeTier} isTentative />
) : null}
</div>
{isShowcase ? (
{startDate ? (
<time
className={clsx(styles.time, {
invisible: !isHydrated,
})}
dateTime={databaseTimestampToDate(
tournament.startTime,
).toISOString()}
dateTime={startDate.toISOString()}
>
{time()}
{isHydrated
? formatDistanceToNow(startDate, { addSuffix: true })
: "Placeholder"}
</time>
) : null}
{isCalendar ? (

View File

@@ -7,6 +7,7 @@ import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { MapPoolStages } from "~/components/MapPoolSelector";
import { Placement } from "~/components/Placement";
@@ -14,8 +15,6 @@ import { Section } from "~/components/Section";
import { Table } from "~/components/Table";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -81,8 +80,6 @@ export default function CalendarEventPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "calendar"]);
const isHydrated = useHydrated();
const { formatDateTime } = useTimeFormat();
return (
<Main className="stack lg">
@@ -99,18 +96,17 @@ export default function CalendarEventPage() {
number: i + 1,
})}
</span>
<time dateTime={databaseTimestampToDate(startTime).toISOString()}>
{isHydrated
? formatDateTime(databaseTimestampToDate(startTime), {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
weekday: "long",
year: "numeric",
})
: null}
</time>
<LocaleTime
date={startTime}
options={{
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
weekday: "long",
year: "numeric",
}}
/>
</React.Fragment>
))}
</div>

View File

@@ -22,8 +22,8 @@ import { SubmitButton } from "~/components/SubmitButton";
import type { CalendarEventTag, Tables } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { RankedModeShort } from "~/modules/in-game-lists/types";
import { useHasRole } from "~/modules/permissions/hooks";
import {
@@ -137,7 +137,10 @@ export default function CalendarNewEventPage() {
function TemplateTournamentForm() {
const { recentTournaments } = useLoaderData<typeof loader>();
const [eventId, setEventId] = React.useState("");
const { formatDate } = useTimeFormat();
const { formatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
});
if (!recentTournaments) return null;
@@ -153,13 +156,8 @@ function TemplateTournamentForm() {
>
<option value="">Select a template</option>
{recentTournaments.map((event) => (
<option key={event.id} value={event.id} suppressHydrationWarning>
{event.name} (
{formatDate(databaseTimestampToDate(event.startTime), {
month: "numeric",
day: "numeric",
})}
)
<option key={event.id} value={event.id}>
{event.name} ({formatter.format(event.startTime) ?? ""})
</option>
))}
</select>

View File

@@ -19,10 +19,11 @@ import {
} from "~/components/elements/Button";
import { SendouCalendar } from "~/components/elements/Calendar";
import { SendouPopover } from "~/components/elements/Popover";
import { LocaleTime } from "~/components/LocaleTime";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { Main } from "~/components/Main";
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
import { useCollapsableEvents } from "~/features/calendar/calendar-hooks";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { dayMonthYearToDateValue } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@@ -144,16 +145,10 @@ function NavigateButton({
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
filters?: CalendarLoaderData["filters"];
}) {
const { formatDateRange } = useTimeFormat();
const lowestDate = daysInterval[0];
const highestDate = daysInterval[daysInterval.length - 1];
const year = new Date().getFullYear();
const rangeString = formatDateRange(
new Date(year, lowestDate.month, lowestDate.day),
new Date(year, highestDate.month, highestDate.day),
{ day: "numeric", month: "numeric" },
);
return (
<Link
@@ -164,7 +159,12 @@ function NavigateButton({
{icon}
<div>
<div>{children}</div>
<div className={styles.navigateArrowButtonRange}>{rangeString}</div>
<LocaleTimeRange
from={new Date(year, lowestDate.month, lowestDate.day)}
to={new Date(year, highestDate.month, highestDate.day)}
options={{ day: "numeric", month: "numeric" }}
className={styles.navigateArrowButtonRange}
/>
</div>
</Link>
);
@@ -247,8 +247,6 @@ function DayEventsColumn({
}
function DayHeader(props: { date: number; month: number; year: number }) {
const { formatDate } = useTimeFormat();
const date = new Date(props.year, props.month, props.date);
const isToday = date.toDateString() === new Date().toDateString();
@@ -259,14 +257,20 @@ function DayHeader(props: { date: number; month: number; year: number }) {
})}
data-testid={isToday ? "today-header" : undefined}
>
{formatDate(date, {
day: "numeric",
month: "numeric",
})}
<LocaleTime
date={date}
options={{
day: "numeric",
month: "long",
}}
/>
<div className={styles.dayHeaderWeekday}>
{formatDate(date, {
weekday: "long",
})}
<LocaleTime
date={date}
options={{
weekday: "long",
}}
/>
</div>
</div>
);
@@ -287,21 +291,33 @@ function ClockHeader({
hiddenShown: boolean;
className?: string;
}) {
const { formatTime } = useTimeFormat();
const isInThePast = (toDate ?? date).getTime() < Date.now();
const timeOptions: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "numeric",
};
return (
<div className={clsx(className, styles.clockHeader)}>
<div className="stack horizontal justify-between">
<span
className={clsx({
"text-lighter italic": isInThePast,
})}
>
{formatTime(date)}
{toDate ? ` - ${formatTime(toDate)}` : ""}
</span>
{toDate ? (
<LocaleTimeRange
from={date}
to={toDate}
options={timeOptions}
className={clsx({
"text-lighter italic": isInThePast,
})}
/>
) : (
<LocaleTime
className={clsx({
"text-lighter italic": isInThePast,
})}
date={date}
options={timeOptions}
/>
)}
{hiddenEventsCount > 0 ? (
<SendouButton
icon={hiddenShown ? <Eye /> : <EyeOff />}

View File

@@ -25,10 +25,10 @@ export function datePlaceholder(date: Date): string {
export function resolveDatePlaceholders(
text: string,
formatDateTime: (date: Date) => string,
format: (date: Date) => string,
): string {
return text.replace(DATE_PLACEHOLDER_PATTERN, (_match, ts) =>
formatDateTime(new Date(Number(ts))),
format(new Date(Number(ts))),
);
}

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { Avatar } from "../../../components/Avatar";
import { SendouButton } from "../../../components/elements/Button";
import { SubmitButton } from "../../../components/SubmitButton";
import { useTimeFormat } from "../../../hooks/useTimeFormat";
import { useDateTimeFormat } from "../../../hooks/intl/useDateTimeFormat";
import { findRoomLinks, MESSAGE_MAX_LENGTH } from "../chat-constants";
import { useChatAutoScroll } from "../chat-hooks";
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
@@ -340,19 +340,23 @@ function MessageContents({ text }: { text: string }) {
}
function MessageTimestamp({ timestamp }: { timestamp: number }) {
const { formatDateTime, formatTime } = useTimeFormat();
const { formatter: dateTimeFormatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
});
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
return (
<time className={styles.messageTime}>
{moreThanDayAgo
? formatDateTime(new Date(timestamp), {
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
})
: formatTime(new Date(timestamp))}
? dateTimeFormatter.format(new Date(timestamp))
: timeFormatter.format(new Date(timestamp))}
</time>
);
}

View File

@@ -11,8 +11,7 @@ import {
} from "~/components/elements/Menu";
import { ListButton } from "~/components/SideNav";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { SENDOUQ_LOOKING_PAGE, tournamentSubsPage } from "~/utils/urls";
export function FriendMenu({
@@ -39,17 +38,17 @@ export function FriendMenu({
onNavigate?: () => void;
}) {
const { t } = useTranslation(["common", "friends"]);
const { formatDate } = useTimeFormat();
const { formatter: dateFormatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
year: "numeric",
});
const fetcher = useFetcher();
const [confirmOpen, setConfirmOpen] = React.useState(false);
const friendSinceText = friendshipCreatedAt
? t("friends:friendsList.friendSince", {
date: formatDate(databaseTimestampToDate(friendshipCreatedAt), {
day: "numeric",
month: "numeric",
year: "numeric",
}),
date: dateFormatter.format(friendshipCreatedAt) ?? "",
})
: null;

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { differenceInSeconds } from "date-fns";
import { differenceInMinutes, differenceInSeconds } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
@@ -8,7 +8,8 @@ import {
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { ModeImage, StageImage } from "~/components/Image";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type { RankedModeShort, StageId } from "~/modules/in-game-lists/types";
import {
@@ -32,6 +33,8 @@ type RotationFromLoader = FrontPageLoaderData["rotations"][number];
const TYPE_ORDER = ["X", "SERIES", "OPEN"];
const RELATIVE_TIME_CUTOFF_MINUTES = 120;
export function SplatoonRotations() {
const { t } = useTranslation(["front"]);
const data = useLoaderData<typeof loader>();
@@ -141,29 +144,15 @@ function useNowUnix(initialNow: number) {
return now;
}
function timeRemaining(now: Date, start: Date, end: Date) {
function rotationProgress(now: Date, start: Date, end: Date) {
const remainingSeconds = differenceInSeconds(end, now);
if (remainingSeconds <= 0) return null;
const totalSeconds = differenceInSeconds(end, start);
const elapsedSeconds = differenceInSeconds(now, start);
const progress =
totalSeconds > 0
? Math.min(1, Math.max(0, elapsedSeconds / totalSeconds))
: 0;
const hours = Math.floor(remainingSeconds / 3600);
const minutes = Math.floor((remainingSeconds % 3600) / 60);
return { hours, minutes, progress };
}
function timeUntil(now: Date, start: Date) {
const diffSeconds = differenceInSeconds(start, now);
if (diffSeconds <= 0) return null;
const hours = Math.floor(diffSeconds / 3600);
const minutes = Math.floor((diffSeconds % 3600) / 60);
return { hours, minutes };
return totalSeconds > 0
? Math.min(1, Math.max(0, elapsedSeconds / totalSeconds))
: 0;
}
function RotationCard({
@@ -180,23 +169,16 @@ function RotationCard({
now: Date;
}) {
const { t } = useTranslation(["front", "game-misc"]);
const { formatTime, formatDuration, formatRelativeTime } = useTimeFormat();
const remaining = timeRemaining(
now,
databaseTimestampToDate(current?.startTime ?? 0),
databaseTimestampToDate(current?.endTime ?? 0),
);
const formatDistanceToNow = useFormatDistanceToNow();
const progress = current
? rotationProgress(
now,
databaseTimestampToDate(current.startTime),
databaseTimestampToDate(current.endTime),
)
: null;
const displayRotation = current ?? next;
const nextStartsIn = timeUntil(
now,
databaseTimestampToDate(next?.startTime ?? 0),
);
const nextAfterStartsIn = timeUntil(
now,
databaseTimestampToDate(nextAfter?.startTime ?? 0),
);
const shownNext = current ? next : nextAfter;
const shownNextStartsIn = current ? nextStartsIn : nextAfterStartsIn;
if (!displayRotation) return null;
@@ -206,18 +188,18 @@ function RotationCard({
<ModeImage mode={displayRotation.mode as RankedModeShort} width={20} />
{t(`front:${ROTATION_TYPE_LABELS[type]}` as any)}
</div>
{current && remaining ? (
{current && progress !== null ? (
<div className={styles.rotationCardProgress}>
<div
className={styles.rotationCardProgressBar}
style={{ width: `${remaining.progress * 100}%` }}
style={{ width: `${progress * 100}%` }}
/>
<span className={styles.rotationCardProgressText}>
{formatDuration(remaining.hours, remaining.minutes)}
{formatDistanceToNow(current.endTime)}
</span>
</div>
) : null}
{!current && next && nextStartsIn ? (
{!current && next ? (
<div
className={clsx(
styles.rotationCardProgress,
@@ -227,9 +209,7 @@ function RotationCard({
<span className={styles.rotationCardProgressText}>
<NextLabel
startTime={databaseTimestampToDate(next.startTime)}
startsIn={nextStartsIn}
formatTime={formatTime}
formatRelativeTime={formatRelativeTime}
now={now}
/>
</span>
</div>
@@ -251,15 +231,13 @@ function RotationCard({
<div className={styles.rotationCardNextInfo}>
{current && shownNext.startTime === current.endTime ? (
t("front:rotations.nextLabel")
) : shownNextStartsIn ? (
) : (
<NextLabel
startTime={databaseTimestampToDate(shownNext.startTime)}
startsIn={shownNextStartsIn}
formatTime={formatTime}
formatRelativeTime={formatRelativeTime}
now={now}
compact
/>
) : null}
)}
<ModeImage mode={shownNext.mode as RankedModeShort} width={16} />{" "}
{shortStageName(t(`game-misc:STAGE_${shownNext.stageId1}` as any))},{" "}
{shortStageName(t(`game-misc:STAGE_${shownNext.stageId2}` as any))}
@@ -272,31 +250,28 @@ function RotationCard({
function NextLabel({
startTime,
startsIn,
formatTime,
formatRelativeTime,
now,
compact,
}: {
startTime: Date;
startsIn: { hours: number; minutes: number };
formatTime: (date: Date) => string;
formatRelativeTime: (hours: number, minutes: number) => string;
now: Date;
compact?: boolean;
}) {
const { t } = useTranslation(["front"]);
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const formatDistanceToNow = useFormatDistanceToNow();
const withinTwoHours = startsIn.hours * 60 + startsIn.minutes <= 120;
const minutesUntilStart = differenceInMinutes(startTime, now);
if (minutesUntilStart <= 0) return null;
if (compact) {
if (withinTwoHours) {
return formatRelativeTime(startsIn.hours, startsIn.minutes);
}
return formatTime(startTime);
}
const withinCutoff = minutesUntilStart <= RELATIVE_TIME_CUTOFF_MINUTES;
const relativeText = withinCutoff
? formatDistanceToNow(startTime, { addSuffix: true })
: timeFormatter.format(startTime);
if (withinTwoHours) {
return `${t("front:rotations.nextLabel")} (${formatRelativeTime(startsIn.hours, startsIn.minutes)})`;
}
return `${t("front:rotations.nextLabel")} (${formatTime(startTime)})`;
if (compact) return relativeText;
return `${t("front:rotations.nextLabel")} (${relativeText})`;
}

View File

@@ -10,14 +10,13 @@ import { BSKYLikeIcon } from "~/components/icons/BSKYLike";
import { BSKYReplyIcon } from "~/components/icons/BSKYReply";
import { BSKYRepostIcon } from "~/components/icons/BSKYRepost";
import { ExternalIcon } from "~/components/icons/External";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { navItems } from "~/components/layout/nav-items";
import { Main } from "~/components/Main";
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
import type * as Changelog from "~/features/front-page/core/Changelog.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import styles from "~/styles/front.module.css";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -66,16 +65,15 @@ function SeasonDates({
season: ReturnType<typeof useSeasonData>["season"];
className: string;
}) {
const isHydrated = useHydrated();
const { formatDate } = useTimeFormat();
return isHydrated ? (
return (
<div className={className}>
{formatDate(season.starts, { month: "numeric", day: "numeric" })} -{" "}
{formatDate(season.ends, { month: "numeric", day: "numeric" })}
<LocaleTimeRange
from={season.starts}
to={season.ends}
options={{ month: "numeric", day: "numeric" }}
inline
/>
</div>
) : (
<div className={clsx(className, "invisible")}>X</div>
);
}
@@ -216,11 +214,7 @@ function ResultHighlights() {
</h2>
<div className={styles.tournamentCardsSpacer}>
{data.tournaments.results.map((tournament) => (
<TournamentCard
key={tournament.id}
tournament={tournament}
withRelativeTime
/>
<TournamentCard key={tournament.id} tournament={tournament} />
))}
</div>
</div>

View File

@@ -9,11 +9,12 @@ import { SendouButton } from "~/components/elements/Button";
import { Flag } from "~/components/Flag";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, TierImage, WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { useUser } from "~/features/auth/core/user";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { lfgNewPostPage, navIconUrl, userPage } from "~/utils/urls";
@@ -262,7 +263,7 @@ function PostTime({
updatedAt: number;
}) {
const { t } = useTranslation(["lfg"]);
const { formatDate, formatDistanceToNow } = useTimeFormat();
const formatDistanceToNow = useFormatDistanceToNow();
const createdAtDate = databaseTimestampToDate(createdAt);
const updatedAtDate = databaseTimestampToDate(updatedAt);
@@ -271,10 +272,13 @@ function PostTime({
return (
<div className="text-lighter text-xs font-bold">
{formatDate(createdAtDate, {
month: "numeric",
day: "numeric",
})}{" "}
<LocaleTime
date={createdAtDate}
options={{
month: "numeric",
day: "numeric",
}}
/>{" "}
{overDayDifferenceBetween ? (
<div className="text-xxs">
<i>

View File

@@ -1,7 +1,7 @@
import clsx from "clsx";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { LocaleTime } from "~/components/LocaleTime";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import { PLUS_SERVER_DISCORD_URL, userPage } from "~/utils/urls";
@@ -22,22 +22,24 @@ export const meta: MetaFunction = (args) => {
export default function PlusVotingResultsPage() {
const data = useLoaderData<typeof loader>();
const { formatDate } = useTimeFormat();
return (
<div className="stack md">
<h2 className="text-center">
Voting results for{" "}
{formatDate(
new Date(
data.lastCompletedVoting.year,
data.lastCompletedVoting.month,
),
{
<LocaleTime
date={
new Date(
data.lastCompletedVoting.year,
data.lastCompletedVoting.month,
)
}
options={{
month: "numeric",
year: "numeric",
},
)}
}}
inline
/>
</h2>
{data.ownScores && data.ownScores.length > 0 ? (
<>

View File

@@ -19,9 +19,10 @@ import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ModeImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { scrimPage, tournamentRegisterPage, userPage } from "~/utils/urls";
@@ -335,7 +336,6 @@ function ScrimActionButtons({
post: ScrimPost;
}) {
const { t } = useTranslation(["scrims", "common"]);
const { formatDateTime } = useTimeFormat();
const user = useUser();
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [isViewRequestModalOpen, setIsViewRequestModalOpen] = useState(false);
@@ -400,14 +400,16 @@ function ScrimActionButtons({
<div className="text-sm font-semi-bold mb-1">
{t("scrims:requestModal.at.label")}
</div>
<div className="text-lighter">
{formatDateTime(databaseTimestampToDate(userRequest.at), {
<LocaleTime
date={userRequest.at}
options={{
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "numeric",
})}
</div>
}}
className="text-lighter"
/>
</div>
) : null}
<Form method="post">
@@ -474,7 +476,10 @@ export function ScrimRequestCard({
showFooter = true,
}: ScrimRequestCardProps) {
const { t } = useTranslation(["scrims", "common"]);
const { formatTime } = useTimeFormat();
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "2-digit",
});
const owner = request.users.find((user) => user.isOwner) ?? request.users[0];
const isPickup = !request.team?.name;
@@ -531,7 +536,7 @@ export function ScrimRequestCard({
data-testid="confirm-modal-trigger-button"
>
{t("scrims:acceptModal.confirmFor", {
time: formatTime(confirmedTime),
time: timeFormatter.format(confirmedTime) ?? "",
})}
</SendouButton>
</FormWithConfirm>
@@ -540,7 +545,7 @@ export function ScrimRequestCard({
trigger={
<SendouButton size="small">
{t("scrims:acceptModal.confirmFor", {
time: formatTime(confirmedTime),
time: timeFormatter.format(confirmedTime) ?? "",
})}
</SendouButton>
}

View File

@@ -4,7 +4,7 @@ import { Divider } from "~/components/Divider";
import { SendouDialog } from "~/components/elements/Dialog";
import type { CustomFieldRenderProps } from "~/form";
import { SendouForm } from "~/form/SendouForm";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import type { loader as scrimsLoader } from "../loaders/scrims.server";
@@ -23,7 +23,10 @@ export function ScrimRequestModal({
}) {
const { t, i18n } = useTranslation(["scrims"]);
const data = useLoaderData<typeof scrimsLoader>();
const { formatTime } = useTimeFormat();
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const timeOptions = post.rangeEnd
? generateTimeOptions(
@@ -31,7 +34,7 @@ export function ScrimRequestModal({
databaseTimestampToDate(post.rangeEnd),
).map((timestamp) => ({
value: String(timestamp),
label: formatTime(new Date(timestamp)),
label: timeFormatter.format(new Date(timestamp)) ?? "",
}))
: [];

View File

@@ -7,9 +7,9 @@ import { useLoaderData } from "react-router";
import * as R from "remeda";
import type { z } from "zod";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { LocaleTime } from "~/components/LocaleTime";
import { useUser } from "~/features/auth/core/user";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@@ -198,7 +198,6 @@ function ScrimsDaySection({
const user = useUser();
const [showFiltered, setShowFiltered] = React.useState(false);
const [showRequestPending, setShowRequestPending] = React.useState(false);
const { formatDate } = useTimeFormat();
const filteredPosts = posts.filter((post) =>
Scrim.applyFilters(post, filters),
@@ -214,11 +213,14 @@ function ScrimsDaySection({
<div className="stack md">
<div className="stack xxs">
<h2 className="text-sm">
{formatDate(databaseTimestampToDate(posts[0].at), {
day: "numeric",
month: "numeric",
weekday: "long",
})}
<LocaleTime
date={posts[0].at}
options={{
day: "numeric",
month: "numeric",
weekday: "long",
}}
/>
</h2>
{user ? (
<AvailableScrimsFilterButtons
@@ -326,7 +328,6 @@ function AvailableScrimsFilterButtons({
function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
const { t } = useTranslation(["scrims"]);
const user = useUser();
const { formatDate } = useTimeFormat();
const postsByDay = R.groupBy(posts, (post) =>
format(databaseTimestampToDate(post.at), "yyyy-MM-dd"),
@@ -340,11 +341,14 @@ function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
return (
<div key={day} className="stack md">
<h2 className="text-sm">
{formatDate(databaseTimestampToDate(posts![0].at), {
day: "numeric",
month: "numeric",
weekday: "long",
})}
<LocaleTime
date={posts![0].at}
options={{
day: "numeric",
month: "numeric",
weekday: "long",
}}
/>
</h2>
<div className="stack lg">
{posts!.map((post) => {
@@ -395,8 +399,6 @@ function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
}
function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {
const { formatDate } = useTimeFormat();
const postsByDay = R.groupBy(posts, (post) =>
format(databaseTimestampToDate(post.at), "yyyy-MM-dd"),
);
@@ -409,11 +411,14 @@ function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {
return (
<div key={day} className="stack md">
<h2 className="text-sm">
{formatDate(databaseTimestampToDate(posts![0].at), {
day: "numeric",
month: "numeric",
weekday: "long",
})}
<LocaleTime
date={posts![0].at}
options={{
day: "numeric",
month: "numeric",
weekday: "long",
}}
/>
</h2>
<div className="stack lg">
{posts!.map((post) => {

View File

@@ -10,6 +10,7 @@ import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { SubmitButton } from "~/components/SubmitButton";
import type { ParsedMemento } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
@@ -17,10 +18,8 @@ import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/le
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { languagesUnified } from "~/modules/i18n/config";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { databaseTimestampToDate } from "~/utils/dates";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import {
navIconUrl,
@@ -279,7 +278,6 @@ function GroupMember({
}) {
const { t } = useTranslation(["q", "user"]);
const user = useUser();
const { formatDateTime } = useTimeFormat();
return (
<div className="stack xxs" data-testid="sendouq-group-card-member">
@@ -307,18 +305,17 @@ function GroupMember({
{ "mt-2": member.privateNote.text },
)}
>
<div className="text-xxs text-lighter">
{formatDateTime(
databaseTimestampToDate(member.privateNote.updatedAt),
{
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
year: "numeric",
},
)}
</div>
<LocaleTime
date={member.privateNote.updatedAt}
options={{
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
year: "numeric",
}}
className="text-xxs text-lighter"
/>
<DeletePrivateNoteForm
name={member.username}
targetId={member.id}

View File

@@ -17,10 +17,10 @@ import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useHydrated } from "~/hooks/useHydrated";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -118,7 +118,10 @@ function InfoText() {
const isHydrated = useHydrated();
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const { formatTime } = useTimeFormat();
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const expiryStatus = data.ownGroup
? groupExpiryStatus(data.ownGroup.latestActionAt)
@@ -183,7 +186,7 @@ function InfoText() {
<span className="text-xxs">
{isHydrated
? t("q:looking.lastUpdatedAt", {
time: formatTime(new Date(data.lastUpdated)),
time: timeFormatter.format(new Date(data.lastUpdated)) ?? "",
})
: "Placeholder"}
</span>

View File

@@ -1,4 +1,3 @@
import clsx from "clsx";
import { User, Users } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -11,14 +10,15 @@ import { Flag } from "~/components/Flag";
import { FormMessage } from "~/components/FormMessage";
import { FriendCodePopover } from "~/components/FriendCodePopover";
import { Image } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type * as Seasons from "~/features/mmr/core/Seasons";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@@ -65,7 +65,6 @@ export const meta: MetaFunction = (args) => {
export default function QPage() {
const { t } = useTranslation(["q"]);
const { formatDateTime } = useTimeFormat();
const [dialogOpen, setDialogOpen] = React.useState(true);
const user = useUser();
const data = useLoaderData<typeof loader>();
@@ -128,18 +127,19 @@ export default function QPage() {
</SubmitButton>
</div>
{queueJoinStatus instanceof Date ? (
<div
className="text-lighter text-xs text-center text-warning"
suppressHydrationWarning
>
<div className="text-lighter text-xs text-center text-warning">
As a fresh account please wait before joining the queue. You
can join{" "}
{formatDateTime(queueJoinStatus, {
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
})}
can join at{" "}
<LocaleTime
date={queueJoinStatus}
options={{
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
}}
inline
/>
</div>
) : (
<PreviewQueueButton />
@@ -187,9 +187,7 @@ const countries = [
{ id: 4, countryCode: "JP", timeZone: "Asia/Tokyo", city: "tokyo" },
] as const;
function Clocks() {
const isHydrated = useHydrated();
const { t } = useTranslation(["q"]);
const { formatDate, formatTime } = useTimeFormat();
const now = useAutoRerender();
return (
@@ -201,25 +199,21 @@ function Clocks() {
{t(`q:front.cities.${country.city}`)}
</div>
<Flag countryCode={country.countryCode} />
<div className={clsx({ invisible: !isHydrated })}>
{isHydrated
? formatDate(now, {
timeZone: country.timeZone,
weekday: "long",
})
: // take space
"Monday"}
</div>
<div className={clsx({ invisible: !isHydrated })}>
{isHydrated
? formatTime(now, {
timeZone: country.timeZone,
hour: "numeric",
minute: "numeric",
})
: // take space
"0:00 PM"}
</div>
<LocaleTime
date={now}
options={{
timeZone: country.timeZone,
weekday: "long",
}}
/>
<LocaleTime
date={now}
options={{
timeZone: country.timeZone,
hour: "numeric",
minute: "numeric",
}}
/>
</div>
);
})}
@@ -275,32 +269,25 @@ function ActiveSeasonInfo({
season: SerializeFrom<Seasons.ListItem>;
}) {
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 (
<div
className={clsx("text-lighter text-xs text-center", {
invisible: !isHydrated,
})}
>
<div className="text-lighter text-xs text-center">
{t("q:front.seasonOpen", { nth: season.nth })}{" "}
{isHydrated ? (
<b>
{dateToString(starts)} - {dateToString(ends)}
</b>
) : null}
<b>
<LocaleTimeRange
from={new Date(season.starts)}
to={new Date(season.ends)}
options={dateOptions}
inline
/>
</b>
</div>
);
}
@@ -383,18 +370,11 @@ function UpcomingSeasonInfo({
season: SerializeFrom<Seasons.ListItem>;
}) {
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 (
<div className="font-semi-bold text-center text-sm">
@@ -402,7 +382,7 @@ function UpcomingSeasonInfo({
<br />
{t("q:front.upcomingSeason.date", {
nth: season.nth,
date: dateToString(starts),
date: formatter.format(new Date(season.starts)) ?? "",
})}
</div>
);

View File

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

View File

@@ -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 }) => <FormField name="newValue" />}
</SendouForm>
) : null}
{user ? (
<SendouForm
schema={dateFormatSchema}
defaultValues={{
newValue: user.preferences.dateFormat ?? "auto",
}}
autoSubmit
revalidateRoot
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
) : null}
{user ? (
<>
<Divider className={styles.divider} smallText>
@@ -201,6 +188,7 @@ function LanguageSelector() {
return (
<SelectFormField
label={t("common:header.language")}
bottomText="forms:bottomTexts.languageClockTimeNote"
items={languageItems}
value={i18n.language}
onChange={handleLanguageChange}

View File

@@ -19,19 +19,6 @@ export const clockFormatSchema = z.object({
}),
});
export const dateFormatSchema = z.object({
_action: stringConstant("UPDATE_DATE_FORMAT"),
newValue: select({
label: "labels.dateFormat",
items: [
{ value: "auto", label: "options.dateFormat.auto" },
{ value: "MDY", label: "options.dateFormat.MDY" },
{ value: "DMY", label: "options.dateFormat.DMY" },
{ value: "YMD", label: "options.dateFormat.YMD" },
],
}),
});
export const disableBuildAbilitySortingSchema = z.object({
_action: stringConstant("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
newValue: toggle({
@@ -86,5 +73,4 @@ export const settingsEditSchema = z.union([
updateNoSplatnetSchema,
clockFormatSchema,
weaponReportDefaultOpenSchema,
dateFormatSchema,
]);

View File

@@ -4,12 +4,11 @@ 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 type { TeamResultsLoaderData } from "~/features/team/loaders/t.$customUrl.results.server";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { tournamentTeamPage, userPage } from "~/utils/urls";
import styles from "./TeamResultsTable.module.css";
@@ -20,7 +19,6 @@ interface TeamResultsTableProps {
export function TeamResultsTable({ results }: TeamResultsTableProps) {
const { t } = useTranslation("user");
const { formatDate } = useTimeFormat();
return (
<Table>
@@ -45,11 +43,14 @@ export function TeamResultsTable({ results }: TeamResultsTableProps) {
</div>
</td>
<td className="whitespace-nowrap">
{formatDate(databaseTimestampToDate(result.startTime), {
day: "numeric",
month: "numeric",
year: "numeric",
})}
<LocaleTime
date={result.startTime}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
/>
</td>
<td>
<div className="stack horizontal xs items-center">

View File

@@ -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<typeof loader>();
const handleSelectChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
@@ -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 (
<Main halfWidth className="stack lg">
@@ -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}`)}
</option>

View File

@@ -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 (
<div className={styles.elimRoundHeaderInfos}>
<div>
{formatDate(date, {
month: "numeric",
day: "numeric",
})}{" "}
<LocaleTime
date={date}
options={{
month: "numeric",
day: "numeric",
}}
inline
/>{" "}
</div>
</div>

View File

@@ -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}
<div>
{preparedMaps ? (
<div
className="text-xs text-center text-lighter"
suppressHydrationWarning
>
<div className="text-xs text-center text-lighter">
Prepared by{" "}
{authorIdToUsername(tournament, preparedMaps.authorId)} @{" "}
{formatDateTime(databaseTimestampToDate(preparedMaps.createdAt), {
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
})}
<LocaleTime
date={preparedMaps.createdAt}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}}
inline
/>
</div>
) : null}
</div>

View File

@@ -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() {
</SubmitButton>
</fetcher.Form>
) : bracket.startTime && bracket.startTime > new Date() ? (
<span className="text-lighter text-xxs" suppressHydrationWarning>
<span className="text-lighter text-xxs">
open{" "}
{formatTime(sub(bracket.startTime, { hours: 1 }), {
hour: "numeric",
minute: "numeric",
weekday: "short",
})}{" "}
-{" "}
{formatTime(bracket.startTime, {
hour: "numeric",
minute: "numeric",
})}
<LocaleTimeRange
from={sub(bracket.startTime, { hours: 1 })}
to={bracket.startTime}
options={{
hour: "numeric",
minute: "numeric",
weekday: "short",
}}
inline
/>
</span>
) : bracket.startTime && bracket.startTime < new Date() ? (
<span className="text-warning">over</span>

View File

@@ -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}
/>
)}
</BracketTabs>
@@ -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({
<div className="text-center text-sm font-semi-bold text-lighter mt-2 text-warning">
Bracket requires check-in{" "}
{bracket.startTime ? (
<span suppressHydrationWarning>
<span>
(open{" "}
{formatDateTime(sub(bracket.startTime, { hours: 1 }), {
hour: "numeric",
minute: "numeric",
weekday: "long",
})}{" "}
- {formatTime(bracket.startTime)})
<LocaleTimeRange
from={sub(bracket.startTime, { hours: 1 })}
to={bracket.startTime}
options={{
hour: "numeric",
minute: "numeric",
weekday: "long",
}}
inline
/>
)
</span>
) : null}
</div>

View File

@@ -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
}

View File

@@ -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<OrganizationPageLoaderData["bannedUsers"]>;
}) {
const { t } = useTranslation(["org"]);
const { formatDate } = useTimeFormat();
const bannedUsersKey = (bannedUsers ?? [])
.map((u) => [u.id, u.privateNote].join("-"))
@@ -80,23 +79,28 @@ export function BannedUsersList({
<BanNote note={bannedUser.privateNote} />
</td>
<td className="text-sm text-lighter whitespace-nowrap">
{formatDate(databaseTimestampToDate(bannedUser.updatedAt), {
day: "numeric",
month: "numeric",
year: "numeric",
})}
<LocaleTime
date={bannedUser.updatedAt}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
/>
</td>
<td className="text-sm text-lighter whitespace-nowrap">
{bannedUser.expiresAt
? formatDate(
databaseTimestampToDate(bannedUser.expiresAt),
{
day: "numeric",
month: "numeric",
year: "numeric",
},
)
: t("org:banned.permanent")}
{bannedUser.expiresAt ? (
<LocaleTime
date={bannedUser.expiresAt}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
/>
) : (
t("org:banned.permanent")
)}
</td>
<td className={styles.actionsCell}>
<FormWithConfirm

View File

@@ -1,9 +1,9 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { LinkButton } from "~/components/elements/Button";
import { LocaleTime } from "~/components/LocaleTime";
import type { MonthYear } from "~/features/plus-voting/core";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate, nullPaddedDatesOfMonth } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import type { loader } from "../loaders/org.$slug.server";
@@ -111,7 +111,6 @@ const monthYearSearchParams = ({ month, year }: MonthYear) =>
]).toString();
function MonthSelector({ month, year }: { month: number; year: number }) {
const date = new Date(Date.UTC(year, month, 15));
const { formatDate } = useTimeFormat();
return (
<div className={styles.calendarMonthSelector}>
@@ -130,10 +129,13 @@ function MonthSelector({ month, year }: { month: number; year: number }) {
{"<"}
</LinkButton>
<div>
{formatDate(date, {
year: "numeric",
month: "numeric",
})}
<LocaleTime
date={date}
options={{
year: "numeric",
month: "numeric",
}}
/>
</div>
<LinkButton
variant="minimal"

View File

@@ -1,4 +1,3 @@
import clsx from "clsx";
import { Link as LinkIcon, Lock, LogOut, SquarePen, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
@@ -15,6 +14,7 @@ import {
} from "~/components/elements/Tabs";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
@@ -23,8 +23,6 @@ import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
import { SendouForm } from "~/form/SendouForm";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
@@ -374,7 +372,6 @@ function SeriesHeader({
series: NonNullable<SerializeFrom<typeof loader>["series"]>;
}) {
const { t } = useTranslation(["org"]);
const { formatDate } = useTimeFormat();
return (
<div className="stack md">
@@ -398,10 +395,11 @@ function SeriesHeader({
{series.established ? (
<div className="text-lighter text-italic text-xs">
{t("org:events.established.short")}{" "}
{formatDate(databaseTimestampToDate(series.established), {
month: "numeric",
year: "numeric",
})}
<LocaleTime
date={series.established}
options={{ month: "numeric", year: "numeric" }}
inline
/>
</div>
) : null}
</div>
@@ -502,9 +500,6 @@ function EventInfo({
event: SerializeFrom<typeof loader>["events"][number];
showYear?: boolean;
}) {
const { formatDateTime } = useTimeFormat();
const isHydrated = useHydrated();
return (
<div className="stack sm">
<Link
@@ -520,19 +515,17 @@ function EventInfo({
) : null}
<div>
<div>{event.name}</div>
<time
className={clsx(styles.eventInfoTime, { invisible: !isHydrated })}
>
{isHydrated
? formatDateTime(databaseTimestampToDate(event.startTime), {
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
year: showYear ? "numeric" : undefined,
})
: "X"}
</time>
<LocaleTime
date={event.startTime}
options={{
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
year: showYear ? "numeric" : undefined,
}}
className={styles.eventInfoTime}
/>
</div>
</Link>
<EventWinners

View File

@@ -45,10 +45,10 @@ import { imgTypeToDimensions } from "~/features/img-upload/upload-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/sendouq-settings/components/ModeMapPoolPicker";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
@@ -398,8 +398,12 @@ function RegistrationProgress({
}) {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const isHydrated = useHydrated();
const { formatDate } = useTimeFormat();
const { formatter: registrationClosesFormatter } = useDateTimeFormat({
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "numeric",
});
const completedIfTruthy = (condition: unknown) =>
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 (
<div>
@@ -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 (

View File

@@ -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({
</div>
</td>
<td className="whitespace-nowrap">
{formatDate(databaseTimestampToDate(result.startTime), {
day: "numeric",
month: "numeric",
year: "2-digit",
})}
<LocaleTime
date={result.startTime}
options={{
day: "numeric",
month: "numeric",
year: "2-digit",
}}
/>
</td>
<td>
<ParticipationPill setResults={result.setResults} />

View File

@@ -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<Tables["User"], "discordId" | "customUrl">;
}) {
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 (
<BigValue
value={formatDate(databaseTimestampToDate(widget.data), {
day: "numeric",
month: "numeric",
year: "numeric",
})}
/>
<BigValue value={patronSinceFormatter.format(widget.data) ?? ""} />
);
case "join-date":
if (!widget.data) return null;
@@ -373,8 +371,6 @@ function HighlightedResults({
}: {
results: Extract<LoadedWidget, { id: "highlighted-results" }>["data"];
}) {
const { formatDate } = useTimeFormat();
return (
<div className={styles.highlightedResults}>
{results.map((result, i) => (
@@ -414,13 +410,15 @@ function HighlightedResults({
</div>
) : null}
</div>
<div className={styles.resultDate}>
{formatDate(databaseTimestampToDate(result.startTime), {
<LocaleTime
date={result.startTime}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
})}
</div>
}}
className={styles.resultDate}
/>
</div>
</div>
))}
@@ -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 (
<div className="stack sm items-center">
<div className={styles.widgetValueMain} suppressHydrationWarning>
{formatTime(currentTime, {
<LocaleTime
date={currentTime}
options={{
timeZone: timezone,
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
</div>
<div className={styles.widgetValueFooter} suppressHydrationWarning>
{formatDate(currentTime, {
}}
className={styles.widgetValueMain}
/>
<LocaleTime
date={currentTime}
options={{
timeZone: timezone,
weekday: "short",
day: "numeric",
month: "numeric",
})}
</div>
}}
className={styles.widgetValueFooter}
/>
</div>
);
} catch {

View File

@@ -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<typeof loader>();
const { formatDateTime } = useTimeFormat();
return (
<dl>
<dt>User account created at</dt>
<dd>
{data.createdAt
? formatDateTime(databaseTimestampToDate(data.createdAt), {
{data.createdAt ? (
<LocaleTime
date={data.createdAt}
options={{
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "―"}
}}
/>
) : (
"―"
)}
</dd>
<dt>Discord account created at</dt>
<dd>
{formatDateTime(new Date(data.discordAccountCreatedAt), {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
<LocaleTime
date={new Date(data.discordAccountCreatedAt)}
options={{
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}}
/>
</dd>
<dt>Discord ID</dt>
@@ -113,7 +119,6 @@ function AccountInfos() {
function ModNotes() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const { formatDateTime } = useTimeFormat();
if (!data.modNotes || data.modNotes.length === 0) {
return (
@@ -128,15 +133,17 @@ function ModNotes() {
<div className="stack lg">
{data.modNotes.map((note) => (
<div key={note.noteId}>
<p className="font-bold">
{formatDateTime(databaseTimestampToDate(note.createdAt), {
<LocaleTime
date={note.createdAt}
options={{
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
hour: "numeric",
minute: "numeric",
}}
className="font-bold"
/>
<p className="ml-2">By: {note.username}</p>
<p className="ml-2 whitespace-pre-wrap">Note: {note.text}</p>
{note.discordId === user?.discordId ? (
@@ -184,7 +191,6 @@ function NewModNoteDialog() {
function BanLog() {
const data = useLoaderData<typeof loader>();
const { formatDateTime } = useTimeFormat();
if (!data.banLogs || data.banLogs.length === 0) {
return <p className="text-center text-lighter italic">No bans</p>;
@@ -194,15 +200,17 @@ function BanLog() {
<div className="stack lg">
{data.banLogs.map((ban) => (
<div key={ban.createdAt}>
<p className="font-bold">
{formatDateTime(databaseTimestampToDate(ban.createdAt), {
<LocaleTime
date={ban.createdAt}
options={{
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
hour: "numeric",
minute: "numeric",
}}
className="font-bold"
/>
{ban.banned === 0 ? (
<p className="text-success ml-2">Unbanned</p>
) : (
@@ -212,15 +220,21 @@ function BanLog() {
{typeof ban.banned === "number" && ban.banned !== 0 ? (
<p className="ml-2">
Banned till:{" "}
{ban.banned !== 1
? formatDateTime(databaseTimestampToDate(ban.banned), {
{ban.banned !== 1 ? (
<LocaleTime
date={ban.banned}
options={{
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "No end date set"}
hour: "numeric",
minute: "numeric",
}}
inline
/>
) : (
"No end date set"
)}
</p>
) : null}
{ban.banned !== 0 ? (
@@ -239,7 +253,6 @@ function BanLog() {
function FriendCodes() {
const data = useLoaderData<typeof loader>();
const { formatDateTime } = useTimeFormat();
if (!data.friendCodes || data.friendCodes.length === 0) {
return <p className="text-center text-lighter italic">No friend codes</p>;
@@ -252,13 +265,17 @@ function FriendCodes() {
<p className="font-bold">{fc.friendCode}</p>
<p className="ml-2">
{index === 0 ? "Current" : "Past"} - Added on{" "}
{formatDateTime(databaseTimestampToDate(fc.createdAt), {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
<LocaleTime
date={fc.createdAt}
options={{
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
inline
/>
</p>
<p className="ml-2">Submitted by: {fc.submitterUsername}</p>
</div>

View File

@@ -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 (
<div>
<SendouSelect
@@ -236,28 +231,17 @@ function SeasonHeader({
</SendouSelectItemSection>
)}
</SendouSelect>
<div
className={clsx("text-sm text-lighter mt-2", {
invisible: !isHydrated,
})}
>
{isHydrated ? (
<>
{formatDate(new Date(starts), {
day: "numeric",
month: "numeric",
year: isDifferentYears ? "numeric" : undefined,
})}{" "}
-{" "}
{formatDate(new Date(ends), {
day: "numeric",
month: "numeric",
year: "numeric",
})}
</>
) : (
"0"
)}
<div className="text-sm text-lighter mt-2">
<LocaleTimeRange
from={new Date(starts)}
to={new Date(ends)}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
inline
/>
</div>
</div>
);
@@ -702,8 +686,6 @@ function CanceledMatchesDialog({
}: {
canceledMatches: NonNullable<UserSeasonsPageLoaderData["canceled"]>;
}) {
const { formatDateTime } = useTimeFormat();
return (
<SendouDialog
trigger={
@@ -720,9 +702,16 @@ function CanceledMatchesDialog({
{canceledMatches.map((match) => (
<div key={match.id}>
<Link to={sendouQMatchPage(match.id)}>#{match.id}</Link>
<div>
{formatDateTime(databaseTimestampToDate(match.createdAt))}
</div>
<LocaleTime
date={match.createdAt}
options={{
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
/>
</div>
))}
</div>
@@ -737,8 +726,6 @@ function Results({
seasonViewed: number;
results: UserSeasonsPageLoaderData["results"];
}) {
const isHydrated = useHydrated();
const { formatDate } = useTimeFormat();
const [, setSearchParams] = useSearchParams();
const ref = React.useRef<HTMLDivElement>(null);
@@ -766,22 +753,20 @@ function Results({
return (
<React.Fragment key={result.id}>
<div
<LocaleTime
date={result.createdAt}
options={{
weekday: "long",
month: "numeric",
day: "numeric",
}}
className={clsx(
"text-xs font-semi-bold text-theme-secondary",
{
invisible: !isHydrated || !shouldRenderDateHeader,
invisible: !shouldRenderDateHeader,
},
)}
>
{isHydrated
? formatDate(databaseTimestampToDate(result.createdAt), {
weekday: "long",
month: "numeric",
day: "numeric",
})
: "t"}
</div>
/>
{result.type === "GROUP_MATCH" ? (
<GroupMatchResult match={result.groupMatch} />
) : (

View File

@@ -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<typeof loader>();
const { t } = useTranslation(["common", "vods"]);
const user = useUser();
const { formatDate } = useTimeFormat();
return (
<Main className="stack lg">
@@ -100,19 +96,15 @@ export default function VodPage() {
<div className="stack horizontal justify-between">
<div className="stack horizontal sm items-center">
<PovUser pov={data.vod.pov} />
<time
className={clsx("text-lighter text-xs", {
invisible: !isHydrated,
})}
>
{isHydrated
? formatDate(databaseTimestampToDate(data.vod.youtubeDate), {
day: "numeric",
month: "numeric",
year: "numeric",
})
: "t"}
</time>
<LocaleTime
date={data.vod.youtubeDate}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
className="text-lighter text-xs"
/>
</div>
{canEditVideo({

View File

@@ -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,
};
}

View File

@@ -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<Parameters<typeof formatDistanceToNowUtil>[1], "language">,
) => {
return formatDistanceToNowUtil(
typeof date === "number" ? databaseTimestampToDate(date) : date,
{
...options,
language: i18n.language as LanguageCode,
},
);
};
}

View File

@@ -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();
}

View File

@@ -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<NonNullable<UserPreferences["dateFormat"]>, "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<typeof formatDistanceToNowUtil>[0],
options?: Omit<Parameters<typeof formatDistanceToNowUtil>[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" };
}

View File

@@ -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 && <HydrationTestIndicator />}
<React.StrictMode>
<RouterProvider navigate={navigate} useHref={useHref}>
<I18nProvider locale={i18n.language}>
<I18nProvider locale={language}>
<SendouToastRegion />
<MyFuse data={data} />
<ChatProvider user={data?.user}>

View File

@@ -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;
}

View File

@@ -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();
}
});
});

View File

@@ -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) => {

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",