From 1051dfd96c49aa41bd353424e52b07472431da0d Mon Sep 17 00:00:00 2001 From: mARTin Date: Mon, 13 Apr 2026 12:56:55 +0200 Subject: [PATCH] feat: list-style PDF layout with per-hour weather symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- src/app/api/user/export-week-view/route.ts | 307 +++++++++------------ 2 files changed, 133 insertions(+), 176 deletions(-) diff --git a/package.json b/package.json index 9549313..aec9332 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/api/user/export-week-view/route.ts b/src/app/api/user/export-week-view/route.ts index dfe822b..ede9e72 100644 --- a/src/app/api/user/export-week-view/route.ts +++ b/src/app/api/user/export-week-view/route.ts @@ -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 = { - 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 = { - 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; - weatherByDay: Map; + weatherHourly: Map; 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(); + // ── Weather (hourly — one entry per hour per day, key = "YYYY-MM-DD HH") + const weatherHourly = new Map(); 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,