feat: expand utility widgets (#6638)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-20 16:26:04 +02:00
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";
};
+49 -152
View File
@@ -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;
};
+19
View File
@@ -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);
};
+46 -18
View File
@@ -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();
});
});
+127
View File
@@ -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;