fix: email-based user lookup, slot controls, sub-hour toggle

API routes (goal, weather, calendar/sync, calendar/events):
- All user lookups now use session email instead of session ID so stale
  JWTs after DB restore/migration no longer break requests (P2003 FK error)

WeeklyView QuickSettings sidebar:
- effectiveCellDuration now uses local cellDuration state as fallback
  instead of stale profile.cellDuration — slot buttons now actually update
  the time grid
- Added 20m slot option to all three button groups (sidebar, header, mobile)
- Added :15/:30/:45 sub-hour slots toggle to QuickSettings sidebar
- Slot controls now highlight using effectiveCellDuration (per-view aware)
- Someday and All-day toggles now use effective value, not global profile

v1.81.15
This commit is contained in:
mARTin 2026-04-02 22:34:26 +02:00
parent 2d785ea2bf
commit 09caf5ffa0
6 changed files with 40 additions and 43 deletions

View File

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

@ -5,10 +5,10 @@ import { prisma } from '@/lib/prisma';
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events'; import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events';
import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache'; import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache';
// Helper to find connection by calendarId // Helper to find connection by calendarId (look up by email to avoid stale session IDs)
async function findConnectionForCalendar(userId: string, calendarId: string) { async function findConnectionForCalendar(email: string, calendarId: string) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { email },
include: { calendarConnections: true } include: { calendarConnections: true }
}); });

View File

@ -9,8 +9,7 @@ import { runSyncRules } from '@/lib/calendar-cross-sync';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id; if (!session?.user?.email) {
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
@ -20,18 +19,11 @@ export async function POST(request: NextRequest) {
const timeMinDate = new Date(timeMin ?? Date.now()); const timeMinDate = new Date(timeMin ?? Date.now());
const timeMaxDate = new Date(timeMax ?? Date.now() + 7 * 24 * 60 * 60 * 1000); const timeMaxDate = new Date(timeMax ?? Date.now() + 7 * 24 * 60 * 60 * 1000);
let user = await prisma.user.findUnique({ // Look up by email — session ID can be stale after DB restore/migration
where: { id: userId }, const user = await prisma.user.findUnique({
include: { calendarConnections: true },
});
// Fallback: session ID may be stale (e.g. after DB restore) — try by email
if (!user && session?.user?.email) {
user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
include: { calendarConnections: true }, include: { calendarConnections: true },
}) ?? null; });
}
if (!user) { if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });

View File

