From ac17ca26357c67bf536e2ffde95b967e2cb6b1ae Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 13 Apr 2026 17:00:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20improve=20PDF=20export=20=E2=80=94=20he?= =?UTF-8?q?ader,=20layout,=20icons,=20tab=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add page header per PDF page: KW + date range (headline font/accent color) + motivational quote (right-aligned italic, from local quotes) - Move all-day strip above weekday/date header to match webapp layout - Increase font sizes across the board (task titles 7.5pt, time labels 7.5pt, day date 8pt) to better match the web view - Remove time prefix from task rows (hour-row label already provides context); completed tasks get strikethrough styling - Calendar event chips in all-day strip now use solid calendar color + white text + small dot indicator (replaces πŸ“… emoji, which Helvetica cannot render in PDFs) - Fix multi-day all-day events: expand CachedCalendarEvents across every day they span (iCal endDate is exclusive); deduplicate by title to prevent double-rendering when the same event exists in both Task and CachedCalendarEvent tables - Someday page is now landscape (matches main week pages) - Print modal: fetch available someday tabs on open; show pill-checkbox filter below "Aufgaben ohne Uhrzeit" toggle when multiple tabs exist; selected tabs are passed to the export route as noTimeTabs param - New API endpoint GET /api/user/someday-tabs for fetching tab list - Export route filters someday lists by noTimeTabs when provided v1.97.2 Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 28 ++- package.json | 2 +- src/app/api/user/export-week-view/route.ts | 270 +++++++++++++-------- src/app/api/user/someday-tabs/route.ts | 26 ++ src/components/WeekPrintModal.tsx | 90 ++++++- 5 files changed, 308 insertions(+), 108 deletions(-) create mode 100644 src/app/api/user/someday-tabs/route.ts diff --git a/.claude/settings.json b/.claude/settings.json index 23c3fd2..faea638 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -19,12 +19,36 @@ "Bash(PRISMA_HIDE_UPDATE_MESSAGE=1 npx prisma db push)", "Bash(npm run:*)", "WebFetch(domain:thequoteshub.com)", - "WebFetch(domain:api.zitat-service.de)" + "WebFetch(domain:api.zitat-service.de)", + "Bash(ldd \"/home/sparky/.cache/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome\")", + "Bash(\"/home/sparky/.cache/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome\" --version)", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'openclaw config --help 2>&1 | head -40; echo ---; openclaw help 2>&1 | head -40'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'openclaw config get agents.defaults.model 2>&1; echo ---; openclaw config get agents.defaults 2>&1 | grep -i timeout'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -r -i \\\\\"timeout\\\\\\\\|fallback\\\\\" /usr/lib/node_modules/openclaw/dist/ 2>/dev/null | grep -i \\\\\"model\\\\\\\\|fallback\\\\\\\\|timeout\\\\\" | grep -v \\\\\".map:\\\\\" | head -30'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'cat /usr/lib/node_modules/openclaw/dist/timeout-DwUOu7ws.js 2>/dev/null | grep -o \\\\\"[a-zA-Z]*[Tt]imeout[a-zA-Z]*[^;,\\)]*\\\\\" | sort -u | head -30'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'cat /usr/lib/node_modules/openclaw/dist/timeout-DwUOu7ws.js'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -r -h \\\\\"firstToken\\\\\\\\|connect.*[Tt]imeout\\\\\\\\|requestTimeout\\\\\\\\|loadTimeout\\\\\\\\|modelTimeout\\\\\\\\|fallback.*[Tt]imeout\\\\\\\\|[Tt]imeout.*fallback\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -v sourceMappingURL | sort -u | head -30'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -rh \\\\\"firstToken\\\\\\\\|first_token\\\\\\\\|ttft\\\\\\\\|modelLoad\\\\\\\\|model.*load\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -i timeout | grep -v sourceMappingURL | sort -u | head -20'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -rh \\\\\"requestTimeout\\\\\\\\|connectionTimeoutMs\\\\\\\\|connectTimeout\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -i \\\\\"cfg\\\\\\\\|config\\\\\\\\|default\\\\\\\\|agents\\\\\\\\|models\\\\\\\\|providers\\\\\" | grep -v sourceMappingURL | grep -v \\\\\"//\\\\\" | sort -u | head -20'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -rh \\\\\"models.*providers\\\\|providers.*requestTimeout\\\\|provider.*timeout\\\\|dgx.*timeout\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -v sourceMappingURL | grep -v \\\\\"^//\\\\\" | sort -u | head -20'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -rh \\\\\"requestTimeout\\\\|connectTimeoutMs\\\\|connectionTimeoutMs\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -i \\\\\"provider\\\\|models\\\\|baseUrl\\\\|openai\\\\\" | grep -v sourceMappingURL | grep -v \\\\\"^//\\\\\" | sort -u | head -20'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'openclaw config schema 2>&1 | grep -A5 -i \\\\\"timeout\\\\\\\\|requestTimeout\\\\\\\\|connectTimeout\\\\\" | head -60'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'openclaw config schema 2>&1 | grep -B2 -A8 \\\\\"requestTimeout\\\\\\\\|connectTimeout\\\\\\\\|modelTimeout\\\\\\\\|loadTimeout\\\\\\\\|firstToken\\\\\"'\")", + "Bash(ssh root@192.168.178.4 'pct exec 200 -- bash -c '\\\\''openclaw config schema 2>&1 | python3 -c \":*)", + "Bash(ssh root@192.168.178.4 'pct exec 200 -- bash -c '\\\\''python3 -c \":*)", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- systemctl restart openclaw-gateway && sleep 2 && systemctl is-active openclaw-gateway\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'journalctl -u openclaw-gateway -n 20 --no-pager'\")", + "Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'sleep 3 && systemctl is-active openclaw-gateway && journalctl -u openclaw-gateway -n 5 --no-pager'\")", + "Bash(openssl rand:*)", + "Bash(ssh root@192.168.178.4:*)", + "Bash(ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@192.168.178.8 \"echo connected && find / -name 'docker-compose.yml' -path '*/orchestrator*' 2>/dev/null | head -5\")", + "Bash(ssh-copy-id root@192.168.178.8)" ], "additionalDirectories": [ "/home/sparky/Projekte/My-Weekly-ToDo-List", "/home/sparky/Projekte/My-Weekly-ToDo-List/src/app/api/calendar/cross-sync/rules", - "/home/sparky/Projekte/My-Weekly-ToDo-List/src/components" + "/home/sparky/Projekte/My-Weekly-ToDo-List/src/components", + "/home/sparky/Projekte/My-Weekly-ToDo-List/src/app/api/user" ] } } diff --git a/package.json b/package.json index aec9332..721c71f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.96.3", + "version": "1.97.2", "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/src/app/api/user/export-week-view/route.ts b/src/app/api/user/export-week-view/route.ts index ede9e72..45107f5 100644 --- a/src/app/api/user/export-week-view/route.ts +++ b/src/app/api/user/export-week-view/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { prisma } from '@/lib/prisma'; +import { getRandomLocalQuote } from '@/lib/quotes'; import React from 'react'; import { Document, Page, Text, View, StyleSheet, Font, renderToBuffer, @@ -58,8 +59,9 @@ const NO_TIME_H = 52; // anyday strip (user tasks without a time, shows ~4) const TIME_COL_W = 32; // left time-label column // Derived -const CONTENT_H = PH - PAD_V * 2 - FOOTER_H; // β‰ˆ 549.28 -const USABLE_W = PW - PAD_H * 2; // β‰ˆ 801.89 +const CONTENT_H = PH - PAD_V * 2 - FOOTER_H; // β‰ˆ 549.28 +const USABLE_W = PW - PAD_H * 2; // β‰ˆ 801.89 +const PAGE_HDR_H = 26; // page header strip: week label + motivational quote // Fixed colours const WHITE = '#ffffff'; @@ -228,6 +230,7 @@ function WeekCalendarPDF({ userStyle, somedayLists, totalPages, + quote, }: { pages: Date[][]; tasksByDay: Map; @@ -241,6 +244,7 @@ function WeekCalendarPDF({ userStyle: UserStyle; somedayLists: SomedayList[]; totalPages: number; + quote: { text: string; author: string } | null; }) { const todayKey = new Date().toLocaleDateString('en-CA'); const locale = de ? 'de-DE' : 'en-GB'; @@ -252,8 +256,8 @@ function WeekCalendarPDF({ ? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400) : undefined; - // GRID_H = remaining space after day header and optional all-day strip - const GRID_H = CONTENT_H - DAY_HDR_H - (showAllDay ? ALL_DAY_H : 0); + // GRID_H = remaining space after page header, day header, and optional all-day strip + const GRID_H = CONTENT_H - (PAGE_HDR_H + 8) - DAY_HDR_H - (showAllDay ? ALL_DAY_H : 0); const exportedOn = new Date().toLocaleDateString(locale, { day: '2-digit', month: 'long', year: 'numeric', @@ -267,7 +271,7 @@ function WeekCalendarPDF({ const dayColW = (USABLE_W - TIME_COL_W) / numDays; // Adaptive font size β€” full names always, size scales with available column width - const dayNameSize = numDays <= 2 ? 16 : numDays <= 3 ? 13 : numDays <= 5 ? 10 : 8.5; + const dayNameSize = numDays <= 2 ? 17 : numDays <= 3 ? 14 : numDays <= 5 ? 11 : 9.5; const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + GRID_H; @@ -294,6 +298,41 @@ function WeekCalendarPDF({ }, }, + // ── Page header: KW + date range on left, motivational quote on right + React.createElement(View, { + style: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end', + height: PAGE_HDR_H, + marginBottom: 8, + borderBottomWidth: 1, + borderBottomColor: '#e8eaed', + paddingBottom: 4, + }, + }, + React.createElement(Text, { + style: { + fontFamily: headlineFontFamily, + fontWeight: headlineFW, + fontSize: 12, + color: userStyle.weekdayColor, + flexShrink: 0, + }, + }, `${de ? 'KW' : 'Wk'} ${getWeekNumber(firstDay)} Β· ${rangeLabel}`), + quote ? React.createElement(Text, { + style: { + fontSize: 7, + color: MUTED, + flex: 1, + textAlign: 'right', + paddingLeft: 10, + paddingBottom: 1, + fontStyle: 'italic', + }, + }, `"${quote.text}" β€” ${quote.author}`) : null, + ), + // ── Calendar grid: [time col] [day cols...] React.createElement(View, { style: { @@ -317,24 +356,7 @@ function WeekCalendarPDF({ backgroundColor: '#fafafa', }, }, - // Spacer matching day header row - React.createElement(View, { - style: { - height: DAY_HDR_H, - borderBottomWidth: 1, - borderBottomColor: BORDER, - justifyContent: 'flex-end', - alignItems: 'flex-end', - paddingRight: 4, - paddingBottom: 4, - }, - }, - React.createElement(Text, { - style: { fontSize: 6, color: '#94a3b8', textAlign: 'right' }, - }, `${de ? 'KW' : 'Wk'} ${getWeekNumber(firstDay)}`), - ), - - // All-day label spacer + // All-day label spacer (top β€” matches day column order) ...(showAllDay ? [ React.createElement(View, { style: { @@ -350,7 +372,7 @@ function WeekCalendarPDF({ }, React.createElement(Text, { style: { - fontSize: 5.5, + fontSize: 6, fontFamily: 'Helvetica-Bold', color: '#3b82f6', textTransform: 'uppercase', @@ -360,6 +382,15 @@ function WeekCalendarPDF({ ), ] : []), + // Spacer matching day header row + React.createElement(View, { + style: { + height: DAY_HDR_H, + borderBottomWidth: 1, + borderBottomColor: BORDER, + }, + }), + // Hour labels β€” flex rows, aligned with day-column hour rows React.createElement(View, { style: { height: GRID_H, flexDirection: 'column' } }, ...Array.from({ length: numHours }, (_, i) => @@ -376,7 +407,7 @@ function WeekCalendarPDF({ }, }, React.createElement(Text, { - style: { fontFamily: 'Helvetica-Bold', fontSize: 6.5, color: MUTED, textAlign: 'right' }, + style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: MUTED, textAlign: 'right' }, }, fmtHour(startHour + i)), ) ), @@ -405,6 +436,59 @@ function WeekCalendarPDF({ }, }, + // ── All-day cell (top β€” above weekday header, matches webapp layout) + ...(showAllDay ? [ + React.createElement(View, { + style: { + height: ALL_DAY_H, + backgroundColor: ALLDAY_BG, + borderBottomWidth: 1, + borderBottomColor: '#bfdbfe', + paddingHorizontal: 3, + paddingTop: 3, + overflow: 'hidden', + }, + }, + ...allDayTasks.slice(0, 5).map(t => { + if (t.completed) { + return React.createElement(View, { + key: t.id, + style: { borderRadius: 2, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2, backgroundColor: DONE_BG }, + }, React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT } }, (t.title || '').slice(0, 36))); + } + if (t.isExternal) { + // Calendar event: solid color chip with white text + dot indicator (matches webapp style) + const evColor = t.project?.color || '#6366f1'; + const evTextColor = contrastColor(evColor); + return React.createElement(View, { + key: t.id, + style: { + flexDirection: 'row', alignItems: 'center', + borderRadius: 2, + paddingVertical: 2, paddingHorizontal: 4, marginBottom: 2, + backgroundColor: evColor, + }, + }, + // Calendar indicator dot (replaces the πŸ“… emoji the webapp uses) + React.createElement(View, { + style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: evTextColor, opacity: 0.7, marginRight: 3, flexShrink: 0 }, + }), + React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: evTextColor, flex: 1 } }, (t.title || '').slice(0, 36)), + ); + } + // User-created all-day task: left border only, no background fill + const accent = t.project?.color || userStyle.taskColor; + return React.createElement(View, { + key: t.id, + style: { borderLeftWidth: 2, borderLeftColor: accent, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2 }, + }, React.createElement(Text, { style: { fontSize: 7.5, color: '#374151' } }, (t.title || '').slice(0, 36))); + }), + allDayTasks.length > 5 ? React.createElement(Text, { + style: { fontSize: 6, color: '#3b82f6', marginTop: 1 }, + }, `+${allDayTasks.length - 5} ${de ? 'weitere' : 'more'}`) : null, + ), + ] : []), + // ── Day header: date first (top), then weekday name (bottom) β€” matches webapp "above" layout React.createElement(View, { style: { @@ -419,7 +503,7 @@ function WeekCalendarPDF({ }, React.createElement(Text, { style: { - fontSize: 7, + fontSize: 8, color: dayColor(d, isToday, userStyle), textAlign: 'center', fontFamily: 'Helvetica', @@ -440,51 +524,6 @@ function WeekCalendarPDF({ }, dayName), ), - // ── All-day cell (external calendar events) - ...(showAllDay ? [ - React.createElement(View, { - style: { - height: ALL_DAY_H, - backgroundColor: ALLDAY_BG, - borderBottomWidth: 1, - borderBottomColor: '#bfdbfe', - paddingHorizontal: 3, - paddingTop: 3, - overflow: 'hidden', - }, - }, - ...allDayTasks.slice(0, 5).map(t => { - if (t.completed) { - return React.createElement(View, { - key: t.id, - style: { borderRadius: 2, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2, backgroundColor: DONE_BG }, - }, React.createElement(Text, { style: { fontSize: 6.5, color: DONE_TEXT } }, (t.title || '').slice(0, 36))); - } - if (t.isExternal) { - // Calendar event: light tinted chip with left border - const evColor = t.project?.color || '#6366f1'; - return React.createElement(View, { - key: t.id, - style: { - borderRadius: 2, borderLeftWidth: 2, borderLeftColor: evColor, - paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2, - backgroundColor: lighten(evColor, 0.78), - }, - }, React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 6.5, color: '#1e293b' } }, (t.title || '').slice(0, 36))); - } - // User-created all-day task: left border only, no background fill - const accent = t.project?.color || userStyle.taskColor; - return React.createElement(View, { - key: t.id, - style: { borderLeftWidth: 2, borderLeftColor: accent, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2 }, - }, React.createElement(Text, { style: { fontSize: 6.5, color: '#374151' } }, (t.title || '').slice(0, 36))); - }), - allDayTasks.length > 5 ? React.createElement(Text, { - style: { fontSize: 5.5, color: '#3b82f6', marginTop: 1 }, - }, `+${allDayTasks.length - 5} ${de ? 'weitere' : 'more'}`) : null, - ), - ] : []), - // ── Time grid: per-hour rows (agenda/list style, no absolute positioning) React.createElement(View, { style: { @@ -509,9 +548,9 @@ function WeekCalendarPDF({ borderBottomColor: '#ececec', overflow: 'hidden', position: 'relative', - paddingTop: 1, - paddingLeft: 2, - paddingRight: hw ? 22 : 2, + paddingTop: 2, + paddingLeft: 3, + paddingRight: hw ? 24 : 3, }, }, // Per-hour weather: small colored dot + temperature @@ -524,7 +563,7 @@ function WeekCalendarPDF({ style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: wmoColor(hw.code), marginRight: 1.5 }, }), hw.temp != null ? React.createElement(Text, { - style: { fontSize: 5.5, color: '#94a3b8' }, + style: { fontSize: 6, color: '#94a3b8' }, }, `${Math.round(hw.temp)}Β°`) : null, ), ] : []), @@ -534,10 +573,9 @@ function WeekCalendarPDF({ if (t.completed) { return React.createElement(View, { key: t.id, - style: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 1, paddingLeft: 1 }, + style: { marginBottom: 1, paddingLeft: 1, paddingVertical: 1 }, }, - React.createElement(Text, { style: { fontSize: 5.5, color: DONE_TEXT, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')), - React.createElement(Text, { style: { fontSize: 6.5, color: DONE_TEXT, flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)), + React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT, lineHeight: 1.3, textDecoration: 'line-through' } }, (t.title || '').slice(0, 60)), ); } @@ -546,14 +584,12 @@ function WeekCalendarPDF({ return React.createElement(View, { key: t.id, style: { - flexDirection: 'row', alignItems: 'flex-start', backgroundColor: lighten(evColor, 0.85), borderRadius: 2, borderLeftWidth: 2, borderLeftColor: evColor, paddingLeft: 3, paddingVertical: 1, marginBottom: 1, }, }, - React.createElement(Text, { style: { fontSize: 5.5, color: evColor, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')), - React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 6.5, color: '#1e293b', flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)), + React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)), ); } @@ -561,13 +597,11 @@ function WeekCalendarPDF({ return React.createElement(View, { key: t.id, style: { - flexDirection: 'row', alignItems: 'flex-start', ...(pc ? { borderLeftWidth: 2, borderLeftColor: pc, paddingLeft: 3 } : { paddingLeft: 1 }), paddingVertical: 1, marginBottom: 1, }, }, - React.createElement(Text, { style: { fontSize: 5.5, color: pc || MUTED, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')), - React.createElement(Text, { style: { fontSize: 6.5, color: '#1e293b', flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)), + React.createElement(Text, { style: { fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)), ); }), ); @@ -612,12 +646,13 @@ function WeekCalendarPDF({ React.createElement(Page, { key: 'someday', size: 'A4', - orientation: 'portrait', + orientation: 'landscape', style: { fontFamily: 'Helvetica', fontSize: 8, color: '#1e293b', - padding: 28, + padding: 24, + paddingBottom: 24 + 18, backgroundColor: WHITE, flexDirection: 'column', }, @@ -719,7 +754,7 @@ function WeekCalendarPDF({ React.createElement(View, { style: { position: 'absolute', - bottom: 20, left: 28, right: 28, + bottom: 24, left: 24, right: 24, borderTopWidth: 1, borderTopColor: BORDER, paddingTop: 3, flexDirection: 'row', justifyContent: 'space-between', @@ -766,6 +801,9 @@ export async function GET(request: Request) { const showNoTime = searchParams.get('showNoTime') !== '0'; const showWeather = searchParams.get('showWeather') === '1'; const inline = searchParams.get('inline') === '1'; + // noTimeTabs: comma-separated tab names to include; absent = include all + const noTimeTabsRaw = searchParams.get('noTimeTabs'); + const noTimeTabFilter: string[] | null = noTimeTabsRaw ? noTimeTabsRaw.split(',').filter(Boolean) : null; if (!startDate || !endDate) return new NextResponse('Missing startDate or endDate', { status: 400 }); @@ -852,22 +890,43 @@ export async function GET(request: Request) { }, select: { id: true, title: true, calendarColor: true, - startDate: true, startDateTime: true, endDateTime: true, + startDate: true, startDateTime: true, endDateTime: true, endDate: true, }, }); for (const ev of calEvents) { if (ev.startDate) { - // All-day external event (e.g. Google all-day) + // All-day external event β€” may span multiple days (iCal endDate is exclusive) if (!showAllDay) continue; - const dk = ev.startDate as string; - if (dk < startDate || dk > endDate) continue; - if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], timed: [] }); - tasksByDay.get(dk)!.allDay.push({ - id: `cal-${ev.id}`, title: ev.title as string | null, - startTime: null, endTime: null, completed: false, isExternal: true, - project: { name: '', color: (ev.calendarColor as string | null) || '#6366f1' }, - }); + const evStart = ev.startDate as string; + // endDate is exclusive in iCalendar; treat null/same as single-day + const evEndExcl = (ev.endDate as string | null) || ''; + const daysToFill: string[] = []; + if (evEndExcl > evStart) { + // Expand across all days in the span (capped to visible range) + let cur = new Date(evStart + 'T00:00:00Z'); + const stop = new Date(evEndExcl + 'T00:00:00Z'); + while (cur < stop) { + const dk = cur.toLocaleDateString('en-CA'); + if (dk >= startDate && dk <= endDate) daysToFill.push(dk); + cur = new Date(cur.getTime() + 86400000); + } + } else { + if (evStart >= startDate && evStart <= endDate) daysToFill.push(evStart); + } + const evTitle = (ev.title as string | null) || ''; + const evColor = (ev.calendarColor as string | null) || '#6366f1'; + for (const dk of daysToFill) { + if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], timed: [] }); + const arr = tasksByDay.get(dk)!.allDay; + // Deduplicate: skip if an entry with the same title already exists in this day + if (arr.some(x => (x.title || '').toLowerCase() === evTitle.toLowerCase())) continue; + arr.push({ + id: `cal-${ev.id}-${dk}`, title: ev.title as string | null, + startTime: null, endTime: null, completed: false, isExternal: true, + project: { name: '', color: evColor }, + }); + } } else if (ev.startDateTime) { // Timed external event const dk = localDateStr(ev.startDateTime as Date, userTz); @@ -925,8 +984,19 @@ export async function GET(request: Request) { }; let somedayLists: SomedayListWithTasks[] = []; if (showNoTime) { + // Build tab filter condition (noTimeTabFilter encodes null tab as the locale-specific default label) + const defaultTabLabel = de ? 'Allgemein' : 'General'; + const tabWhere = noTimeTabFilter ? { + OR: [ + ...(noTimeTabFilter.includes(defaultTabLabel) ? [{ tab: null }] : []), + ...(noTimeTabFilter.filter(t => t !== defaultTabLabel).length > 0 + ? [{ tab: { in: noTimeTabFilter.filter(t => t !== defaultTabLabel) } }] + : []), + ], + } : {}; + const raw = await prisma.somedayList.findMany({ - where: { userId: user.id }, + where: { userId: user.id, ...tabWhere }, orderBy: [{ tab: 'asc' }, { order: 'asc' }], select: { id: true, @@ -952,6 +1022,9 @@ export async function GET(request: Request) { new Date(endDate + 'T00:00:00'), ); + const quoteData = getRandomLocalQuote(de ? 'de' : 'en'); + const quote = quoteData ? { text: quoteData.text, author: quoteData.author } : null; + const pdfBuffer = await renderToBuffer( React.createElement(WeekCalendarPDF, { pages, tasksByDay, weatherHourly, @@ -960,6 +1033,7 @@ export async function GET(request: Request) { userStyle, somedayLists, totalPages: pages.length, + quote, }) as React.ReactElement ); diff --git a/src/app/api/user/someday-tabs/route.ts b/src/app/api/user/someday-tabs/route.ts new file mode 100644 index 0000000..47da20c --- /dev/null +++ b/src/app/api/user/someday-tabs/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// Returns the distinct set of tabs (and list titles within each tab) for the +// current user's someday lists β€” used by the print/export modal to show a +// tab-filter selector. +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return new NextResponse('Unauthorized', { status: 401 }); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true }, + }); + if (!user) return new NextResponse('Not found', { status: 404 }); + + const lists = await prisma.somedayList.findMany({ + where: { userId: user.id }, + select: { id: true, title: true, tab: true }, + orderBy: [{ tab: 'asc' }, { order: 'asc' }], + }); + + return NextResponse.json({ lists }); +} diff --git a/src/components/WeekPrintModal.tsx b/src/components/WeekPrintModal.tsx index a4d29fa..16e0cf4 100644 --- a/src/components/WeekPrintModal.tsx +++ b/src/components/WeekPrintModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo, useCallback } from "react"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; import { X, Download, Printer, Eye } from "lucide-react"; // ── Types ─────────────────────────────────────────────────────────────────── @@ -113,6 +113,11 @@ export default function WeekPrintModal({ const [showWeather, setShowWeather] = useState(weatherEnabled); const [lang, setLang] = useState(language || "de"); + // Someday tab filter β€” fetched once when modal opens + const [somedayTabs, setSomedayTabs] = useState<{ key: string; label: string }[]>([]); + const [selectedTabs, setSelectedTabs] = useState>(new Set()); + const [tabsLoaded, setTabsLoaded] = useState(false); + // ── Computed const endDate = useMemo(() => { const d = new Date(startDate + "T00:00:00"); @@ -138,6 +143,28 @@ export default function WeekPrintModal({ const numPages = useMemo(() => Math.ceil(numDays / 7), [numDays]); + // Fetch available someday tabs once when the modal opens + useEffect(() => { + if (!isOpen || tabsLoaded) return; + fetch("/api/user/someday-tabs") + .then(r => r.json()) + .then((data: { lists: { id: string; title: string; tab: string | null }[] }) => { + const seen = new Set(); + const tabs: { key: string; label: string }[] = []; + const defaultLabel = lang === "de" ? "Allgemein" : "General"; + for (const list of data.lists || []) { + // Encode null tab as the default label so it round-trips through URL params + const key = list.tab ?? defaultLabel; + const label = list.tab ?? defaultLabel; + if (!seen.has(key)) { seen.add(key); tabs.push({ key, label }); } + } + setSomedayTabs(tabs); + setSelectedTabs(new Set(tabs.map(t => t.key))); // all selected by default + setTabsLoaded(true); + }) + .catch(() => setTabsLoaded(true)); + }, [isOpen, tabsLoaded, lang]); + const pdfUrl = useMemo(() => { const params = new URLSearchParams({ startDate, @@ -149,8 +176,12 @@ export default function WeekPrintModal({ showNoTime: showNoTime ? "1" : "0", showWeather: showWeather ? "1" : "0", }); + // Only send tab filter if not all tabs are selected (avoids unnecessary URL noise) + if (showNoTime && somedayTabs.length > 1 && selectedTabs.size < somedayTabs.length) { + params.set("noTimeTabs", Array.from(selectedTabs).join(",")); + } return `/api/user/export-week-view?${params}`; - }, [startDate, endDate, startHour, endHour, lang, showAllDay, showNoTime, showWeather]); + }, [startDate, endDate, startHour, endHour, lang, showAllDay, showNoTime, showWeather, selectedTabs, somedayTabs]); const printUrl = useMemo(() => `${pdfUrl}&inline=1`, [pdfUrl]); @@ -334,11 +365,56 @@ export default function WeekPrintModal({ checked={showAllDay} onChange={setShowAllDay} /> - +
+ + {/* Tab filter β€” shown when the someday section is enabled and there are multiple tabs */} + {showNoTime && somedayTabs.length > 1 && ( +
+
+ {de ? "Tabs einschließen" : "Include tabs"} +
+
+ {somedayTabs.map(({ key, label }) => { + const checked = selectedTabs.has(key); + return ( + + ); + })} +
+
+ )} +