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:
parent
c4214304af
commit
234bb25c1e
@ -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": {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user