diff --git a/config.sample.json b/config.sample.json index 95de38d..8b615b6 100644 --- a/config.sample.json +++ b/config.sample.json @@ -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 diff --git a/src/sources/weather.test.ts b/src/sources/weather.test.ts new file mode 100644 index 0000000..94a1d83 --- /dev/null +++ b/src/sources/weather.test.ts @@ -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); + }); +}); diff --git a/src/sources/weather.ts b/src/sources/weather.ts new file mode 100644 index 0000000..ccfcefe --- /dev/null +++ b/src/sources/weather.ts @@ -0,0 +1,94 @@ +export interface WeatherConfig { + latitude: number; + longitude: number; +} + +export interface WeatherData { + description: string; + icon: string; + high: number; + low: number; + hourly: Map; // 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 = { + 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 { + 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(); + 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, + }; +} diff --git a/src/todayview.tsx b/src/todayview.tsx index cd6ffe4..5b11aea 100644 --- a/src/todayview.tsx +++ b/src/todayview.tsx @@ -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; }) => (
{ /* ---------- 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 (
- + {weather.description} {weather.low}°–{weather.high}° + : `${startLabel} \u2014 ${endLabel}`} + count={events.length}> Agenda @@ -398,6 +409,33 @@ const Agenda = ({ events }: { events: AgendaEvent[] }) => { ); })} + + {/* Temperature column */} + {weather && ( +
+ {ticks.map((h) => { + const p = (h * 60 - startMin) / rangeMin; + const temp = weather.hourly.get(h); + if (temp == null) return null; + return ( + + {temp}° + + ); + })} +
+ )}
); @@ -652,7 +690,7 @@ const TodayPanel = ({ data }: { data: TodayData }) => (
- +
@@ -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();