feat: add weather display, projects tab, iPhone today fix, stale calendar fix

- Add weather feature with Open-Meteo API: hourly temp/icon overlay on time slots,
  location search via geocoding, weather toggle in settings
- Move project management to dedicated settings tab with emoji icon picker
- Fix iPhone single-day view starting on yesterday instead of today
- Fix deleted Apple calendar events persisting until app reboot by
  scheduling a delayed re-fetch after stale background sync

v1.44.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-17 12:15:25 +01:00
parent a20f6673ec
commit 36c4652f8e
6 changed files with 381 additions and 183 deletions

View File

@ -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": {

View File

@ -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[]

View File

@ -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,
}
});

View File

@ -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: [] });
}
}

View File

@ -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<string, { data: any; fetchedAt: number }>();
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<string, { temp: number; code: number }> = {};
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 });
}
}

View File

@ -1560,6 +1560,9 @@ export default function WeeklyView() {
[],
);
// Weather data: { "2026-03-17T08:00": { temp: 5, code: 2 }, ... }
const [weatherData, setWeatherData] = useState<Record<string, { temp: number; code: number }>>({});
// 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() {
<button onClick={() => { const nv = !showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); setShowHeaderMore(false); }}>{showNextTask ? <Play size={16} /> : <Target size={16} />} <span>{showNextTask ? "Next Task" : "Goal"}</span></button>
<button onClick={() => { setShowFocusMode(true); setShowHeaderMore(false); }}><Zap size={16} /> <span>{language === "de" ? "Fokus" : "Focus"}</span></button>
<button onClick={() => { setDarkMode(!darkMode); setShowHeaderMore(false); }}>{darkMode ? <Sun size={16} /> : <Moon size={16} />} <span>{darkMode ? "Light" : "Dark"}</span></button>
<button onClick={() => { setShowSettings(true); setShowHeaderMore(false); }}><FolderPlus size={16} /> <span>{language === "de" ? "Neues Projekt" : "New Project"}</span></button>
<button onClick={() => { setShowSettings(true); setActiveTab("projects"); setShowHeaderMore(false); }}><FolderPlus size={16} /> <span>{language === "de" ? "Neues Projekt" : "New Project"}</span></button>
<div style={{ borderTop: "1px solid var(--border-color, #e5e7eb)", margin: "2px 0" }} />
<div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
<span style={{ fontSize: "12px", color: "#888", marginRight: "4px" }}>{language === "de" ? "Tage" : "Days"}:</span>
@ -6110,7 +6176,7 @@ export default function WeeklyView() {
{/* Desktop only: Recurring Tasks + New Project */}
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowSettings(true)} title="New Project"><FolderPlus size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => { setShowSettings(true); setActiveTab("projects"); }} title="New Project"><FolderPlus size={17} /></button>
{/* User Menu */}
<UserMenu
@ -6775,6 +6841,20 @@ export default function WeeklyView() {
}
onDrop={handleSlotDrop}
>
{/* 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 (
<div style={{ position: "absolute", top: "1px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", opacity: 0.6, pointerEvents: "none", zIndex: 1, lineHeight: 1 }}>
<span>{icon}</span>
<span style={{ fontWeight: 500 }}>{w.temp}°</span>
</div>
);
})()}
{/* Drop preview indicator */}
{isDropTarget && !isProtected && !isOccupiedByTask && (
<div className="drop-preview" />
@ -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() {
<div className="mobile-fab-menu-icon" style={{ background: "#10b981" }}><Search size={18} /></div>
<span>{language === "de" ? "Suche" : "Search"}</span>
</button>
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setShowSettings(true); }}>
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setShowSettings(true); setActiveTab("projects"); }}>
<div className="mobile-fab-menu-icon" style={{ background: "#6366f1" }}><FolderPlus size={18} /></div>
<span>{language === "de" ? "Projekt" : "Project"}</span>
</button>
@ -9559,7 +9639,7 @@ interface SettingsSidebarProps {
fetchAvailableTaskLists: (
provider: "google" | "apple" | "outlook" | "synology",
) => Promise<void>;
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<any[]>([]);
const projectEmojis = [
"📁", "📂", "💼", "🎯", "🚀", "⭐", "💡", "🔥", "🎨", "🎵",
@ -10494,6 +10575,7 @@ function SettingsSidebar({
>
{([
{ key: "general", icon: <Settings size={18} />, label: t.general },
{ key: "projects", icon: <FolderOpen size={18} />, label: t.projects },
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
{ key: "account", icon: <User size={18} />, label: t.account },
@ -11100,187 +11182,72 @@ function SettingsSidebar({
</button>
</div>
{/* Projects Section */}
{/* Weather Settings */}
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
<FolderOpen size={16} /> {t.projects}
{profile.language === "de" ? "Wetter" : "Weather"}
</h4>
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>{t.projectsDesc}</p>
{projects.length === 0 && (
<p style={{ fontSize: "0.85rem", color: "#aaa", fontStyle: "italic", marginBottom: "8px" }}>{t.noProjects}</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
{projects.map((p) => (
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "8px 12px", borderRadius: "10px", background: "var(--bg-secondary, #f9fafb)", borderLeft: `3px solid ${p.color || "#999"}` }}>
{editingProjectId === p.id ? (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ position: "relative" }}>
<button
onClick={() => setShowEditProjectIconPicker(!showEditProjectIconPicker)}
style={{ width: "36px", height: "36px", borderRadius: "8px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "18px", display: "flex", alignItems: "center", justifyContent: "center" }}
title="Change icon"
>
{editProjectIcon || "📁"}
</button>
{showEditProjectIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "10px", padding: "8px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "220px" }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px" }}>
{projectEmojis.map((emoji) => (
<button
key={emoji}
onClick={() => { setEditProjectIcon(emoji); setShowEditProjectIconPicker(false); }}
style={{ width: "24px", height: "24px", border: "none", background: editProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "4px", cursor: "pointer", fontSize: "14px", display: "flex", alignItems: "center", justifyContent: "center" }}
>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
<input
type="text"
value={editProjectName}
onChange={(e) => 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
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input
type="color"
value={editProjectColor}
onChange={(e) => setEditProjectColor(e.target.value)}
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
/>
<span style={{ fontSize: "0.75rem", color: "#888" }}>{profile.language === "de" ? "Farbe" : "Color"}</span>
<div style={{ flex: 1 }} />
<button
onClick={() => setEditingProjectId(null)}
style={{ padding: "4px 10px", fontSize: "0.8rem", background: "none", border: "1px solid var(--border-color, #ddd)", borderRadius: "6px", cursor: "pointer", color: "var(--text-secondary, #666)" }}
>
{profile.language === "de" ? "Abbrechen" : "Cancel"}
</button>
<button
onClick={() => {
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); });
}}
className="weekly-btn-primary"
style={{ padding: "4px 12px", fontSize: "0.8rem" }}
>
<Check size={12} />
</button>
</div>
</div>
) : (
<>
<span style={{ fontSize: "18px", lineHeight: 1 }}>{p.icon || "📁"}</span>
<span style={{ flex: 1, fontSize: "0.85rem", fontWeight: 600 }}>{p.name}</span>
<button
onClick={() => { setEditingProjectId(p.id); setEditProjectName(p.name); setEditProjectColor(p.color || "#999"); setEditProjectIcon(p.icon || "📁"); setShowEditProjectIconPicker(false); }}
style={{ padding: "4px", opacity: 0.5, cursor: "pointer", background: "none", border: "none", borderRadius: "4px" }}
title="Edit"
>
<Pencil size={13} />
</button>
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>
{profile.language === "de" ? "Zeige Temperatur und Wettericons im Stundenraster." : "Show temperature and weather icons in the time grid."}
</p>
<label style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "12px", cursor: "pointer" }}>
<input
type="checkbox"
checked={profile.weatherEnabled || false}
onChange={(e) => {
setProfile({ ...profile, weatherEnabled: e.target.checked });
saveSetting("weatherEnabled", e.target.checked);
}}
style={{ width: "18px", height: "18px" }}
/>
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{profile.language === "de" ? "Wetter anzeigen" : "Show weather"}</span>
</label>
{profile.weatherEnabled && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="text"
placeholder={profile.language === "de" ? "Stadt suchen..." : "Search city..."}
className="weekly-input"
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }}
onChange={async (e) => {
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([]); }
}}
/>
</div>
{weatherSearchResults.length > 0 && (
<div style={{ border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "8px", overflow: "hidden" }}>
{weatherSearchResults.map((r: any, i: number) => (
<button
key={i}
onClick={() => {
if (confirm(profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`)) {
fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged());
}
setProfile({ ...profile, weatherLat: r.lat, weatherLon: r.lon, weatherLocation: `${r.name}, ${r.country}` });
saveSetting("weatherLat", r.lat);
saveSetting("weatherLon", r.lon);
saveSetting("weatherLocation", `${r.name}, ${r.country}`);
setWeatherSearchResults([]);
}}
style={{ padding: "4px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "4px" }}
title="Delete"
style={{ display: "block", width: "100%", padding: "8px 12px", textAlign: "left", border: "none", borderBottom: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-secondary, #f9fafb)", cursor: "pointer", fontSize: "0.85rem" }}
>
<Trash2 size={13} />
{r.name}{r.admin1 ? `, ${r.admin1}` : ""}, {r.country} <span style={{ color: "#888", fontSize: "0.75rem" }}>({r.lat.toFixed(2)}, {r.lon.toFixed(2)})</span>
</button>
</>
)}
</div>
))}
</div>
{/* Add new project */}
<div style={{ padding: "10px 12px", borderRadius: "10px", border: "1px dashed var(--border-color, #d1d5db)", background: "var(--bg-secondary, #f9fafb)" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<div style={{ position: "relative" }}>
<button
onClick={() => setShowNewProjectIconPicker(!showNewProjectIconPicker)}
style={{ width: "36px", height: "36px", borderRadius: "8px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "18px", display: "flex", alignItems: "center", justifyContent: "center" }}
title="Choose icon"
>
{newProjectIcon}
</button>
{showNewProjectIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "10px", padding: "8px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "220px" }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px" }}>
{projectEmojis.map((emoji) => (
<button
key={emoji}
onClick={() => { setNewProjectIcon(emoji); setShowNewProjectIconPicker(false); }}
style={{ width: "24px", height: "24px", border: "none", background: newProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "4px", cursor: "pointer", fontSize: "14px", display: "flex", alignItems: "center", justifyContent: "center" }}
>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
<input
type="color"
value={newProjectColor}
onChange={(e) => setNewProjectColor(e.target.value)}
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
/>
<input
type="text"
value={newProjectName}
onChange={(e) => 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("📁"); });
}
}}
/>
<button
onClick={() => {
if (!newProjectName.trim()) return;
fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }),
}).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("📁"); });
}}
className="weekly-btn-primary"
style={{ padding: "6px 12px", fontSize: "0.8rem", whiteSpace: "nowrap" }}
>
<Plus size={14} /> {t.addProject}
</button>
))}
</div>
)}
{profile.weatherLocation && (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 12px", borderRadius: "8px", background: "var(--bg-secondary, #f9fafb)" }}>
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>📍 {profile.weatherLocation}</span>
<span style={{ fontSize: "0.75rem", color: "#888" }}>({profile.weatherLat?.toFixed(2)}, {profile.weatherLon?.toFixed(2)})</span>
</div>
)}
</div>
</div>
)}
</div>
<div
@ -11559,6 +11526,125 @@ function SettingsSidebar({
<div />
</div>
) : activeTab === "projects" ? (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div>
<h3 style={{ fontSize: "1.1rem", fontWeight: 700, marginBottom: "4px", display: "flex", alignItems: "center", gap: "8px" }}>
<FolderOpen size={20} /> {t.projects}
</h3>
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "16px" }}>{t.projectsDesc}</p>
</div>
{projects.length === 0 ? (
<div style={{ textAlign: "center", padding: "32px 16px" }}>
<div style={{ width: "56px", height: "56px", borderRadius: "50%", background: "var(--bg-secondary, #f3f4f6)", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 12px", fontSize: "24px" }}>📁</div>
<p style={{ fontSize: "0.9rem", color: "#aaa", fontStyle: "italic" }}>{t.noProjects}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{projects.map((p) => (
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px", borderRadius: "12px", background: "var(--bg-secondary, #f9fafb)", borderLeft: `4px solid ${p.color || "#999"}`, transition: "box-shadow 0.15s" }}>
{editingProjectId === p.id ? (
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ position: "relative" }}>
<button
onClick={() => setShowEditProjectIconPicker(!showEditProjectIconPicker)}
style={{ width: "40px", height: "40px", borderRadius: "10px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "20px", display: "flex", alignItems: "center", justifyContent: "center" }}
title="Change icon"
>
{editProjectIcon || "📁"}
</button>
{showEditProjectIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "12px", padding: "10px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "240px" }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "4px" }}>
{projectEmojis.map((emoji) => (
<button
key={emoji}
onClick={() => { setEditProjectIcon(emoji); setShowEditProjectIconPicker(false); }}
style={{ width: "28px", height: "28px", border: "none", background: editProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "6px", cursor: "pointer", fontSize: "16px", display: "flex", alignItems: "center", justifyContent: "center" }}
>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
<input
type="text"
value={editProjectName}
onChange={(e) => 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
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="color" value={editProjectColor} onChange={(e) => setEditProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
<span style={{ fontSize: "0.8rem", color: "#888" }}>{profile.language === "de" ? "Farbe" : "Color"}</span>
<div style={{ flex: 1 }} />
<button onClick={() => setEditingProjectId(null)} style={{ padding: "6px 14px", fontSize: "0.8rem", background: "none", border: "1px solid var(--border-color, #ddd)", borderRadius: "8px", cursor: "pointer", color: "var(--text-secondary, #666)" }}>
{profile.language === "de" ? "Abbrechen" : "Cancel"}
</button>
<button onClick={() => { 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); }); }} className="weekly-btn-primary" style={{ padding: "6px 14px", fontSize: "0.8rem" }}>
<Check size={14} /> {profile.language === "de" ? "Speichern" : "Save"}
</button>
</div>
</div>
) : (
<>
<span style={{ fontSize: "22px", lineHeight: 1 }}>{p.icon || "📁"}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, display: "block" }}>{p.name}</span>
</div>
<button onClick={() => { setEditingProjectId(p.id); setEditProjectName(p.name); setEditProjectColor(p.color || "#999"); setEditProjectIcon(p.icon || "📁"); setShowEditProjectIconPicker(false); }} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", background: "none", border: "none", borderRadius: "6px" }} title="Edit">
<Pencil size={15} />
</button>
<button onClick={() => { if (confirm(profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`)) { fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged()); } }} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "6px" }} title="Delete">
<Trash2 size={15} />
</button>
</>
)}
</div>
))}
</div>
)}
{/* Add new project */}
<div style={{ padding: "14px", borderRadius: "12px", border: "2px dashed var(--border-color, #d1d5db)", background: "var(--bg-secondary, #f9fafb)" }}>
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: "#888", marginBottom: "10px" }}>{t.addProject}</p>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setShowNewProjectIconPicker(!showNewProjectIconPicker)} style={{ width: "40px", height: "40px", borderRadius: "10px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "20px", display: "flex", alignItems: "center", justifyContent: "center" }} title="Choose icon">
{newProjectIcon}
</button>
{showNewProjectIconPicker && (
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "12px", padding: "10px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "240px" }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "4px" }}>
{projectEmojis.map((emoji) => (
<button key={emoji} onClick={() => { setNewProjectIcon(emoji); setShowNewProjectIconPicker(false); }} style={{ width: "28px", height: "28px", border: "none", background: newProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "6px", cursor: "pointer", fontSize: "16px", display: "flex", alignItems: "center", justifyContent: "center" }}>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
<input type="color" value={newProjectColor} onChange={(e) => setNewProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
<input type="text" value={newProjectName} onChange={(e) => 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("📁"); }); } }} />
<button onClick={() => { if (!newProjectName.trim()) return; fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) }).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("📁"); }); }} className="weekly-btn-primary" style={{ padding: "8px 16px", fontSize: "0.85rem", whiteSpace: "nowrap" }}>
<Plus size={14} /> {t.addProject}
</button>
</div>
</div>
</div>
) : activeTab === "calendar" ? (
isLoading ? (
<p>Loading connections...</p>