feat: per-view settings for hour format, sub-hours, weather, checkboxes

Add viewSettings JSON field to User model storing per-view overrides.
Settings like hour label format, sub-hour labels, weather, and task
checkboxes can now be set per view (simple/calendar/list/kanban) or
globally. A clickable badge next to each setting shows "All" (global)
or the current view name (per-view). Click to toggle scope.

Also fixes hourLabelFormat not actually being applied to time column
rendering (was hardcoded, now uses effective per-view value).

v1.49.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-18 01:09:36 +01:00
parent def2c37ddb
commit 4757dc3cdb
4 changed files with 152 additions and 20 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.48.3", "version": "1.49.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

@ -105,6 +105,7 @@ model User {
quoteSourceUrls String[] @default([]) quoteSourceUrls String[] @default([])
quoteLanguages String[] @default(["en", "de"]) quoteLanguages String[] @default(["en", "de"])
kanbanStages String? kanbanStages String?
viewSettings Json?
accounts Account[] accounts Account[]
cachedCalendarEvents CachedCalendarEvent[] cachedCalendarEvents CachedCalendarEvent[]
calendarConnections CalendarConnection[] calendarConnections CalendarConnection[]

View File

@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
weatherLat: true, weatherLat: true,
weatherLon: true, weatherLon: true,
weatherLocation: true, weatherLocation: true,
viewSettings: true,
createdAt: true createdAt: true
} }
}); });
@ -144,7 +145,7 @@ export async function PATCH(request: NextRequest) {
showTaskCheckboxes, dayHeaderGap, showTaskCheckboxes, dayHeaderGap,
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages, showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale, kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
weatherEnabled, weatherLat, weatherLon, weatherLocation weatherEnabled, weatherLat, weatherLon, weatherLocation, viewSettings
} = body; } = body;
const updateData: any = { const updateData: any = {
@ -234,6 +235,7 @@ export async function PATCH(request: NextRequest) {
...(weatherLat !== undefined && { weatherLat: weatherLat !== null ? parseFloat(weatherLat) : null }), ...(weatherLat !== undefined && { weatherLat: weatherLat !== null ? parseFloat(weatherLat) : null }),
...(weatherLon !== undefined && { weatherLon: weatherLon !== null ? parseFloat(weatherLon) : null }), ...(weatherLon !== undefined && { weatherLon: weatherLon !== null ? parseFloat(weatherLon) : null }),
...(weatherLocation !== undefined && { weatherLocation }), ...(weatherLocation !== undefined && { weatherLocation }),
...(viewSettings !== undefined && { viewSettings }),
}; };
if (password && password.trim() !== "") { if (password && password.trim() !== "") {
updateData.passwordHash = await bcrypt.hash(password, 10); updateData.passwordHash = await bcrypt.hash(password, 10);
@ -332,6 +334,7 @@ export async function PATCH(request: NextRequest) {
weatherLat: true, weatherLat: true,
weatherLon: true, weatherLon: true,
weatherLocation: true, weatherLocation: true,
viewSettings: true,
} }
}); });

View File

