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:
parent
075ccdba04
commit
f75bd6ebe5
@ -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": {
|
||||
|
||||
734
src/app/api/user/export-week-view/route.ts
Normal file
734
src/app/api/user/export-week-view/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
621
src/components/WeekPrintModal.tsx
Normal file
621
src/components/WeekPrintModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 && (
|
||||
<>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user