diff --git a/package.json b/package.json index 598a2b7..1d374fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.95.2", + "version": "1.95.3", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/user/export-week-view/route.ts b/src/app/api/user/export-week-view/route.ts index cfeadf2..d5725d9 100644 --- a/src/app/api/user/export-week-view/route.ts +++ b/src/app/api/user/export-week-view/route.ts @@ -10,6 +10,7 @@ import { // ── Font registration (Oswald from Google Fonts, one-time per process) ─────── let _fontPromise: Promise | null = null; +let _oswaldRegistered = false; // tracked explicitly — Font has no public getRegisteredFamilies API function ensureFonts(): Promise { if (!_fontPromise) { @@ -23,7 +24,6 @@ function ensureFonts(): Promise { } ); 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; @@ -32,9 +32,10 @@ function ensureFonts(): Promise { } if (entries.length > 0) { Font.register({ family: 'Oswald', fonts: entries }); + _oswaldRegistered = true; } } catch { - // No Oswald — PDF will use Helvetica-Bold as fallback + // Falls back to Helvetica-Bold } })(); } @@ -51,9 +52,9 @@ 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 = 38; // all-day strip (external calendar events) -const NO_TIME_H = 28; // anyday strip (user tasks without a time) +const DAY_HDR_H = 44; // column header: weekday name + date +const ALL_DAY_H = 40; // all-day strip (external calendar events) +const NO_TIME_H = 52; // anyday strip (user tasks without a time, shows ~4) const TIME_COL_W = 32; // left time-label column // Derived @@ -84,17 +85,12 @@ function lighten(hex: string, amount: number): string { // ── 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']; +const WDAY_LONG_DE = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; +const WDAY_LONG_EN = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; -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(); +// Always use full names — columns are wide enough even at 7 days +function weekdayLabel(d: Date, de: boolean): string { + return (de ? WDAY_LONG_DE : WDAY_LONG_EN)[d.getDay()].toUpperCase(); } function dayDateLabel(d: Date, de: boolean): string { @@ -204,6 +200,12 @@ function dayColor(d: Date, isToday: boolean, us: UserStyle): string { // ── PDF Document ────────────────────────────────────────────────────────────── +type SomedayList = { + id: string; + title: string; + tasks: { id: string; title: string; completed: boolean; project: { name: string; color: string | null } | null }[]; +}; + function WeekCalendarPDF({ pages, tasksByDay, @@ -215,6 +217,7 @@ function WeekCalendarPDF({ de, userName, userStyle, + somedayLists, totalPages, }: { pages: Date[][]; @@ -227,6 +230,7 @@ function WeekCalendarPDF({ de: boolean; userName: string; userStyle: UserStyle; + somedayLists: SomedayList[]; totalPages: number; }) { const todayKey = new Date().toLocaleDateString('en-CA'); @@ -234,8 +238,8 @@ function WeekCalendarPDF({ const numHours = endHour - startHour; const totalMins = numHours * 60; - // Headline font (Oswald if available, else Helvetica-Bold) - const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica'; + // Headline font: Oswald if loaded, else Helvetica-Bold (NOT plain Helvetica) + const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica-Bold'; const headlineFW = userStyle.useOswald ? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400) : undefined; @@ -260,8 +264,8 @@ function WeekCalendarPDF({ 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; + // Adaptive font size — full names always, size scales with available column width + const dayNameSize = numDays <= 2 ? 16 : numDays <= 3 ? 13 : numDays <= 5 ? 10 : 8.5; const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + (showNoTime ? NO_TIME_H : 0) + GRID_H; @@ -415,7 +419,7 @@ function WeekCalendarPDF({ const noTimeTasks = dayData?.noTime || []; const timedTasks = dayData?.timed || []; - const dayName = weekdayLabel(d, de, numDays); + const dayName = weekdayLabel(d, de); const dateStr = dayDateLabel(d, de); const todayColBg = '#f0f7ff'; @@ -480,7 +484,7 @@ function WeekCalendarPDF({ overflow: 'hidden', }, }, - ...allDayTasks.slice(0, 3).map(t => { + ...allDayTasks.slice(0, 4).map(t => { const c = t.completed ? DONE_BG : (t.project?.color || '#3b82f6'); return React.createElement(View, { key: t.id, @@ -503,9 +507,9 @@ function WeekCalendarPDF({ }, (t.title || '').slice(0, 36)), ); }), - allDayTasks.length > 3 ? React.createElement(Text, { + allDayTasks.length > 4 ? React.createElement(Text, { style: { fontSize: 6, color: '#3b82f6', marginTop: 1 }, - }, `+${allDayTasks.length - 3} ${de ? 'weitere' : 'more'}`) : null, + }, `+${allDayTasks.length - 4} ${de ? 'weitere' : 'more'}`) : null, ), ] : []), @@ -522,7 +526,7 @@ function WeekCalendarPDF({ overflow: 'hidden', }, }, - ...noTimeTasks.slice(0, 2).map(t => { + ...noTimeTasks.slice(0, 4).map(t => { const c = t.completed ? DONE_BG : (t.project?.color || userStyle.taskColor); return React.createElement(View, { key: t.id, @@ -545,9 +549,9 @@ function WeekCalendarPDF({ }, (t.title || '').slice(0, 36)), ); }), - noTimeTasks.length > 2 ? React.createElement(Text, { + noTimeTasks.length > 4 ? React.createElement(Text, { style: { fontSize: 6, color: MUTED, marginTop: 1 }, - }, `+${noTimeTasks.length - 2} ${de ? 'weitere' : 'more'}`) : null, + }, `+${noTimeTasks.length - 4} ${de ? 'weitere' : 'more'}`) : null, ), ] : []), @@ -696,7 +700,143 @@ function WeekCalendarPDF({ ), ); // end Page - }) + }), + + // ── Someday page (portrait A4, one column per list) + ...(somedayLists.length > 0 && showNoTime ? [ + React.createElement(Page, { + key: 'someday', + size: 'A4', + orientation: 'portrait', + style: { + fontFamily: 'Helvetica', + fontSize: 8, + color: '#1e293b', + padding: 28, + backgroundColor: WHITE, + flexDirection: 'column', + }, + }, + // Title + React.createElement(View, { + style: { + borderBottomWidth: 1, + borderBottomColor: BORDER, + paddingBottom: 6, + marginBottom: 12, + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', + }, + }, + React.createElement(Text, { + style: { + fontFamily: headlineFontFamily, + fontWeight: headlineFW, + fontSize: 16, + color: userStyle.weekdayColor, + letterSpacing: 0.5, + }, + }, (de ? 'Irgendwann' : 'Someday').toUpperCase()), + React.createElement(Text, { + style: { fontSize: 7, color: '#94a3b8' }, + }, userName), + ), + + // Lists in columns (flex wrap) + React.createElement(View, { + style: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + }, + ...somedayLists.map(list => + React.createElement(View, { + key: list.id, + style: { + width: '30%', + marginBottom: 12, + }, + }, + // List name + React.createElement(View, { + style: { + borderLeftWidth: 3, + borderLeftColor: list.tasks[0]?.project?.color || userStyle.weekdayColor, + paddingLeft: 6, + marginBottom: 5, + }, + }, + React.createElement(Text, { + style: { + fontFamily: 'Helvetica-Bold', + fontSize: 8, + color: '#374151', + textTransform: 'uppercase', + letterSpacing: 0.4, + }, + }, list.title), + ), + + // Task rows + ...list.tasks.slice(0, 20).map(t => + React.createElement(View, { + key: t.id, + style: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: 2.5, + paddingHorizontal: 4, + marginBottom: 1, + borderRadius: 2, + backgroundColor: '#f8fafc', + }, + }, + React.createElement(View, { + style: { + width: 6, height: 6, borderRadius: 3, + borderWidth: 1, + borderColor: t.project?.color || MUTED, + marginRight: 5, + marginTop: 0.5, + flexShrink: 0, + }, + }), + React.createElement(Text, { + style: { fontSize: 7.5, color: '#374151', lineHeight: 1.3, flex: 1 }, + }, (t.title || '').slice(0, 55)), + ) + ), + list.tasks.length > 20 ? React.createElement(Text, { + style: { fontSize: 6.5, color: MUTED, marginTop: 2, paddingLeft: 4 }, + }, `+${list.tasks.length - 20} ${de ? 'weitere' : 'more'}`) : null, + ) + ), + ), + + // Footer + React.createElement(View, { + style: { + position: 'absolute', + bottom: 20, left: 28, right: 28, + borderTopWidth: 1, borderTopColor: BORDER, + paddingTop: 3, + flexDirection: 'row', justifyContent: 'space-between', + }, + fixed: true, + }, + React.createElement(Text, { + style: { fontSize: 7, color: '#94a3b8' }, + }, `${de ? 'Irgendwann' : 'Someday'} · ${userName}`), + React.createElement(Text, { + style: { fontSize: 7, color: '#94a3b8' }, + render: ({ pageNumber, totalPages: tp }: { pageNumber: number; totalPages: number }) => + `${pageNumber} / ${tp}`, + }), + ), + ), + ] : []), ); } @@ -743,9 +883,8 @@ export async function GET(request: Request) { const userTz = (user as any).timezone || 'UTC'; - // Register Oswald font (best effort) + // Register Oswald font (best effort) — use module-level flag, not Font's internal API await ensureFonts(); - const oswaldAvailable = Font.getRegisteredFontFamilies?.()?.includes?.('Oswald') ?? false; const userStyle: UserStyle = { weekdayColor: (user as any).weekdayColor || '#0ea5e9', @@ -754,7 +893,7 @@ export async function GET(request: Request) { dateColor: (user as any).dateColor || '#888888', taskColor: (user as any).taskColor || '#6366f1', headlineFontWeight: (user as any).headlineFontWeight || '900', - useOswald: oswaldAvailable, + useOswald: _oswaldRegistered, }; // ── Fetch tasks (expanded range for timezone safety) @@ -822,6 +961,34 @@ export async function GET(request: Request) { } catch { /* silent */ } } + // ── Fetch someday list tasks (unscheduled, in named lists) + type SomedayListWithTasks = { + id: string; + title: string; + tasks: { id: string; title: string; completed: boolean; project: { name: string; color: string | null } | null }[]; + }; + let somedayLists: SomedayListWithTasks[] = []; + if (showNoTime) { + const raw = await prisma.somedayList.findMany({ + where: { userId: user.id }, + orderBy: { order: 'asc' }, + select: { + id: true, + title: true, + tasks: { + where: { deletedAt: null, completed: false }, + orderBy: { order: 'asc' }, + select: { + id: true, title: true, completed: true, + project: { select: { name: true, color: true } }, + }, + take: 60, + }, + }, + }); + somedayLists = raw.filter(l => l.tasks.length > 0); + } + // ── Build pages & render const pages = groupIntoPages( new Date(startDate + 'T00:00:00'), @@ -834,6 +1001,7 @@ export async function GET(request: Request) { startHour, endHour, showAllDay, showNoTime, de, userName: user.name || user.email || '', userStyle, + somedayLists, totalPages: pages.length, }) as React.ReactElement );