24h clock user preference (#2618)

This commit is contained in:
Kalle 2025-11-09 14:25:14 +02:00 committed by GitHub
parent 48d98b2a27
commit 24008775aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 515 additions and 260 deletions

View File

@ -5,6 +5,7 @@ import type { GearType, Tables, UserWithPlusTier } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type {
Ability as AbilityType,
BuildAbilitiesTuple,
@ -57,7 +58,7 @@ interface BuildProps {
export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
const user = useUser();
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
const { i18n } = useTranslation();
const { formatDate } = useTimeFormat();
const isMounted = useIsMounted();
const {
@ -129,14 +130,11 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
className={clsx("whitespace-nowrap", { invisible: !isMounted })}
>
{isMounted
? databaseTimestampToDate(updatedAt).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "long",
year: "numeric",
},
)
? formatDate(databaseTimestampToDate(updatedAt), {
day: "numeric",
month: "long",
year: "numeric",
})
: "t"}
</time>
</div>

View File

@ -5,6 +5,7 @@ import type { TooltipRendererProps } from "react-charts/types/components/Tooltip
import { useTranslation } from "react-i18next";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
export default function Chart({
options,
@ -104,7 +105,7 @@ function ChartTooltip({
headerSuffix = "",
valueSuffix = "",
}: ChartTooltipProps) {
const { i18n } = useTranslation();
const { formatDate } = useTimeFormat();
const dataPoints = focusedDatum?.interactiveGroup ?? [];
const header = () => {
@ -112,7 +113,7 @@ function ChartTooltip({
if (!primaryValue) return null;
if (primaryValue instanceof Date) {
return primaryValue.toLocaleDateString(i18n.language, {
return formatDate(primaryValue, {
weekday: "short",
day: "numeric",
month: "long",

View File

@ -1,5 +1,6 @@
import type * as React from "react";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
export function RelativeTime({
children,
@ -9,12 +10,13 @@ export function RelativeTime({
timestamp: number;
}) {
const isMounted = useIsMounted();
const { formatDateTime } = useTimeFormat();
return (
<abbr
title={
isMounted
? new Date(timestamp).toLocaleString("en-US", {
? formatDateTime(new Date(timestamp), {
hour: "numeric",
minute: "numeric",
day: "numeric",

View File

@ -4,6 +4,7 @@ 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 { CheckmarkIcon } from "./icons/Checkmark";
import { ClipboardIcon } from "./icons/Clipboard";
@ -26,7 +27,7 @@ export default function TimePopover({
className?: string;
footerText?: string;
}) {
const { i18n } = useTranslation();
const { formatDateTime, formatTime } = useTimeFormat();
const [open, setOpen] = useState(false);
@ -60,7 +61,7 @@ export default function TimePopover({
setOpen(true);
}}
>
{time.toLocaleString(i18n.language, options)}
{formatDateTime(time, options)}
</button>
<Popover
isOpen={open}
@ -71,7 +72,7 @@ export default function TimePopover({
<Dialog>
<div className="stack sm">
<div className="text-center" suppressHydrationWarning>
{time.toLocaleTimeString(i18n.language, {
{formatTime(time, {
timeZoneName: "long",
hour: "numeric",
minute: "numeric",

View File

@ -3,7 +3,7 @@
gap: 8px;
display: flex;
position: fixed;
top: 10px;
top: 55px;
right: 10px;
z-index: 10;
}

View File

@ -832,6 +832,14 @@ export interface UserPreferences {
disallowScrimPickupsFromUntrusted?: boolean;
defaultCalendarFilters?: CalendarFilters;
defaultScrimsFilters?: ScrimFilters;
/**
* What time format the user prefers?
*
* "auto" = use browser default (default value)
* "24h" = 24 hour format (e.g. 14:00)
* "12h" = 12 hour format (e.g. 2:00 PM)
* */
clockFormat?: "24h" | "12h" | "auto";
}
export interface User {

View File

@ -15,6 +15,7 @@ import { Pagination } from "~/components/Pagination";
import { useIsMounted } from "~/hooks/useIsMounted";
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";
@ -90,19 +91,16 @@ export function ArtGrid({
}
function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
const { i18n } = useTranslation();
const [imageLoaded, setImageLoaded] = React.useState(false);
const { formatDate } = useTimeFormat();
return (
<SendouDialog
heading={databaseTimestampToDate(art.createdAt).toLocaleDateString(
i18n.language,
{
year: "numeric",
month: "long",
day: "numeric",
},
)}
heading={formatDate(databaseTimestampToDate(art.createdAt), {
year: "numeric",
month: "long",
day: "numeric",
})}
onClose={close}
isFullScreen
>

View File

@ -1,5 +1,6 @@
import { useLoaderData } from "@remix-run/react";
import { Main } from "~/components/Main";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { loader } from "../loaders/suspended.server";
@ -7,6 +8,7 @@ export { loader };
export default function SuspendedPage() {
const data = useLoaderData<typeof loader>();
const { formatDateTime } = useTimeFormat();
const ends = (() => {
if (!data.banned || data.banned === 1) return null;
@ -21,7 +23,7 @@ export default function SuspendedPage() {
{ends ? (
<div suppressHydrationWarning>
Ends:{" "}
{ends.toLocaleString("en-US", {
{formatDateTime(ends, {
month: "long",
day: "numeric",
year: "numeric",

View File

@ -5,6 +5,7 @@ import { SendouButton } from "~/components/elements/Button";
import { ModeImage } from "~/components/Image";
import { CrossIcon } from "~/components/icons/Cross";
import { possibleApValues } from "~/features/build-analyzer";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { abilities } from "~/modules/in-game-lists/abilities";
import { modesShort } from "~/modules/in-game-lists/modes";
import type {
@ -194,7 +195,8 @@ function DateFilter({
filter: DateBuildFilter;
onChange: (filter: Partial<DateBuildFilter>) => void;
}) {
const { t, i18n } = useTranslation(["builds"]);
const { t } = useTranslation(["builds"]);
const { formatDate } = useTimeFormat();
const selectValue = () => {
const dateString = dateToYYYYMMDD(new Date(filter.date));
@ -239,7 +241,7 @@ function DateFilter({
return (
<option key={patch} value={dateString}>
{patch} (
{date.toLocaleDateString(i18n.language, {
{formatDate(date, {
day: "numeric",
month: "long",
year: "numeric",

View File

@ -9,6 +9,7 @@ import { TrophyIcon } from "~/components/icons/Trophy";
import { UsersIcon } from "~/components/icons/Users";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl } from "~/utils/urls";
import type { CalendarEvent, ShowcaseCalendarEvent } from "../calendar-types";
@ -23,7 +24,7 @@ export function TournamentCard({
className?: string;
}) {
const isMounted = useIsMounted();
const { i18n } = useTranslation(["front", "common"]);
const { formatDateTime } = useTimeFormat();
const isShowcase = tournament.type === "showcase";
const isCalendar = tournament.type === "calendar";
@ -34,7 +35,7 @@ export function TournamentCard({
if (!isMounted) return "Placeholder";
const date = databaseTimestampToDate(tournament.startTime);
return date.toLocaleString(i18n.language, {
return formatDateTime(date, {
month: "short",
day: "numeric",
hour: "numeric",

View File

@ -16,6 +16,7 @@ import { Table } from "~/components/Table";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@ -80,8 +81,9 @@ export const handle: SendouRouteHandle = {
export default function CalendarEventPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const { i18n, t } = useTranslation(["common", "calendar"]);
const { t } = useTranslation(["common", "calendar"]);
const isMounted = useIsMounted();
const { formatDateTime } = useTimeFormat();
return (
<Main className="stack lg">
@ -100,17 +102,14 @@ export default function CalendarEventPage() {
</span>
<time dateTime={databaseTimestampToDate(startTime).toISOString()}>
{isMounted
? databaseTimestampToDate(startTime).toLocaleDateString(
i18n.language,
{
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "long",
weekday: "long",
year: "numeric",
},
)
? formatDateTime(databaseTimestampToDate(startTime), {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "long",
weekday: "long",
year: "numeric",
})
: null}
</time>
</React.Fragment>

View File

@ -23,6 +23,7 @@ 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 { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { RankedModeShort } from "~/modules/in-game-lists/types";
import {
databaseTimestampToDate,
@ -136,6 +137,7 @@ export default function CalendarNewEventPage() {
function TemplateTournamentForm() {
const { recentTournaments } = useLoaderData<typeof loader>();
const [eventId, setEventId] = React.useState("");
const { formatDate } = useTimeFormat();
if (!recentTournaments) return null;
@ -154,10 +156,10 @@ function TemplateTournamentForm() {
{recentTournaments.map((event) => (
<option key={event.id} value={event.id} suppressHydrationWarning>
{event.name} (
{databaseTimestampToDate(event.startTime).toLocaleDateString(
"en-US",
{ month: "numeric", day: "numeric" },
)}
{formatDate(databaseTimestampToDate(event.startTime), {
month: "numeric",
day: "numeric",
})}
)
</option>
))}

View File

@ -21,6 +21,7 @@ import { LinkIcon } from "~/components/icons/Link";
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,20 +145,17 @@ function NavigateButton({
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
filters?: CalendarLoaderData["filters"];
}) {
const { i18n } = useTranslation();
const { formatDate } = useTimeFormat();
const lowestDate = daysInterval[0];
const highestDate = daysInterval[daysInterval.length - 1];
const dateToString = (
day: ReturnType<typeof daysForCalendar>["shown"][number],
) =>
new Date(new Date().getFullYear(), day.month, day.day).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "short",
},
);
formatDate(new Date(new Date().getFullYear(), day.month, day.day), {
day: "numeric",
month: "short",
});
return (
<Link
@ -254,7 +252,7 @@ function DayEventsColumn({
}
function DayHeader(props: { date: number; month: number }) {
const { i18n } = useTranslation();
const { formatDate } = useTimeFormat();
const date = new Date(new Date().getFullYear(), props.month, props.date);
const isToday = date.toDateString() === new Date().toDateString();
@ -266,12 +264,12 @@ function DayHeader(props: { date: number; month: number }) {
})}
data-testid={isToday ? "today-header" : undefined}
>
{date.toLocaleDateString(i18n.language, {
{formatDate(date, {
day: "numeric",
month: "long",
})}
<div className={styles.dayHeaderWeekday}>
{date.toLocaleDateString(i18n.language, {
{formatDate(date, {
weekday: "long",
})}
</div>
@ -294,7 +292,7 @@ function ClockHeader({
hiddenShown: boolean;
className?: string;
}) {
const { i18n } = useTranslation();
const { formatTime } = useTimeFormat();
const isInThePast = (toDate ?? date).getTime() < Date.now();
@ -306,12 +304,12 @@ function ClockHeader({
"text-lighter italic": isInThePast,
})}
>
{date.toLocaleTimeString(i18n.language, {
{formatTime(date, {
hour: "numeric",
minute: "2-digit",
})}
{toDate
? ` - ${toDate.toLocaleTimeString(i18n.language, {
? ` - ${formatTime(toDate, {
hour: "numeric",
minute: "2-digit",
})}`

View File

@ -6,6 +6,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 { MESSAGE_MAX_LENGTH } from "../chat-constants";
import { useChat, useChatAutoScroll } from "../chat-hooks";
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
@ -269,19 +270,19 @@ function SystemMessage({
}
function MessageTimestamp({ timestamp }: { timestamp: number }) {
const { i18n } = useTranslation();
const { formatDateTime, formatTime } = useTimeFormat();
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
return (
<time className="chat__message__time">
{moreThanDayAgo
? new Date(timestamp).toLocaleString(i18n.language, {
? formatDateTime(new Date(timestamp), {
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
})
: new Date(timestamp).toLocaleTimeString(i18n.language)}
: formatTime(new Date(timestamp))}
</time>
);
}

View File

@ -29,6 +29,7 @@ import { TournamentCard } from "~/features/calendar/components/TournamentCard";
import type * as Changelog from "~/features/front-page/core/Changelog.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import styles from "~/styles/front.module.css";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@ -104,10 +105,11 @@ function DesktopSideNav() {
}
function SeasonBanner() {
const { t, i18n } = useTranslation(["front"]);
const { t } = useTranslation(["front"]);
const season = Seasons.next(new Date()) ?? Seasons.currentOrPrevious()!;
const _previousSeason = Seasons.previous();
const isMounted = useIsMounted();
const { formatDate } = useTimeFormat();
const isInFuture = new Date() < season.starts;
const isShowingPreviousSeason = _previousSeason?.nth === season.nth;
@ -122,12 +124,12 @@ function SeasonBanner() {
</div>
{isMounted ? (
<div className={styles.seasonBannerDates}>
{season.starts.toLocaleDateString(i18n.language, {
{formatDate(season.starts, {
month: "long",
day: "numeric",
})}{" "}
-{" "}
{season.ends.toLocaleDateString(i18n.language, {
{formatDate(season.ends, {
month: "long",
day: "numeric",
})}

View File

@ -15,6 +15,7 @@ import { useUser } from "~/features/auth/core/user";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { lfgNewPostPage, navIconUrl, userPage } from "~/utils/urls";
@ -258,7 +259,8 @@ function PostTime({
createdAt: number;
updatedAt: number;
}) {
const { t, i18n } = useTranslation(["lfg"]);
const { t } = useTranslation(["lfg"]);
const { formatDate } = useTimeFormat();
const createdAtDate = databaseTimestampToDate(createdAt);
const updatedAtDate = databaseTimestampToDate(updatedAt);
@ -267,7 +269,7 @@ function PostTime({
return (
<div className="text-lighter text-xs font-bold">
{createdAtDate.toLocaleString(i18n.language, {
{formatDate(createdAtDate, {
month: "long",
day: "numeric",
})}{" "}

View File

@ -19,6 +19,7 @@ import { TrashIcon } from "~/components/icons/Trash";
import { UsersIcon } from "~/components/icons/Users";
import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { scrimPage, tournamentRegisterPage, userPage } from "~/utils/urls";
@ -333,7 +334,8 @@ function ScrimActionButtons({
action: ScrimPostCardProps["action"];
post: ScrimPost;
}) {
const { t, i18n } = useTranslation(["scrims", "common"]);
const { t } = useTranslation(["scrims", "common"]);
const { formatDateTime } = useTimeFormat();
const user = useUser();
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [isViewRequestModalOpen, setIsViewRequestModalOpen] = useState(false);
@ -399,15 +401,12 @@ function ScrimActionButtons({
{t("scrims:requestModal.at.label")}
</div>
<div className="text-lighter">
{databaseTimestampToDate(userRequest.at).toLocaleString(
i18n.language,
{
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "long",
},
)}
{formatDateTime(databaseTimestampToDate(userRequest.at), {
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "long",
})}
</div>
</div>
) : null}
@ -474,7 +473,8 @@ export function ScrimRequestCard({
canAccept,
showFooter = true,
}: ScrimRequestCardProps) {
const { t, i18n } = useTranslation(["scrims", "common"]);
const { t } = useTranslation(["scrims", "common"]);
const { formatTime } = useTimeFormat();
const owner = request.users.find((user) => user.isOwner) ?? request.users[0];
const isPickup = !request.team?.name;
@ -533,7 +533,7 @@ export function ScrimRequestCard({
data-testid="confirm-modal-trigger-button"
>
{t("scrims:acceptModal.confirmFor", {
time: confirmedTime.toLocaleTimeString(i18n.language, {
time: formatTime(confirmedTime, {
hour: "numeric",
minute: "2-digit",
}),
@ -545,7 +545,7 @@ export function ScrimRequestCard({
trigger={
<SendouButton size="small">
{t("scrims:acceptModal.confirmFor", {
time: confirmedTime.toLocaleTimeString(i18n.language, {
time: formatTime(confirmedTime, {
hour: "numeric",
minute: "2-digit",
}),

View File

@ -5,6 +5,7 @@ import { SendouDialog } from "~/components/elements/Dialog";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { joinListToNaturalString, nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import type { loader as scrimsLoader } from "../loaders/scrims.server";
@ -22,8 +23,9 @@ export function ScrimRequestModal({
post: ScrimPost;
close: () => void;
}) {
const { t, i18n } = useTranslation(["scrims"]);
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof scrimsLoader>();
const { formatTime } = useTimeFormat();
const timeOptions = post.rangeEnd
? generateTimeOptions(
@ -31,7 +33,7 @@ export function ScrimRequestModal({
databaseTimestampToDate(post.rangeEnd),
).map((timestamp) => ({
value: timestamp,
label: new Date(timestamp).toLocaleTimeString(i18n.language, {
label: formatTime(new Date(timestamp), {
hour: "numeric",
minute: "2-digit",
}),

View File

@ -9,6 +9,7 @@ import { AddNewButton } from "~/components/AddNewButton";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -201,10 +202,10 @@ function ScrimsDaySection({
posts: ScrimPost[];
filters: ScrimFilters;
}) {
const { i18n } = useTranslation();
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),
@ -220,14 +221,11 @@ function ScrimsDaySection({
<div className="stack md">
<div className="stack xxs">
<h2 className="text-sm">
{databaseTimestampToDate(posts[0].at).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "long",
weekday: "long",
},
)}
{formatDate(databaseTimestampToDate(posts[0].at), {
day: "numeric",
month: "long",
weekday: "long",
})}
</h2>
{user ? (
<AvailableScrimsFilterButtons
@ -333,8 +331,9 @@ function AvailableScrimsFilterButtons({
}
function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
const { i18n, t } = useTranslation(["scrims"]);
const { t } = useTranslation(["scrims"]);
const user = useUser();
const { formatDate } = useTimeFormat();
const postsByDay = R.groupBy(posts, (post) =>
databaseTimestampToDate(post.at).getDate(),
@ -348,14 +347,11 @@ function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
return (
<div key={day} className="stack md">
<h2 className="text-sm">
{databaseTimestampToDate(posts![0].at).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "long",
weekday: "long",
},
)}
{formatDate(databaseTimestampToDate(posts![0].at), {
day: "numeric",
month: "long",
weekday: "long",
})}
</h2>
<div className="stack lg">
{posts!.map((post) => {
@ -406,7 +402,7 @@ function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
}
function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {
const { i18n } = useTranslation();
const { formatDate } = useTimeFormat();
const postsByDay = R.groupBy(posts, (post) =>
databaseTimestampToDate(post.at).getDate(),
@ -420,14 +416,11 @@ function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {
return (
<div key={day} className="stack md">
<h2 className="text-sm">
{databaseTimestampToDate(posts![0].at).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "long",
weekday: "long",
},
)}
{formatDate(databaseTimestampToDate(posts![0].at), {
day: "numeric",
month: "long",
weekday: "long",
})}
</h2>
<div className="stack lg">
{posts!.map((post) => {

View File

@ -45,6 +45,7 @@ import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPri
import type { ReportedWeaponForMerging } from "~/features/sendouq-match/core/reported-weapons.server";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useWindowSize } from "~/hooks/useWindowSize";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
@ -105,7 +106,8 @@ export default function QMatchPage() {
const user = useUser();
const isStaff = useHasRole("STAFF");
const isMounted = useIsMounted();
const { t, i18n } = useTranslation(["q"]);
const { t } = useTranslation(["q"]);
const { formatDateTime } = useTimeFormat();
const data = useLoaderData<typeof loader>();
const [showWeaponsForm, setShowWeaponsForm] = React.useState(false);
const [searchParams] = useSearchParams();
@ -154,16 +156,13 @@ export default function QMatchPage() {
})}
>
{isMounted
? databaseTimestampToDate(data.match.createdAt).toLocaleString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
},
)
? formatDateTime(databaseTimestampToDate(data.match.createdAt), {
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
})
: // reserve place
"0/0/0 0:00"}
</div>
@ -243,7 +242,8 @@ function Score({
ownTeamReported: boolean;
}) {
const isMounted = useIsMounted();
const { t, i18n } = useTranslation(["q"]);
const { t } = useTranslation(["q"]);
const { formatDateTime } = useTimeFormat();
const data = useLoaderData<typeof loader>();
const reporter =
data.groupAlpha.members.find((m) => m.id === data.match.reportedByUserId) ??
@ -292,16 +292,13 @@ function Score({
>
{t("q:match.reportedBy", { name: reporter?.username ?? "admin" })}{" "}
{isMounted
? databaseTimestampToDate(reportedAt).toLocaleString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
},
)
? formatDateTime(databaseTimestampToDate(reportedAt), {
day: "numeric",
month: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
})
: ""}
</div>
) : (

View File

@ -22,6 +22,7 @@ import { useUser } from "~/features/auth/core/user";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { languagesUnified } from "~/modules/i18n/config";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
@ -253,8 +254,9 @@ function GroupMember({
showAddNote?: SqlBool;
showNote?: boolean;
}) {
const { t, i18n } = useTranslation(["q", "user"]);
const { t } = useTranslation(["q", "user"]);
const user = useUser();
const { formatDateTime } = useTimeFormat();
return (
<div className="stack xxs">
@ -283,15 +285,16 @@ function GroupMember({
)}
>
<div className="text-xxs text-lighter">
{databaseTimestampToDate(
member.privateNote.updatedAt,
).toLocaleString(i18n.language, {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "long",
year: "numeric",
})}
{formatDateTime(
databaseTimestampToDate(member.privateNote.updatedAt),
{
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "long",
year: "numeric",
},
)}
</div>
<DeletePrivateNoteForm
name={member.username}

View File

@ -20,6 +20,7 @@ import { useChat } from "~/features/chat/chat-hooks";
import { Chat } from "~/features/chat/components/Chat";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useWindowSize } from "~/hooks/useWindowSize";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -97,10 +98,11 @@ export default function QLookingPage() {
}
function InfoText() {
const { t, i18n } = useTranslation(["q"]);
const { t } = useTranslation(["q"]);
const isMounted = useIsMounted();
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const { formatTime } = useTimeFormat();
if (data.expiryStatus === "EXPIRED") {
return (
@ -161,13 +163,10 @@ function InfoText() {
<span className="text-xxs">
{isMounted
? t("q:looking.lastUpdatedAt", {
time: new Date(data.lastUpdated).toLocaleTimeString(
i18n.language,
{
hour: "2-digit",
minute: "2-digit",
},
),
time: formatTime(new Date(data.lastUpdated), {
hour: "2-digit",
minute: "2-digit",
}),
})
: "Placeholder"}
</span>

View File

@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseRequestPayload } from "~/utils/remix.server";
import { parseRequestPayload, successToast } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { settingsEditSchema } from "../settings-schemas";
@ -33,7 +33,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
break;
}
case "PLACEHOLDER": {
case "UPDATE_CLOCK_FORMAT": {
await UserRepository.updatePreferences(user.id, {
clockFormat: data.newValue,
});
break;
}
default: {
@ -41,5 +44,5 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
}
return null;
return successToast("Settings updated");
};

View File

@ -42,6 +42,7 @@ export default function SettingsPage() {
<h2 className="text-lg">{t("common:pages.settings")}</h2>
<LanguageSelector />
<ThemeSelector />
{user ? <ClockFormatSelector /> : null}
{user ? (
<>
<PushNotificationsEnabler />
@ -157,6 +158,38 @@ function ThemeSelector() {
);
}
function ClockFormatSelector() {
const { t } = useTranslation(["common"]);
const user = useUser();
const fetcher = useFetcher();
const handleClockFormatChange = (
event: React.ChangeEvent<HTMLSelectElement>,
) => {
const newFormat = event.target.value as "auto" | "24h" | "12h";
fetcher.submit(
{ _action: "UPDATE_CLOCK_FORMAT", newValue: newFormat },
{ method: "post", encType: "application/json" },
);
};
return (
<div>
<Label htmlFor="clock-format">{t("common:settings.clockFormat")}</Label>
<select
id="clock-format"
defaultValue={user?.preferences.clockFormat ?? "auto"}
onChange={handleClockFormatChange}
disabled={fetcher.state !== "idle"}
>
<option value="auto">{t("common:clockFormat.auto")}</option>
<option value="24h">{t("common:clockFormat.24h")}</option>
<option value="12h">{t("common:clockFormat.12h")}</option>
</select>
</div>
);
}
// adapted from https://pqvst.com/2023/11/21/web-push-notifications/
function PushNotificationsEnabler() {
const { t } = useTranslation(["common"]);

View File

@ -15,6 +15,7 @@ export const settingsEditSchema = z.union([
newValue: z.boolean(),
}),
z.object({
_action: _action("PLACEHOLDER"),
_action: _action("UPDATE_CLOCK_FORMAT"),
newValue: z.enum(["auto", "24h", "12h"]),
}),
]);

View File

@ -7,6 +7,7 @@ import { UsersIcon } from "~/components/icons/Users";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
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";
@ -17,7 +18,8 @@ interface TeamResultsTableProps {
}
export function TeamResultsTable({ results }: TeamResultsTableProps) {
const { t, i18n } = useTranslation("user");
const { t } = useTranslation("user");
const { formatDate } = useTimeFormat();
return (
<Table>
@ -42,14 +44,11 @@ export function TeamResultsTable({ results }: TeamResultsTableProps) {
</div>
</td>
<td className="whitespace-nowrap">
{databaseTimestampToDate(result.startTime).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "short",
year: "numeric",
},
)}
{formatDate(databaseTimestampToDate(result.startTime), {
day: "numeric",
month: "short",
year: "numeric",
})}
</td>
<td>
<div className="stack horizontal xs items-center">

View File

@ -4,6 +4,7 @@ import { useTournament } from "~/features/tournament/routes/to.$id";
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { TOURNAMENT } from "../../../tournament/tournament-constants";
import { useDeadline } from "./useDeadline";
@ -51,15 +52,7 @@ export function RoundHeader({
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
</div>
) : leagueRoundStartDate ? (
<div className="elim-bracket__round-header__infos">
<div>
{leagueRoundStartDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}{" "}
</div>
</div>
<LeagueRoundStartDate date={leagueRoundStartDate} />
) : (
<div className="elim-bracket__round-header__infos invisible">
Hidden
@ -69,10 +62,27 @@ export function RoundHeader({
);
}
function LeagueRoundStartDate({ date }: { date: Date }) {
const { formatDate } = useTimeFormat();
return (
<div className="elim-bracket__round-header__infos">
<div>
{formatDate(date, {
month: "short",
day: "numeric",
})}{" "}
</div>
</div>
);
}
function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
useAutoRerender("ten seconds");
const isMounted = useIsMounted();
const deadline = useDeadline(roundId, bestOf);
const { formatTime } = useTimeFormat();
if (!deadline) return null;
@ -83,7 +93,7 @@ function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
})}
>
DL{" "}
{deadline.toLocaleTimeString("en-US", {
{formatTime(deadline, {
hour: "numeric",
minute: "numeric",
})}

View File

@ -20,6 +20,7 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
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 {
@ -40,6 +41,7 @@ import "../components/Bracket/bracket.css";
export default function TournamentBracketsPage() {
const { t } = useTranslation(["tournament"]);
const { formatDateTime, formatTime } = useTimeFormat();
const visibility = useVisibilityChange();
const { revalidate } = useRevalidator();
const user = useUser();
@ -253,16 +255,13 @@ export default function TournamentBracketsPage() {
{bracket.startTime ? (
<span suppressHydrationWarning>
(open{" "}
{sub(bracket.startTime, { hours: 1 }).toLocaleString(
"en-US",
{
hour: "numeric",
minute: "numeric",
weekday: "long",
},
)}{" "}
{formatDateTime(sub(bracket.startTime, { hours: 1 }), {
hour: "numeric",
minute: "numeric",
weekday: "long",
})}{" "}
-{" "}
{bracket.startTime.toLocaleTimeString("en-US", {
{formatTime(bracket.startTime, {
hour: "numeric",
minute: "numeric",
})}

View File

@ -9,6 +9,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
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";
@ -20,7 +21,8 @@ export function BannedUsersList({
}: {
bannedUsers: NonNullable<OrganizationPageLoaderData["bannedUsers"]>;
}) {
const { t, i18n } = useTranslation(["org"]);
const { t } = useTranslation(["org"]);
const { formatDate } = useTimeFormat();
const bannedUsersKey = (bannedUsers ?? [])
.map((u) => [u.id, u.privateNote].join("-"))
@ -78,9 +80,7 @@ export function BannedUsersList({
<BanNote note={bannedUser.privateNote} />
</td>
<td className="text-sm text-lighter whitespace-nowrap">
{databaseTimestampToDate(
bannedUser.updatedAt,
).toLocaleDateString(i18n.language, {
{formatDate(databaseTimestampToDate(bannedUser.updatedAt), {
day: "numeric",
month: "short",
year: "numeric",
@ -88,13 +88,14 @@ export function BannedUsersList({
</td>
<td className="text-sm text-lighter whitespace-nowrap">
{bannedUser.expiresAt
? databaseTimestampToDate(
bannedUser.expiresAt,
).toLocaleDateString(i18n.language, {
day: "numeric",
month: "short",
year: "numeric",
})
? formatDate(
databaseTimestampToDate(bannedUser.expiresAt),
{
day: "numeric",
month: "short",
year: "numeric",
},
)
: t("org:banned.permanent")}
</td>
<td className={styles.actionsCell}>

View File

@ -3,6 +3,7 @@ import clsx from "clsx";
import { LinkButton } from "~/components/elements/Button";
import type { MonthYear } from "~/features/plus-voting/core";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate, nullPaddedDatesOfMonth } from "~/utils/dates";
import type { loader } from "../loaders/org.$slug.server";
@ -105,6 +106,7 @@ 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="org__calendar__month-selector">
@ -123,7 +125,7 @@ function MonthSelector({ month, year }: { month: number; year: number }) {
{"<"}
</LinkButton>
<div>
{date.toLocaleDateString("en-US", {
{formatDate(date, {
year: "numeric",
month: "long",
})}

View File

@ -26,6 +26,7 @@ import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
@ -324,7 +325,8 @@ function SeriesHeader({
}: {
series: NonNullable<SerializeFrom<typeof loader>["series"]>;
}) {
const { i18n, t } = useTranslation(["org"]);
const { t } = useTranslation(["org"]);
const { formatDate } = useTimeFormat();
return (
<div className="stack md">
@ -343,13 +345,10 @@ function SeriesHeader({
{series.established ? (
<div className="text-lighter text-italic text-xs">
{t("org:events.established.short")}{" "}
{databaseTimestampToDate(series.established).toLocaleDateString(
i18n.language,
{
month: "long",
year: "numeric",
},
)}
{formatDate(databaseTimestampToDate(series.established), {
month: "long",
year: "numeric",
})}
</div>
) : null}
</div>
@ -450,7 +449,7 @@ function EventInfo({
event: SerializeFrom<typeof loader>["events"][number];
showYear?: boolean;
}) {
const { i18n } = useTranslation();
const { formatDateTime } = useTimeFormat();
return (
<div className="stack sm">
@ -468,16 +467,13 @@ function EventInfo({
<div>
<div className="org__event-info__name">{event.name}</div>
<time className="org__event-info__time" suppressHydrationWarning>
{databaseTimestampToDate(event.startTime).toLocaleString(
i18n.language,
{
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
year: showYear ? "numeric" : undefined,
},
)}
{formatDateTime(databaseTimestampToDate(event.startTime), {
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
year: showYear ? "numeric" : undefined,
})}
</time>
</div>
</Link>

View File

@ -40,6 +40,7 @@ import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tour
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
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";
@ -376,9 +377,10 @@ function RegistrationProgress({
members?: unknown[];
mapPool?: unknown[];
}) {
const { i18n, t } = useTranslation(["tournament"]);
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const isMounted = useIsMounted();
const { formatTime } = useTimeFormat();
const completedIfTruthy = (condition: unknown) =>
condition ? "completed" : "incomplete";
@ -419,15 +421,17 @@ function RegistrationProgress({
tournament.ctx.startTime.getTime();
const registrationClosesAtString = isMounted
? (tournament.isLeagueSignup
? tournament.ctx.startTime
: tournament.registrationClosesAt
).toLocaleTimeString(i18n.language, {
minute: "numeric",
hour: "numeric",
day: "2-digit",
month: "2-digit",
})
? formatTime(
tournament.isLeagueSignup
? tournament.ctx.startTime
: tournament.registrationClosesAt,
{
minute: "numeric",
hour: "numeric",
day: "2-digit",
month: "2-digit",
},
)
: "";
return (
@ -502,14 +506,15 @@ function CheckIn({
endDate: Date;
checkedIn?: boolean;
}) {
const { t, i18n } = useTranslation(["tournament"]);
const { t } = useTranslation(["tournament"]);
const isMounted = useIsMounted();
const fetcher = useFetcher();
const { formatTime } = useTimeFormat();
useAutoRerender();
const checkInStartsString = isMounted
? startDate.toLocaleTimeString(i18n.language, {
? formatTime(startDate, {
minute: "numeric",
hour: "numeric",
day: "2-digit",
@ -518,7 +523,7 @@ function CheckIn({
: "";
const checkInEndsString = isMounted
? endDate.toLocaleTimeString(i18n.language, {
? formatTime(endDate, {
minute: "numeric",
hour: "numeric",
day: "2-digit",

View File

@ -6,6 +6,7 @@ import { SendouPopover } from "~/components/elements/Popover";
import { UsersIcon } from "~/components/icons/Users";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import {
calendarEventPage,
@ -30,7 +31,8 @@ export function UserResultsTable({
id,
hasHighlightCheckboxes,
}: UserResultsTableProps) {
const { t, i18n } = useTranslation("user");
const { t } = useTranslation("user");
const { formatDate } = useTimeFormat();
const placementHeaderId = `${id}-th-placement`;
@ -82,14 +84,11 @@ export function UserResultsTable({
</div>
</td>
<td className="whitespace-nowrap">
{databaseTimestampToDate(result.startTime).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "short",
year: "numeric",
},
)}
{formatDate(databaseTimestampToDate(result.startTime), {
day: "numeric",
month: "short",
year: "numeric",
})}
</td>
<td id={nameCellId}>
<div className="stack horizontal xs items-center">

View File

@ -11,6 +11,7 @@ import { Main } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { USER } from "~/features/user-page/user-page-constants";
import { addModNoteSchema } from "~/features/user-page/user-page-schemas";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { action } from "../actions/u.$identifier.admin.server";
import { loader } from "../loaders/u.$identifier.admin.server";
@ -48,13 +49,14 @@ export default function UserAdminPage() {
function AccountInfos() {
const data = useLoaderData<typeof loader>();
const { formatDateTime } = useTimeFormat();
return (
<dl className={styles.dl}>
<dt>User account created at</dt>
<dd>
{data.createdAt
? databaseTimestampToDate(data.createdAt).toLocaleString("en-US", {
? formatDateTime(databaseTimestampToDate(data.createdAt), {
year: "numeric",
month: "long",
day: "numeric",
@ -66,7 +68,7 @@ function AccountInfos() {
<dt>Discord account created at</dt>
<dd>
{new Date(data.discordAccountCreatedAt).toLocaleString("en-US", {
{formatDateTime(new Date(data.discordAccountCreatedAt), {
year: "numeric",
month: "long",
day: "numeric",
@ -103,6 +105,7 @@ function AccountInfos() {
function ModNotes() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const { formatDateTime } = useTimeFormat();
if (!data.modNotes || data.modNotes.length === 0) {
return (
@ -118,7 +121,7 @@ function ModNotes() {
{data.modNotes.map((note) => (
<div key={note.noteId}>
<p className="font-bold">
{databaseTimestampToDate(note.createdAt).toLocaleString("en-US", {
{formatDateTime(databaseTimestampToDate(note.createdAt), {
year: "numeric",
month: "long",
day: "numeric",
@ -186,6 +189,7 @@ 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>;
@ -196,7 +200,7 @@ function BanLog() {
{data.banLogs.map((ban) => (
<div key={ban.createdAt}>
<p className="font-bold">
{databaseTimestampToDate(ban.createdAt).toLocaleString("en-US", {
{formatDateTime(databaseTimestampToDate(ban.createdAt), {
year: "numeric",
month: "long",
day: "numeric",
@ -214,7 +218,7 @@ function BanLog() {
<p className="ml-2">
Banned till:{" "}
{ban.banned !== 1
? databaseTimestampToDate(ban.banned).toLocaleString("en-US", {
? formatDateTime(databaseTimestampToDate(ban.banned), {
year: "numeric",
month: "long",
day: "numeric",
@ -240,6 +244,7 @@ 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,7 +257,7 @@ function FriendCodes() {
<p className="font-bold">{fc.friendCode}</p>
<p className="ml-2">
{index === 0 ? "Current" : "Past"} - Added on{" "}
{databaseTimestampToDate(fc.createdAt).toLocaleString("en-US", {
{formatDateTime(databaseTimestampToDate(fc.createdAt), {
year: "numeric",
month: "long",
day: "numeric",

View File

@ -42,6 +42,7 @@ import type {
} from "~/features/sendouq-match/QMatchRepository.server";
import { useWeaponUsage } from "~/hooks/swr";
import { useIsMounted } from "~/hooks/useIsMounted";
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";
@ -180,7 +181,8 @@ function SeasonHeader({
seasonViewed: number;
seasonsParticipatedIn: number[];
}) {
const { t, i18n } = useTranslation(["user"]);
const { t } = useTranslation(["user"]);
const { formatDate } = useTimeFormat();
const isMounted = useIsMounted();
const { starts, ends } = Seasons.nthToDateRange(seasonViewed);
const navigate = useNavigate();
@ -218,13 +220,13 @@ function SeasonHeader({
>
{isMounted ? (
<>
{new Date(starts).toLocaleString(i18n.language, {
{formatDate(new Date(starts), {
day: "numeric",
month: "long",
year: isDifferentYears ? "numeric" : undefined,
})}{" "}
-{" "}
{new Date(ends).toLocaleString(i18n.language, {
{formatDate(new Date(ends), {
day: "numeric",
month: "long",
year: "numeric",
@ -667,6 +669,8 @@ function CanceledMatchesDialog({
}: {
canceledMatches: NonNullable<UserSeasonsPageLoaderData["canceled"]>;
}) {
const { formatDateTime } = useTimeFormat();
return (
<SendouDialog
trigger={
@ -684,7 +688,7 @@ function CanceledMatchesDialog({
<div key={match.id}>
<Link to={sendouQMatchPage(match.id)}>#{match.id}</Link>
<div>
{databaseTimestampToDate(match.createdAt).toLocaleString()}
{formatDateTime(databaseTimestampToDate(match.createdAt))}
</div>
</div>
))}
@ -701,6 +705,7 @@ function Results({
results: UserSeasonsPageLoaderData["results"];
}) {
const isMounted = useIsMounted();
const { formatDate } = useTimeFormat();
const [, setSearchParams] = useSearchParams();
const ref = React.useRef<HTMLDivElement>(null);
@ -737,14 +742,11 @@ function Results({
)}
>
{isMounted
? databaseTimestampToDate(result.createdAt).toLocaleString(
"en",
{
weekday: "long",
month: "long",
day: "numeric",
},
)
? formatDate(databaseTimestampToDate(result.createdAt), {
weekday: "long",
month: "long",
day: "numeric",
})
: "t"}
</div>
{result.type === "GROUP_MATCH" ? (

View File

@ -13,6 +13,7 @@ import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -72,12 +73,12 @@ export default function VodPage() {
defaultValue: 0,
revive: Number,
});
const { i18n } = useTranslation();
const isMounted = useIsMounted();
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">
@ -98,9 +99,7 @@ export default function VodPage() {
})}
>
{isMounted
? databaseTimestampToDate(
data.vod.youtubeDate,
).toLocaleDateString(i18n.language, {
? formatDate(databaseTimestampToDate(data.vod.youtubeDate), {
day: "numeric",
month: "numeric",
year: "numeric",

View File

@ -0,0 +1,88 @@
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core/user";
const H12_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
hour12: true,
hourCycle: "h12" as const,
};
const H24_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
hour12: false,
hourCycle: "h23" as const,
minute: "2-digit",
};
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 and times according to user preferences and locale.
* Respects the user's clock format preference (12h/24h) and current language.
*
* @example
* const { formatDateTime, formatTime, formatDate } = 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"
*/
export function useTimeFormat() {
const { i18n } = useTranslation();
const user = useUser();
const clockFormat = user?.preferences?.clockFormat;
const clockOptions = getClockFormatOptions(clockFormat, i18n.language);
const formatDateTime = (date: Date, options?: Intl.DateTimeFormatOptions) => {
return date.toLocaleString(
i18n.language,
options?.hour
? {
...options,
...clockOptions,
}
: {
...options,
},
);
};
const formatTime = (date: Date, options?: Intl.DateTimeFormatOptions) => {
return date.toLocaleTimeString(i18n.language, {
...options,
...clockOptions,
});
};
const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions) => {
return date.toLocaleDateString(i18n.language, options);
};
return { formatDateTime, formatTime, formatDate };
}

View File

@ -64,10 +64,11 @@ import "~/styles/vars.css";
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
if (isRevalidation(args)) return true;
// // reload on language change so the selected language gets set into the cookie
const lang = args.nextUrl.searchParams.get("lng");
// user settings, lang change etc. require revalidation on root loader
const isSettingsPage = args.currentUrl.pathname === "/settings";
if (isSettingsPage) return true;
return Boolean(lang);
return false;
};
export const meta: MetaFunction = (args) => {

View File

@ -38,4 +38,41 @@ 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: "/",
});
const tournamentCard = page.getByTestId("tournament-card").first();
const timeElement = tournamentCard.locator("time");
const initialTime = await timeElement.textContent();
expect(initialTime).toMatch(/AM|PM/);
await navigate({
page,
url: SETTINGS_PAGE,
});
const clockFormatSelect = page.locator("#clock-format");
await clockFormatSelect.selectOption("24h");
await expect(page.getByText("Settings updated")).toBeVisible();
await navigate({
page,
url: "/",
});
const newTime = await tournamentCard.locator("time").textContent();
expect(newTime).not.toMatch(/AM|PM/);
expect(newTime).not.toBe(initialTime);
expect(newTime).toContain(":");
});
});

View File

@ -306,6 +306,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -306,6 +306,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -306,6 +306,10 @@
"settings.notifications.disableInfo": "To disable push notifications check your browser settings",
"settings.notifications.browserNotSupported": "Push notifications are not supported on this browser",
"settings.notifications.permissionDenied": "Push notifications were denied. Check your browser settings to re-enable",
"settings.clockFormat": "Clock format",
"clockFormat.auto": "Auto (use language default)",
"clockFormat.24h": "24-hour",
"clockFormat.12h": "12-hour (AM/PM)",
"badges.selector.none": "No badges selected",
"badges.selector.select": "Select badge to add",
"api.title": "API Access",

View File

@ -308,6 +308,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "Ninguna insignia seleccionada",
"badges.selector.select": "Seleccionar insignia añadir",
"api.title": "",

View File

@ -308,6 +308,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "Ninguna insignia seleccionada",
"badges.selector.select": "Seleccionar insignia añadir",
"api.title": "",

View File

@ -308,6 +308,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -308,6 +308,10 @@
"settings.notifications.disableInfo": "Regarder les paramètre de votre navigateur pour désactiver les notification push.",
"settings.notifications.browserNotSupported": "Les notifications push ne sont pas supporter par cet navigateur",
"settings.notifications.permissionDenied": "Les notifications push ont été refusées. Vérifiez les paramètres de votre navigateur pour les réactiver",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "Aucun badges est sélectionné",
"badges.selector.select": "Selectionner un badge pour l'ajouter",
"api.title": "",

View File

@ -307,6 +307,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -308,6 +308,10 @@
"settings.notifications.disableInfo": "Per disattivare le notifiche push, controllare le impostazioni del browser",
"settings.notifications.browserNotSupported": "Le notifiche push non sono supportate su questo browser",
"settings.notifications.permissionDenied": "Le notifiche push sono state negate. Controlla le impostazioni del browser per riattivarle",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "Nessuna medaglia selezionata",
"badges.selector.select": "Seleziona medaglia da aggiungere",
"api.title": "",

View File

@ -302,6 +302,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -302,6 +302,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -306,6 +306,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -309,6 +309,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -308,6 +308,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "",
"badges.selector.select": "",
"api.title": "",

View File

@ -309,6 +309,10 @@
"settings.notifications.disableInfo": "Чтобы отключить push-уведомления проверьте настройки вашего браузера",
"settings.notifications.browserNotSupported": "Push-уведомления в вашем браузере не поддерживаются",
"settings.notifications.permissionDenied": "Push-уведомления были запрещены. Проверьте настройки вашего бразуера, чтобы включить их обратно",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "Награды не выбраны",
"badges.selector.select": "Выберите награды для добавления",
"api.title": "",

View File

@ -302,6 +302,10 @@
"settings.notifications.disableInfo": "",
"settings.notifications.browserNotSupported": "",
"settings.notifications.permissionDenied": "",
"settings.clockFormat": "",
"clockFormat.auto": "",
"clockFormat.24h": "",
"clockFormat.12h": "",
"badges.selector.none": "没有选择任何徽章",
"badges.selector.select": "选择徽章并添加",
"api.title": "",