fix: address CodeRabbit review
This commit is contained in:
1 parent
e3475d3efe
commit
4f1599dfee
17 files changed
+108
-39
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -9,6 +9,7 @@ vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const MCP_TOOL_ALLOWLIST = {
|
||||
query: [
|
||||
"airQuality_atLocation",
|
||||
"apiKeys_getAll",
|
||||
"app_all",
|
||||
"app_byId",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user