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",
|
||||
"due_date_field_id": "your-due-date-field-id"
|
||||
},
|
||||
"location": {
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522
|
||||
},
|
||||
"donetick": {
|
||||
"token": "your-donetick-access-token",
|
||||
"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 { fetchICSAgenda } from './sources/ics';
|
||||
import { fetchTanaFocus, fetchTanaTasks } from './sources/tana';
|
||||
import { fetchWeather, type WeatherData } from './sources/weather';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ export interface TodayData {
|
|||
chores?: ChoreItem[];
|
||||
todos_due?: TodoItem[];
|
||||
todos_overdue?: OverdueItem[];
|
||||
weather?: WeatherData;
|
||||
}
|
||||
|
||||
export interface TodayConfig extends TodayData {
|
||||
|
|
@ -55,6 +57,10 @@ export interface TodayConfig extends TodayData {
|
|||
token: string;
|
||||
user_id: number;
|
||||
};
|
||||
location?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
max_chores?: number; // default: 8
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +71,7 @@ const FONT_MONO = '"Geist Mono", ui-monospace, monospace';
|
|||
|
||||
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://cdnjs.cloudflare.com/ajax/libs/weather-icons/2.0.12/css/weather-icons.min.css");
|
||||
html, body { margin: 0; background: #fff; }
|
||||
.today-panel {
|
||||
--paper: #ffffff;
|
||||
|
|
@ -123,7 +130,7 @@ const SectionTitle = ({
|
|||
}: {
|
||||
children: string;
|
||||
count?: number | null;
|
||||
right?: string;
|
||||
right?: React.ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -273,7 +280,7 @@ const TopBar = ({ data }: { data: TodayData }) => {
|
|||
|
||||
/* ---------- Agenda ---------- */
|
||||
|
||||
const Agenda = ({ events }: { events: AgendaEvent[] }) => {
|
||||
const Agenda = ({ events, weather }: { events: AgendaEvent[]; weather?: WeatherData }) => {
|
||||
const now = new Date();
|
||||
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const nowMin = now.getHours() * 60 + now.getMinutes();
|
||||
|
|
@ -299,7 +306,11 @@ const Agenda = ({ events }: { events: AgendaEvent[] }) => {
|
|||
|
||||
return (
|
||||
<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
|
||||
</SectionTitle>
|
||||
|
||||
|
|
@ -398,6 +409,33 @@ const Agenda = ({ events }: { events: AgendaEvent[] }) => {
|
|||
);
|
||||
})}
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
|
|
@ -652,7 +690,7 @@ const TodayPanel = ({ data }: { data: TodayData }) => (
|
|||
<TopBar data={data} />
|
||||
<main style={{ display: 'grid', gridTemplateColumns: '1.55fr 1fr', 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} />
|
||||
</div>
|
||||
<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);
|
||||
|
||||
// Limit chores
|
||||
|
|
@ -761,6 +809,7 @@ export class TodayRenderable implements Renderable {
|
|||
chores,
|
||||
todos_due: todosDue,
|
||||
todos_overdue: data.todos_overdue ?? [],
|
||||
weather: data.weather,
|
||||
};
|
||||
|
||||
const html = renderToString(<TodayPanel data={resolved} />);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue