From 4f1599dfee084f241e9806e01b0a7635d8a4fb5e Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 20 Aug 2026 15:43:23 +0200 Subject: [PATCH] fix: address CodeRabbit review --- apps/docs/docs/widgets/timer/index.mdx | 2 +- packages/api/src/mcp.ts | 2 ++ .../api/src/router/widgets/air-quality.ts | 19 +++++++++--- packages/api/src/test/mcp.spec.ts | 1 + packages/translation/src/lang/en.json | 19 ++++++++++++ .../widgets/src/air-quality/component.tsx | 31 +++++++++++-------- packages/widgets/src/air-quality/display.ts | 5 +++ .../widgets/src/clock/time-of-day-bar.tsx | 16 +++++----- .../src/common/use-local-widget-state.ts | 1 + packages/widgets/src/countdown/recurrence.ts | 8 ++++- packages/widgets/src/timer/component.tsx | 5 ++- packages/widgets/src/today/component.tsx | 5 ++- packages/widgets/src/today/date-stats.ts | 2 +- .../widgets/src/weather/animated-icon.tsx | 3 +- packages/widgets/src/weather/compact.tsx | 6 +++- packages/widgets/src/weather/component.tsx | 3 -- packages/widgets/src/weather/layout.ts | 19 +++++++++--- 17 files changed, 108 insertions(+), 39 deletions(-) diff --git a/apps/docs/docs/widgets/timer/index.mdx b/apps/docs/docs/widgets/timer/index.mdx index a0bf2f670..c0afc8ef5 100644 --- a/apps/docs/docs/widgets/timer/index.mdx +++ b/apps/docs/docs/widgets/timer/index.mdx @@ -17,7 +17,7 @@ The Timer / Pomodoro widget runs entirely in your browser and does not require a always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening the board or returning to a background tab restores the correct remaining time instead of restarting the countdown. -Timer mode repeats one configured duration. Pomodoro mode starts with a focus session, follows most completed focus +Timer mode uses one configured duration. Pomodoro mode starts with a focus session, follows most completed focus sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are reconciled from their original deadlines after the board returns to the foreground. diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 06d023863..c2b51dbda 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -12,6 +12,7 @@ import { iconsRouter } from "./router/icons"; import { infoRouter } from "./router/info"; import { integrationRouter } from "./router/integration/integration-router"; import { inviteRouter } from "./router/invite"; +import { airQualityRouter } from "./router/widgets/air-quality"; import { bazarrRouter } from "./router/widgets/bazarr"; import { serverSettingsRouter } from "./router/serverSettings"; import { beszelRouter } from "./router/widgets/beszel"; @@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({ info: infoRouter, integration: integrationRouter, invite: inviteRouter, + airQuality: airQualityRouter, bazarr: bazarrRouter, serverSettings: serverSettingsRouter, beszel: beszelRouter, diff --git a/packages/api/src/router/widgets/air-quality.ts b/packages/api/src/router/widgets/air-quality.ts index 78cad2f08..281ca54c1 100644 --- a/packages/api/src/router/widgets/air-quality.ts +++ b/packages/api/src/router/widgets/air-quality.ts @@ -11,10 +11,19 @@ const atLocationInput = z.object({ }); export const airQualityRouter = createTRPCRouter({ - atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => { - if (env.NO_EXTERNAL_CONNECTION) return null; + atLocation: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get current and forecast air quality, UV, pollutant, and pollen data for geographic coordinates. REQUIRED: latitude (-90 to 90) and longitude (-180 to 180).", + }, + }) + .input(atLocationInput) + .query(async ({ input }) => { + if (env.NO_EXTERNAL_CONNECTION) return null; - const handler = airQualityRequestHandler.handler(input); - return await handler.getDataAsync().then((result) => result.data); - }), + const handler = airQualityRequestHandler.handler(input); + return await handler.getDataAsync().then((result) => result.data); + }), }); diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index 308b36c45..a708701b5 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -9,6 +9,7 @@ vi.mock("@homarr/auth", () => ({})); const MCP_TOOL_ALLOWLIST = { query: [ + "airQuality_atLocation", "apiKeys_getAll", "app_all", "app_byId", diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5a00c70ed..9a688f2a1 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3225,6 +3225,19 @@ "timeBarLabel": "24-hour time-of-day position for {city}", "dayProgress": "{percent}% elapsed · {hours}h {minutes}m remaining", "phaseLegend": "Time-of-day legend", + "tick": { + "midnight": "00", + "morning": "06", + "noon": "12", + "evening": "18", + "end": "24" + }, + "phaseRange": { + "night": "00–05 · 21–24", + "dawn": "05–08", + "day": "08–18", + "dusk": "18–21" + }, "phase": { "night": "Night", "dawn": "Dawn", @@ -3283,7 +3296,12 @@ } }, "dominantPollutant": "Dominant pollutant", + "aqiLabel": "AQI", "strongestPollen": "Highest pollen", + "unit": { + "concentration": "{value} µg/m³", + "pollen": "{value} grains/m³" + }, "pollutant": { "pm2_5": "PM2.5", "pm10": "PM10", @@ -3478,6 +3496,7 @@ "week": "Week number", "dayOfYear": "Day of year", "quarter": "Quarter", + "quarterValue": "Q{quarter}", "daysRemaining": "Days after today" } }, diff --git a/packages/widgets/src/air-quality/component.tsx b/packages/widgets/src/air-quality/component.tsx index b891f6d94..476655c38 100644 --- a/packages/widgets/src/air-quality/component.tsx +++ b/packages/widgets/src/air-quality/component.tsx @@ -20,6 +20,7 @@ import { getAqiStandard, getAqiValue, getCompactAirQualityLayout, + getDailyAqiValue, getDominantPollutant, getStrongestPollen, getUpcomingHours, @@ -32,7 +33,7 @@ export default function AirQualityWidget({ options, width, height, displayMode } latitude: options.location.latitude, longitude: options.location.longitude, }); - const airQuality = getUsableWidgetQueryData(query) as AirQualityData | null | undefined; + const airQuality = getUsableWidgetQueryData(query); if (isInitialWidgetQueryPending(query)) return ; if (!airQuality) return ; @@ -118,18 +119,18 @@ const CompactAirQuality = ({ <> )} {options.showPollen && strongestPollen && ( )} @@ -169,7 +170,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps uv: hour.uvIndex ?? Number.NaN, })); const series = [ - { name: "aqi", label: "AQI", color: getAqiColor(category), type: "line" as const }, + { name: "aqi", label: t("aqiLabel"), color: getAqiColor(category), type: "line" as const }, ...(options.showUv ? [{ name: "uv", label: t("uv.label"), color: "yellow.7", type: "line" as const, yAxisId: "right" as const }] : []), @@ -201,7 +202,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps - + {options.showUv && ( )} @@ -209,11 +210,11 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps <> )} @@ -279,7 +280,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps ))} @@ -295,7 +296,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps {pollenKeys.map((key) => { const pollen = airQuality.current.pollen[key]; if (pollen === null) return null; - return ; + return ; })} @@ -309,7 +310,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps {airQuality.daily.map((day) => { - const dailyAqi = standard === "us" ? day.usAqiMax : day.europeanAqiMax; + const dailyAqi = getDailyAqiValue(day, standard); return ( @@ -388,7 +389,11 @@ const formatDecimal = (value: number | null, locale: string) => { return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value); }; -const formatConcentration = (value: number | null, locale: string) => `${formatDecimal(value, locale)} µg/m³`; -const formatPollen = (value: number | null, locale: string) => `${formatDecimal(value, locale)} grains/m³`; +type ScopedAirQualityTranslator = ReturnType>; + +const formatConcentration = (value: number | null, locale: string, t: ScopedAirQualityTranslator) => + t("unit.concentration", { value: formatDecimal(value, locale) }); +const formatPollen = (value: number | null, locale: string, t: ScopedAirQualityTranslator) => + t("unit.pollen", { value: formatDecimal(value, locale) }); const hasAvailableValue = (values: Record) => Object.values(values).some((value) => value !== null); diff --git a/packages/widgets/src/air-quality/display.ts b/packages/widgets/src/air-quality/display.ts index 08e497af5..817ebac89 100644 --- a/packages/widgets/src/air-quality/display.ts +++ b/packages/widgets/src/air-quality/display.ts @@ -65,6 +65,11 @@ export const getAqiValue = (point: AirQualityPoint, standard: AqiStandard): numb return point.europeanAqi; }; +export const getDailyAqiValue = (day: AirQualityData["daily"][number], standard: AqiStandard): number | null => { + if (standard === "us") return day.usAqiMax; + return day.europeanAqiMax; +}; + export const getAqiCategory = (value: number | null, standard: AqiStandard): AqiCategory => { if (value === null) return "unknown"; diff --git a/packages/widgets/src/clock/time-of-day-bar.tsx b/packages/widgets/src/clock/time-of-day-bar.tsx index 9f4ba5dce..f95044ff4 100644 --- a/packages/widgets/src/clock/time-of-day-bar.tsx +++ b/packages/widgets/src/clock/time-of-day-bar.tsx @@ -21,12 +21,14 @@ const phaseSections = [ ] as const; const phaseLegend = [ - { key: "night", range: "00–05 · 21–24", color: "indigo.8" }, - { key: "dawn", range: "05–08", color: "orange.6" }, - { key: "day", range: "08–18", color: "cyan.6" }, - { key: "dusk", range: "18–21", color: "grape.6" }, + { 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, @@ -72,9 +74,9 @@ export const TimeOfDayBar = ({ {showTicks && ( - {["00", "06", "12", "18", "24"].map((tick) => ( + {timeTicks.map((tick) => ( - {tick} + {t(`worldClock.tick.${tick}`)} ))} @@ -88,7 +90,7 @@ export const TimeOfDayBar = ({ - {t(`worldClock.phase.${item.key}`)} · {item.range} + {t(`worldClock.phase.${item.key}`)} · {t(`worldClock.phaseRange.${item.key}`)} ))} diff --git a/packages/widgets/src/common/use-local-widget-state.ts b/packages/widgets/src/common/use-local-widget-state.ts index c32d30278..968200fc3 100644 --- a/packages/widgets/src/common/use-local-widget-state.ts +++ b/packages/widgets/src/common/use-local-widget-state.ts @@ -57,6 +57,7 @@ export const useLocalWidgetState = ({ const defaultValueRef = useRef(defaultValue); const validateRef = useRef(validate); const migrateRef = useRef(migrate); + defaultValueRef.current = defaultValue; validateRef.current = validate; migrateRef.current = migrate; diff --git a/packages/widgets/src/countdown/recurrence.ts b/packages/widgets/src/countdown/recurrence.ts index 8d161b05e..f419c8c87 100644 --- a/packages/widgets/src/countdown/recurrence.ts +++ b/packages/widgets/src/countdown/recurrence.ts @@ -9,6 +9,7 @@ dayjs.extend(timezone); const minuteMs = 60_000; const dayMs = 86_400_000; +const timeZoneSupportCache = new Map(); export interface WallDateTime { year: number; @@ -28,10 +29,14 @@ export interface CountdownEventState { } export const isCountdownTimeZoneSupported = (timeZone: string) => { + const cached = timeZoneSupportCache.get(timeZone); + if (cached !== undefined) return cached; try { new Intl.DateTimeFormat("en", { timeZone }).format(0); + timeZoneSupportCache.set(timeZone, true); return true; } catch { + timeZoneSupportCache.set(timeZone, false); return false; } }; @@ -95,7 +100,8 @@ const resolveExactWallDateTime = (value: WallDateTime, timeZone: string): Date[] const candidate = new Date(naiveUtc - offset * minuteMs); if (wallDateTimeEquals(value, dayjs(candidate).tz(timeZone))) matches.push(candidate); } - return matches.toSorted((left, right) => left.getTime() - right.getTime()); + // oxlint-disable-next-line unicorn/no-array-sort -- Array.toSorted is not supported in all target browsers. + return [...matches].sort((left, right) => left.getTime() - right.getTime()); }; /** Resolves a local wall-clock value, preferring the earlier repeated time and the first valid time after a DST gap. */ diff --git a/packages/widgets/src/timer/component.tsx b/packages/widgets/src/timer/component.tsx index 75c65da32..5fb339187 100644 --- a/packages/widgets/src/timer/component.tsx +++ b/packages/widgets/src/timer/component.tsx @@ -202,7 +202,10 @@ export default function TimerWidget({ const nowMs = now.getTime(); const remainingMs = getRemainingMs(runtime, nowMs); const phaseDurationMs = runtime.totalDurationMs; - const progress = Math.min(100, Math.max(0, ((phaseDurationMs - remainingMs) / phaseDurationMs) * 100)); + let progress = 0; + if (phaseDurationMs > 0) { + progress = Math.min(100, Math.max(0, ((phaseDurationMs - remainingMs) / phaseDurationMs) * 100)); + } const controlSize = getTimerControlSize(width, height, displayMode); const attentionStyle = getTimerAttentionStyle({ awaitingManualStart: runtime.awaitingManualStart, diff --git a/packages/widgets/src/today/component.tsx b/packages/widgets/src/today/component.tsx index b98158324..9b7493577 100644 --- a/packages/widgets/src/today/component.tsx +++ b/packages/widgets/src/today/component.tsx @@ -130,7 +130,10 @@ const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => { label: t("advanced.dayOfYear"), value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`, }, - options.showQuarter && { label: t("advanced.quarter"), value: `Q${stats.quarter}` }, + options.showQuarter && { + label: t("advanced.quarter"), + value: t("advanced.quarterValue", { quarter: stats.quarter }), + }, { label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) }, ].filter((metric): metric is { label: string; value: string } => Boolean(metric)); diff --git a/packages/widgets/src/today/date-stats.ts b/packages/widgets/src/today/date-stats.ts index da9313775..790d41016 100644 --- a/packages/widgets/src/today/date-stats.ts +++ b/packages/widgets/src/today/date-stats.ts @@ -36,7 +36,7 @@ export const getTodayStats = (date: Date, weekConvention: WeekConvention, locale const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceWeekStart); const nextWeekStart = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7); const currentDayOfYear = value.dayOfYear(); - const daysInYear = nextYearStart.diff(yearStart, "day"); + const daysInYear = yearStart.endOf("year").dayOfYear(); return { dayOfYear: currentDayOfYear, diff --git a/packages/widgets/src/weather/animated-icon.tsx b/packages/widgets/src/weather/animated-icon.tsx index cbe2e0949..334727a30 100644 --- a/packages/widgets/src/weather/animated-icon.tsx +++ b/packages/widgets/src/weather/animated-icon.tsx @@ -20,7 +20,8 @@ const getAnimationClass = (code: number): string => { }; export const AnimatedWeatherIcon = ({ animated = false, code, isDay, size = 26 }: AnimatedWeatherIconProps) => { - const animationClass = animated ? getAnimationClass(code) : ""; + let animationClass = ""; + if (animated && (code !== 0 || isDay !== false)) animationClass = getAnimationClass(code); return ( diff --git a/packages/widgets/src/weather/compact.tsx b/packages/widgets/src/weather/compact.tsx index f2cb7e5ad..f8371f5ed 100644 --- a/packages/widgets/src/weather/compact.tsx +++ b/packages/widgets/src/weather/compact.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Group, Popover, Stack, Text, UnstyledButton } from "@mantine/core"; import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react"; @@ -24,7 +25,10 @@ export const CompactWeather = ({ height, isEditMode, options, weather, width }: const locale = useCurrentIntlLocale(); const t = useScopedI18n("widget.weather"); const tCommon = useScopedI18n("common"); - const layout = getCompactWeatherLayout(width, height, options.hasForecast, options.forecastDayCount); + const layout = useMemo( + () => getCompactWeatherLayout(width, height, options.hasForecast, options.forecastDayCount), + [height, options.forecastDayCount, options.hasForecast, width], + ); const today = weather.daily[0]; const speedUnit = options.useImperialSpeed ? tCommon("unit.speed.milesPerHour") diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index f30077865..bb84fdc72 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -13,9 +13,6 @@ import { AdvancedWeather } from "./advanced"; import classes from "./component.module.css"; import { CompactWeather } from "./compact"; -export { getPreferredTime, getPreferredUnit, getPreferredWindSpeed } from "./format"; -export { getCompactWeatherLayout } from "./layout"; - export default function WeatherWidget({ isEditMode, options, diff --git a/packages/widgets/src/weather/layout.ts b/packages/widgets/src/weather/layout.ts index bf647b7af..a1bede357 100644 --- a/packages/widgets/src/weather/layout.ts +++ b/packages/widgets/src/weather/layout.ts @@ -9,13 +9,25 @@ export interface CompactWeatherLayout { showSecondary: boolean; } +const compactWeatherBreakpoints = [ + { tier: "roomy", minWidth: 500, minHeight: 280 }, + { tier: "standard", minWidth: 280, minHeight: 180 }, + { tier: "compact", minWidth: 160, minHeight: 120 }, + { tier: "micro", minWidth: 0, minHeight: 0 }, +] as const satisfies readonly { tier: CompactWeatherTier; minWidth: number; minHeight: number }[]; + export const getCompactWeatherLayout = ( width: number, height: number, hasForecast: boolean, configuredDays: number, ): CompactWeatherLayout => { - if (height < 120 || width < 160) { + const breakpoint = compactWeatherBreakpoints.find( + ({ minHeight, minWidth }) => width >= minWidth && height >= minHeight, + ); + const tier = breakpoint?.tier ?? "micro"; + + if (tier === "micro") { return { tier: "micro", forecastDays: 0, @@ -26,7 +38,7 @@ export const getCompactWeatherLayout = ( }; } - if (height < 180 || width < 280) { + if (tier === "compact") { return { tier: "compact", forecastDays: hasForecast ? Math.min(configuredDays, Math.max(1, Math.floor(width / 92))) : 0, @@ -37,10 +49,9 @@ export const getCompactWeatherLayout = ( }; } - const roomy = width >= 500 && height >= 280; const availableForecastWidth = Math.max(0, width - 24); return { - tier: roomy ? "roomy" : "standard", + tier, forecastDays: hasForecast ? Math.min(configuredDays, Math.max(1, Math.floor(availableForecastWidth / 64))) : 0, showCity: true, showCondition: true,