@ -1950,6 +1950,72 @@ export default function WeeklyView() {
slotIdx?: number; slotIdx?: number;
} | null>(null); } | null>(null);
const [viewStyle, setViewStyle] = useState<ViewStyle>("simple"); const [viewStyle, setViewStyle] = useState<ViewStyle>("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;
const [viewSettings, setViewSettings] = useState<Record<string, PerViewOverrides>>({});
const getEffective = <K extends keyof PerViewOverrides>(key: K, globalVal: PerViewOverrides[K]): PerViewOverrides[K] => {
const vs = viewSettings[viewStyle];
if (vs && vs[key] !== undefined) return vs[key] as PerViewOverrides[K];
return globalVal;
};
const isPerView = (key: keyof PerViewOverrides): boolean => {
const vs = viewSettings[viewStyle];
return !!(vs && vs[key] !== undefined);
};
const saveViewSetting = async <K extends keyof PerViewOverrides>(key: K, value: PerViewOverrides[K], perView: boolean) => {
const updated = { ...viewSettings };
if (perView) {
updated[viewStyle] = { ...(updated[viewStyle] || {}), [key]: value };
} else {
// Remove per-view overrides for this key from ALL views and set globally
for (const v of Object.keys(updated)) {
if (updated[v] && updated[v][key] !== undefined) {
const { [key]: _, ...rest } = updated[v] as any;
updated[v] = rest;
}
}
}
setViewSettings(updated);
// Save to DB
try {
await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ viewSettings: updated }),
});
} catch (e) { console.error("Failed to save view settings:", e); }
};
const togglePerView = async (key: keyof PerViewOverrides, globalVal: any) => {
if (isPerView(key)) {
// Remove per-view override (revert to global)
const updated = { ...viewSettings };
if (updated[viewStyle]) {
const { [key]: _, ...rest } = updated[viewStyle] as any;
updated[viewStyle] = rest;
}
setViewSettings(updated);
try {
await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ viewSettings: updated }),
});
} catch (e) { console.error("Failed to save view settings:", e); }
} else {
// Set per-view override to current global value
saveViewSetting(key, globalVal, 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 defaultKanbanStages: KanbanStage[] = [ const defaultKanbanStages: KanbanStage[] = [
{ id: "backlog", name: "Backlog", color: "#94a3b8" }, { id: "backlog", name: "Backlog", color: "#94a3b8" },
{ id: "todo", name: "To Do", color: "#3b82f6" }, { id: "todo", name: "To Do", color: "#3b82f6" },
@ -2132,6 +2198,7 @@ export default function WeeklyView() {
if (profileData.hourLabelFormat) setHourLabelFormat(profileData.hourLabelFormat); if (profileData.hourLabelFormat) setHourLabelFormat(profileData.hourLabelFormat);
if (profileData.showSubHourSlots !== undefined) setShowSubHourSlots(profileData.showSubHourSlots); if (profileData.showSubHourSlots !== undefined) setShowSubHourSlots(profileData.showSubHourSlots);
if (profileData.allDayPosition) setAllDayPosition(profileData.allDayPosition); if (profileData.allDayPosition) setAllDayPosition(profileData.allDayPosition);
if (profileData.viewSettings) setViewSettings(profileData.viewSettings);
} }
} }
} catch (err) { } catch (err) {
@ -3010,6 +3077,7 @@ export default function WeeklyView() {
setShowSubHourSlots(data.user.showSubHourSlots); setShowSubHourSlots(data.user.showSubHourSlots);
if (data.user.allDayPosition) if (data.user.allDayPosition)
setAllDayPosition(data.user.allDayPosition as "above" | "below"); setAllDayPosition(data.user.allDayPosition as "above" | "below");
if (data.user.viewSettings) setViewSettings(data.user.viewSettings);
if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont); if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont);
if (data.user.headlineFontSize) if (data.user.headlineFontSize)
setHeadlineFontSize(data.user.headlineFontSize); setHeadlineFontSize(data.user.headlineFontSize);
@ -6599,7 +6667,7 @@ export default function WeeklyView() {
const hour = getHourFromSlot(slot); const hour = getHourFromSlot(slot);
const minutes = slot.split(":")[1]; const minutes = slot.split(":")[1];
const isHourStart = minutes === "00"; const isHourStart = minutes === "00";
if (!isHourStart && !showSubHourSlots) return ( if (!isHourStart && !effectiveShowSubHourSlots) return (
<div <div
key={slot} key={slot}
className="time-slot-label" className="time-slot-label"
@ -6614,8 +6682,8 @@ export default function WeeklyView() {
onClick={isHourStart ? () => jumpToHour(hour) : undefined} onClick={isHourStart ? () => jumpToHour(hour) : undefined}
title={isHourStart ? `Jump to ${hour}:00` : undefined} title={isHourStart ? `Jump to ${hour}:00` : undefined}
> >
{(isHourStart || showSubHourSlots) && ( {(isHourStart || effectiveShowSubHourSlots) && (
<span>{formatHour(hour, parseInt(minutes), (isHourStart ? 'short' : 'full') as "short" | "full", timeFormat)}</span> <span>{formatHour(hour, parseInt(minutes), (isHourStart ? effectiveHourLabelFormat : 'full') as "short" | "full", timeFormat)}</span>
)} )}
</div> </div>
); );
@ -6868,7 +6936,7 @@ export default function WeeklyView() {
deleteSubTask={deleteSubTask} deleteSubTask={deleteSubTask}
onSetEditingTaskId={setEditingTaskId} onSetEditingTaskId={setEditingTaskId}
workingHoursStart={workingHoursStart} workingHoursStart={workingHoursStart}
showTaskCheckboxes={profile.showTaskCheckboxes} showTaskCheckboxes={effectiveShowTaskCheckboxes}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages} kanbanStages={kanbanStages}
@ -6945,7 +7013,7 @@ export default function WeeklyView() {
onDrop={handleSlotDrop} onDrop={handleSlotDrop}
> >
{/* Weather indicator for hour-start slots */} {/* Weather indicator for hour-start slots */}
{isHourStart && profile.weatherEnabled && (() => { {isHourStart && effectiveWeatherEnabled && (() => {
const h = parseInt(slot.split(":")[0]); 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 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];
@ -7167,7 +7235,7 @@ export default function WeeklyView() {
onUpdateSubTask={updateSubTask} onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId} editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId} onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes} showTaskCheckboxes={effectiveShowTaskCheckboxes}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages} kanbanStages={kanbanStages}
@ -7863,7 +7931,7 @@ export default function WeeklyView() {
onUpdateSubTask={updateSubTask} onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId} editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId} onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes} showTaskCheckboxes={effectiveShowTaskCheckboxes}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages} kanbanStages={kanbanStages}
@ -7934,7 +8002,7 @@ export default function WeeklyView() {
onUpdateSubTask={updateSubTask} onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId} editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId} onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes} showTaskCheckboxes={effectiveShowTaskCheckboxes}
projects={projects} projects={projects}
onProjectAssign={assignProject} onProjectAssign={assignProject}
kanbanStages={kanbanStages} kanbanStages={kanbanStages}
@ -8443,6 +8511,12 @@ export default function WeeklyView() {
onStartHourChange: (h: number) => { setStartHour(h); saveSetting("startHour", h); }, onStartHourChange: (h: number) => { setStartHour(h); saveSetting("startHour", h); },
onEndHourChange: (h: number) => { setEndHour(h); saveSetting("endHour", h); }, onEndHourChange: (h: number) => { setEndHour(h); saveSetting("endHour", h); },
}} }}
perView={{
isPerView: isPerView as any,
togglePerView: togglePerView as any,
saveViewSetting: saveViewSetting as any,
viewLabel: viewStyle === "simple" ? (language === "de" ? "Einfach" : "Simple") : viewStyle === "calendar" ? (language === "de" ? "Kalender" : "Calendar") : viewStyle === "list" ? (language === "de" ? "Liste" : "List") : "Kanban",
}}
/> />
) )
} }
@ -9978,6 +10052,12 @@ interface SettingsSidebarProps {
onStartHourChange: (h: number) => void; onStartHourChange: (h: number) => void;
onEndHourChange: (h: number) => void; onEndHourChange: (h: number) => void;
}; };
perView: {
isPerView: (key: string) => boolean;
togglePerView: (key: string, globalVal: any) => void;
saveViewSetting: (key: string, value: any, perView: boolean) => void;
viewLabel: string;
};
} }
// Notes Sidebar Component // Notes Sidebar Component
interface NotesSidebarProps { interface NotesSidebarProps {
@ -10202,7 +10282,33 @@ function SettingsSidebar({
setProfile, setProfile,
isMobile: isMobileSidebar, isMobile: isMobileSidebar,
mobileActions, mobileActions,
perView,
}: SettingsSidebarProps) { }: SettingsSidebarProps) {
// Per-view badge: shows which view a setting applies to, click to toggle
const PerViewBadge = ({ settingKey, globalVal }: { settingKey: string; globalVal: any }) => {
const isPV = perView.isPerView(settingKey);
return (
<button
onClick={() => perView.togglePerView(settingKey, globalVal)}
title={isPV ? (profile.language === "de" ? `Nur für ${perView.viewLabel} — klicken für alle Ansichten` : `Only for ${perView.viewLabel} — click for all views`) : (profile.language === "de" ? `Alle Ansichten — klicken für nur ${perView.viewLabel}` : `All views — click for ${perView.viewLabel} only`)}
style={{
fontSize: "0.55rem",
padding: "1px 5px",
borderRadius: "8px",
border: isPV ? "1px solid #6366f1" : "1px solid #d1d5db",
background: isPV ? "#eef2ff" : "transparent",
color: isPV ? "#6366f1" : "#9ca3af",
cursor: "pointer",
fontWeight: 600,
whiteSpace: "nowrap" as const,
marginLeft: "4px",
}}
>
{isPV ? perView.viewLabel : (profile.language === "de" ? "Alle" : "All")}
</button>
);
};
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
>(initialTab || "general"); >(initialTab || "general");
@ -10850,16 +10956,20 @@ function SettingsSidebar({
type="checkbox" type="checkbox"
id="showTaskCheckboxes" id="showTaskCheckboxes"
checked={profile.showTaskCheckboxes || false} checked={profile.showTaskCheckboxes || false}
onChange={(e) => onChange={(e) => {
setProfile({ ...profile, showTaskCheckboxes: e.target.checked }) setProfile({ ...profile, showTaskCheckboxes: e.target.checked });
} if (perView.isPerView("showTaskCheckboxes")) {
perView.saveViewSetting("showTaskCheckboxes", e.target.checked, true);
}
}}
style={{ width: "16px", height: "16px" }} style={{ width: "16px", height: "16px" }}
/> />
<label <label
htmlFor="showTaskCheckboxes" htmlFor="showTaskCheckboxes"
style={{ fontSize: "0.9rem", fontWeight: 600 }} style={{ fontSize: "0.9rem", fontWeight: 600, display: "flex", alignItems: "center" }}
> >
{t.showTaskCheckboxes} {t.showTaskCheckboxes}
<PerViewBadge settingKey="showTaskCheckboxes" globalVal={profile.showTaskCheckboxes} />
</label> </label>
</div> </div>
<div <div
@ -11068,20 +11178,26 @@ function SettingsSidebar({
<div style={{ marginTop: "4px" }}> <div style={{ marginTop: "4px" }}>
<label <label
style={{ style={{
display: "block", display: "flex",
alignItems: "center",
fontSize: "0.9rem", fontSize: "0.9rem",
fontWeight: 600, fontWeight: 600,
marginBottom: "4px", marginBottom: "4px",
}} }}
> >
{t.hourLabelFormat} {t.hourLabelFormat}
<PerViewBadge settingKey="hourLabelFormat" globalVal={hourLabelFormat} />
</label> </label>
<select <select
value={hourLabelFormat} value={hourLabelFormat}
onChange={(e) => { onChange={(e) => {
const fmt = e.target.value as "short" | "full"; const fmt = e.target.value as "short" | "full";
setHourLabelFormat(fmt); setHourLabelFormat(fmt);
saveSetting("hourLabelFormat", fmt); if (perView.isPerView("hourLabelFormat")) {
perView.saveViewSetting("hourLabelFormat", fmt, true);
} else {
saveSetting("hourLabelFormat", fmt);
}
}} }}
className="weekly-input" className="weekly-input"
style={{ style={{
@ -11106,15 +11222,20 @@ function SettingsSidebar({
checked={showSubHourSlots} checked={showSubHourSlots}
onChange={(e) => { onChange={(e) => {
setShowSubHourSlots(e.target.checked); setShowSubHourSlots(e.target.checked);
saveSetting("showSubHourSlots", e.target.checked); if (perView.isPerView("showSubHourSlots")) {
perView.saveViewSetting("showSubHourSlots", e.target.checked, true);
} else {
saveSetting("showSubHourSlots", e.target.checked);
}
}} }}
style={{ width: "16px", height: "16px" }} style={{ width: "16px", height: "16px" }}
/> />
<label <label
htmlFor="showSubHourSlots" htmlFor="showSubHourSlots"
style={{ fontSize: "0.9rem", fontWeight: 600 }} style={{ fontSize: "0.9rem", fontWeight: 600, display: "flex", alignItems: "center" }}
> >
{t.showSubhourLabels} {t.showSubhourLabels}
<PerViewBadge settingKey="showSubHourSlots" globalVal={showSubHourSlots} />
</label> </label>
</div> </div>
</div> </div>
@ -11245,11 +11366,18 @@ function SettingsSidebar({
checked={profile.weatherEnabled || false} checked={profile.weatherEnabled || false}
onChange={(e) => { onChange={(e) => {
setProfile({ ...profile, weatherEnabled: e.target.checked }); setProfile({ ...profile, weatherEnabled: e.target.checked });
saveSetting("weatherEnabled", e.target.checked); if (perView.isPerView("weatherEnabled")) {
perView.saveViewSetting("weatherEnabled", e.target.checked, true);
} else {
saveSetting("weatherEnabled", e.target.checked);
}
}} }}
style={{ width: "18px", height: "18px" }} style={{ width: "18px", height: "18px" }}
/> />
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{profile.language === "de" ? "Wetter anzeigen" : "Show weather"}</span> <span style={{ fontSize: "0.85rem", fontWeight: 500, display: "flex", alignItems: "center" }}>
{profile.language === "de" ? "Wetter anzeigen" : "Show weather"}
<PerViewBadge settingKey="weatherEnabled" globalVal={profile.weatherEnabled} />
</span>
</label> </label>
{profile.weatherEnabled && ( {profile.weatherEnabled && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>