fix: address utility widget review

This commit is contained in:
ajnart committed 2026-08-20 15:33:28 +02:00
1 parent 52d96505a3
commit e3475d3efe
17 files changed
+138 -73

No files matched your search

+3 -3
View File
@@ -18,9 +18,9 @@ dominant pollutant, particulate matter and the highest available pollen reading
view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima.
Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose
locale region is the United States and the European scale everywhere else. The selected scale is always shown next
to the value. Pollen values are individual measured concentrations; the widget does not combine them into a single
pollen index.
locale region is the United States and the European scale everywhere else. The selected scale stays in widget options
instead of being repeated beside every reading. Pollen values are individual measured concentrations; the widget does
not combine them into a single pollen index.
The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use
micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter.
+3 -4
View File
@@ -24,10 +24,9 @@ comparable card. The primary clock includes a segmented 24-hour rail with a curr
time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not
astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets.
The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live
examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and
24-hour display are selected through those same formats. The date-format selector likewise
shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a
The compact widget remains a single numeric clock. The 12/24-hour and seconds switches apply unless a custom Day.js
time format overrides them. The date-format selector shows common presets with live examples, while the custom date
format preserves support for any compatible Day.js tokens. Secondary cities always include a short localized date and a
Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone
rules, including daylight-saving changes.
+22 -5
View File
@@ -20,6 +20,18 @@ export const clockWidget: WidgetDefinition = {
values: { type: "string" },
defaultValue: "-",
},
{
name: "24-hour format",
description: "Use 24-hour format instead of 12-hour format when no custom time format is set.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Display seconds",
description: "Show seconds when no custom time format is set.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Use fixed timezone",
description: "Select custom timezone to display instead of the client timezone.",
@@ -45,11 +57,16 @@ export const clockWidget: WidgetDefinition = {
defaultValue: "dddd, MMMM D",
},
{
name: "Time format",
description:
"Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
defaultValue: "HH:mm",
name: "Custom time format",
description: "Use Day.js tokens to override the 12/24-hour and seconds options.",
values: "A Day.js format such as HH:mm:ss or h:mm A",
defaultValue: "-",
},
{
name: "Custom date format",
description: "Use Day.js tokens to override the selected date format.",
values: "A Day.js format such as YYYY-MM-DD",
defaultValue: "-",
},
{
name: "Show weather",
+3 -2
View File
@@ -35,8 +35,9 @@ supplement the visible timer state rather than replacing it.
When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
Compact labels, timer text, and controls use the dashboard theme's responsive size scale, with secondary details hidden
when the widget preview is narrow or short.
The current phase stays pinned to the compact widget's top-left corner and wraps when needed. Timer text and controls
use the dashboard theme's responsive size scale, with secondary details hidden when the widget preview is narrow or
short.
Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
+4
View File
@@ -2106,6 +2106,10 @@
"enum": [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
@@ -75,6 +75,7 @@ describe("weather request handler", () => {
it("requests bounded useful forecasts and normalizes location times", async () => {
const response = makeWeatherResponse();
response.hourly.temperature_2m = [null];
Reflect.deleteProperty(response.hourly, "wind_gusts_10m");
response.daily.sunrise[1] = null;
mockFetch.mockResolvedValue(new Response(JSON.stringify(response)));
@@ -87,6 +88,7 @@ describe("weather request handler", () => {
expect(first.data.daily[0]?.date).toBe("2026-07-01");
expect(first.data.hourly[0]?.temperature).toBeNull();
expect(first.data.hourly[1]?.temperature).toBeNull();
expect(first.data.hourly[0]?.windGusts).toBeNull();
expect(first.data.daily[0]?.sunriseAt).toBeNull();
expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z");
expect(second.data).toStrictEqual(first.data);
+4 -3
View File
@@ -144,7 +144,8 @@ export const weatherRequestHandler = createWidgetRequestHandler({
requestAsync: requestWeatherAsync,
});
const nullableNumberArray = z.array(z.number().nullable());
const nullableNumberArray = z.array(z.number().nullable()).default([]);
const nullableStringArray = z.array(z.string().nullable()).default([]);
const atLocationOutput = z.object({
timezone: z.string().min(1),
@@ -184,8 +185,8 @@ const atLocationOutput = z.object({
wind_speed_10m_max: nullableNumberArray,
wind_gusts_10m_max: nullableNumberArray,
uv_index_max: nullableNumberArray,
sunrise: z.array(z.string().nullable()),
sunset: z.array(z.string().nullable()),
sunrise: nullableStringArray,
sunset: nullableStringArray,
daylight_duration: nullableNumberArray,
}),
});
+9 -2
View File
@@ -3153,6 +3153,9 @@
"label": "24-hour format",
"description": "Use 24-hour format instead of 12-hour format"
},
"showSeconds": {
"label": "Display seconds"
},
"useCustomTimezone": {
"label": "Use a fixed timezone"
},
@@ -3168,8 +3171,12 @@
"description": "Choose a Day.js preset. Each option shows a live example and its format tokens."
},
"customTimeFormat": {
"label": "Time format",
"description": "Choose a Day.js preset. The selected format controls 12/24-hour display and whether the clock updates every second."
"label": "Custom time format",
"description": "Use Day.js tokens to override the 12/24-hour and seconds options."
},
"customDateFormat": {
"label": "Custom date format",
"description": "Use Day.js tokens to override the selected date format."
},
"showWeather": {
"label": "Show weather",
+4 -2
View File
@@ -222,11 +222,13 @@ const ClockTime = ({
if (!entry.zonedTime) return null;
let formattedTime = formatLocalizedTime(now, locale, {
hour12: clockTimeFormatUses12Hours(options.customTimeFormat, options.is24HourFormat),
includeSeconds: clockTimeFormatShowsSeconds(options.customTimeFormat),
includeSeconds: clockTimeFormatShowsSeconds(options.customTimeFormat, options.showSeconds),
timeZone: entry.timeZone,
});
if (primary) {
formattedTime = entry.zonedTime.format(resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat));
formattedTime = entry.zonedTime.format(
resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat, options.showSeconds),
);
}
const accessibleDate = formatLocalizedDate(now, locale, {
dateStyle: "medium",
+8 -3
View File
@@ -15,7 +15,7 @@ dayjs.extend(advancedFormat);
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
const isAdvanced = displayMode === "advanced";
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat, options.showSeconds);
const time = useCurrentTime({ showSeconds });
const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone();
const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone);
@@ -37,7 +37,12 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
return <Center h="100%">--:--</Center>;
}
const resolvedTimeFormat = resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat);
const resolvedTimeFormat = resolveClockTimeFormat(
options.customTimeFormat,
options.is24HourFormat,
options.showSeconds,
);
const resolvedDateFormat = options.customDateFormat || options.dateFormat;
const minimumAxis = Math.min(width, height * 1.5);
let sizing: "xs" | "sm" | "md" = "md";
if (minimumAxis < 128) sizing = "xs";
@@ -71,7 +76,7 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
</Title>
{options.showDate && (
<Text className="clock-date-text" size={sizing} lineClamp={1}>
{zonedTime?.format(options.dateFormat)}
{zonedTime?.format(resolvedDateFormat)}
</Text>
)}
</Stack>
+5 -5
View File
@@ -1,9 +1,9 @@
export const automaticTimeFormat = "automatic";
export const resolveClockTimeFormat = (format: string, legacyIs24HourFormat: boolean) => {
export const resolveClockTimeFormat = (format: string, legacyIs24HourFormat: boolean, showSeconds = false) => {
if (format && format !== automaticTimeFormat) return format;
if (legacyIs24HourFormat) return "HH:mm";
return "hh:mm A";
if (legacyIs24HourFormat) return showSeconds ? "HH:mm:ss" : "HH:mm";
return showSeconds ? "hh:mm:ss A" : "hh:mm A";
};
export const clockTimeFormatUses12Hours = (format: string, legacyIs24HourFormat: boolean) => {
@@ -12,8 +12,8 @@ export const clockTimeFormatUses12Hours = (format: string, legacyIs24HourFormat:
return /h{1,2}/.test(formatWithoutLiterals);
};
export const clockTimeFormatShowsSeconds = (format: string) => {
if (!format || format === automaticTimeFormat) return false;
export const clockTimeFormatShowsSeconds = (format: string, showSeconds = false) => {
if (!format || format === automaticTimeFormat) return showSeconds;
const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, "");
return /s{1,2}/.test(formatWithoutLiterals);
};
+12 -11
View File
@@ -46,6 +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: timeZoneOptions,
@@ -73,14 +76,12 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
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",
customTimeFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
customDateFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
showWeather: factory.switch({
@@ -110,9 +111,6 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
}),
}),
{
is24HourFormat: {
shouldHide: () => true,
},
customTitle: {
shouldHide: (options) => !options.customTitleToggle,
},
@@ -122,6 +120,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
dateFormat: {
shouldHide: (options) => !options.showDate,
},
customDateFormat: {
shouldHide: (options) => !options.showDate,
},
weatherLocation: {
shouldHide: (options) => !options.showWeather,
},
@@ -22,20 +22,20 @@
min-width: 0;
}
@container (max-width: 170px) {
@container (max-width: 10.625rem) {
.compactStatus,
.compactTarget {
display: none;
}
}
@container (max-width: 140px) {
@container (max-width: 8.75rem) {
.compactDuration {
font-size: clamp(var(--mantine-font-size-sm), 15cqw, var(--mantine-font-size-md));
}
}
@container (max-height: 105px) {
@container (max-height: 6.5625rem) {
.compactTarget {
display: none;
}
+40 -15
View File
@@ -293,9 +293,10 @@ const CompactTimer = ({
}: TimerDisplayProps) => {
const t = useScopedI18n("widget.timer");
const phaseLabel = getTimerPhaseLabel(runtime, t);
const showPhaseBadge = height >= 110;
const showCompletedFocus = runtime.mode === "pomodoro" && width >= 210;
const showCompletedFocus = runtime.mode === "pomodoro" && width >= 320;
const showProgress = height >= 125;
let phaseBadgeMaxWidth = "calc(100% - var(--mantine-spacing-md))";
if (showCompletedFocus) phaseBadgeMaxWidth = "60%";
return (
<Stack
h="100%"
@@ -304,20 +305,44 @@ const CompactTimer = ({
justify="center"
gap="xs"
p="xs"
style={{ ...attentionStyle, overflow: "hidden" }}
style={{ ...attentionStyle, overflow: "hidden", position: "relative" }}
>
<Group gap="xs" wrap="nowrap">
{showPhaseBadge && (
<Badge variant="light" size="xs" maw="70%" aria-live="polite">
{phaseLabel}
</Badge>
)}
{showCompletedFocus && (
<Text size="xs" c="dimmed">
{t("compact.completedFocus", { count: runtime.completedFocusSessions })}
</Text>
)}
</Group>
<Badge
variant="light"
size="xs"
h="auto"
maw={phaseBadgeMaxWidth}
py="calc(var(--mantine-spacing-xs) / 2)"
aria-live="polite"
style={{
insetInlineStart: "var(--mantine-spacing-xs)",
position: "absolute",
top: "var(--mantine-spacing-xs)",
}}
styles={{
label: {
lineHeight: "var(--mantine-line-height-xs)",
overflow: "visible",
textOverflow: "clip",
whiteSpace: "normal",
},
}}
>
{phaseLabel}
</Badge>
{showCompletedFocus && (
<Text
size="xs"
c="dimmed"
style={{
insetInlineEnd: "var(--mantine-spacing-xs)",
position: "absolute",
top: "var(--mantine-spacing-xs)",
}}
>
{t("compact.completedFocus", { count: runtime.completedFocusSessions })}
</Text>
)}
<Text component="time" fz="xl" fw={700} lh={1} style={{ fontVariantNumeric: "tabular-nums" }}>
{formatTimerDuration(remainingMs)}
</Text>
+2 -1
View File
@@ -101,7 +101,8 @@ export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWea
const tCommon = useScopedI18n("common");
const layout = getAdvancedWeatherLayout(width, height);
const today = weather.daily[0];
const nextHour = weather.hourly[0];
const observedAt = Date.parse(weather.current.observedAt);
const nextHour = weather.hourly.find((hour) => Date.parse(hour.observedAt) > observedAt) ?? weather.hourly[0];
const hourly = getHourlyChartData(weather, options.isFormatFahrenheit);
const speedUnit = options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
@@ -24,7 +24,7 @@
}
.locationSummary {
max-width: min(100%, 320px);
max-width: min(100%, 20rem);
}
.metricCard,
@@ -32,7 +32,7 @@
.detailMetric,
.solarDetails {
background: color-mix(in srgb, var(--mantine-color-body) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
border: 0.0625rem solid color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
}
.dailyCard {
@@ -48,7 +48,7 @@
}
.dailyDetailsDropdown {
width: min(390px, calc(100vw - 32px));
width: min(24.375rem, calc(100vw - 2rem));
padding: var(--mantine-spacing-sm);
}
@@ -58,7 +58,7 @@
.dailyDetailsTemperature {
flex-shrink: 0;
max-width: 132px;
max-width: 8.25rem;
}
.dailyCardFacts {
@@ -88,8 +88,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
min-width: 1.5rem;
min-height: 1.5rem;
}
.forecastButton,
@@ -106,8 +106,8 @@
.detailsButton:focus-visible,
.forecastButton:focus-visible,
.dailyCardButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
outline: 0.125rem solid var(--mantine-primary-color-filled);
outline-offset: 0.125rem;
}
.errorIndicator {
@@ -118,8 +118,8 @@
.detailsButton,
.forecastButton,
.dailyCardButton {
min-width: 44px;
min-height: 44px;
min-width: 2.75rem;
min-height: 2.75rem;
}
}
@@ -134,6 +134,6 @@
.dailyCard,
.detailMetric,
.solarDetails {
border: 1px solid CanvasText;
border: 0.0625rem solid CanvasText;
}
}
+3 -3
View File
@@ -92,13 +92,13 @@ export const WeatherDescription = ({
const name = getWeatherKind(weatherCode);
if (weatherOnly) {
return <Text fz="16px">{t(`kind.${name}`)}</Text>;
return <Text fz="md">{t(`kind.${name}`)}</Text>;
}
return (
<Stack align="center" gap="0">
<Text fz="24px">{formatWeatherDate(time, locale, dateFormat)}</Text>
<Text fz="16px">{t(`kind.${name}`)}</Text>
<Text fz="xl">{formatWeatherDate(time, locale, dateFormat)}</Text>
<Text fz="md">{t(`kind.${name}`)}</Text>
<List>
<List.Item
icon={<IconTemperaturePlus size="var(--mantine-font-size-sm)" />}