diff --git a/src/app/api/user/export-week-view/route.ts b/src/app/api/user/export-week-view/route.ts index 75457b2..cfeadf2 100644 --- a/src/app/api/user/export-week-view/route.ts +++ b/src/app/api/user/export-week-view/route.ts @@ -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 @@ -60,12 +61,26 @@ 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 WHITE = '#ffffff'; +const BORDER = '#e2e8f0'; +const MUTED = '#64748b'; +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,12 +171,14 @@ type TaskItem = { startTime: string | null; endTime: string | null; completed: boolean; + externalProvider: string | null; project: { name: string; color: string | null } | null; }; type DayData = { - allDay: TaskItem[]; - timed: TaskItem[]; + allDay: TaskItem[]; // external all-day calendar events (showAllDay) + noTime: TaskItem[]; // user tasks with no time (showNoTime) + timed: TaskItem[]; }; type WeatherDay = { maxTemp: number | null; code: number | null; }; @@ -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) => { @@ -363,8 +411,9 @@ function WeekCalendarPDF({ ? `${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 allDayTasks = dayData?.allDay || []; + const noTimeTasks = dayData?.noTime || []; + const timedTasks = dayData?.timed || []; const dayName = weekdayLabel(d, de, numDays); const dateStr = dayDateLabel(d, de); @@ -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,10 +621,14 @@ 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 hasMinutes = t.startTime && !t.startTime.endsWith(':00'); - const isShort = (ceMin - csMin) <= 30; + 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; return React.createElement(View, { key: t.id, @@ -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,