feat: improve PDF export — header, layout, icons, tab filter

- 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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-04-13 17:00:13 +02:00
parent 1051dfd96c
commit ac17ca2635
5 changed files with 308 additions and 108 deletions

View File

@ -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"
]
}
}

View File

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

View File

@ -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<string, DayData>;
@ -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<any>
);

View File

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

View File

@ -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<Set<string>>(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<string>();
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}
/>
<ToggleRow
label={t.showNoTime}
checked={showNoTime}
onChange={setShowNoTime}
/>
<div>
<ToggleRow
label={t.showNoTime}
checked={showNoTime}
onChange={setShowNoTime}
/>
{/* Tab filter — shown when the someday section is enabled and there are multiple tabs */}
{showNoTime && somedayTabs.length > 1 && (
<div style={{ paddingLeft: 46, marginTop: 8 }}>
<div style={{ fontSize: "0.73rem", fontWeight: 600, color: "#6b7280", marginBottom: 6, textTransform: "uppercase", letterSpacing: "0.05em" }}>
{de ? "Tabs einschließen" : "Include tabs"}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{somedayTabs.map(({ key, label }) => {
const checked = selectedTabs.has(key);
return (
<label
key={key}
style={{
display: "flex", alignItems: "center", gap: 5,
cursor: "pointer", fontSize: "0.82rem",
padding: "3px 10px 3px 8px",
borderRadius: 6,
border: `1px solid ${checked ? "#6366f1" : "#d1d5db"}`,
background: checked ? "#eff6ff" : "#f9fafb",
color: checked ? "#4f46e5" : "#6b7280",
fontWeight: checked ? 600 : 400,
transition: "all 0.15s",
userSelect: "none",
}}
>
<input
type="checkbox"
checked={checked}
onChange={e => {
const next = new Set(selectedTabs);
if (e.target.checked) next.add(key);
else if (next.size > 1) next.delete(key); // keep at least 1 selected
setSelectedTabs(next);
}}
style={{ accentColor: "#6366f1", width: 13, height: 13, flexShrink: 0 }}
/>
{label}
</label>
);
})}
</div>
</div>
)}
</div>
<div>
<ToggleRow
label={t.showWeather}