mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 05:36:28 -05:00
New hooks and components for localized date time (#3065)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
49
app/components/LocaleTime.tsx
Normal file
49
app/components/LocaleTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
app/components/LocaleTimeRange.tsx
Normal file
53
app/components/LocaleTimeRange.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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))),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)) ?? "",
|
||||
}))
|
||||
: [];
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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({
|
||||
|
||||
45
app/hooks/intl/useDateTimeFormat.ts
Normal file
45
app/hooks/intl/useDateTimeFormat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
35
app/hooks/intl/useFormatDistanceToNow.ts
Normal file
35
app/hooks/intl/useFormatDistanceToNow.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
45
app/hooks/intl/useUserIntlPreference.ts
Normal file
45
app/hooks/intl/useUserIntlPreference.ts
Normal 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();
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user