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",
"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",
"main": "index.js",
"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')}`;
}
// 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 ─────────────────────────────────────────────────────
const WDAY_LONG_DE = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
@ -144,20 +157,6 @@ function groupIntoPages(start: Date, end: Date): Date[][] {
// ── 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 ─────────────────────────────────────────────────────────────────────
@ -186,7 +185,7 @@ function contrastColor(hex: string): string {
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
type UserStyle = {
@ -219,7 +218,7 @@ type SomedayList = {
function WeekCalendarPDF({
pages,
tasksByDay,
weatherByDay,
weatherHourly,
startHour,
endHour,
showAllDay,
@ -232,7 +231,7 @@ function WeekCalendarPDF({
}: {
pages: Date[][];
tasksByDay: Map<string, DayData>;
weatherByDay: Map<string, WeatherDay>;
weatherHourly: Map<string, WeatherHour>;
startHour: number;
endHour: number;
showAllDay: boolean;
@ -245,8 +244,7 @@ function WeekCalendarPDF({
}) {
const todayKey = new Date().toLocaleDateString('en-CA');
const locale = de ? 'de-DE' : 'en-GB';
const numHours = endHour - startHour;
const totalMins = numHours * 60;
const numHours = endHour - startHour;
// Headline font: Oswald if loaded, else Helvetica-Bold (NOT plain Helvetica)
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
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, {
day: '2-digit', month: 'long', year: 'numeric',
});
@ -366,45 +360,41 @@ function WeekCalendarPDF({
),
] : []),
// Hour labels (absolute within container)
React.createElement(View, { style: { height: GRID_H, position: 'relative' } },
...Array.from({ length: numHours + 1 }, (_, i) => {
if (startHour + i > endHour) return null;
const y = minutesToY(i * 60);
return React.createElement(Text, {
key: `hl${i}`,
// Hour labels — flex rows, aligned with day-column hour rows
React.createElement(View, { style: { height: GRID_H, flexDirection: 'column' } },
...Array.from({ length: numHours }, (_, i) =>
React.createElement(View, {
key: `tl${i}`,
style: {
position: 'absolute',
top: Math.max(y - 4, 0),
right: 4,
fontFamily: 'Helvetica-Bold',
fontSize: 6.5,
color: MUTED,
textAlign: 'right',
flex: 1,
borderBottomWidth: i < numHours - 1 ? 1 : 0,
borderBottomColor: '#ececec',
alignItems: 'flex-end',
justifyContent: 'flex-start',
paddingRight: 4,
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
...days.map((d) => {
const dk = d.toLocaleDateString('en-CA');
const isToday = dk === todayKey;
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 dk = d.toLocaleDateString('en-CA');
const isToday = dk === todayKey;
const dayData = tasksByDay.get(dk);
const allDayTasks = dayData?.allDay || [];
const timedTasks = dayData?.timed || [];
const allDayTasks = dayData?.allDay || [];
const timedTasks = dayData?.timed || [];
const dayName = weekdayLabel(d, de);
const dateStr = dayDateLabel(d, de);
const todayColBg = '#f0f7ff';
return React.createElement(View, {
key: dk,
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, {
style: {
height: GRID_H,
position: 'relative',
overflow: 'hidden',
backgroundColor: isToday ? todayColBg : WHITE,
flexDirection: 'column',
backgroundColor: isToday ? '#f9fbff' : WHITE,
},
},
// Very subtle alternating hour stripe (lighter than before)
...Array.from({ length: numHours }, (_, i) => {
if (i % 2 === 0) return null;
return React.createElement(View, {
key: `s${i}`,
style: {
position: 'absolute',
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,
const h = startHour + i;
const hk = `${dk} ${String(h).padStart(2, '0')}`;
const hw = weatherHourly.get(hk);
const hourTasks = timedTasks.filter(t =>
t.startTime != null && parseInt(t.startTime.split(':')[0], 10) === h
);
}).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 day column
@ -943,18 +896,22 @@ export async function GET(request: Request) {
dayData.timed.sort((a, b) => (a.startTime || '').localeCompare(b.startTime || ''));
}
// ── Weather
const weatherByDay = new Map<string, WeatherDay>();
// ── Weather (hourly — one entry per hour per day, key = "YYYY-MM-DD HH")
const weatherHourly = new Map<string, WeatherHour>();
if (showWeather && (user as any).weatherLat && (user as any).weatherLon) {
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) });
if (res.ok) {
const raw = await res.json();
const dates: string[] = raw.daily?.time || [];
const temps: number[] = raw.daily?.temperature_2m_max || [];
const codes: number[] = raw.daily?.weather_code || [];
dates.forEach((d, i) => weatherByDay.set(d, { maxTemp: temps[i] ?? null, code: codes[i] ?? null }));
const times: string[] = raw.hourly?.time || []; // "2026-04-13T09:00"
const temps: number[] = raw.hourly?.temperature_2m || [];
const codes: number[] = raw.hourly?.weather_code || [];
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 */ }
}
@ -997,7 +954,7 @@ export async function GET(request: Request) {
const pdfBuffer = await renderToBuffer(
React.createElement(WeekCalendarPDF, {
pages, tasksByDay, weatherByDay,
pages, tasksByDay, weatherHourly,
startHour, endHour, showAllDay, showNoTime, de,
userName: user.name || user.email || '',
userStyle,