fix: address utility widget review
This commit is contained in:
1 parent
52d96505a3
commit
e3475d3efe
17 files changed
+138
-73
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)" />}
|
||||
|
||||
Reference in new issue
Block a user