feat: list-style PDF layout with per-hour weather symbols

Layout — switched from absolute-positioned calendar grid to agenda/list:
- Each day column divided into equal-height hour rows (flex: 1)
- Tasks listed as text rows within their hour slot (time + title inline)
- No absolute positioning, no background stripes, no floating blocks
- External calendar events: light tinted row with left border + bold title
- User tasks with project: left border in project color
- User tasks without project: plain text, no border
- Time column updated to flex rows, stays aligned with day column rows

Weather — switched to hourly API data (temperature_2m + weather_code):
- Small colored circle + temperature shown in top-right of each hour row
- Color encodes condition: amber=clear, gray=cloudy, blue=rain,
  pale-blue=snow, purple=thunderstorm
- Removed WMO text dictionaries (no more written-out condition text)

v1.96.3
This commit is contained in:
mARTin 2026-04-13 12:56:55 +02:00
parent c414c2718d
commit 1051dfd96c
2 changed files with 133 additions and 176 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.96.2", "version": "1.96.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

@ -83,6 +83,19 @@ function lighten(hex: string, amount: number): string {
return `#${lr.toString(16).padStart(2,'0')}${lg.toString(16).padStart(2,'0')}${lb.toString(16).padStart(2,'0')}`; return `#${lr.toString(16).padStart(2,'0')}${lg.toString(16).padStart(2,'0')}${lb.toString(16).padStart(2,'0')}`;
} }
// Map WMO weather code → a distinct color (used as small dot indicator in cells)
function wmoColor(code: number | null): string {
if (code === null || code === undefined) return '#9ca3af';
if (code === 0) return '#fbbf24'; // clear → amber
if (code <= 3) return '#9ca3af'; // partly cloudy → gray
if (code <= 48) return '#cbd5e1'; // fog → light slate
if (code <= 67) return '#3b82f6'; // rain/drizzle → blue
if (code <= 77) return '#bfdbfe'; // snow → pale blue
if (code <= 82) return '#2563eb'; // heavy showers → dark blue
if (code <= 86) return '#93c5fd'; // snow showers → light blue
return '#7c3aed'; // thunderstorm → purple
}
// ── Weekday label helpers ───────────────────────────────────────────────────── // ── Weekday label helpers ─────────────────────────────────────────────────────
const WDAY_LONG_DE = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; const WDAY_LONG_DE = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
@ -144,20 +157,6 @@ function groupIntoPages(start: Date, end: Date): Date[][] {
// ── Weather labels ──────────────────────────────────────────────────────────── // ── Weather labels ────────────────────────────────────────────────────────────
const WMO_EN: Record<number, string> = {
0:'Clear',1:'Mainly Clear',2:'Partly Cloudy',3:'Overcast',
45:'Fog',48:'Icy Fog',51:'Lt Drizzle',53:'Drizzle',55:'Hvy Drizzle',
61:'Lt Rain',63:'Rain',65:'Hvy Rain',71:'Lt Snow',73:'Snow',75:'Hvy Snow',
77:'Snow Grains',80:'Showers',81:'Mod Showers',82:'Hvy Showers',
85:'Snow Showers',86:'Hvy Snow',95:'Thunderstorm',96:'T-Storm+Hail',99:'T-Storm+Hail',
};
const WMO_DE: Record<number, string> = {
0:'Klar',1:'Meist klar',2:'Halbbedeckt',3:'Bedeckt',45:'Nebel',48:'Raureif',
51:'Nieselregen',53:'Nieselregen',55:'Stark Niesel',61:'Leic Regen',63:'Regen',
65:'Stark Regen',71:'Leic Schnee',73:'Schnee',75:'Stark Schnee',77:'Körner',
80:'Schauer',81:'Schauer',82:'Hvy Schauer',85:'Schneeschauer',86:'Schneeschauer',
95:'Gewitter',96:'Gewitter+Hagel',99:'Gewitter+Hagel',
};
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@ -186,7 +185,7 @@ function contrastColor(hex: string): string {
return lum > 0.42 ? '#1e293b' : '#ffffff'; return lum > 0.42 ? '#1e293b' : '#ffffff';
} }
type WeatherDay = { maxTemp: number | null; code: number | null; }; type WeatherHour = { temp: number | null; code: number | null; };
// User-specific style settings passed to the PDF // User-specific style settings passed to the PDF
type UserStyle = { type UserStyle = {
@ -219,7 +218,7 @@ type SomedayList = {
function WeekCalendarPDF({ function WeekCalendarPDF({
pages, pages,
tasksByDay, tasksByDay,
weatherByDay, weatherHourly,
startHour, startHour,
endHour, endHour,
showAllDay, showAllDay,
@ -232,7 +231,7 @@ function WeekCalendarPDF({
}: { }: {
pages: Date[][]; pages: Date[][];
tasksByDay: Map<string, DayData>; tasksByDay: Map<string, DayData>;
weatherByDay: Map<string, WeatherDay>; weatherHourly: Map<string, WeatherHour>;
startHour: number; startHour: number;
endHour: number; endHour: number;
showAllDay: boolean; showAllDay: boolean;
@ -245,8 +244,7 @@ function WeekCalendarPDF({
}) { }) {
const todayKey = new Date().toLocaleDateString('en-CA'); const todayKey = new Date().toLocaleDateString('en-CA');
const locale = de ? 'de-DE' : 'en-GB'; const locale = de ? 'de-DE' : 'en-GB';
const numHours = endHour - startHour; const numHours = endHour - startHour;
const totalMins = numHours * 60;
// Headline font: Oswald if loaded, else Helvetica-Bold (NOT plain Helvetica) // Headline font: Oswald if loaded, else Helvetica-Bold (NOT plain Helvetica)
const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica-Bold'; const headlineFontFamily = userStyle.useOswald ? 'Oswald' : 'Helvetica-Bold';
@ -257,10 +255,6 @@ function WeekCalendarPDF({
// GRID_H = remaining space after day header and optional all-day strip // GRID_H = remaining space after day header and optional all-day strip
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);
function minutesToY(mins: number): number {
return Math.max(0, (mins / totalMins) * GRID_H);
}
const exportedOn = new Date().toLocaleDateString(locale, { const exportedOn = new Date().toLocaleDateString(locale, {
day: '2-digit', month: 'long', year: 'numeric', day: '2-digit', month: 'long', year: 'numeric',
}); });
@ -366,45 +360,41 @@ function WeekCalendarPDF({
), ),
] : []), ] : []),
// Hour labels (absolute within container) // Hour labels — flex rows, aligned with day-column hour rows
React.createElement(View, { style: { height: GRID_H, position: 'relative' } }, React.createElement(View, { style: { height: GRID_H, flexDirection: 'column' } },
...Array.from({ length: numHours + 1 }, (_, i) => { ...Array.from({ length: numHours }, (_, i) =>
if (startHour + i > endHour) return null; React.createElement(View, {
const y = minutesToY(i * 60); key: `tl${i}`,
return React.createElement(Text, {
key: `hl${i}`,
style: { style: {
position: 'absolute', flex: 1,
top: Math.max(y - 4, 0), borderBottomWidth: i < numHours - 1 ? 1 : 0,
right: 4, borderBottomColor: '#ececec',
fontFamily: 'Helvetica-Bold', alignItems: 'flex-end',
fontSize: 6.5, justifyContent: 'flex-start',
color: MUTED, paddingRight: 4,
textAlign: 'right', paddingTop: 2,
}, },
}, fmtHour(startHour + i)); },
}).filter(Boolean), React.createElement(Text, {
style: { fontFamily: 'Helvetica-Bold', fontSize: 6.5, color: MUTED, textAlign: 'right' },
}, fmtHour(startHour + i)),
)
),
), ),
), ),
// ── Day columns // ── Day columns
...days.map((d) => { ...days.map((d) => {
const dk = d.toLocaleDateString('en-CA'); const dk = d.toLocaleDateString('en-CA');
const isToday = dk === todayKey; const isToday = dk === todayKey;
const dayData = tasksByDay.get(dk); const dayData = tasksByDay.get(dk);
const weather = weatherByDay.get(dk);
const wLabel = weather?.code != null
? `${de ? (WMO_DE[weather.code] || '') : (WMO_EN[weather.code] || '')}${weather.maxTemp != null ? ` ${Math.round(weather.maxTemp)}°` : ''}`
: null;
const allDayTasks = dayData?.allDay || []; const allDayTasks = dayData?.allDay || [];
const timedTasks = dayData?.timed || []; const timedTasks = dayData?.timed || [];
const dayName = weekdayLabel(d, de); const dayName = weekdayLabel(d, de);
const dateStr = dayDateLabel(d, de); const dateStr = dayDateLabel(d, de);
const todayColBg = '#f0f7ff';
return React.createElement(View, { return React.createElement(View, {
key: dk, key: dk,
style: { style: {
@ -495,130 +485,93 @@ function WeekCalendarPDF({
), ),
] : []), ] : []),
// ── Time grid (solid colored blocks, matching webapp style) // ── Time grid: per-hour rows (agenda/list style, no absolute positioning)
React.createElement(View, { React.createElement(View, {
style: { style: {
height: GRID_H, height: GRID_H,
position: 'relative', flexDirection: 'column',
overflow: 'hidden', backgroundColor: isToday ? '#f9fbff' : WHITE,
backgroundColor: isToday ? todayColBg : WHITE,
}, },
}, },
// Very subtle alternating hour stripe (lighter than before)
...Array.from({ length: numHours }, (_, i) => { ...Array.from({ length: numHours }, (_, i) => {
if (i % 2 === 0) return null; const h = startHour + i;
return React.createElement(View, { const hk = `${dk} ${String(h).padStart(2, '0')}`;
key: `s${i}`, const hw = weatherHourly.get(hk);
style: { const hourTasks = timedTasks.filter(t =>
position: 'absolute', t.startTime != null && parseInt(t.startTime.split(':')[0], 10) === h
top: minutesToY(i * 60),
left: 0, right: 0,
height: minutesToY(60),
backgroundColor: isToday ? '#f0f7ff' : '#fafafa',
},
});
}).filter(Boolean),
// Light hour separator lines
...Array.from({ length: numHours - 1 }, (_, i) =>
React.createElement(View, {
key: `hl${i}`,
style: {
position: 'absolute',
top: minutesToY((i + 1) * 60),
left: 0, right: 0,
height: 1,
backgroundColor: '#ececec',
},
})
),
// Weather chip: top-right of the time grid, matching webapp hour-cell style
...(wLabel ? [
React.createElement(View, {
key: 'weather',
style: {
position: 'absolute',
top: 3, right: 3,
backgroundColor: 'rgba(255,255,255,0.88)',
borderRadius: 4,
paddingVertical: 1.5,
paddingHorizontal: 4,
flexDirection: 'row',
alignItems: 'center',
},
},
React.createElement(Text, {
style: { fontSize: 6, color: '#64748b', fontFamily: 'Helvetica-Bold' },
}, wLabel),
),
] : []),
// Task blocks:
// User tasks → transparent + optional colored left border (project only)
// Calendar events → light tinted bg + colored left border + rounded corners
...timedTasks.map(t => {
const startMins = parseTimeMinutes(t.startTime)!;
const endMins = t.endTime
? parseTimeMinutes(t.endTime)!
: startMins + 30; // 30min default, not 60
const gridStart = startHour * 60;
const gridEnd = endHour * 60;
const csMin = Math.max(startMins, gridStart);
const ceMin = Math.min(endMins, gridEnd);
if (csMin >= ceMin) return null;
const topPx = minutesToY(csMin - gridStart);
const rawH = minutesToY(ceMin - csMin);
const taskH = Math.max(rawH, 14);
// Project color → left border; external events always have calendarColor
const projectColor = t.project?.color || null;
const isShort = (ceMin - csMin) <= 30;
const showTime = !!t.startTime && (!t.startTime.endsWith(':00') || isShort);
const baseStyle = { position: 'absolute' as const, top: topPx + 1, left: 2, right: 2, height: taskH - 2, overflow: 'hidden' as const };
if (t.completed) {
return React.createElement(View, {
key: t.id,
style: { ...baseStyle, paddingLeft: 4, paddingRight: 3, paddingVertical: 2 },
},
React.createElement(Text, { style: { fontSize: 7, color: DONE_TEXT, lineHeight: 1.25 } }, (t.title || '').slice(0, 44)),
);
}
if (t.isExternal) {
// Calendar event: light tinted bg + left border + rounded corners
const evColor = projectColor || '#6366f1';
const bg = lighten(evColor, 0.8);
return React.createElement(View, {
key: t.id,
style: {
...baseStyle, backgroundColor: bg,
borderRadius: 3, borderLeftWidth: 3, borderLeftColor: evColor,
paddingLeft: 5, paddingRight: 3, paddingVertical: 2,
},
},
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7, color: '#1e293b', lineHeight: 1.25 } }, (t.title || '').slice(0, 44)),
showTime ? React.createElement(Text, { style: { fontSize: 6, color: evColor, marginTop: 0.5 } }, fmtTime(t.startTime!)) : null,
);
}
// Regular user task: left border only if task has a project color
return React.createElement(View, {
key: t.id,
style: {
...baseStyle,
...(projectColor ? { borderLeftWidth: 3, borderLeftColor: projectColor } : {}),
paddingLeft: projectColor ? 5 : 2,
paddingRight: 3, paddingVertical: 2,
},
},
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7, color: '#1e293b', lineHeight: 1.25 } }, (t.title || '').slice(0, 44)),
showTime && projectColor ? React.createElement(Text, { style: { fontSize: 6, color: projectColor, marginTop: 0.5 } }, fmtTime(t.startTime!)) : null,
); );
}).filter(Boolean),
return React.createElement(View, {
key: `hr${i}`,
style: {
flex: 1,
borderBottomWidth: i < numHours - 1 ? 1 : 0,
borderBottomColor: '#ececec',
overflow: 'hidden',
position: 'relative',
paddingTop: 1,
paddingLeft: 2,
paddingRight: hw ? 22 : 2,
},
},
// Per-hour weather: small colored dot + temperature
...(hw && (hw.temp != null || hw.code != null) ? [
React.createElement(View, {
key: 'w',
style: { position: 'absolute', top: 2, right: 2, flexDirection: 'row', alignItems: 'center' },
},
React.createElement(View, {
style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: wmoColor(hw.code), marginRight: 1.5 },
}),
hw.temp != null ? React.createElement(Text, {
style: { fontSize: 5.5, color: '#94a3b8' },
}, `${Math.round(hw.temp)}°`) : null,
),
] : []),
// Tasks starting in this hour
...hourTasks.map(t => {
if (t.completed) {
return React.createElement(View, {
key: t.id,
style: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 1, paddingLeft: 1 },
},
React.createElement(Text, { style: { fontSize: 5.5, color: DONE_TEXT, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')),
React.createElement(Text, { style: { fontSize: 6.5, color: DONE_TEXT, flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)),
);
}
if (t.isExternal) {
const evColor = t.project?.color || '#6366f1';
return React.createElement(View, {
key: t.id,
style: {
flexDirection: 'row', alignItems: 'flex-start',
backgroundColor: lighten(evColor, 0.85),
borderRadius: 2, borderLeftWidth: 2, borderLeftColor: evColor,
paddingLeft: 3, paddingVertical: 1, marginBottom: 1,
},
},
React.createElement(Text, { style: { fontSize: 5.5, color: evColor, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')),
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 6.5, color: '#1e293b', flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)),
);
}
const pc = t.project?.color;
return React.createElement(View, {
key: t.id,
style: {
flexDirection: 'row', alignItems: 'flex-start',
...(pc ? { borderLeftWidth: 2, borderLeftColor: pc, paddingLeft: 3 } : { paddingLeft: 1 }),
paddingVertical: 1, marginBottom: 1,
},
},
React.createElement(Text, { style: { fontSize: 5.5, color: pc || MUTED, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')),
React.createElement(Text, { style: { fontSize: 6.5, color: '#1e293b', flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)),
);
}),
);
}),
), // end time grid ), // end time grid
); // end day column ); // end day column
@ -943,18 +896,22 @@ export async function GET(request: Request) {
dayData.timed.sort((a, b) => (a.startTime || '').localeCompare(b.startTime || '')); dayData.timed.sort((a, b) => (a.startTime || '').localeCompare(b.startTime || ''));
} }
// ── Weather // ── Weather (hourly — one entry per hour per day, key = "YYYY-MM-DD HH")
const weatherByDay = new Map<string, WeatherDay>(); const weatherHourly = new Map<string, WeatherHour>();
if (showWeather && (user as any).weatherLat && (user as any).weatherLon) { if (showWeather && (user as any).weatherLat && (user as any).weatherLon) {
try { try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${(user as any).weatherLat}&longitude=${(user as any).weatherLon}&daily=temperature_2m_max,weather_code&start_date=${startDate}&end_date=${endDate}&timezone=auto`; const url = `https://api.open-meteo.com/v1/forecast?latitude=${(user as any).weatherLat}&longitude=${(user as any).weatherLon}&hourly=temperature_2m,weather_code&start_date=${startDate}&end_date=${endDate}&timezone=auto`;
const res = await fetch(url, { signal: AbortSignal.timeout(4000) }); const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
if (res.ok) { if (res.ok) {
const raw = await res.json(); const raw = await res.json();
const dates: string[] = raw.daily?.time || []; const times: string[] = raw.hourly?.time || []; // "2026-04-13T09:00"
const temps: number[] = raw.daily?.temperature_2m_max || []; const temps: number[] = raw.hourly?.temperature_2m || [];
const codes: number[] = raw.daily?.weather_code || []; const codes: number[] = raw.hourly?.weather_code || [];
dates.forEach((d, i) => weatherByDay.set(d, { maxTemp: temps[i] ?? null, code: codes[i] ?? null })); times.forEach((t, i) => {
const [date, time] = t.split('T');
const hour = time ? time.split(':')[0] : '00';
weatherHourly.set(`${date} ${hour}`, { temp: temps[i] ?? null, code: codes[i] ?? null });
});
} }
} catch { /* silent */ } } catch { /* silent */ }
} }
@ -997,7 +954,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, weatherHourly,
startHour, endHour, showAllDay, showNoTime, de, startHour, endHour, showAllDay, showNoTime, de,
userName: user.name || user.email || '', userName: user.name || user.email || '',
userStyle, userStyle,