fix: improve PDF export layout and task block styling

- Separate all-day (external calendar events) from anyday (user tasks
  without time) using externalProvider field; each gets its own strip
- Increase ALL_DAY_H from 22→38px and add NO_TIME_H=28px so both rows
  are actually visible
- Task blocks switch from solid fill to light-tinted background + colored
  left border (Google Calendar style) — much easier to read in print
- Use user's taskColor setting as fallback instead of hardcoded indigo
- Show up to 3 items in all-day strip, 2 in anyday strip with overflow count
- GRID_H now accounts for both optional strips in height calculation

v1.95.2
This commit is contained in:
mARTin 2026-04-13 09:08:16 +02:00
parent 7ab6dd5aa8
commit 786ccf6921

View File

@ -52,7 +52,8 @@ const FOOTER_H = 18;
// Fixed structural heights // Fixed structural heights
const DAY_HDR_H = 40; // column header: weekday name + date const DAY_HDR_H = 40; // column header: weekday name + date
const ALL_DAY_H = 22; // optional all-day strip const ALL_DAY_H = 38; // all-day strip (external calendar events)
const NO_TIME_H = 28; // anyday strip (user tasks without a time)
const TIME_COL_W = 32; // left time-label column const TIME_COL_W = 32; // left time-label column
// Derived // Derived
@ -60,12 +61,26 @@ const CONTENT_H = PH - PAD_V * 2 - FOOTER_H; // ≈ 549.28
const USABLE_W = PW - PAD_H * 2; // ≈ 801.89 const USABLE_W = PW - PAD_H * 2; // ≈ 801.89
// Fixed colours // Fixed colours
const WHITE = '#ffffff'; const WHITE = '#ffffff';
const BORDER = '#e2e8f0'; const BORDER = '#e2e8f0';
const MUTED = '#64748b'; const MUTED = '#64748b';
const DONE_BG = '#cbd5e1'; const DONE_BG = '#e2e8f0';
const STRIPE = '#f9fafb'; const DONE_TEXT = '#94a3b8';
const STRIPE = '#f9fafb';
const ALLDAY_BG = '#eff6ff'; const ALLDAY_BG = '#eff6ff';
const NOTIME_BG = '#fafaf9';
// Lighten a hex color toward white (amount 01)
function lighten(hex: string, amount: number): string {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const lr = Math.round(r + (255 - r) * amount);
const lg = Math.round(g + (255 - g) * amount);
const lb = Math.round(b + (255 - b) * amount);
return `#${lr.toString(16).padStart(2,'0')}${lg.toString(16).padStart(2,'0')}${lb.toString(16).padStart(2,'0')}`;
}
// ── Weekday label helpers ───────────────────────────────────────────────────── // ── Weekday label helpers ─────────────────────────────────────────────────────
@ -156,12 +171,14 @@ type TaskItem = {
startTime: string | null; startTime: string | null;
endTime: string | null; endTime: string | null;
completed: boolean; completed: boolean;
externalProvider: string | null;
project: { name: string; color: string | null } | null; project: { name: string; color: string | null } | null;
}; };
type DayData = { type DayData = {
allDay: TaskItem[]; allDay: TaskItem[]; // external all-day calendar events (showAllDay)
timed: TaskItem[]; noTime: TaskItem[]; // user tasks with no time (showNoTime)
timed: TaskItem[];
}; };
type WeatherDay = { maxTemp: number | null; code: number | null; }; type WeatherDay = { maxTemp: number | null; code: number | null; };
@ -172,6 +189,7 @@ type UserStyle = {
weekendColorSat: string; // e.g. "#ffc107" weekendColorSat: string; // e.g. "#ffc107"
weekendColorSun: string; // e.g. "#dc2626" weekendColorSun: string; // e.g. "#dc2626"
dateColor: string; // e.g. "#888888" dateColor: string; // e.g. "#888888"
taskColor: string; // default task block color when no project
headlineFontWeight: string; // e.g. "900" headlineFontWeight: string; // e.g. "900"
useOswald: boolean; // whether Oswald was successfully registered useOswald: boolean; // whether Oswald was successfully registered
}; };
@ -193,6 +211,7 @@ function WeekCalendarPDF({
startHour, startHour,
endHour, endHour,
showAllDay, showAllDay,
showNoTime,
de, de,
userName, userName,
userStyle, userStyle,
@ -204,6 +223,7 @@ function WeekCalendarPDF({
startHour: number; startHour: number;
endHour: number; endHour: number;
showAllDay: boolean; showAllDay: boolean;
showNoTime: boolean;
de: boolean; de: boolean;
userName: string; userName: string;
userStyle: UserStyle; userStyle: UserStyle;
@ -215,14 +235,15 @@ function WeekCalendarPDF({
const totalMins = numHours * 60; const totalMins = numHours * 60;
// Headline font (Oswald if available, else Helvetica-Bold) // Headline font (Oswald if available, else Helvetica-Bold)
const headlineFont = userStyle.useOswald ? 'Oswald' : 'Helvetica-Bold';
const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica'; const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica';
const headlineFW = userStyle.useOswald const headlineFW = userStyle.useOswald
? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400) ? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400)
: undefined; : undefined;
// GRID_H = remaining space after headers // GRID_H = remaining space after headers and optional strips
const GRID_H = CONTENT_H - DAY_HDR_H - (showAllDay ? ALL_DAY_H : 0); const GRID_H = CONTENT_H - DAY_HDR_H
- (showAllDay ? ALL_DAY_H : 0)
- (showNoTime ? NO_TIME_H : 0);
function minutesToY(mins: number): number { function minutesToY(mins: number): number {
return Math.max(0, (mins / totalMins) * GRID_H); return Math.max(0, (mins / totalMins) * GRID_H);
@ -242,7 +263,7 @@ function WeekCalendarPDF({
// Responsive day-name size // Responsive day-name size
const dayNameSize = numDays <= 3 ? 16 : numDays <= 5 ? 12 : 9; const dayNameSize = numDays <= 3 ? 16 : numDays <= 5 ? 12 : 9;
const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + GRID_H; const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + (showNoTime ? NO_TIME_H : 0) + GRID_H;
const firstDay = days[0]; const firstDay = days[0];
const lastDay = days[days.length - 1]; const lastDay = days[days.length - 1];
@ -313,11 +334,12 @@ function WeekCalendarPDF({
style: { style: {
height: ALL_DAY_H, height: ALL_DAY_H,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: BORDER, borderBottomColor: '#bfdbfe',
backgroundColor: ALLDAY_BG, backgroundColor: ALLDAY_BG,
alignItems: 'flex-end', alignItems: 'flex-end',
justifyContent: 'center', justifyContent: 'flex-start',
paddingRight: 4, paddingRight: 4,
paddingTop: 3,
}, },
}, },
React.createElement(Text, { React.createElement(Text, {
@ -332,6 +354,32 @@ function WeekCalendarPDF({
), ),
] : []), ] : []),
// Anyday label spacer
...(showNoTime ? [
React.createElement(View, {
style: {
height: NO_TIME_H,
borderBottomWidth: 1,
borderBottomColor: BORDER,
backgroundColor: NOTIME_BG,
alignItems: 'flex-end',
justifyContent: 'flex-start',
paddingRight: 4,
paddingTop: 3,
},
},
React.createElement(Text, {
style: {
fontSize: 5.5,
fontFamily: 'Helvetica-Bold',
color: MUTED,
textTransform: 'uppercase',
letterSpacing: 0.3,
},
}, de ? 'Jederzeit' : 'Any Day'),
),
] : []),
// Hour labels (absolute within container) // Hour labels (absolute within container)
React.createElement(View, { style: { height: GRID_H, position: 'relative' } }, React.createElement(View, { style: { height: GRID_H, position: 'relative' } },
...Array.from({ length: numHours + 1 }, (_, i) => { ...Array.from({ length: numHours + 1 }, (_, i) => {
@ -363,8 +411,9 @@ function WeekCalendarPDF({
? `${de ? (WMO_DE[weather.code] || '') : (WMO_EN[weather.code] || '')}${weather.maxTemp != null ? ` ${Math.round(weather.maxTemp)}°` : ''}` ? `${de ? (WMO_DE[weather.code] || '') : (WMO_EN[weather.code] || '')}${weather.maxTemp != null ? ` ${Math.round(weather.maxTemp)}°` : ''}`
: null; : null;
const allDayTasks = dayData?.allDay || []; const allDayTasks = dayData?.allDay || [];
const timedTasks = dayData?.timed || []; const noTimeTasks = dayData?.noTime || [];
const timedTasks = dayData?.timed || [];
const dayName = weekdayLabel(d, de, numDays); const dayName = weekdayLabel(d, de, numDays);
const dateStr = dayDateLabel(d, de); const dateStr = dayDateLabel(d, de);
@ -418,7 +467,7 @@ function WeekCalendarPDF({
}, wLabel) : null, }, wLabel) : null,
), ),
// ── All-day cell // ── All-day cell (external calendar events)
...(showAllDay ? [ ...(showAllDay ? [
React.createElement(View, { React.createElement(View, {
style: { style: {
@ -427,33 +476,78 @@ function WeekCalendarPDF({
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#bfdbfe', borderBottomColor: '#bfdbfe',
paddingHorizontal: 3, paddingHorizontal: 3,
paddingTop: 2, paddingTop: 3,
overflow: 'hidden', overflow: 'hidden',
}, },
}, },
...allDayTasks.slice(0, 2).map(t => ...allDayTasks.slice(0, 3).map(t => {
React.createElement(View, { const c = t.completed ? DONE_BG : (t.project?.color || '#3b82f6');
return React.createElement(View, {
key: t.id, key: t.id,
style: { style: {
borderRadius: 2, borderRadius: 2,
paddingVertical: 1, paddingVertical: 1.5,
paddingHorizontal: 3, paddingHorizontal: 4,
marginBottom: 1.5, marginBottom: 2,
backgroundColor: t.completed ? DONE_BG : (t.project?.color || '#3b82f6'), backgroundColor: t.completed ? DONE_BG : lighten(c, 0.7),
borderLeftWidth: 3,
borderLeftColor: c,
}, },
}, },
React.createElement(Text, { React.createElement(Text, {
style: { style: {
fontFamily: 'Helvetica-Bold', fontFamily: 'Helvetica-Bold',
fontSize: 6.5, fontSize: 6.5,
color: t.completed ? MUTED : WHITE, color: t.completed ? DONE_TEXT : '#1e293b',
}, },
}, (t.title || '').slice(0, 38)), }, (t.title || '').slice(0, 36)),
) );
), }),
allDayTasks.length > 2 ? React.createElement(Text, { allDayTasks.length > 3 ? React.createElement(Text, {
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 }, style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
}, `+${allDayTasks.length - 2} ${de ? 'weitere' : 'more'}`) : null, }, `+${allDayTasks.length - 3} ${de ? 'weitere' : 'more'}`) : null,
),
] : []),
// ── No-time cell (anyday tasks)
...(showNoTime ? [
React.createElement(View, {
style: {
height: NO_TIME_H,
backgroundColor: NOTIME_BG,
borderBottomWidth: 1,
borderBottomColor: BORDER,
paddingHorizontal: 3,
paddingTop: 3,
overflow: 'hidden',
},
},
...noTimeTasks.slice(0, 2).map(t => {
const c = t.completed ? DONE_BG : (t.project?.color || userStyle.taskColor);
return React.createElement(View, {
key: t.id,
style: {
borderRadius: 2,
paddingVertical: 1.5,
paddingHorizontal: 4,
marginBottom: 2,
backgroundColor: t.completed ? DONE_BG : lighten(c, 0.75),
borderLeftWidth: 3,
borderLeftColor: c,
},
},
React.createElement(Text, {
style: {
fontFamily: 'Helvetica-Bold',
fontSize: 6.5,
color: t.completed ? DONE_TEXT : '#1e293b',
},
}, (t.title || '').slice(0, 36)),
);
}),
noTimeTasks.length > 2 ? React.createElement(Text, {
style: { fontSize: 6, color: MUTED, marginTop: 1 },
}, `+${noTimeTasks.length - 2} ${de ? 'weitere' : 'more'}`) : null,
), ),
] : []), ] : []),
@ -510,6 +604,7 @@ function WeekCalendarPDF({
), ),
// Task blocks (absolute, spanning actual duration) // Task blocks (absolute, spanning actual duration)
// Style: light tinted background + colored left border (print-friendly)
...timedTasks.map(t => { ...timedTasks.map(t => {
const startMins = parseTimeMinutes(t.startTime)!; const startMins = parseTimeMinutes(t.startTime)!;
const endMins = t.endTime const endMins = t.endTime
@ -526,10 +621,14 @@ function WeekCalendarPDF({
const rawH = minutesToY(ceMin - csMin); const rawH = minutesToY(ceMin - csMin);
const taskH = Math.max(rawH, 14); const taskH = Math.max(rawH, 14);
const color = t.completed ? DONE_BG : (t.project?.color || '#6366f1'); const accentColor = t.completed
const textColor = t.completed ? MUTED : WHITE; ? DONE_BG
const hasMinutes = t.startTime && !t.startTime.endsWith(':00'); : (t.project?.color || userStyle.taskColor);
const isShort = (ceMin - csMin) <= 30; const bgColor = t.completed ? DONE_BG : lighten(accentColor, 0.78);
const textColor = t.completed ? DONE_TEXT : '#1e293b';
const subColor = t.completed ? DONE_TEXT : accentColor;
const hasMinutes = t.startTime && !t.startTime.endsWith(':00');
const isShort = (ceMin - csMin) <= 30;
return React.createElement(View, { return React.createElement(View, {
key: t.id, key: t.id,
@ -539,9 +638,12 @@ function WeekCalendarPDF({
left: 2, left: 2,
right: 2, right: 2,
height: taskH - 2, height: taskH - 2,
backgroundColor: color, backgroundColor: bgColor,
borderRadius: 3, borderRadius: 3,
paddingHorizontal: 4, borderLeftWidth: 3,
borderLeftColor: accentColor,
paddingLeft: 4,
paddingRight: 3,
paddingVertical: 2, paddingVertical: 2,
overflow: 'hidden', overflow: 'hidden',
}, },
@ -554,10 +656,9 @@ function WeekCalendarPDF({
lineHeight: 1.25, lineHeight: 1.25,
}, },
}, (t.title || '').slice(0, 44)), }, (t.title || '').slice(0, 44)),
// Show time when block is short or has non-zero minutes
(hasMinutes || isShort) && t.startTime (hasMinutes || isShort) && t.startTime
? React.createElement(Text, { ? React.createElement(Text, {
style: { fontSize: 6, color: t.completed ? MUTED : 'rgba(255,255,255,0.82)', marginTop: 0.5 }, style: { fontSize: 6, color: subColor, marginTop: 0.5 },
}, fmtTime(t.startTime)) }, fmtTime(t.startTime))
: null, : null,
); );
@ -634,7 +735,7 @@ export async function GET(request: Request) {
id: true, name: true, email: true, id: true, name: true, email: true,
timezone: true, timezone: true,
weekdayColor: true, weekendColorSat: true, weekendColorSun: true, weekdayColor: true, weekendColorSat: true, weekendColorSun: true,
dateColor: true, headlineFontWeight: true, dateColor: true, taskColor: true, headlineFontWeight: true,
weatherLat: true, weatherLon: true, weatherLat: true, weatherLon: true,
}, },
}); });
@ -651,6 +752,7 @@ export async function GET(request: Request) {
weekendColorSat: (user as any).weekendColorSat || '#ffc107', weekendColorSat: (user as any).weekendColorSat || '#ffc107',
weekendColorSun: (user as any).weekendColorSun || '#dc2626', weekendColorSun: (user as any).weekendColorSun || '#dc2626',
dateColor: (user as any).dateColor || '#888888', dateColor: (user as any).dateColor || '#888888',
taskColor: (user as any).taskColor || '#6366f1',
headlineFontWeight: (user as any).headlineFontWeight || '900', headlineFontWeight: (user as any).headlineFontWeight || '900',
useOswald: oswaldAvailable, useOswald: oswaldAvailable,
}; };
@ -678,21 +780,28 @@ export async function GET(request: Request) {
const dk = localDateStr(task.scheduledDate, userTz); const dk = localDateStr(task.scheduledDate, userTz);
if (dk < startDate || dk > endDate) continue; if (dk < startDate || dk > endDate) continue;
if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], timed: [] }); if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], noTime: [], timed: [] });
const d = tasksByDay.get(dk)!; const d = tasksByDay.get(dk)!;
const item: TaskItem = { const item: TaskItem = {
id: task.id, title: task.title, startTime: task.startTime, id: task.id, title: task.title, startTime: task.startTime,
endTime: task.endTime ?? null, completed: task.completed, project: task.project, endTime: task.endTime ?? null, completed: task.completed,
externalProvider: (task as any).externalProvider ?? null,
project: task.project,
}; };
if (!task.startTime) { if (!task.startTime) {
if (showNoTime) d.allDay.push(item); // External calendar all-day events → allDay strip; user tasks → noTime strip
if (item.externalProvider) {
if (showAllDay) d.allDay.push(item);
} else {
if (showNoTime) d.noTime.push(item);
}
} else { } else {
const h = parseInt(task.startTime.split(':')[0], 10); const h = parseInt(task.startTime.split(':')[0], 10);
if (h >= startHour && h < endHour) { if (h >= startHour && h < endHour) {
d.timed.push(item); d.timed.push(item);
} else if (showNoTime) { } else if (showNoTime) {
d.allDay.push(item); d.noTime.push(item); // tasks outside the time range → anyday strip
} }
} }
} }
@ -722,7 +831,7 @@ export async function GET(request: Request) {
const pdfBuffer = await renderToBuffer( const pdfBuffer = await renderToBuffer(
React.createElement(WeekCalendarPDF, { React.createElement(WeekCalendarPDF, {
pages, tasksByDay, weatherByDay, pages, tasksByDay, weatherByDay,
startHour, endHour, showAllDay, de, startHour, endHour, showAllDay, showNoTime, de,
userName: user.name || user.email || '', userName: user.name || user.email || '',
userStyle, userStyle,
totalPages: pages.length, totalPages: pages.length,