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:
parent
c414c2718d
commit
1051dfd96c
@ -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": {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user