feat: add week view PDF/print export with live preview

Adds a print/export modal (WeekPrintModal) accessible via a Printer icon
in the header toolbar. Users can configure date range, time window (startHour–endHour),
number of days, and toggle all-day, no-time, and weather sections. A live
CSS-grid preview updates as settings change. The PDF is generated server-side
via @react-pdf/renderer on the /api/user/export-week-view route:
- Landscape A4 with absolute-positioned time-spanning task blocks
- Day headers use the user's weekdayColor/dateColor/headlineFontWeight from DB
- Oswald font loaded dynamically from Google Fonts (falls back to Helvetica-Bold)
- Timezone-aware task grouping (±1 day query buffer + local date key)
- Optional weather line (Open-Meteo) and all-day strip
- Footer with date range, username, and page numbers

v1.95.0
This commit is contained in:
mARTin 2026-04-09 10:26:06 +02:00
parent 075ccdba04
commit f75bd6ebe5
4 changed files with 1372 additions and 1 deletions

View File

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

View File

@ -0,0 +1,734 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import React from 'react';
import {
Document, Page, Text, View, StyleSheet, Font, renderToBuffer,
} from '@react-pdf/renderer';
// ── Font registration (Oswald from Google Fonts, one-time per process) ───────
let _fontPromise: Promise<void> | null = null;
function ensureFonts(): Promise<void> {
if (!_fontPromise) {
_fontPromise = (async () => {
try {
const res = await fetch(
'https://fonts.googleapis.com/css2?family=Oswald:wght@400;600;700&display=swap',
{
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Node.js)' },
signal: AbortSignal.timeout(5000),
}
);
const css = await res.text();
// Extract all woff2 URLs with their weights
const entries: { src: string; fontWeight: number }[] = [];
const re = /font-weight:\s*(\d+)[\s\S]*?url\((https:\/\/fonts\.gstatic\.com\/[^)'"]+)\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(css)) !== null) {
entries.push({ fontWeight: parseInt(m[1]), src: m[2] });
}
if (entries.length > 0) {
Font.register({ family: 'Oswald', fonts: entries });
}
} catch {
// No Oswald — PDF will use Helvetica-Bold as fallback
}
})();
}
return _fontPromise;
}
// ── Constants ────────────────────────────────────────────────────────────────
// Landscape A4
const PW = 841.89;
const PH = 595.28;
const PAD_H = 20;
const PAD_V = 14;
const FOOTER_H = 18;
// Fixed structural heights
const DAY_HDR_H = 40; // column header: weekday name + date
const ALL_DAY_H = 22; // optional all-day strip
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
// Fixed colours
const WHITE = '#ffffff';
const BORDER = '#e2e8f0';
const MUTED = '#64748b';
const DONE_BG = '#cbd5e1';
const STRIPE = '#f9fafb';
const ALLDAY_BG = '#eff6ff';
// ── Weekday label helpers ─────────────────────────────────────────────────────
const WDAY_LONG_DE = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const WDAY_LONG_EN = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const WDAY_SHORT_DE = ['So','Mo','Di','Mi','Do','Fr','Sa'];
const WDAY_SHORT_EN = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
function weekdayLabel(d: Date, de: boolean, numDays: number): string {
const idx = d.getDay();
// Use full names for ≤5 columns, short for 6-7
return numDays <= 5
? (de ? WDAY_LONG_DE : WDAY_LONG_EN)[idx].toUpperCase()
: (de ? WDAY_SHORT_DE : WDAY_SHORT_EN)[idx].toUpperCase();
}
function dayDateLabel(d: Date, de: boolean): string {
return d.toLocaleDateString(de ? 'de-DE' : 'en-GB', {
day: '2-digit', month: 'short', year: 'numeric',
});
}
function fmtHour(h: number): string {
return `${String(h).padStart(2, '0')}:00`;
}
function fmtTime(t: string): string {
const [h, m] = t.split(':');
return `${h}:${m}`;
}
function parseTimeMinutes(t: string | null | undefined): number | null {
if (!t) return null;
const [h, m] = t.split(':').map(Number);
return h * 60 + (m || 0);
}
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function localDateStr(d: Date, tz: string): string {
return d.toLocaleDateString('en-CA', { timeZone: tz });
}
function groupIntoPages(start: Date, end: Date): Date[][] {
const pages: Date[][] = [];
const dow = start.getDay();
const offset = dow === 0 ? -6 : 1 - dow;
let ws = addDays(start, offset);
ws.setHours(0, 0, 0, 0);
while (ws <= end) {
const page: Date[] = [];
for (let i = 0; i < 7; i++) {
const d = addDays(ws, i);
if (d >= start && d <= end) page.push(d);
}
if (page.length > 0) pages.push(page);
ws = addDays(ws, 7);
}
return pages;
}
// ── Weather labels ────────────────────────────────────────────────────────────
const WMO_EN: Record<number, string> = {
0:'Clear',1:'Mainly Clear',2:'Partly Cloudy',3:'Overcast',
45:'Fog',48:'Icy Fog',51:'Lt Drizzle',53:'Drizzle',55:'Hvy Drizzle',
61:'Lt Rain',63:'Rain',65:'Hvy Rain',71:'Lt Snow',73:'Snow',75:'Hvy Snow',
77:'Snow Grains',80:'Showers',81:'Mod Showers',82:'Hvy Showers',
85:'Snow Showers',86:'Hvy Snow',95:'Thunderstorm',96:'T-Storm+Hail',99:'T-Storm+Hail',
};
const WMO_DE: Record<number, string> = {
0:'Klar',1:'Meist klar',2:'Halbbedeckt',3:'Bedeckt',45:'Nebel',48:'Raureif',
51:'Nieselregen',53:'Nieselregen',55:'Stark Niesel',61:'Leic Regen',63:'Regen',
65:'Stark Regen',71:'Leic Schnee',73:'Schnee',75:'Stark Schnee',77:'Körner',
80:'Schauer',81:'Schauer',82:'Hvy Schauer',85:'Schneeschauer',86:'Schneeschauer',
95:'Gewitter',96:'Gewitter+Hagel',99:'Gewitter+Hagel',
};
// ── Types ─────────────────────────────────────────────────────────────────────
type TaskItem = {
id: string;
title: string | null;
startTime: string | null;
endTime: string | null;
completed: boolean;
project: { name: string; color: string | null } | null;
};
type DayData = {
allDay: TaskItem[];
timed: TaskItem[];
};
type WeatherDay = { maxTemp: number | null; code: number | null; };
// User-specific style settings passed to the PDF
type UserStyle = {
weekdayColor: string; // e.g. "#0ea5e9"
dateColor: string; // e.g. "#888888"
headlineFontWeight: string; // e.g. "900"
useOswald: boolean; // whether Oswald was successfully registered
};
// ── PDF Document ──────────────────────────────────────────────────────────────
function WeekCalendarPDF({
pages,
tasksByDay,
weatherByDay,
startHour,
endHour,
showAllDay,
de,
userName,
userStyle,
totalPages,
}: {
pages: Date[][];
tasksByDay: Map<string, DayData>;
weatherByDay: Map<string, WeatherDay>;
startHour: number;
endHour: number;
showAllDay: boolean;
de: boolean;
userName: string;
userStyle: UserStyle;
totalPages: number;
}) {
const todayKey = new Date().toLocaleDateString('en-CA');
const locale = de ? 'de-DE' : 'en-GB';
const numHours = endHour - startHour;
const totalMins = numHours * 60;
// Headline font (Oswald if available, else Helvetica-Bold)
const headlineFont = userStyle.useOswald ? 'Oswald' : 'Helvetica-Bold';
const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica';
const headlineFW = userStyle.useOswald
? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400)
: undefined;
// GRID_H = remaining space after headers
const GRID_H = CONTENT_H - DAY_HDR_H - (showAllDay ? ALL_DAY_H : 0);
function minutesToY(mins: number): number {
return Math.max(0, (mins / totalMins) * GRID_H);
}
const exportedOn = new Date().toLocaleDateString(locale, {
day: '2-digit', month: 'long', year: 'numeric',
});
return React.createElement(Document,
{ title: de ? 'Wochenplan' : 'Week View' },
...pages.map((days, pageIdx) => {
const numDays = days.length;
const dayColW = (USABLE_W - TIME_COL_W) / numDays;
// Responsive day-name size
const dayNameSize = numDays <= 3 ? 16 : numDays <= 5 ? 12 : 9;
const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + GRID_H;
const firstDay = days[0];
const lastDay = days[days.length - 1];
const sameYear = firstDay.getFullYear() === lastDay.getFullYear();
const rangeLabel = sameYear
? `${firstDay.toLocaleDateString(locale, { day: '2-digit', month: 'short' })} ${lastDay.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })}`
: `${firstDay.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })} ${lastDay.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })}`;
return React.createElement(Page, {
key: `p${pageIdx}`,
size: 'A4',
orientation: 'landscape',
style: {
fontFamily: 'Helvetica',
fontSize: 8,
color: '#1e293b',
paddingTop: PAD_V,
paddingBottom: PAD_V + FOOTER_H,
paddingHorizontal: PAD_H,
backgroundColor: WHITE,
flexDirection: 'column',
},
},
// ── Calendar grid: [time col] [day cols...]
React.createElement(View, {
style: {
flexDirection: 'row',
height: calendarH,
borderWidth: 1,
borderColor: BORDER,
borderRadius: 4,
overflow: 'hidden',
},
},
// ── Left time column
React.createElement(View, {
style: {
width: TIME_COL_W,
flexDirection: 'column',
flexShrink: 0,
borderRightWidth: 1,
borderRightColor: BORDER,
backgroundColor: '#f8fafc',
},
},
// 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
...(showAllDay ? [
React.createElement(View, {
style: {
height: ALL_DAY_H,
borderBottomWidth: 1,
borderBottomColor: BORDER,
backgroundColor: ALLDAY_BG,
alignItems: 'flex-end',
justifyContent: 'center',
paddingRight: 4,
},
},
React.createElement(Text, {
style: {
fontSize: 5.5,
fontFamily: 'Helvetica-Bold',
color: '#3b82f6',
textTransform: 'uppercase',
letterSpacing: 0.3,
},
}, de ? 'Ganztag' : 'All Day'),
),
] : []),
// Hour labels (absolute within container)
React.createElement(View, { style: { height: GRID_H, position: 'relative' } },
...Array.from({ length: numHours + 1 }, (_, i) => {
if (startHour + i > endHour) return null;
const y = minutesToY(i * 60);
return React.createElement(Text, {
key: `hl${i}`,
style: {
position: 'absolute',
top: Math.max(y - 4, 0),
right: 4,
fontFamily: 'Helvetica-Bold',
fontSize: 6.5,
color: MUTED,
textAlign: 'right',
},
}, fmtHour(startHour + i));
}).filter(Boolean),
),
),
// ── Day columns
...days.map((d) => {
const dk = d.toLocaleDateString('en-CA');
const isToday = dk === todayKey;
const dayData = tasksByDay.get(dk);
const weather = weatherByDay.get(dk);
const wLabel = weather?.code != null
? `${de ? (WMO_DE[weather.code] || '') : (WMO_EN[weather.code] || '')}${weather.maxTemp != null ? ` ${Math.round(weather.maxTemp)}°` : ''}`
: null;
const allDayTasks = dayData?.allDay || [];
const timedTasks = dayData?.timed || [];
const dayName = weekdayLabel(d, de, numDays);
const dateStr = dayDateLabel(d, de);
const todayColBg = '#f0f7ff';
return React.createElement(View, {
key: dk,
style: {
flex: 1,
flexDirection: 'column',
borderRightWidth: 1,
borderRightColor: BORDER,
},
},
// ── Day header (colored weekday name + date, matching webapp)
React.createElement(View, {
style: {
height: DAY_HDR_H,
backgroundColor: isToday ? '#e0eeff' : WHITE,
borderBottomWidth: 1,
borderBottomColor: isToday ? '#bfdbfe' : BORDER,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 3,
},
},
React.createElement(Text, {
style: {
fontFamily: headlineFontFamily,
fontWeight: headlineFW,
fontSize: dayNameSize,
color: isToday ? '#6366f1' : userStyle.weekdayColor,
textAlign: 'center',
letterSpacing: 0.5,
lineHeight: 1.1,
},
}, dayName),
React.createElement(Text, {
style: {
fontSize: 7.5,
color: isToday ? '#6366f1' : userStyle.dateColor,
textAlign: 'center',
marginTop: 2,
fontFamily: 'Helvetica',
},
}, dateStr),
wLabel ? React.createElement(Text, {
style: { fontSize: 6, color: MUTED, textAlign: 'center', marginTop: 1 },
}, wLabel) : null,
),
// ── All-day cell
...(showAllDay ? [
React.createElement(View, {
style: {
height: ALL_DAY_H,
backgroundColor: ALLDAY_BG,
borderBottomWidth: 1,
borderBottomColor: '#bfdbfe',
paddingHorizontal: 3,
paddingTop: 2,
overflow: 'hidden',
},
},
...allDayTasks.slice(0, 2).map(t =>
React.createElement(View, {
key: t.id,
style: {
borderRadius: 2,
paddingVertical: 1,
paddingHorizontal: 3,
marginBottom: 1.5,
backgroundColor: t.completed ? DONE_BG : (t.project?.color || '#3b82f6'),
},
},
React.createElement(Text, {
style: {
fontFamily: 'Helvetica-Bold',
fontSize: 6.5,
color: t.completed ? MUTED : WHITE,
},
}, (t.title || '').slice(0, 38)),
)
),
allDayTasks.length > 2 ? React.createElement(Text, {
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
}, `+${allDayTasks.length - 2} ${de ? 'weitere' : 'more'}`) : null,
),
] : []),
// ── Time grid
React.createElement(View, {
style: {
height: GRID_H,
position: 'relative',
overflow: 'hidden',
backgroundColor: isToday ? todayColBg : WHITE,
},
},
// Alternating hour stripe
...Array.from({ length: numHours }, (_, i) => {
if (i % 2 === 0) return null;
return React.createElement(View, {
key: `s${i}`,
style: {
position: 'absolute',
top: minutesToY(i * 60),
left: 0, right: 0,
height: minutesToY(60),
backgroundColor: isToday ? '#e8f2ff' : STRIPE,
},
});
}).filter(Boolean),
// Hour separator lines
...Array.from({ length: numHours - 1 }, (_, i) =>
React.createElement(View, {
key: `hl${i}`,
style: {
position: 'absolute',
top: minutesToY((i + 1) * 60),
left: 0, right: 0,
height: 1,
backgroundColor: BORDER,
},
})
),
// Half-hour tick lines
...Array.from({ length: numHours }, (_, i) =>
React.createElement(View, {
key: `hh${i}`,
style: {
position: 'absolute',
top: minutesToY(i * 60 + 30),
left: 0, right: 0,
height: 1,
backgroundColor: '#f0f4f8',
},
})
),
// Task blocks (absolute, spanning actual duration)
...timedTasks.map(t => {
const startMins = parseTimeMinutes(t.startTime)!;
const endMins = t.endTime
? parseTimeMinutes(t.endTime)!
: startMins + 60;
const gridStart = startHour * 60;
const gridEnd = endHour * 60;
const csMin = Math.max(startMins, gridStart);
const ceMin = Math.min(endMins, gridEnd);
if (csMin >= ceMin) return null;
const topPx = minutesToY(csMin - gridStart);
const rawH = minutesToY(ceMin - csMin);
const taskH = Math.max(rawH, 14);
const color = t.completed ? DONE_BG : (t.project?.color || '#6366f1');
const textColor = t.completed ? MUTED : WHITE;
const hasMinutes = t.startTime && !t.startTime.endsWith(':00');
const isShort = (ceMin - csMin) <= 30;
return React.createElement(View, {
key: t.id,
style: {
position: 'absolute',
top: topPx + 1,
left: 2,
right: 2,
height: taskH - 2,
backgroundColor: color,
borderRadius: 3,
paddingHorizontal: 4,
paddingVertical: 2,
overflow: 'hidden',
},
},
React.createElement(Text, {
style: {
fontFamily: 'Helvetica-Bold',
fontSize: 7,
color: textColor,
lineHeight: 1.25,
},
}, (t.title || '').slice(0, 44)),
// Show time when block is short or has non-zero minutes
(hasMinutes || isShort) && t.startTime
? React.createElement(Text, {
style: { fontSize: 6, color: t.completed ? MUTED : 'rgba(255,255,255,0.82)', marginTop: 0.5 },
}, fmtTime(t.startTime))
: null,
);
}).filter(Boolean),
), // end time grid
); // end day column
}), // end days.map
), // end calendar grid
// ── Footer (absolute)
React.createElement(View, {
style: {
position: 'absolute',
bottom: PAD_V,
left: PAD_H,
right: PAD_H,
flexDirection: 'row',
justifyContent: 'space-between',
borderTopWidth: 1,
borderTopColor: BORDER,
paddingTop: 3,
},
fixed: true,
},
React.createElement(Text, {
style: { fontSize: 7, color: '#94a3b8' },
}, `${de ? 'Wochenplan' : 'Week View'}${rangeLabel}${userName ? ` · ${userName}` : ''}`),
React.createElement(Text, {
style: { fontSize: 7, color: '#94a3b8' },
render: ({ pageNumber, totalPages: tp }: { pageNumber: number; totalPages: number }) =>
`${pageNumber} / ${tp}`,
}),
),
); // end Page
})
);
}
// Week number helper
function getWeekNumber(d: Date): number {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}
// ── Route handler ─────────────────────────────────────────────────────────────
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return new NextResponse('Unauthorized', { status: 401 });
const { searchParams } = new URL(request.url);
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
const startHour = Math.max(0, Math.min(23, parseInt(searchParams.get('startHour') || '8', 10)));
const endHour = Math.max(startHour + 1, Math.min(24, parseInt(searchParams.get('endHour') || '18', 10)));
const lang = searchParams.get('lang') || 'de';
const de = lang === 'de';
const showAllDay = searchParams.get('showAllDay') !== '0';
const showNoTime = searchParams.get('showNoTime') !== '0';
const showWeather = searchParams.get('showWeather') === '1';
if (!startDate || !endDate) return new NextResponse('Missing startDate or endDate', { status: 400 });
try {
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: {
id: true, name: true, email: true,
timezone: true,
weekdayColor: true, dateColor: true,
headlineFontWeight: true,
weatherLat: true, weatherLon: true,
},
});
if (!user) return new NextResponse('User not found', { status: 404 });
const userTz = (user as any).timezone || 'UTC';
// Register Oswald font (best effort)
await ensureFonts();
const oswaldAvailable = Font.getRegisteredFontFamilies?.()?.includes?.('Oswald') ?? false;
const userStyle: UserStyle = {
weekdayColor: (user as any).weekdayColor || '#0ea5e9',
dateColor: (user as any).dateColor || '#888888',
headlineFontWeight: (user as any).headlineFontWeight || '900',
useOswald: oswaldAvailable,
};
// ── Fetch tasks (expanded range for timezone safety)
const startDt = new Date(startDate + 'T00:00:00.000Z');
startDt.setTime(startDt.getTime() - 24 * 60 * 60 * 1000);
const endDt = new Date(endDate + 'T23:59:59.999Z');
endDt.setTime(endDt.getTime() + 24 * 60 * 60 * 1000);
const tasks = await prisma.task.findMany({
where: {
userId: user.id,
deletedAt: null,
scheduledDate: { gte: startDt, lte: endDt },
},
include: { project: { select: { name: true, color: true } } },
orderBy: [{ scheduledDate: 'asc' }, { startTime: 'asc' }],
});
// ── Group by user-local date
const tasksByDay = new Map<string, DayData>();
for (const task of tasks) {
if (!task.scheduledDate) continue;
const dk = localDateStr(task.scheduledDate, userTz);
if (dk < startDate || dk > endDate) continue;
if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], timed: [] });
const d = tasksByDay.get(dk)!;
const item: TaskItem = {
id: task.id, title: task.title, startTime: task.startTime,
endTime: task.endTime ?? null, completed: task.completed, project: task.project,
};
if (!task.startTime) {
if (showNoTime) d.allDay.push(item);
} else {
const h = parseInt(task.startTime.split(':')[0], 10);
if (h >= startHour && h < endHour) {
d.timed.push(item);
} else if (showNoTime) {
d.allDay.push(item);
}
}
}
// ── Weather
const weatherByDay = new Map<string, WeatherDay>();
if (showWeather && (user as any).weatherLat && (user as any).weatherLon) {
try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${(user as any).weatherLat}&longitude=${(user as any).weatherLon}&daily=temperature_2m_max,weather_code&start_date=${startDate}&end_date=${endDate}&timezone=auto`;
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
if (res.ok) {
const raw = await res.json();
const dates: string[] = raw.daily?.time || [];
const temps: number[] = raw.daily?.temperature_2m_max || [];
const codes: number[] = raw.daily?.weather_code || [];
dates.forEach((d, i) => weatherByDay.set(d, { maxTemp: temps[i] ?? null, code: codes[i] ?? null }));
}
} catch { /* silent */ }
}
// ── Build pages & render
const pages = groupIntoPages(
new Date(startDate + 'T00:00:00'),
new Date(endDate + 'T00:00:00'),
);
const pdfBuffer = await renderToBuffer(
React.createElement(WeekCalendarPDF, {
pages, tasksByDay, weatherByDay,
startHour, endHour, showAllDay, de,
userName: user.name || user.email || '',
userStyle,
totalPages: pages.length,
}) as React.ReactElement<any>
);
const filename = de
? `wochenplan_${startDate}_${endDate}.pdf`
: `week-view_${startDate}_${endDate}.pdf`;
return new NextResponse(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
} catch (error) {
console.error('Week view PDF error:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@ -0,0 +1,621 @@
"use client";
import React, { useState, useMemo, useCallback } from "react";
import { X, Download, Printer, Eye } from "lucide-react";
// ── Types ───────────────────────────────────────────────────────────────────
interface WeekPrintModalProps {
isOpen: boolean;
onClose: () => void;
currentWeekStart: Date; // Monday of currently displayed week
viewDays: number; // days shown in current view
startHour: number; // user's configured start hour
endHour: number; // user's configured end hour
language: string; // 'de' | 'en'
weatherEnabled: boolean; // whether weather is configured
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function toDateInput(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${dd}`;
}
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function getDayName(d: Date, lang: string): string {
return d.toLocaleDateString(lang === "de" ? "de-DE" : "en-GB", { weekday: "short" });
}
function getDateLabel(d: Date, lang: string): string {
return d.toLocaleDateString(lang === "de" ? "de-DE" : "en-GB", { day: "2-digit", month: "short" });
}
function getWeekNumber(d: Date): number {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}
// ── Sample task positions for preview ───────────────────────────────────────
const SAMPLE_TASKS = [
{ dayIdx: 0, hourOffset: 0, color: "#6366f1", w: 0.8 },
{ dayIdx: 2, hourOffset: 1, color: "#10b981", w: 0.65 },
{ dayIdx: 1, hourOffset: 2, color: "#f59e0b", w: 0.75 },
{ dayIdx: 3, hourOffset: 0, color: "#ef4444", w: 0.55 },
{ dayIdx: 4, hourOffset: 3, color: "#6366f1", w: 0.9 },
{ dayIdx: 0, hourOffset: 4, color: "#8b5cf6", w: 0.6 },
{ dayIdx: 5, hourOffset: 1, color: "#06b6d4", w: 0.7 },
{ dayIdx: 3, hourOffset: 2, color: "#10b981", w: 0.5 },
{ dayIdx: 6, hourOffset: 0, color: "#f59e0b", w: 0.8 },
{ dayIdx: 2, hourOffset: 4, color: "#ef4444", w: 0.6 },
];
// ── Toggle component ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 36, height: 20, borderRadius: 10,
backgroundColor: checked && !disabled ? "#6366f1" : disabled ? "#d1d5db" : "#d1d5db",
border: "none", cursor: disabled ? "not-allowed" : "pointer",
position: "relative", transition: "background-color 0.2s", flexShrink: 0,
opacity: disabled ? 0.5 : 1,
}}
>
<span style={{
position: "absolute", top: 2, left: checked ? 18 : 2,
width: 16, height: 16, borderRadius: "50%", backgroundColor: "#fff",
transition: "left 0.2s", boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}} />
</button>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export default function WeekPrintModal({
isOpen,
onClose,
currentWeekStart,
viewDays,
startHour: defaultStartHour,
endHour: defaultEndHour,
language,
weatherEnabled,
}: WeekPrintModalProps) {
const de = language === "de";
// ── State
const [startDate, setStartDate] = useState(() => toDateInput(currentWeekStart));
const [numDays, setNumDays] = useState(() => Math.min(Math.max(viewDays, 1), 14));
const [startHour, setStartHour] = useState(() => defaultStartHour);
const [endHour, setEndHour] = useState(() => defaultEndHour);
const [showAllDay, setShowAllDay] = useState(true);
const [showNoTime, setShowNoTime] = useState(true);
const [showWeather, setShowWeather] = useState(weatherEnabled);
const [lang, setLang] = useState(language || "de");
// ── Computed
const endDate = useMemo(() => {
const d = new Date(startDate + "T00:00:00");
return toDateInput(addDays(d, numDays - 1));
}, [startDate, numDays]);
const previewDays = useMemo(() => {
const days: Date[] = [];
const d = new Date(startDate + "T00:00:00");
for (let i = 0; i < Math.min(numDays, 7); i++) {
days.push(addDays(d, i));
}
return days;
}, [startDate, numDays]);
const previewHours = useMemo(() => {
const range = endHour - startHour;
const step = range <= 6 ? 1 : range <= 10 ? 2 : 3;
const hrs: number[] = [];
for (let h = startHour; h < endHour; h += step) hrs.push(h);
return hrs;
}, [startHour, endHour]);
const numPages = useMemo(() => Math.ceil(numDays / 7), [numDays]);
const pdfUrl = useMemo(() => {
const params = new URLSearchParams({
startDate,
endDate,
startHour: String(startHour),
endHour: String(endHour),
lang,
showAllDay: showAllDay ? "1" : "0",
showNoTime: showNoTime ? "1" : "0",
showWeather: showWeather ? "1" : "0",
});
return `/api/user/export-week-view?${params}`;
}, [startDate, endDate, startHour, endHour, lang, showAllDay, showNoTime, showWeather]);
const handleStartDateChange = useCallback((val: string) => {
setStartDate(val);
}, []);
const handleNumDaysChange = useCallback((val: number) => {
setNumDays(Math.min(Math.max(val, 1), 14));
}, []);
const handleStartHourChange = useCallback((val: number) => {
const clamped = Math.min(Math.max(val, 0), 23);
setStartHour(clamped);
if (endHour <= clamped) setEndHour(Math.min(clamped + 1, 24));
}, [endHour]);
const handleEndHourChange = useCallback((val: number) => {
const clamped = Math.min(Math.max(val, 1), 24);
setEndHour(clamped);
if (startHour >= clamped) setStartHour(Math.max(clamped - 1, 0));
}, [startHour]);
if (!isOpen) return null;
const today = toDateInput(new Date());
const numHours = endHour - startHour;
// ── i18n
const t = {
title: de ? "Woche drucken / Exportieren" : "Print / Export Week View",
subtitle: de ? "Als PDF exportieren oder im Browser drucken" : "Export as PDF or print in browser",
dateRange: de ? "Datum" : "Date",
startDateLbl: de ? "Startdatum" : "Start date",
numDaysLbl: de ? "Anzahl Tage" : "Number of days",
timeRange: de ? "Zeitraum" : "Time range",
fromHour: de ? "Von" : "From",
toHour: de ? "Bis" : "To",
display: de ? "Anzeigen" : "Display",
showAllDay: de ? "Ganztag-Bereich" : "All-day section",
showNoTime: de ? "Aufgaben ohne Uhrzeit" : "Tasks without time",
showWeather: de ? "Wetter anzeigen" : "Show weather",
weatherNote: de ? "(Wetterstandort muss in Einstellungen konfiguriert sein)" : "(Weather location must be configured in settings)",
previewTitle: de ? "Vorschau" : "Preview",
pages: de ? "Seiten" : "pages",
hours: de ? "Std." : "hrs",
days: de ? "Tage" : "days",
language: de ? "Sprache" : "Language",
cancel: de ? "Abbrechen" : "Cancel",
download: de ? "PDF herunterladen" : "Download PDF",
kw: de ? "KW" : "W",
};
return (
<div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[200] px-4 py-6"
onClick={onClose}
>
<div
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-2xl flex flex-col overflow-hidden"
style={{ maxHeight: "92vh" }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ background: "#6366f1", padding: "20px 24px", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{
width: 38, height: 38, borderRadius: 10,
background: "rgba(255,255,255,0.2)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<Printer size={20} color="#fff" />
</div>
<div>
<div style={{ color: "#fff", fontWeight: 700, fontSize: "1rem" }}>{t.title}</div>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "0.78rem", marginTop: 2 }}>{t.subtitle}</div>
</div>
</div>
<button
onClick={onClose}
style={{ background: "rgba(255,255,255,0.2)", border: "none", borderRadius: 8, padding: "6px 8px", cursor: "pointer", color: "#fff", display: "flex" }}
>
<X size={18} />
</button>
</div>
</div>
{/* Body — scrollable */}
<div style={{ overflowY: "auto", flex: 1, padding: "20px 24px", display: "flex", flexDirection: "column", gap: 20 }}>
{/* ── Date & Days */}
<div>
<SectionLabel>{t.dateRange}</SectionLabel>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<FieldLabel>{t.startDateLbl}</FieldLabel>
<input
type="date"
value={startDate}
onChange={e => handleStartDateChange(e.target.value)}
style={{
width: "100%", fontSize: "0.88rem", padding: "7px 10px",
borderRadius: 6, border: "1px solid #d1d5db",
background: "#fff", color: "#374151", fontFamily: "inherit",
boxSizing: "border-box",
}}
/>
</div>
<div>
<FieldLabel>{t.numDaysLbl}</FieldLabel>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<input
type="range"
min={1}
max={14}
value={numDays}
onChange={e => handleNumDaysChange(parseInt(e.target.value))}
style={{ flex: 1, accentColor: "#6366f1" }}
/>
<span style={{
minWidth: 42, textAlign: "center", fontWeight: 700,
fontSize: "0.88rem", color: "#6366f1",
background: "#eff6ff", borderRadius: 6, padding: "3px 8px",
}}>
{numDays}
</span>
</div>
<div style={{ fontSize: "0.73rem", color: "#94a3b8", marginTop: 3 }}>
{endDate} · {numPages > 1 ? `${numPages} ${t.pages}` : "1 PDF"}
</div>
</div>
</div>
</div>
{/* ── Time range */}
<div>
<SectionLabel>{t.timeRange}</SectionLabel>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<FieldLabel>{t.fromHour}</FieldLabel>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="range"
min={0}
max={22}
value={startHour}
onChange={e => handleStartHourChange(parseInt(e.target.value))}
style={{ flex: 1, accentColor: "#6366f1" }}
/>
<HourBadge>{String(startHour).padStart(2, "0")}:00</HourBadge>
</div>
</div>
<div>
<FieldLabel>{t.toHour}</FieldLabel>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="range"
min={1}
max={24}
value={endHour}
onChange={e => handleEndHourChange(parseInt(e.target.value))}
style={{ flex: 1, accentColor: "#6366f1" }}
/>
<HourBadge>{endHour < 24 ? `${String(endHour).padStart(2, "0")}:00` : "24:00"}</HourBadge>
</div>
</div>
</div>
<div style={{ fontSize: "0.73rem", color: "#94a3b8", marginTop: 4 }}>
{numHours} {t.hours} · {String(startHour).padStart(2, "0")}:00 {endHour < 24 ? String(endHour).padStart(2, "0") : "24"}:00
</div>
</div>
{/* ── Display toggles */}
<div>
<SectionLabel>{t.display}</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<ToggleRow
label={t.showAllDay}
checked={showAllDay}
onChange={setShowAllDay}
/>
<ToggleRow
label={t.showNoTime}
checked={showNoTime}
onChange={setShowNoTime}
/>
<div>
<ToggleRow
label={t.showWeather}
checked={showWeather}
onChange={setShowWeather}
disabled={!weatherEnabled}
/>
{!weatherEnabled && (
<div style={{ fontSize: "0.72rem", color: "#94a3b8", marginTop: 3, paddingLeft: 44 }}>
{t.weatherNote}
</div>
)}
</div>
</div>
</div>
{/* ── Language */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<span style={{ fontSize: "0.82rem", fontWeight: 600, color: "#374151" }}>{t.language}</span>
{["de", "en"].map(l => (
<label key={l} style={{ display: "flex", alignItems: "center", gap: 5, cursor: "pointer", fontSize: "0.85rem" }}>
<input
type="radio"
name="lang"
value={l}
checked={lang === l}
onChange={() => setLang(l)}
style={{ accentColor: "#6366f1" }}
/>
{l === "de" ? "Deutsch" : "English"}
</label>
))}
</div>
{/* ── Preview */}
<div>
<SectionLabel>
<Eye size={13} style={{ marginRight: 5, verticalAlign: "middle" }} />
{t.previewTitle}
</SectionLabel>
<CalendarPreview
days={previewDays}
hours={previewHours}
startHour={startHour}
showAllDay={showAllDay}
lang={lang}
numDays={numDays}
/>
{numDays > 7 && (
<div style={{ fontSize: "0.72rem", color: "#94a3b8", marginTop: 6, textAlign: "center" }}>
{de ? `${numDays} Tage → ${numPages} PDF-Seiten (je 7 Tage)` : `${numDays} days → ${numPages} PDF pages (7 days each)`}
</div>
)}
</div>
</div>
{/* Footer */}
<div style={{
padding: "14px 24px",
borderTop: "1px solid #e5e7eb",
display: "flex",
gap: 10,
justifyContent: "flex-end",
flexShrink: 0,
}}>
<button
onClick={onClose}
style={{
padding: "9px 18px", borderRadius: 8, border: "1px solid #e5e7eb",
background: "#fff", cursor: "pointer", fontWeight: 500, fontSize: "0.88rem", color: "#374151",
}}
>
{t.cancel}
</button>
<a
href={pdfUrl}
target="_blank"
rel="noreferrer"
style={{
padding: "9px 20px", borderRadius: 8, border: "none",
background: "#6366f1", color: "#fff", fontWeight: 600,
fontSize: "0.88rem", textDecoration: "none",
display: "flex", alignItems: "center", gap: 7, cursor: "pointer",
}}
>
<Download size={15} />
{t.download}
</a>
</div>
</div>
</div>
);
}
// ── Sub-components ───────────────────────────────────────────────────────────
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: "0.75rem", fontWeight: 700, textTransform: "uppercase",
letterSpacing: "0.06em", color: "#6b7280", marginBottom: 10,
display: "flex", alignItems: "center",
}}>
{children}
</div>
);
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{ fontSize: "0.78rem", fontWeight: 600, color: "#374151", marginBottom: 5 }}>
{children}
</div>
);
}
function HourBadge({ children }: { children: React.ReactNode }) {
return (
<span style={{
minWidth: 48, textAlign: "center", fontWeight: 700,
fontSize: "0.82rem", color: "#6366f1",
background: "#eff6ff", borderRadius: 6, padding: "3px 6px",
fontVariantNumeric: "tabular-nums", flexShrink: 0,
}}>
{children}
</span>
);
}
function ToggleRow({
label, checked, onChange, disabled,
}: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Toggle checked={checked} onChange={onChange} disabled={disabled} />
<span style={{ fontSize: "0.85rem", color: disabled ? "#94a3b8" : "#374151" }}>{label}</span>
</div>
);
}
// ── Calendar preview ─────────────────────────────────────────────────────────
function CalendarPreview({
days, hours, startHour, showAllDay, lang, numDays,
}: {
days: Date[];
hours: number[];
startHour: number;
showAllDay: boolean;
lang: string;
numDays: number;
}) {
const PREVIEW_H = 180;
const PREVIEW_TIME_W = 28;
const today = toDateInput(new Date());
return (
<div style={{
border: "1px solid #e2e8f0",
borderRadius: 8,
overflow: "hidden",
background: "#fff",
fontSize: 9,
userSelect: "none",
height: PREVIEW_H,
display: "flex",
flexDirection: "column",
}}>
{/* Aspect ratio indicator + day headers */}
<div style={{
display: "flex", flexShrink: 0,
background: "#f1f5f9", borderBottom: "1px solid #e2e8f0",
}}>
{/* time spacer */}
<div style={{ width: PREVIEW_TIME_W, borderRight: "1px solid #e2e8f0", flexShrink: 0 }} />
{days.map((d, i) => {
const dk = toDateInput(d);
const isToday = dk === today;
return (
<div key={i} style={{
flex: 1, textAlign: "center", padding: "4px 2px",
borderRight: "1px solid #e2e8f0",
background: isToday ? "#dbeafe" : undefined,
}}>
<div style={{ fontWeight: 700, fontSize: 8, color: "#6b7280", lineHeight: 1 }}>
{getDayName(d, lang).toUpperCase()}
</div>
<div style={{ fontWeight: 700, fontSize: 9, color: isToday ? "#6366f1" : "#1e293b", marginTop: 1, lineHeight: 1 }}>
{getDateLabel(d, lang)}
</div>
</div>
);
})}
{/* placeholder if numDays > 7 */}
{numDays > 7 && (
<div style={{ width: 18, flexShrink: 0, background: "#f1f5f9", display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ color: "#94a3b8", fontSize: 9 }}></span>
</div>
)}
</div>
{/* All-day row */}
{showAllDay && (
<div style={{
display: "flex", flexShrink: 0,
background: "#f0f4ff", borderBottom: "1px solid #e2e8f0",
height: 16,
}}>
<div style={{
width: PREVIEW_TIME_W, borderRight: "1px solid #e2e8f0",
display: "flex", alignItems: "center", justifyContent: "flex-end",
paddingRight: 3, flexShrink: 0,
}}>
<span style={{ fontSize: 6, color: "#94a3b8", fontWeight: 700 }}>
{lang === "de" ? "GT" : "AD"}
</span>
</div>
{days.map((_, i) => (
<div key={i} style={{
flex: 1, borderRight: "1px solid #e2e8f0",
display: "flex", alignItems: "center", padding: "1px 2px",
}}>
{SAMPLE_TASKS.filter(t => t.dayIdx === i && t.hourOffset === 0).slice(0, 1).map((t, j) => (
<div key={j} style={{
background: t.color, borderRadius: 2, height: 8,
width: `${t.w * 80}%`, opacity: 0.8,
}} />
))}
</div>
))}
</div>
)}
{/* Hour rows */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{hours.map((h, hi) => (
<div key={h} style={{
flex: 1, display: "flex",
borderBottom: hi < hours.length - 1 ? "1px solid #e2e8f0" : undefined,
background: hi % 2 === 1 ? "#f8fafc" : "#fff",
minHeight: 0,
}}>
{/* Time label */}
<div style={{
width: PREVIEW_TIME_W, borderRight: "1px solid #e2e8f0",
display: "flex", alignItems: "flex-start", justifyContent: "flex-end",
paddingRight: 3, paddingTop: 2, flexShrink: 0,
}}>
<span style={{ fontSize: 7, color: "#94a3b8", fontWeight: 700 }}>
{String(h).padStart(2, "0")}
</span>
</div>
{/* Day cells */}
{days.map((_, di) => {
// Show sample tasks: map hourOffset to preview rows
const tasks = SAMPLE_TASKS.filter(
t => t.dayIdx === di && t.hourOffset === hi
);
const dk = toDateInput(days[di] ?? new Date());
const isToday = dk === today;
return (
<div key={di} style={{
flex: 1, borderRight: "1px solid #e2e8f0",
padding: "2px 2px 1px",
background: isToday ? "#eff6ff" : undefined,
overflow: "hidden",
}}>
{tasks.slice(0, 2).map((t, j) => (
<div key={j} style={{
background: t.color, borderRadius: 2,
height: 9, width: `${t.w * 90}%`,
marginBottom: 1, opacity: 0.85,
}} />
))}
</div>
);
})}
</div>
))}
</div>
</div>
);
}

View File

@ -88,6 +88,7 @@ import {
CornerUpRight,
Archive,
Bot,
Printer,
} from "lucide-react";
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim();
@ -97,6 +98,7 @@ import UserMenu from "./UserMenu";
import SearchModal from "./SearchModal";
import SimpleDatePicker from "./SimpleDatePicker";
import RecurringTasksManager from "./RecurringTasksManager";
import WeekPrintModal from "./WeekPrintModal";
export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; }
import { ImportListModal } from "./ImportListModal";
import OnboardingWizard from "./OnboardingWizard";
@ -884,6 +886,7 @@ export default function WeeklyView() {
>("general");
const [exportStartDate, setExportStartDate] = useState("");
const [exportEndDate, setExportEndDate] = useState("");
const [showWeekPrintModal, setShowWeekPrintModal] = useState(false);
const [passwords, setPasswords] = useState({ new: "", confirm: "" });
const [accountMsg, setAccountMsg] = useState<string>("");
const [importingTasksState, setImportingTasksState] =
@ -6220,6 +6223,7 @@ export default function WeeklyView() {
{/* Desktop only: Recurring Tasks + New Project */}
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowProjectsSidebar(true)} title="New Project"><FolderPlus size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowWeekPrintModal(true)} title={profile.language === "de" ? "Woche drucken / exportieren" : "Print / export week"}><Printer size={17} /></button>
{/* User Menu */}
<UserMenu
@ -9230,6 +9234,18 @@ export default function WeeklyView() {
isLoading={isFetchingLists}
/>
{/* Week Print / PDF Export Modal */}
<WeekPrintModal
isOpen={showWeekPrintModal}
onClose={() => setShowWeekPrintModal(false)}
currentWeekStart={currentWeekStart}
viewDays={viewDays}
startHour={startHour}
endHour={endHour}
language={profile.language || "de"}
weatherEnabled={!!(profile.weatherEnabled && profile.weatherLat && profile.weatherLon)}
/>
{/* Mobile: Floating Action Button with quick menu */}
{isMobile && !showMobileFabSheet && !showSettings && !showFocusMode && (
<>