@ -10,15 +10,10 @@ export const dynamic = 'force-dynamic';
export async function GET(req: Request) { export async function GET(req: Request) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) { if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const userId = (session.user as any).id;
if (!userId) {
return NextResponse.json({ error: 'User ID missing from session' }, { status: 400 });
}
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const weekStartParam = searchParams.get('weekStart'); const weekStartParam = searchParams.get('weekStart');
@ -29,10 +24,11 @@ export async function GET(req: Request) {
const date = new Date(weekStartParam); const date = new Date(weekStartParam);
date.setUTCHours(0, 0, 0, 0); date.setUTCHours(0, 0, 0, 0);
// Fetch user preferences // Look up by email — session ID can be stale after DB restore
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { email: session.user.email },
select: { select: {
id: true,
goalFallbackType: true, goalFallbackType: true,
goalDefaultSentence: true, goalDefaultSentence: true,
language: true, language: true,
@ -40,6 +36,9 @@ export async function GET(req: Request) {
} }
}); });
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
const userId = user.id;
// 1. Check if user has a custom set goal for THIS week specifically // 1. Check if user has a custom set goal for THIS week specifically
const goal = await prisma.weeklyGoal.findUnique({ const goal = await prisma.weeklyGoal.findUnique({
where: { where: {
@ -134,14 +133,13 @@ export async function POST(req: Request) {
export async function PUT(req: Request) { export async function PUT(req: Request) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) { if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const userId = (session.user as any).id; const dbUser = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true } });
if (!userId) { if (!dbUser) return NextResponse.json({ error: 'User not found' }, { status: 404 });
return NextResponse.json({ error: 'User ID missing from session' }, { status: 400 }); const userId = dbUser.id;
}
const { weekStart, text } = await req.json(); const { weekStart, text } = await req.json();

View File

@ -11,13 +11,12 @@ const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id; if (!session?.user?.email) {
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { email: session.user.email },
select: { weatherEnabled: true, weatherLat: true, weatherLon: true }, select: { weatherEnabled: true, weatherLat: true, weatherLon: true },
}); });

View File

@ -1040,7 +1040,7 @@ export default function WeeklyView() {
const effectiveShowAllDay = getEffective("showAllDayEvents", profile.showAllDayEvents ?? true); const effectiveShowAllDay = getEffective("showAllDayEvents", profile.showAllDayEvents ?? true);
const effectiveAllDayPosition = getEffective("allDayPosition", profile.allDayPosition ?? "above") || "above"; const effectiveAllDayPosition = getEffective("allDayPosition", profile.allDayPosition ?? "above") || "above";
const effectiveShowCompletedTasks = getEffective("showCompletedTasks", profile.showCompletedTasks !== false); const effectiveShowCompletedTasks = getEffective("showCompletedTasks", profile.showCompletedTasks !== false);
const effectiveCellDuration = getEffective("cellDuration", profile.cellDuration ?? 30) as CellDuration; const effectiveCellDuration = getEffective("cellDuration", cellDuration) as CellDuration;
const effectiveStartHour = getEffective("startHour", profile.startHour ?? 8) ?? 8; const effectiveStartHour = getEffective("startHour", profile.startHour ?? 8) ?? 8;
const effectiveEndHour = getEffective("endHour", profile.endHour ?? 18) ?? 18; const effectiveEndHour = getEffective("endHour", profile.endHour ?? 18) ?? 18;
@ -5444,22 +5444,30 @@ export default function WeeklyView() {
</div> </div>
</div> </div>
{/* Slot Duration (only with time grid) */} {/* Slot Duration + sub-hour slots (only with time grid) */}
{profile.showTimeGrid && ( {profile.showTimeGrid && (
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}> <span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{profile.language === "de" ? "Zeitfenster" : "Slot"} {profile.language === "de" ? "Zeitfenster" : "Slot"}
</span> </span>
<div style={{ display: "flex", gap: "4px" }}> <div style={{ display: "flex", gap: "4px" }}>
{[15, 30, 60].map((d) => ( {([15, 20, 30, 60] as CellDuration[]).map((d) => (
<button key={d} onClick={() => { setCellDuration(d as CellDuration); saveSetting("cellDuration", d); }} <button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); }}
style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: cellDuration === d ? 700 : 400, background: cellDuration === d ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: cellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}> style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>
{d}m {d}m
</button> </button>
))} ))}
</div> </div>
</div> </div>
)} )}
{profile.showTimeGrid && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? ":15/:30/:45" : ":15/:30/:45"}</span>
<button onClick={() => { const v = !effectiveShowSubHourSlots; setShowSubHourSlots(v); saveSetting("showSubHourSlots", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>
{effectiveShowSubHourSlots ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
)}
{/* Text size */} {/* Text size */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
@ -5482,13 +5490,13 @@ export default function WeeklyView() {
{/* Toggle switches */} {/* Toggle switches */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Irgendwann" : "Someday"}</span> <span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Irgendwann" : "Someday"}</span>
<button onClick={() => { const v = !profile.showSomeday; setShowSomeday(v); saveSetting("showSomeday", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}> <button onClick={() => { const v = !effectiveShowSomeday; setShowSomeday(v); saveSetting("showSomeday", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>
{effectiveShowSomeday ? <Eye size={16} /> : <EyeOff size={16} />} {effectiveShowSomeday ? <Eye size={16} /> : <EyeOff size={16} />}
</button> </button>
</div> </div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Ganztägig" : "All-day"}</span> <span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Ganztägig" : "All-day"}</span>
<button onClick={() => { const v = !profile.showAllDayEvents; setShowAllDay(v); saveSetting("showAllDayEvents", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}> <button onClick={() => { const v = !effectiveShowAllDay; setShowAllDay(v); saveSetting("showAllDayEvents", v); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>
{effectiveShowAllDay ? <Eye size={16} /> : <EyeOff size={16} />} {effectiveShowAllDay ? <Eye size={16} /> : <EyeOff size={16} />}
</button> </button>
</div> </div>
@ -5738,11 +5746,11 @@ export default function WeeklyView() {
title="Slot Duration" title="Slot Duration"
> >
<Clock size={16} className="text-gray-500 mr-1" /> <Clock size={16} className="text-gray-500 mr-1" />
{[15, 30, 60].map((duration) => ( {([15, 20, 30, 60] as CellDuration[]).map((duration) => (
<button <button
key={duration} key={duration}
onClick={() => { onClick={() => {
setCellDuration(duration as CellDuration); setCellDuration(duration);
saveSetting("cellDuration", duration); saveSetting("cellDuration", duration);
}} }}
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"}`} 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"}`}
@ -5986,10 +5994,10 @@ export default function WeeklyView() {
{profile.showTimeGrid && ( {profile.showTimeGrid && (
<div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px" }}> <div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontSize: "12px", color: "#888", marginRight: "4px" }}>{profile.language === "de" ? "Slot" : "Slot"}:</span> <span style={{ fontSize: "12px", color: "#888", marginRight: "4px" }}>{profile.language === "de" ? "Slot" : "Slot"}:</span>
{[15, 30, 60].map((duration) => ( {([15, 20, 30, 60] as CellDuration[]).map((duration) => (
<button <button
key={duration} key={duration}
onClick={() => { setCellDuration(duration as CellDuration); saveSetting("cellDuration", duration); }} onClick={() => { setCellDuration(duration); saveSetting("cellDuration", duration); }}
style={{ padding: "2px 8px", fontSize: "12px", borderRadius: "4px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === duration ? 700 : 400, background: effectiveCellDuration === duration ? "var(--bg-secondary, #e5e7eb)" : "transparent", color: "inherit" }} style={{ padding: "2px 8px", fontSize: "12px", borderRadius: "4px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === duration ? 700 : 400, background: effectiveCellDuration === duration ? "var(--bg-secondary, #e5e7eb)" : "transparent", color: "inherit" }}
> >
{duration}m {duration}m