diff --git a/package.json b/package.json index 0747a40..b6b4b82 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/api/user/export-week-view/route.ts b/src/app/api/user/export-week-view/route.ts new file mode 100644 index 0000000..6686127 --- /dev/null +++ b/src/app/api/user/export-week-view/route.ts @@ -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 | null = null; + +function ensureFonts(): Promise { + 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 = { + 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 = { + 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; + weatherByDay: Map; + 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(); + 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(); + 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 + ); + + 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 }); + } +} diff --git a/src/components/WeekPrintModal.tsx b/src/components/WeekPrintModal.tsx new file mode 100644 index 0000000..b73156f --- /dev/null +++ b/src/components/WeekPrintModal.tsx @@ -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 ( + + ); +} + +// ── 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+
+ +
+
+
{t.title}
+
{t.subtitle}
+
+
+ +
+
+ + {/* Body — scrollable */} +
+ + {/* ── Date & Days */} +
+ {t.dateRange} +
+
+ {t.startDateLbl} + 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", + }} + /> +
+
+ {t.numDaysLbl} +
+ handleNumDaysChange(parseInt(e.target.value))} + style={{ flex: 1, accentColor: "#6366f1" }} + /> + + {numDays} + +
+
+ {endDate} · {numPages > 1 ? `${numPages} ${t.pages}` : "1 PDF"} +
+
+
+
+ + {/* ── Time range */} +
+ {t.timeRange} +
+
+ {t.fromHour} +
+ handleStartHourChange(parseInt(e.target.value))} + style={{ flex: 1, accentColor: "#6366f1" }} + /> + {String(startHour).padStart(2, "0")}:00 +
+
+
+ {t.toHour} +
+ handleEndHourChange(parseInt(e.target.value))} + style={{ flex: 1, accentColor: "#6366f1" }} + /> + {endHour < 24 ? `${String(endHour).padStart(2, "0")}:00` : "24:00"} +
+
+
+
+ {numHours} {t.hours} · {String(startHour).padStart(2, "0")}:00 – {endHour < 24 ? String(endHour).padStart(2, "0") : "24"}:00 +
+
+ + {/* ── Display toggles */} +
+ {t.display} +
+ + +
+ + {!weatherEnabled && ( +
+ {t.weatherNote} +
+ )} +
+
+
+ + {/* ── Language */} +
+ {t.language} + {["de", "en"].map(l => ( + + ))} +
+ + {/* ── Preview */} +
+ + + {t.previewTitle} + + + {numDays > 7 && ( +
+ {de ? `${numDays} Tage → ${numPages} PDF-Seiten (je 7 Tage)` : `${numDays} days → ${numPages} PDF pages (7 days each)`} +
+ )} +
+
+ + {/* Footer */} +
+ + + + {t.download} + +
+
+
+ ); +} + +// ── Sub-components ─────────────────────────────────────────────────────────── + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function HourBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function ToggleRow({ + label, checked, onChange, disabled, +}: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { + return ( +
+ + {label} +
+ ); +} + +// ── 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 ( +
+ {/* Aspect ratio indicator + day headers */} +
+ {/* time spacer */} +
+ {days.map((d, i) => { + const dk = toDateInput(d); + const isToday = dk === today; + return ( +
+
+ {getDayName(d, lang).toUpperCase()} +
+
+ {getDateLabel(d, lang)} +
+
+ ); + })} + {/* placeholder if numDays > 7 */} + {numDays > 7 && ( +
+ +
+ )} +
+ + {/* All-day row */} + {showAllDay && ( +
+
+ + {lang === "de" ? "GT" : "AD"} + +
+ {days.map((_, i) => ( +
+ {SAMPLE_TASKS.filter(t => t.dayIdx === i && t.hourOffset === 0).slice(0, 1).map((t, j) => ( +
+ ))} +
+ ))} +
+ )} + + {/* Hour rows */} +
+ {hours.map((h, hi) => ( +
+ {/* Time label */} +
+ + {String(h).padStart(2, "0")} + +
+ {/* 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 ( +
+ {tasks.slice(0, 2).map((t, j) => ( +
+ ))} +
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index ad909ed..96d7b05 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -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(""); const [importingTasksState, setImportingTasksState] = @@ -6220,6 +6223,7 @@ export default function WeeklyView() { {/* Desktop only: Recurring Tasks + New Project */} + {/* User Menu */} + {/* Week Print / PDF Export Modal */} + 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 && ( <>