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:
parent
2e34246d14
commit
32e9b85857
4 changed files with 221 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
70
src/sources/weather.test.ts
Normal file
70
src/sources/weather.test.ts
Normal 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
94
src/sources/weather.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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}°–{weather.high}°</>
|
||||||
|
: `${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} />);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue