fix: address CodeRabbit review

This commit is contained in:
ajnart committed 2026-08-20 15:43:23 +02:00
1 parent e3475d3efe
commit 4f1599dfee
17 files changed
+108 -39

No files matched your search

+1 -1
View File
@@ -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.
+2
View File
@@ -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,
+14 -5
View File
@@ -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);
}),
});
+1
View File
@@ -9,6 +9,7 @@ vi.mock("@homarr/auth", () => ({}));
const MCP_TOOL_ALLOWLIST = {
query: [
"airQuality_atLocation",
"apiKeys_getAll",
"app_all",
"app_byId",
+19
View File
@@ -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"
}
},
+18 -13
View File
@@ -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 <WidgetQueryLoadingState />;
if (!airQuality) return <WidgetEmptyState />;
@@ -118,18 +119,18 @@ const CompactAirQuality = ({
<>
<MetricLine
label={labels.pollutants.pm2_5}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale)}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale, t)}
/>
<MetricLine
label={labels.pollutants.pm10}
value={formatConcentration(airQuality.current.pollutants.pm10, locale)}
value={formatConcentration(airQuality.current.pollutants.pm10, locale, t)}
/>
</>
)}
{options.showPollen && strongestPollen && (
<MetricLine
label={t("strongestPollen")}
value={`${labels.pollen[strongestPollen.key]} · ${formatPollen(strongestPollen.value, locale)}`}
value={`${labels.pollen[strongestPollen.key]} · ${formatPollen(strongestPollen.value, locale, t)}`}
/>
)}
</Group>
@@ -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
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
<MetricCard label="AQI" value={formatAqi(value, locale)} />
<MetricCard label={t("aqiLabel")} value={formatAqi(value, locale)} />
{options.showUv && (
<MetricCard label={t("uv.label")} value={formatDecimal(airQuality.current.uvIndex, locale)} />
)}
@@ -209,11 +210,11 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps
<>
<MetricCard
label={labels.pollutants.pm2_5}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale)}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale, t)}
/>
<MetricCard
label={labels.pollutants.pm10}
value={formatConcentration(airQuality.current.pollutants.pm10, locale)}
value={formatConcentration(airQuality.current.pollutants.pm10, locale, t)}
/>
</>
)}
@@ -279,7 +280,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps
<MetricLine
key={key}
label={labels.pollutants[key]}
value={formatConcentration(airQuality.current.pollutants[key], locale)}
value={formatConcentration(airQuality.current.pollutants[key], locale, t)}
/>
))}
</SimpleGrid>
@@ -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 <MetricLine key={key} label={labels.pollen[key]} value={formatPollen(pollen, locale)} />;
return <MetricLine key={key} label={labels.pollen[key]} value={formatPollen(pollen, locale, t)} />;
})}
</SimpleGrid>
</Paper>
@@ -309,7 +310,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps
</Text>
<SimpleGrid cols={{ base: 2, sm: 4, md: 7 }} spacing="xs">
{airQuality.daily.map((day) => {
const dailyAqi = standard === "us" ? day.usAqiMax : day.europeanAqiMax;
const dailyAqi = getDailyAqiValue(day, standard);
return (
<Stack key={day.date} gap={2} align="center">
<Text size="xs" c="dimmed">
@@ -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<typeof useScopedI18n<"widget.airQuality">>;
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<string, number | null>) =>
Object.values(values).some((value) => value !== null);
@@ -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";
@@ -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 = ({
</Box>
{showTicks && (
<Group justify="space-between" wrap="nowrap">
{["00", "06", "12", "18", "24"].map((tick) => (
{timeTicks.map((tick) => (
<Text key={tick} size="xs" c="dimmed" aria-hidden>
{tick}
{t(`worldClock.tick.${tick}`)}
</Text>
))}
</Group>
@@ -88,7 +90,7 @@ export const TimeOfDayBar = ({
<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}`)} · {item.range}
{t(`worldClock.phase.${item.key}`)} · {t(`worldClock.phaseRange.${item.key}`)}
</Text>
</Group>
))}
@@ -57,6 +57,7 @@ export const useLocalWidgetState = <T>({
const defaultValueRef = useRef(defaultValue);
const validateRef = useRef(validate);
const migrateRef = useRef(migrate);
defaultValueRef.current = defaultValue;
validateRef.current = validate;
migrateRef.current = migrate;
+7 -1
View File
@@ -9,6 +9,7 @@ dayjs.extend(timezone);
const minuteMs = 60_000;
const dayMs = 86_400_000;
const timeZoneSupportCache = new Map<string, boolean>();
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. */
+4 -1
View File
@@ -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,
+4 -1
View File
@@ -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));
+1 -1
View File
@@ -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,
@@ -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 (
<span className={`weather-anim-wrapper ${animationClass}`}>
<WeatherIcon code={code} isDay={isDay} size={size} />
+5 -1
View File
@@ -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")
@@ -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,
+15 -4
View File
@@ -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,