Add weather to agenda header and timeline via OpenMeteo

Shows daily weather summary (icon, description, high/low) in the agenda
section header, and hourly temperatures alongside the timeline hour ticks.
Weather data fetched from OpenMeteo (no API key needed), configured via
location coordinates in the plugin settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kate Meerburg 2026-05-24 23:29:31 +02:00
parent 2e34246d14
commit 32e9b85857
4 changed files with 221 additions and 4 deletions

View file

@ -14,6 +14,10 @@
"task_tag_id": "your-task-tag-node-id", "task_tag_id": "your-task-tag-node-id",
"due_date_field_id": "your-due-date-field-id" "due_date_field_id": "your-due-date-field-id"
}, },
"location": {
"latitude": 48.8566,
"longitude": 2.3522
},
"donetick": { "donetick": {
"token": "your-donetick-access-token", "token": "your-donetick-access-token",
"user_id": 1 "user_id": 1

View file

@ -0,0 +1,70 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import type { Server } from 'bun';
import { fetchWeather } from './weather';
let server: Server;
let baseUrl: string;
const MOCK_RESPONSE = {
daily: {
temperature_2m_max: [22],
temperature_2m_min: [14],
weather_code: [2],
},
hourly: {
time: [
'2026-05-24T08:00', '2026-05-24T10:00',
'2026-05-24T12:00', '2026-05-24T14:00',
'2026-05-24T16:00',
],
temperature_2m: [15.2, 17.8, 20.1, 21.6, 19.3],
},
};
// Patch global fetch to intercept open-meteo calls
const originalFetch = globalThis.fetch;
describe('weather source', () => {
beforeAll(() => {
server = Bun.serve({
port: 0,
hostname: '127.0.0.1',
routes: {
'/v1/forecast': () => Response.json(MOCK_RESPONSE),
},
});
baseUrl = `http://127.0.0.1:${server.port}`;
// Override fetch to redirect open-meteo to our mock
globalThis.fetch = (input, init?) => {
const url = typeof input === 'string' ? input : (input as Request).url;
if (url.startsWith('https://api.open-meteo.com')) {
const replaced = url.replace('https://api.open-meteo.com', baseUrl);
return originalFetch(replaced, init);
}
return originalFetch(input, init);
};
});
afterAll(() => {
globalThis.fetch = originalFetch;
server.stop();
});
test('fetches and parses weather data', async () => {
const weather = await fetchWeather({ latitude: 52.37, longitude: 4.89 });
expect(weather.high).toBe(22);
expect(weather.low).toBe(14);
expect(weather.description).toBe('Partly cloudy');
expect(weather.icon).toBe('wi-day-cloudy');
});
test('provides hourly temperatures', async () => {
const weather = await fetchWeather({ latitude: 52.37, longitude: 4.89 });
expect(weather.hourly.get(8)).toBe(15);
expect(weather.hourly.get(12)).toBe(20);
expect(weather.hourly.get(14)).toBe(22);
});
});

94
src/sources/weather.ts Normal file
View file

@ -0,0 +1,94 @@
export interface WeatherConfig {
latitude: number;
longitude: number;
}
export interface WeatherData {
description: string;
icon: string;
high: number;
low: number;
hourly: Map<number, number>; // hour (0-23) → temperature in °C
}
// WMO Weather interpretation codes → [description, Weather Icons class]
// See https://erikflowers.github.io/weather-icons/
const WMO_CODES: Record<number, [string, string]> = {
0: ['Clear', 'wi-day-sunny'],
1: ['Mostly clear', 'wi-day-sunny-overcast'],
2: ['Partly cloudy', 'wi-day-cloudy'],
3: ['Overcast', 'wi-cloudy'],
45: ['Fog', 'wi-fog'],
48: ['Fog', 'wi-fog'],
51: ['Light drizzle', 'wi-sprinkle'],
53: ['Drizzle', 'wi-sprinkle'],
55: ['Heavy drizzle', 'wi-rain'],
56: ['Freezing drizzle', 'wi-rain-mix'],
57: ['Freezing drizzle', 'wi-rain-mix'],
61: ['Light rain', 'wi-showers'],
63: ['Rain', 'wi-rain'],
65: ['Heavy rain', 'wi-rain-wind'],
66: ['Freezing rain', 'wi-rain-mix'],
67: ['Freezing rain', 'wi-rain-mix'],
71: ['Light snow', 'wi-snow'],
73: ['Snow', 'wi-snow'],
75: ['Heavy snow', 'wi-snow-wind'],
77: ['Snow grains', 'wi-snow'],
80: ['Light showers', 'wi-showers'],
81: ['Showers', 'wi-rain'],
82: ['Heavy showers', 'wi-rain-wind'],
85: ['Snow showers', 'wi-snow'],
86: ['Snow showers', 'wi-snow-wind'],
95: ['Thunderstorm', 'wi-thunderstorm'],
96: ['Thunderstorm + hail', 'wi-storm-showers'],
99: ['Thunderstorm + hail', 'wi-storm-showers'],
};
interface OpenMeteoResponse {
daily: {
temperature_2m_max: number[];
temperature_2m_min: number[];
weather_code: number[];
};
hourly: {
time: string[];
temperature_2m: number[];
};
}
export async function fetchWeather(
config: WeatherConfig
): Promise<WeatherData> {
const params = new URLSearchParams({
latitude: config.latitude.toString(),
longitude: config.longitude.toString(),
daily: 'temperature_2m_max,temperature_2m_min,weather_code',
hourly: 'temperature_2m',
timezone: 'auto',
forecast_days: '1',
});
const resp = await fetch(
`https://api.open-meteo.com/v1/forecast?${params}`
);
if (!resp.ok) throw new Error(`Weather fetch failed: ${resp.status}`);
const data = (await resp.json()) as OpenMeteoResponse;
const code = data.daily.weather_code[0] ?? 0;
const [description, icon] = WMO_CODES[code] ?? ['Unknown', '?'];
const hourly = new Map<number, number>();
for (let i = 0; i < data.hourly.time.length; i++) {
const hour = new Date(data.hourly.time[i]!).getHours();
hourly.set(hour, Math.round(data.hourly.temperature_2m[i]!));
}
return {
description,
icon,
high: Math.round(data.daily.temperature_2m_max[0]!),
low: Math.round(data.daily.temperature_2m_min[0]!),
hourly,
};
}

View file

@ -4,6 +4,7 @@ import type { DeviceModel } from './devices';
import { fetchDonetickChores } from './sources/donetick'; import { fetchDonetickChores } from './sources/donetick';
import { fetchICSAgenda } from './sources/ics'; import { fetchICSAgenda } from './sources/ics';
import { fetchTanaFocus, fetchTanaTasks } from './sources/tana'; import { fetchTanaFocus, fetchTanaTasks } from './sources/tana';
import { fetchWeather, type WeatherData } from './sources/weather';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@ -41,6 +42,7 @@ export interface TodayData {
chores?: ChoreItem[]; chores?: ChoreItem[];
todos_due?: TodoItem[]; todos_due?: TodoItem[];
todos_overdue?: OverdueItem[]; todos_overdue?: OverdueItem[];
weather?: WeatherData;
} }
export interface TodayConfig extends TodayData { export interface TodayConfig extends TodayData {
@ -55,6 +57,10 @@ export interface TodayConfig extends TodayData {
token: string; token: string;
user_id: number; user_id: number;
}; };
location?: {
latitude: number;
longitude: number;
};
max_chores?: number; // default: 8 max_chores?: number; // default: 8
} }
@ -65,6 +71,7 @@ const FONT_MONO = '"Geist Mono", ui-monospace, monospace';
const STYLES = ` const STYLES = `
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap");
@import url("https://cdnjs.cloudflare.com/ajax/libs/weather-icons/2.0.12/css/weather-icons.min.css");
html, body { margin: 0; background: #fff; } html, body { margin: 0; background: #fff; }
.today-panel { .today-panel {
--paper: #ffffff; --paper: #ffffff;
@ -123,7 +130,7 @@ const SectionTitle = ({
}: { }: {
children: string; children: string;
count?: number | null; count?: number | null;
right?: string; right?: React.ReactNode;
}) => ( }) => (
<div <div
style={{ style={{
@ -273,7 +280,7 @@ const TopBar = ({ data }: { data: TodayData }) => {
/* ---------- Agenda ---------- */ /* ---------- Agenda ---------- */
const Agenda = ({ events }: { events: AgendaEvent[] }) => { const Agenda = ({ events, weather }: { events: AgendaEvent[]; weather?: WeatherData }) => {
const now = new Date(); const now = new Date();
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const nowMin = now.getHours() * 60 + now.getMinutes(); const nowMin = now.getHours() * 60 + now.getMinutes();
@ -299,7 +306,11 @@ const Agenda = ({ events }: { events: AgendaEvent[] }) => {
return ( return (
<section style={{ padding: '10px 14px 10px 18px', display: 'flex', flexDirection: 'column', minHeight: 0 }}> <section style={{ padding: '10px 14px 10px 18px', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<SectionTitle right={`${startLabel} \u2014 ${endLabel}`} count={events.length}> <SectionTitle
right={weather
? <><i className={`wi ${weather.icon}`} /> {weather.description} {weather.low}&deg;&ndash;{weather.high}&deg;</>
: `${startLabel} \u2014 ${endLabel}`}
count={events.length}>
Agenda Agenda
</SectionTitle> </SectionTitle>
@ -398,6 +409,33 @@ const Agenda = ({ events }: { events: AgendaEvent[] }) => {
); );
})} })}
</ul> </ul>
{/* Temperature column */}
{weather && (
<div style={{ width: 24, position: 'relative', flex: '0 0 24px' }}>
{ticks.map((h) => {
const p = (h * 60 - startMin) / rangeMin;
const temp = weather.hourly.get(h);
if (temp == null) return null;
return (
<span
key={h}
style={{
position: 'absolute',
top: `${p * 100}%`,
right: 0,
transform: 'translateY(-50%)',
fontFamily: FONT_MONO,
fontSize: 8,
color: 'var(--ink-3)',
whiteSpace: 'nowrap',
}}>
{temp}°
</span>
);
})}
</div>
)}
</div> </div>
</section> </section>
); );
@ -652,7 +690,7 @@ const TodayPanel = ({ data }: { data: TodayData }) => (
<TopBar data={data} /> <TopBar data={data} />
<main style={{ display: 'grid', gridTemplateColumns: '1.55fr 1fr', minHeight: 0 }}> <main style={{ display: 'grid', gridTemplateColumns: '1.55fr 1fr', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateRows: '1fr auto', minHeight: 0 }}> <div style={{ display: 'grid', gridTemplateRows: '1fr auto', minHeight: 0 }}>
<Agenda events={data.agenda} /> <Agenda events={data.agenda} weather={data.weather} />
<Chores items={data.chores} /> <Chores items={data.chores} />
</div> </div>
<Tasks due={data.todos_due} overdue={data.todos_overdue} /> <Tasks due={data.todos_due} overdue={data.todos_overdue} />
@ -709,6 +747,16 @@ export class TodayRenderable implements Renderable {
); );
} }
if (this.config.location) {
fetches.push(
fetchWeather(this.config.location)
.then((weather) => {
data.weather = weather;
})
.catch((e) => console.error('Weather fetch failed:', e))
);
}
await Promise.all(fetches); await Promise.all(fetches);
// Limit chores // Limit chores
@ -761,6 +809,7 @@ export class TodayRenderable implements Renderable {
chores, chores,
todos_due: todosDue, todos_due: todosDue,
todos_overdue: data.todos_overdue ?? [], todos_overdue: data.todos_overdue ?? [],
weather: data.weather,
}; };
const html = renderToString(<TodayPanel data={resolved} />); const html = renderToString(<TodayPanel data={resolved} />);