diff --git a/package.json b/package.json index bcbf26f..da195d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.43.2", + "version": "1.44.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b33894f..4eb269d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,10 @@ model User { calendarConnections CalendarConnection[] projects Project[] sessions Session[] + weatherEnabled Boolean @default(false) + weatherLat Float? + weatherLon Float? + weatherLocation String? notificationsEnabled Boolean @default(false) somedayLists SomedayList[] tasks Task[] diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 2742659..2db5aa7 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -97,6 +97,10 @@ export async function GET(request: NextRequest) { darkTheme: true, notificationsEnabled: true, mobileFontScale: true, + weatherEnabled: true, + weatherLat: true, + weatherLon: true, + weatherLocation: true, createdAt: true } }); @@ -139,7 +143,8 @@ export async function PATCH(request: NextRequest) { yearFontFamily, yearFontSize, yearFontWeight, yearColor, showTaskCheckboxes, dayHeaderGap, showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages, - kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale + kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale, + weatherEnabled, weatherLat, weatherLon, weatherLocation } = body; const updateData: any = { @@ -225,6 +230,10 @@ export async function PATCH(request: NextRequest) { ...(darkTheme !== undefined && { darkTheme }), ...(notificationsEnabled !== undefined && { notificationsEnabled }), ...(mobileFontScale !== undefined && { mobileFontScale: parseFloat(mobileFontScale) || 1.0 }), + ...(weatherEnabled !== undefined && { weatherEnabled }), + ...(weatherLat !== undefined && { weatherLat: weatherLat !== null ? parseFloat(weatherLat) : null }), + ...(weatherLon !== undefined && { weatherLon: weatherLon !== null ? parseFloat(weatherLon) : null }), + ...(weatherLocation !== undefined && { weatherLocation }), }; if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); @@ -319,6 +328,10 @@ export async function PATCH(request: NextRequest) { darkTheme: true, notificationsEnabled: true, mobileFontScale: true, + weatherEnabled: true, + weatherLat: true, + weatherLon: true, + weatherLocation: true, } }); diff --git a/src/app/api/weather/geocode/route.ts b/src/app/api/weather/geocode/route.ts new file mode 100644 index 0000000..5dc4c19 --- /dev/null +++ b/src/app/api/weather/geocode/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + if (!query || query.length < 2) { + return NextResponse.json({ results: [] }); + } + + try { + const res = await fetch( + `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=5&language=en&format=json` + ); + const data = await res.json(); + const results = (data.results || []).map((r: any) => ({ + name: r.name, + country: r.country, + admin1: r.admin1, + lat: r.latitude, + lon: r.longitude, + })); + return NextResponse.json({ results }); + } catch { + return NextResponse.json({ results: [] }); + } +} diff --git a/src/app/api/weather/route.ts b/src/app/api/weather/route.ts new file mode 100644 index 0000000..4dc8b3c --- /dev/null +++ b/src/app/api/weather/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export const dynamic = 'force-dynamic'; + +// In-memory cache: { key: { data, fetchedAt } } +const weatherCache = new Map(); +const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { weatherEnabled: true, weatherLat: true, weatherLon: true }, + }); + + if (!user?.weatherEnabled || !user.weatherLat || !user.weatherLon) { + return NextResponse.json({ error: 'Weather not configured' }, { status: 400 }); + } + + const { searchParams } = new URL(request.url); + const startDate = searchParams.get('start') || new Date().toISOString().slice(0, 10); + const endDate = searchParams.get('end') || startDate; + + const cacheKey = `${user.weatherLat},${user.weatherLon},${startDate},${endDate}`; + const cached = weatherCache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return NextResponse.json(cached.data); + } + + 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 res = await fetch(url, { next: { revalidate: 900 } }); + + if (!res.ok) { + return NextResponse.json({ error: 'Weather API failed' }, { status: 502 }); + } + + const raw = await res.json(); + + // Transform into { "2026-03-17T08:00": { temp: 5, code: 2 }, ... } + const hourly: Record = {}; + if (raw.hourly?.time && raw.hourly?.temperature_2m && raw.hourly?.weather_code) { + for (let i = 0; i < raw.hourly.time.length; i++) { + hourly[raw.hourly.time[i]] = { + temp: Math.round(raw.hourly.temperature_2m[i]), + code: raw.hourly.weather_code[i], + }; + } + } + + const data = { hourly, timezone: raw.timezone }; + weatherCache.set(cacheKey, { data, fetchedAt: Date.now() }); + + return NextResponse.json(data); + } catch (err) { + console.error('[WEATHER] Fetch failed:', err); + return NextResponse.json({ error: 'Weather fetch failed' }, { status: 500 }); + } +} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 99eb66d..171d674 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -1560,6 +1560,9 @@ export default function WeeklyView() { [], ); + // Weather data: { "2026-03-17T08:00": { temp: 5, code: 2 }, ... } + const [weatherData, setWeatherData] = useState>({}); + // Extend events with editable flag from connections const calendarEvents = useMemo(() => { return rawCalendarEvents.map((event) => { @@ -1804,7 +1807,7 @@ export default function WeeklyView() { // Moved state definitions to the top const [showSettings, setShowSettings] = useState(false); const [activeTab, setActiveTab] = useState< - "calendar" | "general" | "account" | "styling" | "motivation" | "about" + "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "projects" >("general"); const [exportStartDate, setExportStartDate] = useState(""); const [exportEndDate, setExportEndDate] = useState(""); @@ -1898,6 +1901,10 @@ export default function WeeklyView() { quoteSourceUrl: "", quoteSourceUrls: [], quoteLanguages: ["en", "de"], + weatherEnabled: false, + weatherLat: null, + weatherLon: null, + weatherLocation: "", }); const [motivationalQuote, setMotivationalQuote] = useState(""); const [showSummary, setShowSummary] = useState(false); @@ -2278,6 +2285,22 @@ export default function WeeklyView() { }; // Slot and Header height based on cell duration + // WMO weather code β†’ emoji icon + const getWeatherIcon = (code: number): string => { + if (code === 0) return "β˜€οΈ"; + if (code <= 3) return "β›…"; + if (code >= 45 && code <= 48) return "🌫️"; + if (code >= 51 && code <= 55) return "🌦️"; + if (code >= 56 && code <= 57) return "🌧️"; + if (code >= 61 && code <= 65) return "🌧️"; + if (code >= 66 && code <= 67) return "🌨️"; + if (code >= 71 && code <= 77) return "❄️"; + if (code >= 80 && code <= 82) return "🌧️"; + if (code >= 85 && code <= 86) return "❄️"; + if (code >= 95) return "β›ˆοΈ"; + return "☁️"; + }; + const getSlotHeight = (duration: number) => { switch (duration) { case 15: return 25; @@ -2329,6 +2352,21 @@ export default function WeeklyView() { if (data.events) { setRawCalendarEvents(data.events); } + // If stale connections were refreshing in background, re-fetch after they finish + if (data.staleConnectionCount > 0 && !forceRefresh) { + setTimeout(() => { + fetch("/api/calendar/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + timeMin: new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), + timeMax: new Date(currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString(), + }), + }).then(r => r.json()).then(d => { + if (d.events) setRawCalendarEvents(d.events); + }).catch(() => {}); + }, 5000); // 5s delay for background refresh to finish + } } catch (e) { console.error( "Failed to parse calendar sync response:", @@ -2344,6 +2382,26 @@ export default function WeeklyView() { } }, [currentWeekStart, startSync, endSync]); + // Weather fetch + const fetchWeather = useCallback(async () => { + if (!profile.weatherEnabled) return; + try { + const start = new Date(currentWeekStart.getTime() - 1 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const end = new Date(currentWeekStart.getTime() + 8 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const res = await fetch(`/api/weather?start=${start}&end=${end}`); + if (res.ok) { + const data = await res.json(); + if (data.hourly) setWeatherData(data.hourly); + } + } catch (e) { + console.error("Weather fetch failed:", e); + } + }, [currentWeekStart, profile.weatherEnabled]); + + useEffect(() => { + if (profile.weatherEnabled) fetchWeather(); + }, [fetchWeather, profile.weatherEnabled]); + // Calendar Event Handlers const handleEventSave = async (eventData: any) => { const controller = new AbortController(); @@ -2985,8 +3043,16 @@ export default function WeeklyView() { todayHighlightColor: data.user.todayHighlightColor || "#f0fafa", })); - // Apply start day offset (e.g. -1 for yesterday) - if (data.user.startDayOffset && data.user.startDayOffset !== 0) { + // Apply start day offset (e.g. -1 for yesterday) β€” only for multi-day views + // On single-day view (phones), always start on today + const effectiveViewDays = (() => { + const width = window.innerWidth; + if (width <= 480) return 1; + if (width <= 768) return 3; + if (width <= 1024) return Math.min(data.user.viewDays || 7, 5); + return data.user.viewDays || 7; + })(); + if (data.user.startDayOffset && data.user.startDayOffset !== 0 && effectiveViewDays > 1) { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + data.user.startDayOffset); @@ -6075,7 +6141,7 @@ export default function WeeklyView() { - +
{language === "de" ? "Tage" : "Days"}: @@ -6110,7 +6176,7 @@ export default function WeeklyView() { {/* Desktop only: Recurring Tasks + New Project */} - + {/* User Menu */} + {/* Weather indicator for hour-start slots */} + {isHourStart && profile.weatherEnabled && (() => { + const h = parseInt(slot.split(":")[0]); + 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]; + if (!w) return null; + const icon = getWeatherIcon(w.code); + return ( +
+ {icon} + {w.temp}Β° +
+ ); + })()} {/* Drop preview indicator */} {isDropTarget && !isProtected && !isOccupiedByTask && (
@@ -8235,7 +8315,7 @@ export default function WeeklyView() { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }, - onAddProject: () => { setShowSettings(true); setActiveTab("general"); }, + onAddProject: () => { setShowSettings(true); setActiveTab("projects"); }, onRecurringTasks: () => setIsRecurringTasksOpen(true), onToggleNextTask: () => { const newVal = !showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); }, onFocusMode: () => setShowFocusMode(true), @@ -8321,7 +8401,7 @@ export default function WeeklyView() {
{language === "de" ? "Suche" : "Search"} - @@ -9559,7 +9639,7 @@ interface SettingsSidebarProps { fetchAvailableTaskLists: ( provider: "google" | "apple" | "outlook" | "synology", ) => Promise; - initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about"; + initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "projects"; projects: { id: string; name: string; icon?: string | null; color?: string | null }[]; onProjectsChanged: () => void; kanbanStages: KanbanStage[]; @@ -9825,7 +9905,7 @@ function SettingsSidebar({ mobileActions, }: SettingsSidebarProps) { const [activeTab, setActiveTab] = useState< - "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" + "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "projects" >(initialTab || "general"); const [isLoading, setIsLoading] = useState(true); const [isSyncing, setIsSyncing] = useState(false); @@ -9868,6 +9948,7 @@ function SettingsSidebar({ const [editProjectColor, setEditProjectColor] = useState(""); const [editProjectIcon, setEditProjectIcon] = useState(""); const [showEditProjectIconPicker, setShowEditProjectIconPicker] = useState(false); + const [weatherSearchResults, setWeatherSearchResults] = useState([]); const projectEmojis = [ "πŸ“", "πŸ“‚", "πŸ’Ό", "🎯", "πŸš€", "⭐", "πŸ’‘", "πŸ”₯", "🎨", "🎡", @@ -10494,6 +10575,7 @@ function SettingsSidebar({ > {([ { key: "general", icon: , label: t.general }, + { key: "projects", icon: , label: t.projects }, { key: "localisation", icon: , label: t.localisation }, { key: "calendar", icon: , label: t.calendar }, { key: "account", icon: , label: t.account }, @@ -11100,187 +11182,72 @@ function SettingsSidebar({
- {/* Projects Section */} + {/* Weather Settings */}

- {t.projects} + β˜€οΈ {profile.language === "de" ? "Wetter" : "Weather"}

-

{t.projectsDesc}

- {projects.length === 0 && ( -

{t.noProjects}

- )} -
- {projects.map((p) => ( -
- {editingProjectId === p.id ? ( -
-
-
- - {showEditProjectIconPicker && ( -
-
- {projectEmojis.map((emoji) => ( - - ))} -
-
- )} -
- setEditProjectName(e.target.value)} - className="weekly-input" - style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }} - onKeyDown={(e) => { - if (e.key === "Enter") { - fetch("/api/projects", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }), - }).then(() => { onProjectsChanged(); setEditingProjectId(null); }); - } - if (e.key === "Escape") setEditingProjectId(null); - }} - autoFocus - /> -
-
- setEditProjectColor(e.target.value)} - style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }} - /> - {profile.language === "de" ? "Farbe" : "Color"} -
- - -
-
- ) : ( - <> - {p.icon || "πŸ“"} - {p.name} - +

+ {profile.language === "de" ? "Zeige Temperatur und Wettericons im Stundenraster." : "Show temperature and weather icons in the time grid."} +

+ + {profile.weatherEnabled && ( +
+
+ { + const q = e.target.value; + if (q.length < 2) { setWeatherSearchResults([]); return; } + try { + const res = await fetch(`/api/weather/geocode?q=${encodeURIComponent(q)}`); + const data = await res.json(); + setWeatherSearchResults(data.results || []); + } catch { setWeatherSearchResults([]); } + }} + /> +
+ {weatherSearchResults.length > 0 && ( +
+ {weatherSearchResults.map((r: any, i: number) => ( - - )} -
- ))} -
- {/* Add new project */} -
-
-
- - {showNewProjectIconPicker && ( -
-
- {projectEmojis.map((emoji) => ( - - ))} -
-
- )} -
- setNewProjectColor(e.target.value)} - style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }} - /> - setNewProjectName(e.target.value)} - placeholder={t.projectName} - className="weekly-input" - style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }} - onKeyDown={(e) => { - if (e.key === "Enter" && newProjectName.trim()) { - fetch("/api/projects", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }), - }).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("πŸ“"); }); - } - }} - /> - + ))} +
+ )} + {profile.weatherLocation && ( +
+ πŸ“ {profile.weatherLocation} + ({profile.weatherLat?.toFixed(2)}, {profile.weatherLon?.toFixed(2)}) +
+ )}
-
+ )}
+ ) : activeTab === "projects" ? ( +
+
+

+ {t.projects} +

+

{t.projectsDesc}

+
+ + {projects.length === 0 ? ( +
+
πŸ“
+

{t.noProjects}

+
+ ) : ( +
+ {projects.map((p) => ( +
+ {editingProjectId === p.id ? ( +
+
+
+ + {showEditProjectIconPicker && ( +
+
+ {projectEmojis.map((emoji) => ( + + ))} +
+
+ )} +
+ setEditProjectName(e.target.value)} + className="weekly-input" + style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} + onKeyDown={(e) => { + if (e.key === "Enter") { + fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }) }).then(() => { onProjectsChanged(); setEditingProjectId(null); }); + } + if (e.key === "Escape") setEditingProjectId(null); + }} + autoFocus + /> +
+
+ setEditProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} /> + {profile.language === "de" ? "Farbe" : "Color"} +
+ + +
+
+ ) : ( + <> + {p.icon || "πŸ“"} +
+ {p.name} +
+ + + + )} +
+ ))} +
+ )} + + {/* Add new project */} +
+

{t.addProject}

+
+
+ + {showNewProjectIconPicker && ( +
+
+ {projectEmojis.map((emoji) => ( + + ))} +
+
+ )} +
+ setNewProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} /> + setNewProjectName(e.target.value)} placeholder={t.projectName} className="weekly-input" style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} onKeyDown={(e) => { if (e.key === "Enter" && newProjectName.trim()) { fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) }).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("πŸ“"); }); } }} /> + +
+
+
) : activeTab === "calendar" ? ( isLoading ? (

Loading connections...