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:
parent
7ab6dd5aa8
commit
786ccf6921
@ -52,7 +52,8 @@ 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 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
|
||||
|
||||
// Derived
|
||||
@ -63,9 +64,23 @@ const USABLE_W = PW - PAD_H * 2; // ≈ 801.89
|
||||
const WHITE = '#ffffff';
|
||||
const BORDER = '#e2e8f0';
|
||||
const MUTED = '#64748b';
|
||||
const DONE_BG = '#cbd5e1';
|
||||
const DONE_BG = '#e2e8f0';
|
||||
const DONE_TEXT = '#94a3b8';
|
||||
const STRIPE = '#f9fafb';
|
||||
const ALLDAY_BG = '#eff6ff';
|
||||
const NOTIME_BG = '#fafaf9';
|
||||
|
||||
// Lighten a hex color toward white (amount 0–1)
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
@ -156,11 +171,13 @@ type TaskItem = {
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
completed: boolean;
|
||||
externalProvider: string | null;
|
||||
project: { name: string; color: string | null } | null;
|
||||
};
|
||||
|
||||
type DayData = {
|
||||
allDay: TaskItem[];
|
||||
allDay: TaskItem[]; // external all-day calendar events (showAllDay)
|
||||
noTime: TaskItem[]; // user tasks with no time (showNoTime)
|
||||
timed: TaskItem[];
|
||||
};
|
||||
|
||||
@ -172,6 +189,7 @@ type UserStyle = {
|
||||
weekendColorSat: string; // e.g. "#ffc107"
|
||||
weekendColorSun: string; // e.g. "#dc2626"
|
||||
dateColor: string; // e.g. "#888888"
|
||||
taskColor: string; // default task block color when no project
|
||||
headlineFontWeight: string; // e.g. "900"
|
||||
useOswald: boolean; // whether Oswald was successfully registered
|
||||
};
|
||||
@ -193,6 +211,7 @@ function WeekCalendarPDF({
|
||||
startHour,
|
||||
endHour,
|
||||
showAllDay,
|
||||
showNoTime,
|
||||
de,
|
||||
userName,
|
||||
userStyle,
|
||||
@ -204,6 +223,7 @@ function WeekCalendarPDF({
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
showAllDay: boolean;
|
||||
showNoTime: boolean;
|
||||
de: boolean;
|
||||
userName: string;
|
||||
userStyle: UserStyle;
|
||||
@ -215,14 +235,15 @@ function WeekCalendarPDF({
|
||||
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);
|
||||
// GRID_H = remaining space after headers and optional strips
|
||||
const GRID_H = CONTENT_H - DAY_HDR_H
|
||||
- (showAllDay ? ALL_DAY_H : 0)
|
||||
- (showNoTime ? NO_TIME_H : 0);
|
||||
|
||||
function minutesToY(mins: number): number {
|
||||
return Math.max(0, (mins / totalMins) * GRID_H);
|
||||
@ -242,7 +263,7 @@ function WeekCalendarPDF({
|
||||
// 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 calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + (showNoTime ? NO_TIME_H : 0) + GRID_H;
|
||||
|
||||
const firstDay = days[0];
|
||||
const lastDay = days[days.length - 1];
|
||||
@ -313,11 +334,12 @@ function WeekCalendarPDF({
|
||||
style: {
|
||||
height: ALL_DAY_H,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: BORDER,
|
||||
borderBottomColor: '#bfdbfe',
|
||||
backgroundColor: ALLDAY_BG,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
paddingRight: 4,
|
||||
paddingTop: 3,
|
||||
},
|
||||
},
|
||||
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)
|
||||
React.createElement(View, { style: { height: GRID_H, position: 'relative' } },
|
||||
...Array.from({ length: numHours + 1 }, (_, i) => {
|
||||
@ -364,6 +412,7 @@ function WeekCalendarPDF({
|
||||
: null;
|
||||
|
||||
const allDayTasks = dayData?.allDay || [];
|
||||
const noTimeTasks = dayData?.noTime || [];
|
||||
const timedTasks = dayData?.timed || [];
|
||||
|
||||
const dayName = weekdayLabel(d, de, numDays);
|
||||
@ -418,7 +467,7 @@ function WeekCalendarPDF({
|
||||
}, wLabel) : null,
|
||||
),
|
||||
|
||||
// ── All-day cell
|
||||
// ── All-day cell (external calendar events)
|
||||
...(showAllDay ? [
|
||||
React.createElement(View, {
|
||||
style: {
|
||||
@ -427,33 +476,78 @@ function WeekCalendarPDF({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#bfdbfe',
|
||||
paddingHorizontal: 3,
|
||||
paddingTop: 2,
|
||||
paddingTop: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
...allDayTasks.slice(0, 2).map(t =>
|
||||
React.createElement(View, {
|
||||
...allDayTasks.slice(0, 3).map(t => {
|
||||
const c = t.completed ? DONE_BG : (t.project?.color || '#3b82f6');
|
||||
return 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'),
|
||||
paddingVertical: 1.5,
|
||||
paddingHorizontal: 4,
|
||||
marginBottom: 2,
|
||||
backgroundColor: t.completed ? DONE_BG : lighten(c, 0.7),
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: c,
|
||||
},
|
||||
},
|
||||
React.createElement(Text, {
|
||||
style: {
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
fontSize: 6.5,
|
||||
color: t.completed ? MUTED : WHITE,
|
||||
color: t.completed ? DONE_TEXT : '#1e293b',
|
||||
},
|
||||
}, (t.title || '').slice(0, 38)),
|
||||
)
|
||||
),
|
||||
allDayTasks.length > 2 ? React.createElement(Text, {
|
||||
}, (t.title || '').slice(0, 36)),
|
||||
);
|
||||
}),
|
||||
allDayTasks.length > 3 ? React.createElement(Text, {
|
||||
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)
|
||||
// Style: light tinted background + colored left border (print-friendly)
|
||||
...timedTasks.map(t => {
|
||||
const startMins = parseTimeMinutes(t.startTime)!;
|
||||
const endMins = t.endTime
|
||||
@ -526,8 +621,12 @@ function WeekCalendarPDF({
|
||||
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 accentColor = t.completed
|
||||
? DONE_BG
|
||||
: (t.project?.color || userStyle.taskColor);
|
||||
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;
|
||||
|
||||
@ -539,9 +638,12 @@ function WeekCalendarPDF({
|
||||
left: 2,
|
||||
right: 2,
|
||||
height: taskH - 2,
|
||||
backgroundColor: color,
|
||||
backgroundColor: bgColor,
|
||||
borderRadius: 3,
|
||||
paddingHorizontal: 4,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: accentColor,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 3,
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
@ -554,10 +656,9 @@ function WeekCalendarPDF({
|
||||
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 },
|
||||
style: { fontSize: 6, color: subColor, marginTop: 0.5 },
|
||||
}, fmtTime(t.startTime))
|
||||
: null,
|
||||
);
|
||||
@ -634,7 +735,7 @@ export async function GET(request: Request) {
|
||||
id: true, name: true, email: true,
|
||||
timezone: true,
|
||||
weekdayColor: true, weekendColorSat: true, weekendColorSun: true,
|
||||
dateColor: true, headlineFontWeight: true,
|
||||
dateColor: true, taskColor: true, headlineFontWeight: true,
|
||||
weatherLat: true, weatherLon: true,
|
||||
},
|
||||
});
|
||||
@ -651,6 +752,7 @@ export async function GET(request: Request) {
|
||||
weekendColorSat: (user as any).weekendColorSat || '#ffc107',
|
||||
weekendColorSun: (user as any).weekendColorSun || '#dc2626',
|
||||
dateColor: (user as any).dateColor || '#888888',
|
||||
taskColor: (user as any).taskColor || '#6366f1',
|
||||
headlineFontWeight: (user as any).headlineFontWeight || '900',
|
||||
useOswald: oswaldAvailable,
|
||||
};
|
||||
@ -678,21 +780,28 @@ export async function GET(request: Request) {
|
||||
const dk = localDateStr(task.scheduledDate, userTz);
|
||||
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 item: TaskItem = {
|
||||
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 (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 {
|
||||
const h = parseInt(task.startTime.split(':')[0], 10);
|
||||
if (h >= startHour && h < endHour) {
|
||||
d.timed.push(item);
|
||||
} 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(
|
||||
React.createElement(WeekCalendarPDF, {
|
||||
pages, tasksByDay, weatherByDay,
|
||||
startHour, endHour, showAllDay, de,
|
||||
startHour, endHour, showAllDay, showNoTime, de,
|
||||
userName: user.name || user.email || '',
|
||||
userStyle,
|
||||
totalPages: pages.length,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user