diff --git a/package.json b/package.json index 249fbdb..1e3a2bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.50.0", + "version": "1.51.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 08944ef..d1e0880 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -115,6 +115,7 @@ model User { weatherLat Float? weatherLon Float? weatherLocation String? + weatherRecentCities Json? 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 a0ead7e..4046454 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -101,6 +101,7 @@ export async function GET(request: NextRequest) { weatherLat: true, weatherLon: true, weatherLocation: true, + weatherRecentCities: true, viewSettings: true, createdAt: true } @@ -145,7 +146,7 @@ export async function PATCH(request: NextRequest) { showTaskCheckboxes, dayHeaderGap, showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages, kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale, - weatherEnabled, weatherLat, weatherLon, weatherLocation, viewSettings + weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings } = body; const updateData: any = { @@ -235,6 +236,7 @@ export async function PATCH(request: NextRequest) { ...(weatherLat !== undefined && { weatherLat: weatherLat !== null ? parseFloat(weatherLat) : null }), ...(weatherLon !== undefined && { weatherLon: weatherLon !== null ? parseFloat(weatherLon) : null }), ...(weatherLocation !== undefined && { weatherLocation }), + ...(weatherRecentCities !== undefined && { weatherRecentCities }), ...(viewSettings !== undefined && { viewSettings }), }; if (password && password.trim() !== "") { @@ -334,6 +336,7 @@ export async function PATCH(request: NextRequest) { weatherLat: true, weatherLon: true, weatherLocation: true, + weatherRecentCities: true, viewSettings: true, } }); diff --git a/src/app/api/weather/route.ts b/src/app/api/weather/route.ts index 4dc8b3c..97323dd 100644 --- a/src/app/api/weather/route.ts +++ b/src/app/api/weather/route.ts @@ -21,7 +21,7 @@ export async function GET(request: NextRequest) { select: { weatherEnabled: true, weatherLat: true, weatherLon: true }, }); - if (!user?.weatherEnabled || !user.weatherLat || !user.weatherLon) { + if (!user?.weatherLat || !user.weatherLon) { return NextResponse.json({ error: 'Weather not configured' }, { status: 400 }); } diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index fb5a8eb..f59068f 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -1954,21 +1954,35 @@ export default function WeeklyView() { const [viewStyle, setViewStyle] = useState("simple"); // Per-view settings: overrides that apply only to a specific view - type PerViewOverrides = { hourLabelFormat?: "short" | "full"; showSubHourSlots?: boolean; weatherEnabled?: boolean; showTaskCheckboxes?: boolean }; - const PER_VIEW_KEYS = ["hourLabelFormat", "showSubHourSlots", "weatherEnabled", "showTaskCheckboxes"] as const; + type PerViewOverrides = { + hourLabelFormat?: "short" | "full"; + showSubHourSlots?: boolean; + weatherEnabled?: boolean; + showTaskCheckboxes?: boolean; + showSomeday?: boolean; + showAllDayEvents?: boolean; + allDayPosition?: "above" | "below"; + showCompletedTasks?: boolean; + cellDuration?: number; + startHour?: number; + endHour?: number; + }; + const PER_VIEW_KEYS = ["hourLabelFormat", "showSubHourSlots", "weatherEnabled", "showTaskCheckboxes", "showSomeday", "showAllDayEvents", "allDayPosition", "showCompletedTasks", "cellDuration", "startHour", "endHour"] as const; const [viewSettings, setViewSettings] = useState>({}); + const viewSettingsRef = useRef>({}); + viewSettingsRef.current = viewSettings; const getEffective = (key: K, globalVal: PerViewOverrides[K]): PerViewOverrides[K] => { - const vs = viewSettings[viewStyle]; + const vs = viewSettingsRef.current[viewStyle]; if (vs && vs[key] !== undefined) return vs[key] as PerViewOverrides[K]; return globalVal; }; const isPerView = (key: keyof PerViewOverrides): boolean => { - const vs = viewSettings[viewStyle]; + const vs = viewSettingsRef.current[viewStyle]; return !!(vs && vs[key] !== undefined); }; const saveViewSetting = async (key: K, value: PerViewOverrides[K], perView: boolean) => { - const updated = { ...viewSettings }; + const updated = { ...viewSettingsRef.current }; if (perView) { updated[viewStyle] = { ...(updated[viewStyle] || {}), [key]: value }; } else { @@ -1980,6 +1994,7 @@ export default function WeeklyView() { } } } + viewSettingsRef.current = updated; setViewSettings(updated); // Save to DB try { @@ -1993,11 +2008,12 @@ export default function WeeklyView() { const togglePerView = async (key: keyof PerViewOverrides, globalVal: any) => { if (isPerView(key)) { // Remove per-view override (revert to global) - const updated = { ...viewSettings }; + const updated = { ...viewSettingsRef.current }; if (updated[viewStyle]) { const { [key]: _, ...rest } = updated[viewStyle] as any; updated[viewStyle] = rest; } + viewSettingsRef.current = updated; setViewSettings(updated); try { await fetch("/api/user/profile", { @@ -2012,11 +2028,22 @@ export default function WeeklyView() { } }; + // State declarations needed before effective per-view values + const [showSomeday, setShowSomeday] = useState(true); + const [showAllDay, setShowAllDay] = useState(true); + // Effective per-view values (override if set for current view, else global) const effectiveHourLabelFormat = getEffective("hourLabelFormat", hourLabelFormat); const effectiveShowSubHourSlots = getEffective("showSubHourSlots", showSubHourSlots); const effectiveWeatherEnabled = getEffective("weatherEnabled", profile.weatherEnabled); const effectiveShowTaskCheckboxes = getEffective("showTaskCheckboxes", profile.showTaskCheckboxes); + const effectiveShowSomeday = getEffective("showSomeday", showSomeday); + const effectiveShowAllDay = getEffective("showAllDayEvents", showAllDay); + const effectiveAllDayPosition = getEffective("allDayPosition", allDayPosition) || "above"; + const effectiveShowCompletedTasks = getEffective("showCompletedTasks", profile.showCompletedTasks !== false); + const effectiveCellDuration = getEffective("cellDuration", cellDuration) as CellDuration; + const effectiveStartHour = getEffective("startHour", profile.startHour ?? 8) ?? 8; + const effectiveEndHour = getEffective("endHour", profile.endHour ?? 18) ?? 18; const defaultKanbanStages: KanbanStage[] = [ { id: "backlog", name: "Backlog", color: "#94a3b8" }, @@ -2049,8 +2076,6 @@ export default function WeeklyView() { const [startHour, setStartHour] = useState(8); const [endHour, setEndHour] = useState(18); const [weekStartDay, setWeekStartDay] = useState(1); // 1 = Monday, 0 = Sunday - const [showSomeday, setShowSomeday] = useState(true); - const [showAllDay, setShowAllDay] = useState(true); const [goal, setGoal] = useState("your goal of this week"); const [isEditingGoal, setIsEditingGoal] = useState(false); const [showNextTask, setShowNextTask] = useState(false); @@ -2465,7 +2490,8 @@ export default function WeeklyView() { // Weather fetch const fetchWeather = useCallback(async () => { - if (!profile.weatherEnabled) return; + if (!effectiveWeatherEnabled) return; + if (!profile.weatherLat || !profile.weatherLon) 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); @@ -2477,11 +2503,11 @@ export default function WeeklyView() { } catch (e) { console.error("Weather fetch failed:", e); } - }, [currentWeekStart, profile.weatherEnabled]); + }, [currentWeekStart, effectiveWeatherEnabled, profile.weatherLat, profile.weatherLon]); useEffect(() => { - if (profile.weatherEnabled) fetchWeather(); - }, [fetchWeather, profile.weatherEnabled]); + if (effectiveWeatherEnabled) fetchWeather(); + }, [fetchWeather, effectiveWeatherEnabled]); // Calendar Event Handlers const handleEventSave = async (eventData: any) => { @@ -3335,6 +3361,8 @@ export default function WeeklyView() { if (!task.scheduledDate) return false; // Exclude sub-tasks from top-level list (they render inside their parent) if (task.parentTaskId) return false; + // Hide completed tasks if setting is off + if (!effectiveShowCompletedTasks && task.completed) return false; // Use string comparison to avoid timezone shifts const taskDateStr = typeof task.scheduledDate === "string" @@ -3352,7 +3380,7 @@ export default function WeeklyView() { return a.order - b.order; }); }, - [tasks], + [tasks, effectiveShowCompletedTasks], ); // Get tasks for a specific time slot @@ -3363,6 +3391,8 @@ export default function WeeklyView() { if (!task.scheduledDate) return false; // Exclude sub-tasks from top-level list if (task.parentTaskId) return false; + // Hide completed tasks if setting is off + if (!effectiveShowCompletedTasks && task.completed) return false; // Use string comparison to avoid timezone shifts const taskDateStr = typeof task.scheduledDate === "string" @@ -3376,12 +3406,12 @@ export default function WeeklyView() { const [slotHour, slotMinute] = slot.split(":").map(Number); const slotStart = slotHour * 60 + slotMinute; - const slotEnd = slotStart + cellDuration; + const slotEnd = slotStart + effectiveCellDuration; return taskStart >= slotStart && taskStart < slotEnd; }); }, - [tasks, cellDuration], + [tasks, effectiveCellDuration, effectiveShowCompletedTasks], ); // Get calendar events for a specific date @@ -3420,13 +3450,13 @@ export default function WeeklyView() { // Match if event starts within this slot const [slotHour, slotMinute] = slot.split(":").map(Number); const slotStart = slotHour * 60 + slotMinute; - const slotEnd = slotStart + cellDuration; + const slotEnd = slotStart + effectiveCellDuration; const eventStart = eventHour * 60 + eventMinute; return eventStart >= slotStart && eventStart < slotEnd; }); }, - [calendarEvents, cellDuration], + [calendarEvents, effectiveCellDuration], ); // Calculate event duration in pixels for proper height display @@ -3438,10 +3468,10 @@ export default function WeeklyView() { const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); // Calculate height based on duration and slot height - const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration; + const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration; return Math.max( durationMinutes * pixelsPerMinute, - getSlotHeight(cellDuration), + getSlotHeight(effectiveCellDuration), ); }; @@ -3544,7 +3574,7 @@ export default function WeeklyView() { const slotStart = new Date(date); slotStart.setHours(h, m, 0, 0); const slotEnd = new Date(slotStart); - slotEnd.setMinutes(slotEnd.getMinutes() + cellDuration); + slotEnd.setMinutes(slotEnd.getMinutes() + effectiveCellDuration); return dailyEvents.some((event) => { const eventStart = new Date(event.startTime); @@ -3562,12 +3592,12 @@ export default function WeeklyView() { let [h, m] = current.split(":").map(Number); while (isBlocked(date, current, tasksToCheck)) { - m += cellDuration; + m += effectiveCellDuration; if (m >= 60) { h += 1; m = 0; } - if (h >= endHour) break; + if (h >= effectiveEndHour) break; current = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; } return current; @@ -3608,7 +3638,7 @@ export default function WeeklyView() { setTasks(updatedTasks.filter((t) => !t.somedayListId)); } }, - [profile.autoRolling, cellDuration, endHour, getEventsForDate], + [profile.autoRolling, effectiveCellDuration, effectiveEndHour, getEventsForDate], ); // Run rolling after profile is loaded and tasks are available @@ -3656,7 +3686,7 @@ export default function WeeklyView() { const dateStr = formatDateToISO(date); const [slotHour, slotMinute] = slot.split(":").map(Number); const slotStart = slotHour * 60 + slotMinute; - const slotEnd = slotStart + cellDuration; + const slotEnd = slotStart + effectiveCellDuration; return tasks.some(task => { if (!task.scheduledDate || !task.startTime) return false; @@ -3682,7 +3712,7 @@ export default function WeeklyView() { return taskStart < slotEnd && taskEnd > slotStart; }); }, - [tasks, cellDuration], + [tasks, effectiveCellDuration], ); // Navigation handlers with CSS class-based slide animation (works in all browsers) @@ -4789,7 +4819,7 @@ export default function WeeklyView() { if (targetSlotTasks.length > 0) { // Slot is taken — find next free slot const allSlots = getTimeSlots( - cellDuration, + effectiveCellDuration, 0, 24, ); @@ -4904,7 +4934,7 @@ export default function WeeklyView() { if (targetSlot) { if (isSlotOccupiedByTask(targetDateObj, targetSlot, draggedTask.id)) { const allSlots = getTimeSlots( - cellDuration, + effectiveCellDuration, 0, 24, ); @@ -5252,7 +5282,7 @@ export default function WeeklyView() { // Get time slots to display const visibleSlots = getTimeSlots( - cellDuration, + effectiveCellDuration, 0, 24, ); @@ -5375,7 +5405,7 @@ export default function WeeklyView() { // All-Day Events Section (reusable for above/below positioning) const allDaySection = (() => { - if (!showAllDay) return null; + if (!effectiveShowAllDay) return null; const allDayEvents = calendarEvents.filter((event) => isAllDayEvent(event), ); @@ -5620,7 +5650,7 @@ export default function WeeklyView() {
{[15, 30, 60].map((d) => ( ))} @@ -5663,7 +5693,7 @@ export default function WeeklyView() {
{language === "de" ? "Irgendwann" : "Someday"}
@@ -5675,7 +5705,7 @@ export default function WeeklyView() {
{language === "de" ? "Ganztägig" : "All-day"}
@@ -5927,12 +5957,12 @@ export default function WeeklyView() { { const val = Math.max( 0, - Math.min(parseInt(e.target.value) || 0, endHour - 1), + Math.min(parseInt(e.target.value) || 0, effectiveEndHour - 1), ); setStartHour(val); saveSetting("startHour", val); @@ -5942,12 +5972,12 @@ export default function WeeklyView() { - { const val = Math.max( - startHour + 1, + effectiveStartHour + 1, Math.min(parseInt(e.target.value) || 24, 24), ); setEndHour(val); @@ -5973,7 +6003,7 @@ export default function WeeklyView() { setCellDuration(duration as CellDuration); saveSetting("cellDuration", duration); }} - className={`px-2 py-0.5 text-xs rounded transition-colors ${cellDuration === duration ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`} + className={`px-2 py-0.5 text-xs rounded transition-colors ${effectiveCellDuration === duration ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`} > {duration}m @@ -6224,7 +6254,7 @@ export default function WeeklyView() { @@ -6251,7 +6281,7 @@ export default function WeeklyView() { {/* All-Day Events Section (above position) — hidden in kanban */} - {viewStyle !== "kanban" && allDayPosition === "above" && allDaySection} + {viewStyle !== "kanban" && effectiveAllDayPosition === "above" && allDaySection} {/* Kanban Board View */} {viewStyle === "kanban" && (() => { @@ -6279,6 +6309,7 @@ export default function WeeklyView() { // Apply filters const filteredKanbanTasks = allKanbanTasks.filter(t => { + if (!effectiveShowCompletedTasks && t.completed) return false; if (kanbanSearch && !t.title.toLowerCase().includes(kanbanSearch.toLowerCase())) return false; if (kanbanFilterProject && t.projectId !== kanbanFilterProject) return false; if (kanbanFilterList) { @@ -6307,12 +6338,14 @@ export default function WeeklyView() { }} >
- toggleTask(task.id)} - className="kanban-card-checkbox" - /> + {effectiveShowTaskCheckboxes && ( + toggleTask(task.id)} + className="kanban-card-checkbox" + /> + )} ); return (
jumpToHour(hour) : undefined} title={isHourStart ? `Jump to ${hour}:00` : undefined} > @@ -6702,8 +6735,8 @@ export default function WeeklyView() { data-nav-type={viewDays > 1 ? "week" : "day"} onScroll={handleGridScroll} style={{ - height: `${24 * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`, - maxHeight: `${24 * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`, + height: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + getHeaderHeight(effectiveCellDuration)}px`, + maxHeight: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + getHeaderHeight(effectiveCellDuration)}px`, flex: 1, alignSelf: "flex-start", overflowY: 'auto' @@ -6816,7 +6849,7 @@ export default function WeeklyView() { const minutesSinceStart = nowHour * 60 + nowMinute; const pixelsPerMinute = - getSlotHeight(cellDuration) / cellDuration; + getSlotHeight(effectiveCellDuration) / effectiveCellDuration; const topPosition = minutesSinceStart * pixelsPerMinute; const timeString = `${String(nowHour).padStart(2, "0")}:${String(nowMinute).padStart(2, "0")}`; @@ -6851,7 +6884,7 @@ export default function WeeklyView() { eventStartHour * 60 + eventStartMinute; const pixelsPerMinute = - getSlotHeight(cellDuration) / cellDuration; + getSlotHeight(effectiveCellDuration) / effectiveCellDuration; const topPosition = minutesSinceStart * pixelsPerMinute; @@ -6915,7 +6948,7 @@ export default function WeeklyView() { task={task} date={date} activeDate={currentWeekStart} - cellDuration={cellDuration} + cellDuration={effectiveCellDuration} darkMode={darkMode} isProtected={false} editingTaskId={editingTaskId} @@ -7003,7 +7036,7 @@ export default function WeeklyView() { key={slot} className={`time-slot ${isHourStart ? "hour-start" : ""} ${draggedTask && !isProtected && !isOccupiedByTask ? "drop-target" : ""} ${isActive ? "active" : ""}`} style={{ - height: `${getSlotHeight(cellDuration)}px`, + height: `${getSlotHeight(effectiveCellDuration)}px`, position: "relative", cursor: isProtected || isOccupiedByAnyTask ? "not-allowed" : "text", }} @@ -7050,7 +7083,7 @@ export default function WeeklyView() { const offsetMinutes = eventStartMinutes - slotStartMinutes; const pixelsPerMinute = - getSlotHeight(cellDuration) / cellDuration; + getSlotHeight(effectiveCellDuration) / effectiveCellDuration; const topOffset = offsetMinutes * pixelsPerMinute; // Convert hex to rgba for background, or use default @@ -7274,10 +7307,10 @@ export default function WeeklyView() {
} {/* All-Day Events Section (below position) — hidden in kanban */} - {viewStyle !== "kanban" && allDayPosition === "below" && allDaySection} + {viewStyle !== "kanban" && effectiveAllDayPosition === "below" && allDaySection} {/* Someday Section */} - {showSomeday && (<> + {effectiveShowSomeday && (<> {/* Resize handle - on top border of someday section */} {somedayExpanded && (
) @@ -10053,6 +10087,7 @@ interface SettingsSidebarProps { }; perView: { saveViewSetting: (key: string, value: any, perView: boolean) => void; + getEffective: (key: string, globalVal: any) => any; }; } // Notes Sidebar Component @@ -10352,6 +10387,25 @@ function SettingsSidebar({ const t = translations[profile.language || "en"] || translations["en"]; + // --- Auto-save helpers: save settings immediately on change --- + // For discrete inputs (checkbox, select, button) — save right away + const saveField = (key: string, value: any) => { + setProfile((p: any) => ({ ...p, [key]: value })); + saveSetting(key, value); + }; + // For continuous inputs (text, number, color picker) — debounce 500ms + const debouncedTimers = useRef>({}); + const saveFieldDebounced = (key: string, value: any) => { + setProfile((p: any) => ({ ...p, [key]: value })); + if (debouncedTimers.current[key]) clearTimeout(debouncedTimers.current[key]); + debouncedTimers.current[key] = setTimeout(() => saveSetting(key, value), 500); + }; + // For standalone state + saveSetting (showSomeday, showTimeGrid, etc.) + const saveStateAndSetting = (setter: (v: any) => void, key: string, value: any) => { + setter(value); + saveSetting(key, value); + }; + // Load fonts for preview // Font loading moved to top level WeeklyView component @@ -10804,577 +10858,429 @@ function SettingsSidebar({ > {activeTab === "general" ? (
- {/* View Style Tabs — at top, acts as context for per-view settings */} -
- -
- - - - -
-

- {profile.language === "de" ? "Einstellungen unten gelten für diese Ansicht" : "Settings below apply to this view"} -

-
- -
- setShowSomeday(e.target.checked)} - style={{ width: "16px", height: "16px" }} - /> - -
- -
- setShowAllDay(e.target.checked)} - style={{ width: "16px", height: "16px" }} - /> - -
- - {showAllDay && ( -
-
) : activeTab === "about" ? ( @@ -13972,9 +13763,7 @@ function SettingsSidebar({ - setProfile({ ...profile, name: e.target.value }) - } + onChange={(e) => saveFieldDebounced("name", e.target.value)} className="weekly-input" style={{ width: "100%", @@ -14092,9 +13881,7 @@ function SettingsSidebar({