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