fix: restore full day names, fix Oswald font, add someday page to PDF

- Always use full weekday names (MONTAG, DIENSTAG…) at all column counts;
  adaptive font size 8.5pt–16pt based on number of columns
- Fix Oswald detection: Font.getRegisteredFontFamilies() doesn't exist in
  react-pdf — now use module-level _oswaldRegistered flag set on successful
  Font.register() call, so Oswald actually gets applied
- Fix headline fallback: Helvetica-Bold (not plain Helvetica) when no Oswald
- Increase strip heights: ALL_DAY_H 38→40, NO_TIME_H 28→52, DAY_HDR_H 40→44
- Show 4 tasks in both all-day and anyday strips (was 2/3)
- Add someday page: last page (portrait) with all SomedayList tasks grouped
  by list, shown when showNoTime is enabled

v1.95.3
This commit is contained in:
mARTin 2026-04-13 09:23:21 +02:00
parent 9703d9ffa3
commit 3fd738b185
2 changed files with 199 additions and 31 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -10,6 +10,7 @@ import {
// ── Font registration (Oswald from Google Fonts, one-time per process) ─────── // ── Font registration (Oswald from Google Fonts, one-time per process) ───────
let _fontPromise: Promise<void> | null = null; let _fontPromise: Promise<void> | null = null;
let _oswaldRegistered = false; // tracked explicitly — Font has no public getRegisteredFamilies API
function ensureFonts(): Promise<void> { function ensureFonts(): Promise<void> {
if (!_fontPromise) { if (!_fontPromise) {
@ -23,7 +24,6 @@ function ensureFonts(): Promise<void> {
} }
); );
const css = await res.text(); const css = await res.text();
// Extract all woff2 URLs with their weights
const entries: { src: string; fontWeight: number }[] = []; const entries: { src: string; fontWeight: number }[] = [];
const re = /font-weight:\s*(\d+)[\s\S]*?url\((https:\/\/fonts\.gstatic\.com\/[^)'"]+)\)/g; const re = /font-weight:\s*(\d+)[\s\S]*?url\((https:\/\/fonts\.gstatic\.com\/[^)'"]+)\)/g;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
@ -32,9 +32,10 @@ function ensureFonts(): Promise<void> {
} }
if (entries.length > 0) { if (entries.length > 0) {
Font.register({ family: 'Oswald', fonts: entries }); Font.register({ family: 'Oswald', fonts: entries });
_oswaldRegistered = true;
} }
} catch { } 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; const FOOTER_H = 18;
// Fixed structural heights // Fixed structural heights
const DAY_HDR_H = 40; // column header: weekday name + date const DAY_HDR_H = 44; // column header: weekday name + date
const ALL_DAY_H = 38; // all-day strip (external calendar events) const ALL_DAY_H = 40; // all-day strip (external calendar events)
const NO_TIME_H = 28; // anyday strip (user tasks without a time) const NO_TIME_H = 52; // anyday strip (user tasks without a time, shows ~4)
const TIME_COL_W = 32; // left time-label column const TIME_COL_W = 32; // left time-label column
// Derived // Derived
@ -86,15 +87,10 @@ function lighten(hex: string, amount: number): string {
const WDAY_LONG_DE = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; const WDAY_LONG_DE = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const WDAY_LONG_EN = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; 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 { // Always use full names — columns are wide enough even at 7 days
const idx = d.getDay(); function weekdayLabel(d: Date, de: boolean): string {
// Use full names for ≤5 columns, short for 6-7 return (de ? WDAY_LONG_DE : WDAY_LONG_EN)[d.getDay()].toUpperCase();
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 { function dayDateLabel(d: Date, de: boolean): string {
@ -204,6 +200,12 @@ function dayColor(d: Date, isToday: boolean, us: UserStyle): string {
// ── PDF Document ────────────────────────────────────────────────────────────── // ── PDF Document ──────────────────────────────────────────────────────────────
type SomedayList = {
id: string;
title: string;
tasks: { id: string; title: string; completed: boolean; project: { name: string; color: string | null } | null }[];
};
function WeekCalendarPDF({ function WeekCalendarPDF({
pages, pages,
tasksByDay, tasksByDay,
@ -215,6 +217,7 @@ function WeekCalendarPDF({
de, de,
userName, userName,
userStyle, userStyle,
somedayLists,
totalPages, totalPages,
}: { }: {
pages: Date[][]; pages: Date[][];
@ -227,6 +230,7 @@ function WeekCalendarPDF({
de: boolean; de: boolean;
userName: string; userName: string;
userStyle: UserStyle; userStyle: UserStyle;
somedayLists: SomedayList[];
totalPages: number; totalPages: number;
}) { }) {
const todayKey = new Date().toLocaleDateString('en-CA'); const todayKey = new Date().toLocaleDateString('en-CA');
@ -234,8 +238,8 @@ function WeekCalendarPDF({
const numHours = endHour - startHour; const numHours = endHour - startHour;
const totalMins = numHours * 60; const totalMins = numHours * 60;
// Headline font (Oswald if available, else Helvetica-Bold) // Headline font: Oswald if loaded, else Helvetica-Bold (NOT plain Helvetica)
const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica'; const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica-Bold';
const headlineFW = userStyle.useOswald const headlineFW = userStyle.useOswald
? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400) ? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400)
: undefined; : undefined;
@ -260,8 +264,8 @@ function WeekCalendarPDF({
const numDays = days.length; const numDays = days.length;
const dayColW = (USABLE_W - TIME_COL_W) / numDays; const dayColW = (USABLE_W - TIME_COL_W) / numDays;
// Responsive day-name size // Adaptive font size — full names always, size scales with available column width
const dayNameSize = numDays <= 3 ? 16 : numDays <= 5 ? 12 : 9; 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; 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 noTimeTasks = dayData?.noTime || [];
const timedTasks = dayData?.timed || []; const timedTasks = dayData?.timed || [];
const dayName = weekdayLabel(d, de, numDays); const dayName = weekdayLabel(d, de);
const dateStr = dayDateLabel(d, de); const dateStr = dayDateLabel(d, de);
const todayColBg = '#f0f7ff'; const todayColBg = '#f0f7ff';
@ -480,7 +484,7 @@ function WeekCalendarPDF({
overflow: 'hidden', overflow: 'hidden',
}, },
}, },
...allDayTasks.slice(0, 3).map(t => { ...allDayTasks.slice(0, 4).map(t => {
const c = t.completed ? DONE_BG : (t.project?.color || '#3b82f6'); const c = t.completed ? DONE_BG : (t.project?.color || '#3b82f6');
return React.createElement(View, { return React.createElement(View, {
key: t.id, key: t.id,
@ -503,9 +507,9 @@ function WeekCalendarPDF({
}, (t.title || '').slice(0, 36)), }, (t.title || '').slice(0, 36)),
); );
}), }),
allDayTasks.length > 3 ? React.createElement(Text, { allDayTasks.length > 4 ? React.createElement(Text, {
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 }, 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', 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); const c = t.completed ? DONE_BG : (t.project?.color || userStyle.taskColor);
return React.createElement(View, { return React.createElement(View, {
key: t.id, key: t.id,
@ -545,9 +549,9 @@ function WeekCalendarPDF({
}, (t.title || '').slice(0, 36)), }, (t.title || '').slice(0, 36)),
); );
}), }),
noTimeTasks.length > 2 ? React.createElement(Text, { noTimeTasks.length > 4 ? React.createElement(Text, {
style: { fontSize: 6, color: MUTED, marginTop: 1 }, 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 ); // 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'; 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(); await ensureFonts();
const oswaldAvailable = Font.getRegisteredFontFamilies?.()?.includes?.('Oswald') ?? false;
const userStyle: UserStyle = { const userStyle: UserStyle = {
weekdayColor: (user as any).weekdayColor || '#0ea5e9', weekdayColor: (user as any).weekdayColor || '#0ea5e9',
@ -754,7 +893,7 @@ export async function GET(request: Request) {
dateColor: (user as any).dateColor || '#888888', dateColor: (user as any).dateColor || '#888888',
taskColor: (user as any).taskColor || '#6366f1', taskColor: (user as any).taskColor || '#6366f1',
headlineFontWeight: (user as any).headlineFontWeight || '900', headlineFontWeight: (user as any).headlineFontWeight || '900',
useOswald: oswaldAvailable, useOswald: _oswaldRegistered,
}; };
// ── Fetch tasks (expanded range for timezone safety) // ── Fetch tasks (expanded range for timezone safety)
@ -822,6 +961,34 @@ export async function GET(request: Request) {
} catch { /* silent */ } } 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 // ── Build pages & render
const pages = groupIntoPages( const pages = groupIntoPages(
new Date(startDate + 'T00:00:00'), new Date(startDate + 'T00:00:00'),
@ -834,6 +1001,7 @@ export async function GET(request: Request) {
startHour, endHour, showAllDay, showNoTime, de, startHour, endHour, showAllDay, showNoTime, de,
userName: user.name || user.email || '', userName: user.name || user.email || '',
userStyle, userStyle,
somedayLists,
totalPages: pages.length, totalPages: pages.length,
}) as React.ReactElement<any> }) as React.ReactElement<any>
); );