feat: expand utility widgets (#6638)
This commit is contained in:
1 parent
53b518960c
commit
58a24569ce
86 files changed
+7660
-751
No files matched your search
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconClock } from "@tabler/icons-react";
|
||||
import type { Dayjs } from "dayjs";
|
||||
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { clockTimeFormatShowsSeconds, clockTimeFormatUses12Hours, resolveClockTimeFormat } from "./format";
|
||||
import { TimeOfDayBar } from "./time-of-day-bar";
|
||||
import { ClockWeatherSummary } from "./weather-summary";
|
||||
import {
|
||||
createWorldClockTime,
|
||||
formatRelativeOffset,
|
||||
formatUtcOffset,
|
||||
getTimeOfDayPhase,
|
||||
humanizeTimeZone,
|
||||
maximumWorldClockCities,
|
||||
} from "./world-clock";
|
||||
import type { TimeOfDayPhase, WorldClockTime } from "./world-clock";
|
||||
|
||||
interface AdvancedClockViewProps {
|
||||
now: Date;
|
||||
options: WidgetComponentProps<"clock">["options"];
|
||||
primaryTime: Dayjs;
|
||||
primaryTimeZone: string;
|
||||
primaryTimeZoneInvalid: boolean;
|
||||
}
|
||||
|
||||
export const AdvancedClockView = ({
|
||||
now,
|
||||
options,
|
||||
primaryTime,
|
||||
primaryTimeZone,
|
||||
primaryTimeZoneInvalid,
|
||||
}: AdvancedClockViewProps) => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
const primaryLabel = getPrimaryLabel(options, t);
|
||||
const primary: WorldClockTime = {
|
||||
id: "primary",
|
||||
label: primaryLabel,
|
||||
timeZone: primaryTimeZone,
|
||||
zonedTime: primaryTime,
|
||||
phase: getTimeOfDayPhase(primaryTime.hour()),
|
||||
minuteOfDay: primaryTime.hour() * 60 + primaryTime.minute() + primaryTime.second() / 60,
|
||||
utcOffset: primaryTime.utcOffset(),
|
||||
dayDifference: 0,
|
||||
};
|
||||
const cities = options.worldClockCities
|
||||
.slice(0, maximumWorldClockCities)
|
||||
.filter(({ timeZone }) => timeZone !== primaryTimeZone)
|
||||
.map((city) => createWorldClockTime({ city, now, primaryTime }));
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
w="100%"
|
||||
type="auto"
|
||||
offsetScrollbars
|
||||
scrollbarSize={6}
|
||||
aria-label={t("worldClock.advancedLabel")}
|
||||
>
|
||||
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
|
||||
{primaryTimeZoneInvalid && (
|
||||
<Alert icon={<IconAlertTriangle />} color="yellow" title={t("worldClock.unsupportedPrimaryTitle")}>
|
||||
{t("worldClock.unsupportedPrimary", { timeZone: options.timezone })}
|
||||
</Alert>
|
||||
)}
|
||||
<PrimaryClock now={now} options={options} primary={primary} />
|
||||
<OverviewMode now={now} options={options} primary={primary} cities={cities} />
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModeProps {
|
||||
now: Date;
|
||||
options: WidgetComponentProps<"clock">["options"];
|
||||
primary: WorldClockTime;
|
||||
cities: WorldClockTime[];
|
||||
}
|
||||
|
||||
const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "options" | "primary">) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.clock");
|
||||
const primaryTime = primary.zonedTime;
|
||||
if (!primaryTime || primary.phase === null || primary.minuteOfDay === null) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" align="flex-start" gap="lg" wrap="wrap">
|
||||
<Stack gap={5} miw={0}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon color={getPhaseColor(primary.phase)} variant="light" radius="xl" size="md">
|
||||
<IconClock size={16} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} size="h4" fw={600}>
|
||||
{primary.label}
|
||||
</Title>
|
||||
</Group>
|
||||
<ClockTime entry={primary} now={now} options={options} primary />
|
||||
{options.showDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatLocalizedDate(now, locale, { dateStyle: "full", timeZone: primary.timeZone })}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Badge color="gray" variant="light" tt="none">
|
||||
{primary.timeZone}
|
||||
</Badge>
|
||||
<Badge color="gray" variant="outline" tt="none">
|
||||
{formatUtcOffset(primaryTime.utcOffset())}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
{options.showWeather && (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<ClockWeatherSummary
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
animateIcon={options.animateWeatherIcon}
|
||||
detailed
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Group>
|
||||
<Divider my="sm" />
|
||||
<TimeOfDayBar
|
||||
minuteOfDay={primary.minuteOfDay}
|
||||
phase={primary.phase}
|
||||
showLegend
|
||||
showTicks
|
||||
label={t("worldClock.timeBarLabel", { city: primary.label })}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const OverviewMode = ({ now, options, primary, cities }: ModeProps) => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
if (cities.length === 0) return <EmptyCities />;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="baseline">
|
||||
<Title order={3} size="h5">
|
||||
{t("worldClock.cities")}
|
||||
</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("worldClock.cityCount", { count: cities.length })}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid component="ul" cols={{ base: 1, sm: 2 }} spacing="sm" p={0} m={0} style={{ listStyle: "none" }}>
|
||||
{cities.map((city) => (
|
||||
<WorldClockCard
|
||||
key={city.id}
|
||||
entry={city}
|
||||
now={now}
|
||||
options={options}
|
||||
primaryOffset={primary.utcOffset ?? 0}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface ClockEntryProps {
|
||||
entry: WorldClockTime;
|
||||
now: Date;
|
||||
options: WidgetComponentProps<"clock">["options"];
|
||||
primaryOffset: number;
|
||||
}
|
||||
|
||||
const WorldClockCard = ({ entry, now, options, primaryOffset }: ClockEntryProps) => {
|
||||
if (!entry.zonedTime || entry.utcOffset === null) {
|
||||
return <InvalidTimeZone entry={entry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper component="li" withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text fw={600} truncate>
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{entry.timeZone}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ClockTime entry={entry} now={now} options={options} compact />
|
||||
<DayAndOffset entry={entry} now={now} primaryOffset={primaryOffset} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const ClockTime = ({
|
||||
entry,
|
||||
now,
|
||||
options,
|
||||
primary = false,
|
||||
compact = false,
|
||||
}: Omit<ClockEntryProps, "primaryOffset"> & { primary?: boolean; compact?: boolean }) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.clock");
|
||||
if (!entry.zonedTime) return null;
|
||||
let formattedTime = formatLocalizedTime(now, locale, {
|
||||
hour12: clockTimeFormatUses12Hours(options.customTimeFormat, options.is24HourFormat),
|
||||
includeSeconds: clockTimeFormatShowsSeconds(options.customTimeFormat),
|
||||
timeZone: entry.timeZone,
|
||||
});
|
||||
if (primary) {
|
||||
formattedTime = entry.zonedTime.format(resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat));
|
||||
}
|
||||
const accessibleDate = formatLocalizedDate(now, locale, {
|
||||
dateStyle: "medium",
|
||||
timeZone: entry.timeZone,
|
||||
});
|
||||
const fontSize = compact ? { base: 30, sm: 34 } : { base: 44, sm: 56 };
|
||||
|
||||
return (
|
||||
<Text
|
||||
component="time"
|
||||
dateTime={now.toISOString()}
|
||||
fz={fontSize}
|
||||
fw={700}
|
||||
ff="var(--mantine-font-family-monospace)"
|
||||
lh={1}
|
||||
lts="-0.04em"
|
||||
aria-label={t("worldClock.accessibleTime", {
|
||||
city: entry.label,
|
||||
time: formattedTime,
|
||||
date: accessibleDate,
|
||||
timeZone: entry.timeZone,
|
||||
})}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const DayAndOffset = ({ entry, now, primaryOffset }: Pick<ClockEntryProps, "entry" | "now" | "primaryOffset">) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.clock");
|
||||
if (entry.dayDifference === null || entry.utcOffset === null) return null;
|
||||
const dayRelation = getDayRelation(entry.dayDifference, t);
|
||||
const date = formatLocalizedDate(now, locale, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: entry.timeZone,
|
||||
});
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Badge color="gray" variant="light" size="sm" tt="none">
|
||||
{date} · {dayRelation}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatRelativeOffset(entry.utcOffset - primaryOffset)} · {formatUtcOffset(entry.utcOffset)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const InvalidTimeZone = ({ entry }: { entry: WorldClockTime }) => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
return (
|
||||
<Paper component="li" withBorder radius="md" p="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconAlertTriangle color="var(--mantine-color-yellow-6)" aria-hidden />
|
||||
<Box>
|
||||
<Text fw={600}>{entry.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("worldClock.unsupportedTimeZone", { timeZone: entry.timeZone })}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyCities = () => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
return (
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("worldClock.noSecondaryCities")}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
type ScopedClockTranslator = ReturnType<typeof useScopedI18n<"widget.clock">>;
|
||||
|
||||
const getDayRelation = (difference: number, t: ScopedClockTranslator) => {
|
||||
if (difference < 0) return t("worldClock.yesterday");
|
||||
if (difference > 0) return t("worldClock.tomorrow");
|
||||
return t("worldClock.today");
|
||||
};
|
||||
|
||||
const getPrimaryLabel = (options: ModeProps["options"], t: ScopedClockTranslator) => {
|
||||
if (options.customTitleToggle) return options.customTitle || t("worldClock.primary");
|
||||
if (options.useCustomTimezone) return humanizeTimeZone(options.timezone);
|
||||
return t("worldClock.local");
|
||||
};
|
||||
|
||||
const getPhaseColor = (phase: TimeOfDayPhase) => {
|
||||
if (phase === "night") return "indigo";
|
||||
if (phase === "dawn") return "orange";
|
||||
if (phase === "day") return "cyan";
|
||||
return "grape";
|
||||
};
|
||||
@@ -1,51 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import timezones from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { metricToImperial } from "@homarr/common";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
import { WeatherDescription } from "../weather/icon";
|
||||
import { AdvancedClockView } from "./advanced-view";
|
||||
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
|
||||
import { useCurrentTime } from "./use-current-time";
|
||||
import { ClockWeatherSummary } from "./weather-summary";
|
||||
import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezones);
|
||||
|
||||
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : "";
|
||||
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
|
||||
const dateFormat = options.dateFormat;
|
||||
const customTimeFormat = options.customTimeFormat;
|
||||
const customDateFormat = options.customDateFormat;
|
||||
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced });
|
||||
const zonedTime = time === null ? null : dayjs(time).tz(timezone);
|
||||
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
|
||||
const time = useCurrentTime({ showSeconds });
|
||||
const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone();
|
||||
const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone);
|
||||
const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone;
|
||||
const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone);
|
||||
|
||||
if (isAdvanced) {
|
||||
if (time && zonedTime) {
|
||||
return (
|
||||
<AdvancedClockView
|
||||
now={time}
|
||||
options={options}
|
||||
primaryTime={zonedTime}
|
||||
primaryTimeZone={primaryTimeZone}
|
||||
primaryTimeZoneInvalid={primaryTimeZoneInvalid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Center h="100%">--:--</Center>;
|
||||
}
|
||||
|
||||
const resolvedTimeFormat = resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat);
|
||||
const minimumAxis = Math.min(width, height * 1.5);
|
||||
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
|
||||
let sizing: "xs" | "sm" | "md" = "md";
|
||||
if (minimumAxis < 128) sizing = "xs";
|
||||
else if (minimumAxis < 196) sizing = "sm";
|
||||
const showWeatherCorner = options.showWeather && sizing !== "xs";
|
||||
|
||||
return (
|
||||
<Box className="clock-widget-container" h="100%" pos="relative">
|
||||
{showWeatherCorner && (
|
||||
<ClockWeatherCorner
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
showDetails={isAdvanced}
|
||||
/>
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
<ClockWeatherSummary
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
animateIcon={options.animateWeatherIcon}
|
||||
detailed={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
||||
{options.customTitleToggle && (
|
||||
@@ -53,19 +64,14 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
{options.customTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
|
||||
<Title className="clock-time-text" fw={700} order={getTitleOrder(sizing)} lh="1">
|
||||
<time dateTime={zonedTime?.toISOString()}>
|
||||
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
|
||||
{zonedTime === null ? "--:--" : zonedTime.format(resolvedTimeFormat)}
|
||||
</time>
|
||||
</Title>
|
||||
{(isAdvanced || options.showDate) && (
|
||||
{options.showDate && (
|
||||
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
||||
{zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
|
||||
{zonedTime?.format(options.dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -73,117 +79,8 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
);
|
||||
}
|
||||
|
||||
interface ClockWeatherCornerProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locationName: string;
|
||||
isFahrenheit: boolean;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
const ClockWeatherCorner = ({
|
||||
latitude,
|
||||
longitude,
|
||||
locationName,
|
||||
isFahrenheit,
|
||||
showDetails,
|
||||
}: ClockWeatherCornerProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) {
|
||||
return (
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
{isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
const today = weather.daily[0];
|
||||
const formatTemperature = (value: number | undefined) => {
|
||||
if (value === undefined) return "—";
|
||||
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
|
||||
return `${Math.round(preferred)}${unit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="clock-weather-corner"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
gap={showDetails ? 4 : 0}
|
||||
align="flex-start"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
|
||||
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
|
||||
{Math.round(temp)}
|
||||
{unit}
|
||||
</Text>
|
||||
{showDetails && <WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />}
|
||||
</Group>
|
||||
{showDetails && (
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" fw={600}>
|
||||
{locationName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
|
||||
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "}
|
||||
{t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—";
|
||||
|
||||
interface UseCurrentTimeProps {
|
||||
showSeconds: boolean;
|
||||
}
|
||||
|
||||
const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout>(null);
|
||||
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(new Date());
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
setTime(new Date());
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTime(new Date());
|
||||
}, intervalMultiplier * 1000);
|
||||
},
|
||||
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [intervalMultiplier, showSeconds]);
|
||||
|
||||
return time;
|
||||
const getTitleOrder = (sizing: "xs" | "sm" | "md") => {
|
||||
if (sizing === "md") return 2;
|
||||
if (sizing === "sm") return 4;
|
||||
return 6;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
export const automaticTimeFormat = "automatic";
|
||||
|
||||
export const resolveClockTimeFormat = (format: string, legacyIs24HourFormat: boolean) => {
|
||||
if (format && format !== automaticTimeFormat) return format;
|
||||
if (legacyIs24HourFormat) return "HH:mm";
|
||||
return "hh:mm A";
|
||||
};
|
||||
|
||||
export const clockTimeFormatUses12Hours = (format: string, legacyIs24HourFormat: boolean) => {
|
||||
if (!format || format === automaticTimeFormat) return !legacyIs24HourFormat;
|
||||
const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, "");
|
||||
return /h{1,2}/.test(formatWithoutLiterals);
|
||||
};
|
||||
|
||||
export const clockTimeFormatShowsSeconds = (format: string) => {
|
||||
if (!format || format === automaticTimeFormat) return false;
|
||||
const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, "");
|
||||
return /s{1,2}/.test(formatWithoutLiterals);
|
||||
};
|
||||
@@ -3,9 +3,18 @@ import dayjs from "dayjs";
|
||||
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import {
|
||||
defaultWorldClockCities,
|
||||
getTimeZoneOptions,
|
||||
maximumWorldClockCities,
|
||||
worldClockCityPresets,
|
||||
} from "./world-clock";
|
||||
|
||||
const timeZoneOptions = getTimeZoneOptions();
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "weather", "atLocation"]],
|
||||
queryMatcher: ({ input }, scope) => {
|
||||
const location = scope.options.weatherLocation;
|
||||
@@ -37,12 +46,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showSeconds: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
useCustomTimezone: factory.switch({ defaultValue: false }),
|
||||
timezone: factory.select({
|
||||
options: Intl.supportedValuesOf("timeZone").map((value) => value),
|
||||
options: timeZoneOptions,
|
||||
defaultValue: "Europe/London",
|
||||
searchable: true,
|
||||
withDescription: true,
|
||||
@@ -52,24 +58,29 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
}),
|
||||
dateFormat: factory.select({
|
||||
options: [
|
||||
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
|
||||
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
|
||||
{ value: "MMM D", label: dayjs().format("MMM D") },
|
||||
{ value: "D MMM", label: dayjs().format("D MMM") },
|
||||
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
|
||||
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
|
||||
{ value: "DD/MM", label: dayjs().format("DD/MM") },
|
||||
{ value: "MM/DD", label: dayjs().format("MM/DD") },
|
||||
{ value: "YYYY-MM-DD", label: `${dayjs().format("YYYY-MM-DD")} · YYYY-MM-DD` },
|
||||
{ value: "DD/MM/YYYY", label: `${dayjs().format("DD/MM/YYYY")} · DD/MM/YYYY` },
|
||||
{ value: "MM/DD/YYYY", label: `${dayjs().format("MM/DD/YYYY")} · MM/DD/YYYY` },
|
||||
{ value: "dddd, MMMM D", label: `${dayjs().format("dddd, MMMM D")} · dddd, MMMM D` },
|
||||
{ value: "dddd, D MMMM", label: `${dayjs().format("dddd, D MMMM")} · dddd, D MMMM` },
|
||||
{ value: "MMM D, YYYY", label: `${dayjs().format("MMM D, YYYY")} · MMM D, YYYY` },
|
||||
{ value: "D MMM YYYY", label: `${dayjs().format("D MMM YYYY")} · D MMM YYYY` },
|
||||
{ value: "MMM D", label: `${dayjs().format("MMM D")} · MMM D` },
|
||||
{ value: "D MMM", label: `${dayjs().format("D MMM")} · D MMM` },
|
||||
{ value: "DD/MM", label: `${dayjs().format("DD/MM")} · DD/MM` },
|
||||
{ value: "MM/DD", label: `${dayjs().format("MM/DD")} · MM/DD` },
|
||||
],
|
||||
defaultValue: "dddd, MMMM D",
|
||||
withDescription: true,
|
||||
}),
|
||||
customTimeFormat: factory.text({
|
||||
defaultValue: "",
|
||||
withDescription: true,
|
||||
}),
|
||||
customDateFormat: factory.text({
|
||||
defaultValue: "",
|
||||
customTimeFormat: factory.select({
|
||||
options: [
|
||||
{ value: "HH:mm", label: `${dayjs().format("HH:mm")} · HH:mm` },
|
||||
{ value: "HH:mm:ss", label: `${dayjs().format("HH:mm:ss")} · HH:mm:ss` },
|
||||
{ value: "h:mm A", label: `${dayjs().format("h:mm A")} · h:mm A` },
|
||||
{ value: "h:mm:ss A", label: `${dayjs().format("h:mm:ss A")} · h:mm:ss A` },
|
||||
],
|
||||
defaultValue: "HH:mm",
|
||||
withDescription: true,
|
||||
}),
|
||||
showWeather: factory.switch({
|
||||
@@ -86,8 +97,22 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
isWeatherFormatFahrenheit: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
animateWeatherIcon: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
worldClockCities: factory.timezoneList({
|
||||
defaultValue: defaultWorldClockCities.map((city) => ({ ...city })),
|
||||
maxValues: maximumWorldClockCities,
|
||||
presets: worldClockCityPresets,
|
||||
timeZoneOptions,
|
||||
withDescription: true,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
is24HourFormat: {
|
||||
shouldHide: () => true,
|
||||
},
|
||||
customTitle: {
|
||||
shouldHide: (options) => !options.customTitleToggle,
|
||||
},
|
||||
@@ -103,6 +128,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
isWeatherFormatFahrenheit: {
|
||||
shouldHide: (options) => !options.showWeather,
|
||||
},
|
||||
animateWeatherIcon: {
|
||||
shouldHide: (options) => !options.showWeather,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Badge, Box, Group, Progress, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { TimeOfDayPhase } from "./world-clock";
|
||||
|
||||
interface TimeOfDayBarProps {
|
||||
minuteOfDay: number;
|
||||
phase: TimeOfDayPhase;
|
||||
showLegend?: boolean;
|
||||
showTicks?: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const phaseSections = [
|
||||
{ key: "nightStart", value: 20.833, color: "indigo.8" },
|
||||
{ key: "dawn", value: 12.5, color: "orange.6" },
|
||||
{ key: "day", value: 41.667, color: "cyan.6" },
|
||||
{ key: "dusk", value: 12.5, color: "grape.6" },
|
||||
{ key: "nightEnd", value: 12.5, color: "indigo.8" },
|
||||
] as const;
|
||||
|
||||
const phaseLegend = [
|
||||
{ key: "night", color: "indigo.8" },
|
||||
{ key: "dawn", color: "orange.6" },
|
||||
{ key: "day", color: "cyan.6" },
|
||||
{ key: "dusk", color: "grape.6" },
|
||||
] as const;
|
||||
|
||||
const timeTicks = ["midnight", "morning", "noon", "evening", "end"] as const;
|
||||
|
||||
export const TimeOfDayBar = ({
|
||||
minuteOfDay,
|
||||
phase,
|
||||
showLegend = false,
|
||||
showTicks = false,
|
||||
label,
|
||||
}: TimeOfDayBarProps) => {
|
||||
const t = useScopedI18n("widget.clock");
|
||||
const progress = Math.max(0, Math.min(100, (minuteOfDay / 1440) * 100));
|
||||
const remainingMinutes = Math.max(0, 1440 - Math.floor(minuteOfDay));
|
||||
const remainingHours = Math.floor(remainingMinutes / 60);
|
||||
const remainder = remainingMinutes % 60;
|
||||
|
||||
return (
|
||||
<Stack component="figure" gap={6} m={0} aria-label={label}>
|
||||
<Group justify="space-between" align="center" gap="xs" wrap="nowrap">
|
||||
<Badge color={getPhaseColor(phase)} variant="dot" size="sm">
|
||||
{t(`worldClock.phase.${phase}`)}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{t("worldClock.dayProgress", {
|
||||
percent: Math.round(progress),
|
||||
hours: remainingHours,
|
||||
minutes: remainder.toString().padStart(2, "0"),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box pos="relative">
|
||||
<Progress.Root size="md" radius="xl" aria-hidden>
|
||||
{phaseSections.map((section) => (
|
||||
<Progress.Section key={section.key} value={section.value} color={section.color} withAria={false} />
|
||||
))}
|
||||
</Progress.Root>
|
||||
<Box
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left={`${progress}%`}
|
||||
w={4}
|
||||
h={24}
|
||||
bg="var(--mantine-color-text)"
|
||||
style={{ borderRadius: "var(--mantine-radius-xl)", transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
</Box>
|
||||
{showTicks && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
{timeTicks.map((tick) => (
|
||||
<Text key={tick} size="xs" c="dimmed" aria-hidden>
|
||||
{t(`worldClock.tick.${tick}`)}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{showLegend && (
|
||||
<Group gap="md" mt={4} align="center" wrap="wrap">
|
||||
<Text size="xs" fw={600}>
|
||||
{t("worldClock.phaseLegend")}
|
||||
</Text>
|
||||
{phaseLegend.map((item) => (
|
||||
<Group key={item.key} gap={6} wrap="nowrap">
|
||||
<Box w={9} h={9} bg={item.color} style={{ borderRadius: "var(--mantine-radius-xl)" }} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`worldClock.phase.${item.key}`)} · {t(`worldClock.phaseRange.${item.key}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getPhaseColor = (phase: TimeOfDayPhase) => {
|
||||
if (phase === "night") return "indigo";
|
||||
if (phase === "dawn") return "orange";
|
||||
if (phase === "day") return "cyan";
|
||||
return "grape";
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) => {
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(null);
|
||||
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(new Date());
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
setTime(new Date());
|
||||
intervalRef.current = setInterval(() => setTime(new Date()), intervalMultiplier * 1000);
|
||||
},
|
||||
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [intervalMultiplier, showSeconds]);
|
||||
|
||||
return time;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
import { WeatherDescription } from "../weather/icon";
|
||||
|
||||
interface ClockWeatherSummaryProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locationName: string;
|
||||
isFahrenheit: boolean;
|
||||
animateIcon: boolean;
|
||||
detailed: boolean;
|
||||
}
|
||||
|
||||
export const ClockWeatherSummary = ({
|
||||
latitude,
|
||||
longitude,
|
||||
locationName,
|
||||
isFahrenheit,
|
||||
animateIcon,
|
||||
detailed,
|
||||
}: ClockWeatherSummaryProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) {
|
||||
return isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
);
|
||||
}
|
||||
|
||||
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
const today = weather.daily[0];
|
||||
const formatTemperature = (value: number | null | undefined) => {
|
||||
if (value === undefined || value === null) return "—";
|
||||
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
|
||||
return `${Math.round(preferred)}${unit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={detailed ? 3 : 0} align="flex-start">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<AnimatedWeatherIcon
|
||||
animated={animateIcon}
|
||||
code={weather.current.weatherCode}
|
||||
isDay={weather.current.isDay}
|
||||
size={detailed ? 28 : 22}
|
||||
/>
|
||||
<Text size={detailed ? "sm" : "xs"} fw={detailed ? 600 : undefined} c={detailed ? undefined : "dimmed"}>
|
||||
{Math.round(temperature)}
|
||||
{unit}
|
||||
</Text>
|
||||
{detailed && <WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />}
|
||||
</Group>
|
||||
{detailed && (
|
||||
<Box>
|
||||
<Text size="xs" fw={600} lineClamp={1}>
|
||||
{locationName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTemperature(today?.maxTemperature)} / {formatTemperature(today?.minTemperature)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import dayjs from "dayjs";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
createWorldClockTime,
|
||||
formatRelativeOffset,
|
||||
formatUtcOffset,
|
||||
getTimeOfDayPhase,
|
||||
getTimeZoneOptions,
|
||||
} from "./world-clock";
|
||||
|
||||
describe("world clock helpers", () => {
|
||||
test("adds UTC to the available timezone options", () => {
|
||||
expect(getTimeZoneOptions()).toContainEqual({ value: "UTC", label: "UTC (UTC)" });
|
||||
});
|
||||
|
||||
test("classifies the fixed time-of-day phases at their boundaries", () => {
|
||||
expect([0, 4, 5, 7, 8, 17, 18, 20, 21, 23].map(getTimeOfDayPhase)).toEqual([
|
||||
"night",
|
||||
"night",
|
||||
"dawn",
|
||||
"dawn",
|
||||
"day",
|
||||
"day",
|
||||
"dusk",
|
||||
"dusk",
|
||||
"night",
|
||||
"night",
|
||||
]);
|
||||
});
|
||||
|
||||
test("formats whole-hour and fractional offsets", () => {
|
||||
expect(formatUtcOffset(345)).toBe("UTC+05:45");
|
||||
expect(formatRelativeOffset(-210)).toBe("−3h 30m");
|
||||
expect(formatRelativeOffset(0)).toBe("±0h");
|
||||
});
|
||||
|
||||
test("compares civil dates across the date line from one instant", () => {
|
||||
const now = new Date("2026-01-01T02:30:00.000Z");
|
||||
const primaryTime = dayjs(now).tz("America/New_York");
|
||||
const tokyo = createWorldClockTime({
|
||||
city: { id: "tokyo", label: "Tokyo", timeZone: "Asia/Tokyo" },
|
||||
now,
|
||||
primaryTime,
|
||||
});
|
||||
|
||||
expect(primaryTime.format("YYYY-MM-DD")).toBe("2025-12-31");
|
||||
expect(tokyo.zonedTime?.format("YYYY-MM-DD")).toBe("2026-01-01");
|
||||
expect(tokyo.dayDifference).toBe(1);
|
||||
});
|
||||
|
||||
test("isolates unsupported timezones", () => {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z");
|
||||
const result = createWorldClockTime({
|
||||
city: { id: "invalid", label: "Invalid", timeZone: "Invalid/Timezone" },
|
||||
now,
|
||||
primaryTime: dayjs(now).tz("UTC"),
|
||||
});
|
||||
|
||||
expect(result.zonedTime).toBeNull();
|
||||
expect(result.utcOffset).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import type { OptionTimezone } from "../options";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export const maximumWorldClockCities = 6;
|
||||
|
||||
export const worldClockCityPresets = [
|
||||
{ id: "new-york", label: "New York", timeZone: "America/New_York" },
|
||||
{ id: "london", label: "London", timeZone: "Europe/London" },
|
||||
{ id: "paris", label: "Paris", timeZone: "Europe/Paris" },
|
||||
{ id: "tokyo", label: "Tokyo", timeZone: "Asia/Tokyo" },
|
||||
{ id: "sydney", label: "Sydney", timeZone: "Australia/Sydney" },
|
||||
{ id: "utc", label: "UTC", timeZone: "UTC" },
|
||||
] as const satisfies readonly OptionTimezone[];
|
||||
|
||||
export const defaultWorldClockCities = worldClockCityPresets.filter(({ id }) =>
|
||||
["new-york", "paris", "tokyo"].includes(id),
|
||||
);
|
||||
|
||||
export const getTimeZoneOptions = () => {
|
||||
const timeZones = Intl.supportedValuesOf("timeZone");
|
||||
if (!timeZones.includes("UTC")) timeZones.push("UTC");
|
||||
|
||||
return timeZones.map((timeZone) => ({
|
||||
value: timeZone,
|
||||
label: `${humanizeTimeZone(timeZone)} (${timeZone})`,
|
||||
}));
|
||||
};
|
||||
|
||||
export const humanizeTimeZone = (timeZone: string) => {
|
||||
if (timeZone === "UTC") return "UTC";
|
||||
const segments = timeZone.split("/");
|
||||
return (segments.at(-1) ?? timeZone).replaceAll("_", " ");
|
||||
};
|
||||
|
||||
export const isTimeZoneSupported = (timeZone: string) => {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en", { timeZone }).format(0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export type TimeOfDayPhase = "night" | "dawn" | "day" | "dusk";
|
||||
|
||||
export interface WorldClockTime {
|
||||
id: string;
|
||||
label: string;
|
||||
timeZone: string;
|
||||
zonedTime: Dayjs | null;
|
||||
phase: TimeOfDayPhase | null;
|
||||
minuteOfDay: number | null;
|
||||
utcOffset: number | null;
|
||||
dayDifference: number | null;
|
||||
}
|
||||
|
||||
export const getResolvedLocalTimeZone = () => {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return timeZone && isTimeZoneSupported(timeZone) ? timeZone : "UTC";
|
||||
};
|
||||
|
||||
export const createWorldClockTime = ({
|
||||
city,
|
||||
now,
|
||||
primaryTime,
|
||||
}: {
|
||||
city: OptionTimezone;
|
||||
now: Date;
|
||||
primaryTime: Dayjs;
|
||||
}): WorldClockTime => {
|
||||
if (!isTimeZoneSupported(city.timeZone)) {
|
||||
return {
|
||||
...city,
|
||||
zonedTime: null,
|
||||
phase: null,
|
||||
minuteOfDay: null,
|
||||
utcOffset: null,
|
||||
dayDifference: null,
|
||||
};
|
||||
}
|
||||
|
||||
const zonedTime = dayjs(now).tz(city.timeZone);
|
||||
return {
|
||||
...city,
|
||||
zonedTime,
|
||||
phase: getTimeOfDayPhase(zonedTime.hour()),
|
||||
minuteOfDay: zonedTime.hour() * 60 + zonedTime.minute() + zonedTime.second() / 60,
|
||||
utcOffset: zonedTime.utcOffset(),
|
||||
dayDifference: getCivilDayNumber(zonedTime) - getCivilDayNumber(primaryTime),
|
||||
};
|
||||
};
|
||||
|
||||
export const getTimeOfDayPhase = (hour: number): TimeOfDayPhase => {
|
||||
if (hour < 5 || hour >= 21) return "night";
|
||||
if (hour < 8) return "dawn";
|
||||
if (hour < 18) return "day";
|
||||
return "dusk";
|
||||
};
|
||||
|
||||
export const formatUtcOffset = (minutes: number) => {
|
||||
if (minutes === 0) return "UTC±00:00";
|
||||
const sign = minutes < 0 ? "−" : "+";
|
||||
const absoluteMinutes = Math.abs(minutes);
|
||||
const hours = Math.floor(absoluteMinutes / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const remainingMinutes = (absoluteMinutes % 60).toString().padStart(2, "0");
|
||||
return `UTC${sign}${hours}:${remainingMinutes}`;
|
||||
};
|
||||
|
||||
export const formatRelativeOffset = (minutes: number) => {
|
||||
if (minutes === 0) return "±0h";
|
||||
const sign = minutes < 0 ? "−" : "+";
|
||||
const absoluteMinutes = Math.abs(minutes);
|
||||
const hours = Math.floor(absoluteMinutes / 60);
|
||||
const remainingMinutes = absoluteMinutes % 60;
|
||||
return remainingMinutes === 0 ? `${sign}${hours}h` : `${sign}${hours}h ${remainingMinutes}m`;
|
||||
};
|
||||
|
||||
const getCivilDayNumber = (value: Dayjs) => Date.UTC(value.year(), value.month(), value.date()) / 86_400_000;
|
||||
Reference in new issue
Block a user