feat: configurable weather display data per view

Users can now pick which weather data to show per time slot: icon,
temperature, feels like, wind speed, gusts, rain probability,
precipitation, humidity, and UV index. API now fetches all data
from Open-Meteo. Also fixed slot height for 20min duration.

v1.52.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-19 22:44:04 +01:00
parent c4214304af
commit 234bb25c1e
3 changed files with 74 additions and 15 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.51.6", "version": "1.52.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -36,7 +36,7 @@ export async function GET(request: NextRequest) {
} }
try { try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${user.weatherLat}&longitude=${user.weatherLon}&hourly=temperature_2m,weather_code&start_date=${startDate}&end_date=${endDate}&timezone=auto`; const url = `https://api.open-meteo.com/v1/forecast?latitude=${user.weatherLat}&longitude=${user.weatherLon}&hourly=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,wind_gusts_10m,precipitation_probability,precipitation,relative_humidity_2m,uv_index&start_date=${startDate}&end_date=${endDate}&timezone=auto`;
const res = await fetch(url, { next: { revalidate: 900 } }); const res = await fetch(url, { next: { revalidate: 900 } });
if (!res.ok) { if (!res.ok) {
@ -45,13 +45,23 @@ export async function GET(request: NextRequest) {
const raw = await res.json(); const raw = await res.json();
// Transform into { "2026-03-17T08:00": { temp: 5, code: 2 }, ... } const hourly: Record<string, {
const hourly: Record<string, { temp: number; code: number }> = {}; temp: number; code: number;
if (raw.hourly?.time && raw.hourly?.temperature_2m && raw.hourly?.weather_code) { feelsLike?: number; wind?: number; gusts?: number;
precipProb?: number; precip?: number; humidity?: number; uv?: number;
}> = {};
if (raw.hourly?.time) {
for (let i = 0; i < raw.hourly.time.length; i++) { for (let i = 0; i < raw.hourly.time.length; i++) {
hourly[raw.hourly.time[i]] = { hourly[raw.hourly.time[i]] = {
temp: Math.round(raw.hourly.temperature_2m[i]), temp: Math.round(raw.hourly.temperature_2m?.[i] ?? 0),
code: raw.hourly.weather_code[i], code: raw.hourly.weather_code?.[i] ?? 0,
feelsLike: raw.hourly.apparent_temperature?.[i] != null ? Math.round(raw.hourly.apparent_temperature[i]) : undefined,
wind: raw.hourly.wind_speed_10m?.[i] != null ? Math.round(raw.hourly.wind_speed_10m[i]) : undefined,
gusts: raw.hourly.wind_gusts_10m?.[i] != null ? Math.round(raw.hourly.wind_gusts_10m[i]) : undefined,
precipProb: raw.hourly.precipitation_probability?.[i] ?? undefined,
precip: raw.hourly.precipitation?.[i] != null ? Math.round(raw.hourly.precipitation[i] * 10) / 10 : undefined,
humidity: raw.hourly.relative_humidity_2m?.[i] ?? undefined,
uv: raw.hourly.uv_index?.[i] != null ? Math.round(raw.hourly.uv_index[i] * 10) / 10 : undefined,
}; };
} }
} }

View File

@ -178,6 +178,8 @@ interface SomedayList {
// Time grid configuration options // Time grid configuration options
type CellDuration = 15 | 20 | 30 | 60; type CellDuration = 15 | 20 | 30 | 60;
type WeatherDisplayKey = "icon" | "temp" | "feelsLike" | "wind" | "gusts" | "precipProb" | "precip" | "humidity" | "uv";
const WEATHER_DISPLAY_DEFAULTS: WeatherDisplayKey[] = ["icon", "temp"];
const DEFAULT_SOMEDAY_SLOT_COUNT = 5; const DEFAULT_SOMEDAY_SLOT_COUNT = 5;
const getSomedaySlotCount = (tasks: Task[]) => { const getSomedaySlotCount = (tasks: Task[]) => {
@ -1572,8 +1574,9 @@ export default function WeeklyView() {
[], [],
); );
// Weather data: { "2026-03-17T08:00": { temp: 5, code: 2 }, ... } // Weather data: { "2026-03-17T08:00": { temp: 5, code: 2, wind: 12, ... }, ... }
const [weatherData, setWeatherData] = useState<Record<string, { temp: number; code: number }>>({}); type WeatherHour = { temp: number; code: number; feelsLike?: number; wind?: number; gusts?: number; precipProb?: number; precip?: number; humidity?: number; uv?: number };
const [weatherData, setWeatherData] = useState<Record<string, WeatherHour>>({});
// Extend events with editable flag from connections // Extend events with editable flag from connections
const calendarEvents = useMemo(() => { const calendarEvents = useMemo(() => {
@ -1958,6 +1961,7 @@ export default function WeeklyView() {
hourLabelFormat?: "short" | "full"; hourLabelFormat?: "short" | "full";
showSubHourSlots?: boolean; showSubHourSlots?: boolean;
weatherEnabled?: boolean; weatherEnabled?: boolean;
weatherDisplay?: WeatherDisplayKey[];
showTaskCheckboxes?: boolean; showTaskCheckboxes?: boolean;
showSomeday?: boolean; showSomeday?: boolean;
showAllDayEvents?: boolean; showAllDayEvents?: boolean;
@ -1967,7 +1971,7 @@ export default function WeeklyView() {
startHour?: number; startHour?: number;
endHour?: number; endHour?: number;
}; };
const PER_VIEW_KEYS = ["hourLabelFormat", "showSubHourSlots", "weatherEnabled", "showTaskCheckboxes", "showSomeday", "showAllDayEvents", "allDayPosition", "showCompletedTasks", "cellDuration", "startHour", "endHour"] as const; const PER_VIEW_KEYS = ["hourLabelFormat", "showSubHourSlots", "weatherEnabled", "weatherDisplay", "showTaskCheckboxes", "showSomeday", "showAllDayEvents", "allDayPosition", "showCompletedTasks", "cellDuration", "startHour", "endHour"] as const;
const [viewSettings, setViewSettings] = useState<Record<string, PerViewOverrides>>({}); const [viewSettings, setViewSettings] = useState<Record<string, PerViewOverrides>>({});
const viewSettingsRef = useRef<Record<string, PerViewOverrides>>({}); const viewSettingsRef = useRef<Record<string, PerViewOverrides>>({});
viewSettingsRef.current = viewSettings; viewSettingsRef.current = viewSettings;
@ -2036,6 +2040,7 @@ export default function WeeklyView() {
const effectiveHourLabelFormat = getEffective("hourLabelFormat", hourLabelFormat); const effectiveHourLabelFormat = getEffective("hourLabelFormat", hourLabelFormat);
const effectiveShowSubHourSlots = getEffective("showSubHourSlots", showSubHourSlots); const effectiveShowSubHourSlots = getEffective("showSubHourSlots", showSubHourSlots);
const effectiveWeatherEnabled = getEffective("weatherEnabled", profile.weatherEnabled); const effectiveWeatherEnabled = getEffective("weatherEnabled", profile.weatherEnabled);
const effectiveWeatherDisplay = (getEffective("weatherDisplay", WEATHER_DISPLAY_DEFAULTS) || WEATHER_DISPLAY_DEFAULTS) as WeatherDisplayKey[];
const effectiveShowTaskCheckboxes = getEffective("showTaskCheckboxes", profile.showTaskCheckboxes); const effectiveShowTaskCheckboxes = getEffective("showTaskCheckboxes", profile.showTaskCheckboxes);
const effectiveShowSomeday = getEffective("showSomeday", showSomeday); const effectiveShowSomeday = getEffective("showSomeday", showSomeday);
const effectiveShowAllDay = getEffective("showAllDayEvents", showAllDay); const effectiveShowAllDay = getEffective("showAllDayEvents", showAllDay);
@ -2410,9 +2415,9 @@ export default function WeeklyView() {
const getSlotHeight = (duration: number) => { const getSlotHeight = (duration: number) => {
switch (duration) { switch (duration) {
case 15: return 25; case 15: return 25;
case 20: return 30;
case 30: return 35; case 30: return 35;
case 60: return 50; case 60: return 50;
case 120: return 80;
default: return 50; default: return 50;
} }
}; };
@ -7056,11 +7061,20 @@ export default function WeeklyView() {
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(h).padStart(2, "0")}:00`; const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(h).padStart(2, "0")}:00`;
const w = weatherData[dateStr]; const w = weatherData[dateStr];
if (!w) return null; if (!w) return null;
const icon = getWeatherIcon(w.code); const d = effectiveWeatherDisplay;
const parts: string[] = [];
if (d.includes("temp")) parts.push(`${w.temp}°`);
if (d.includes("feelsLike") && w.feelsLike != null) parts.push(`(${w.feelsLike}°)`);
if (d.includes("wind") && w.wind != null) parts.push(`${w.wind}km/h`);
if (d.includes("gusts") && w.gusts != null) parts.push(`💨${w.gusts}`);
if (d.includes("precipProb") && w.precipProb != null) parts.push(`${w.precipProb}%`);
if (d.includes("precip") && w.precip != null && w.precip > 0) parts.push(`${w.precip}mm`);
if (d.includes("humidity") && w.humidity != null) parts.push(`💧${w.humidity}%`);
if (d.includes("uv") && w.uv != null && w.uv > 0) parts.push(`UV${w.uv}`);
return ( return (
<div style={{ position: "absolute", top: "1px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", opacity: 0.7, pointerEvents: "none", zIndex: 1, lineHeight: 1, color: "#555" }}> <div style={{ position: "absolute", top: "1px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", opacity: 0.7, pointerEvents: "none", zIndex: 1, lineHeight: 1, color: darkMode ? "#aaa" : "#555" }}>
<span>{icon}</span> {d.includes("icon") && <span>{getWeatherIcon(w.code)}</span>}
<span style={{ fontWeight: 600, color: darkMode ? "#aaa" : "#555" }}>{w.temp}°</span> {parts.length > 0 && <span style={{ fontWeight: 600 }}>{parts.join(" ")}</span>}
</div> </div>
); );
})()} })()}
@ -11199,6 +11213,41 @@ function SettingsSidebar({
</div> </div>
</div> </div>
)} )}
{/* Weather display options */}
<div style={{ marginTop: "8px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "8px" }}>
<div style={{ fontSize: "0.8rem", fontWeight: 600, marginBottom: "6px", color: "var(--weekly-text-light, #666)" }}>
{profile.language === "de" ? "Angezeigte Daten" : "Display data"}
</div>
{([
{ key: "icon" as WeatherDisplayKey, label: profile.language === "de" ? "Wettersymbol" : "Weather icon", icon: "☀️" },
{ key: "temp" as WeatherDisplayKey, label: profile.language === "de" ? "Temperatur" : "Temperature", icon: "🌡️" },
{ key: "feelsLike" as WeatherDisplayKey, label: profile.language === "de" ? "Gefühlte Temp." : "Feels like", icon: "🤒" },
{ key: "wind" as WeatherDisplayKey, label: profile.language === "de" ? "Windgeschwindigkeit" : "Wind speed", icon: "🌬️" },
{ key: "gusts" as WeatherDisplayKey, label: profile.language === "de" ? "Windböen" : "Wind gusts", icon: "💨" },
{ key: "precipProb" as WeatherDisplayKey, label: profile.language === "de" ? "Regenwahrscheinl." : "Rain probability", icon: "🌧️" },
{ key: "precip" as WeatherDisplayKey, label: profile.language === "de" ? "Niederschlag (mm)" : "Precipitation (mm)", icon: "💦" },
{ key: "humidity" as WeatherDisplayKey, label: profile.language === "de" ? "Luftfeuchtigkeit" : "Humidity", icon: "💧" },
{ key: "uv" as WeatherDisplayKey, label: "UV Index", icon: "☀️" },
]).map(({ key, label, icon }) => {
const current = (perView.getEffective("weatherDisplay", WEATHER_DISPLAY_DEFAULTS) || WEATHER_DISPLAY_DEFAULTS) as WeatherDisplayKey[];
const checked = current.includes(key);
return (
<label key={key} style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", padding: "3px 0", fontSize: "0.82rem" }}>
<input
type="checkbox"
checked={checked}
onChange={() => {
const updated = checked ? current.filter(k => k !== key) : [...current, key];
perView.saveViewSetting("weatherDisplay", updated.length > 0 ? updated : ["icon"], true);
}}
style={{ width: "14px", height: "14px" }}
/>
<span>{icon}</span>
<span>{label}</span>
</label>
);
})}
</div>
</div> </div>
); );
})()} })()}