diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1ca13959a..6e4e372de 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -38,6 +38,7 @@ import { tracesRoutes } from './modules/traces/routes'; import { presiRoutes } from './modules/presi/routes'; import { researchRoutes } from './modules/research/routes'; import { whoRoutes } from './modules/who/routes'; +import { wetterRoutes } from './modules/wetter/routes'; const PORT = parseInt(process.env.PORT || '3060', 10); const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','); @@ -75,6 +76,7 @@ app.route('/api/v1/traces', tracesRoutes); app.route('/api/v1/presi', presiRoutes); app.route('/api/v1/research', researchRoutes); app.route('/api/v1/who', whoRoutes); +app.route('/api/v1/wetter', wetterRoutes); // ─── Server Info ──────────────────────────────────────────── console.log(`mana-api starting on port ${PORT}...`); diff --git a/apps/api/src/modules/wetter/routes.ts b/apps/api/src/modules/wetter/routes.ts new file mode 100644 index 000000000..a6724e3ec --- /dev/null +++ b/apps/api/src/modules/wetter/routes.ts @@ -0,0 +1,346 @@ +/** + * Wetter module — weather proxy routes. + * + * Proxies Open-Meteo (forecast), DWD (alerts), and Rainbow.ai (nowcast) + * with in-memory caching to reduce upstream calls. + */ + +import { Hono } from 'hono'; + +const routes = new Hono(); + +// ─── Cache ───────────────────────────────────────────────── + +interface CacheEntry { + data: T; + expiresAt: number; +} + +const cache = new Map>(); + +function getCached(key: string): T | null { + const entry = cache.get(key); + if (!entry || Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.data as T; +} + +function setCache(key: string, data: T, ttlMs: number): void { + cache.set(key, { data, expiresAt: Date.now() + ttlMs }); + // Prevent unbounded growth — drop expired entries periodically + if (cache.size > 500) { + const now = Date.now(); + for (const [k, v] of cache) { + if (now > v.expiresAt) cache.delete(k); + } + } +} + +/** Round coordinates to ~1km grid for cache key */ +function coordKey(lat: number, lon: number): string { + return `${Math.round(lat * 100) / 100},${Math.round(lon * 100) / 100}`; +} + +const WEATHER_TTL = 15 * 60 * 1000; // 15 min +const ALERTS_TTL = 5 * 60 * 1000; // 5 min + +// ─── Open-Meteo: Current + Forecast ──────────────────────── + +const OPEN_METEO_BASE = 'https://api.open-meteo.com/v1'; +const OPEN_METEO_GEOCODING = 'https://geocoding-api.open-meteo.com/v1'; + +const CURRENT_VARS = [ + 'temperature_2m', + 'apparent_temperature', + 'weather_code', + 'relative_humidity_2m', + 'surface_pressure', + 'wind_speed_10m', + 'wind_direction_10m', + 'uv_index', + 'precipitation', + 'cloud_cover', + 'visibility', + 'is_day', +].join(','); + +const HOURLY_VARS = [ + 'temperature_2m', + 'precipitation', + 'precipitation_probability', + 'weather_code', + 'wind_speed_10m', + 'wind_direction_10m', + 'relative_humidity_2m', + 'apparent_temperature', + 'is_day', +].join(','); + +const DAILY_VARS = [ + 'temperature_2m_min', + 'temperature_2m_max', + 'weather_code', + 'precipitation_sum', + 'precipitation_probability_max', + 'sunrise', + 'sunset', + 'uv_index_max', + 'wind_speed_10m_max', + 'wind_direction_10m_dominant', +].join(','); + +routes.post('/current', async (c) => { + const { lat, lon } = await c.req.json<{ lat: number; lon: number }>(); + if (lat == null || lon == null) return c.json({ error: 'lat and lon required' }, 400); + + const key = `current:${coordKey(lat, lon)}`; + const cached = getCached(key); + if (cached) return c.json(cached); + + const url = `${OPEN_METEO_BASE}/forecast?latitude=${lat}&longitude=${lon}¤t=${CURRENT_VARS}&models=icon_d2&timezone=auto`; + const res = await fetch(url); + if (!res.ok) return c.json({ error: 'Open-Meteo request failed' }, 502); + + const data = await res.json(); + setCache(key, data, WEATHER_TTL); + return c.json(data); +}); + +routes.post('/forecast', async (c) => { + const { lat, lon } = await c.req.json<{ lat: number; lon: number }>(); + if (lat == null || lon == null) return c.json({ error: 'lat and lon required' }, 400); + + const key = `forecast:${coordKey(lat, lon)}`; + const cached = getCached(key); + if (cached) return c.json(cached); + + const url = `${OPEN_METEO_BASE}/forecast?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_VARS}&daily=${DAILY_VARS}&models=icon_d2&timezone=auto&forecast_hours=48`; + const res = await fetch(url); + if (!res.ok) return c.json({ error: 'Open-Meteo request failed' }, 502); + + const data = await res.json(); + setCache(key, data, WEATHER_TTL); + return c.json(data); +}); + +// ─── Geocoding (Open-Meteo) ──────────────────────────────── + +routes.post('/geocode', async (c) => { + const { query } = await c.req.json<{ query: string }>(); + if (!query) return c.json({ error: 'query required' }, 400); + + const key = `geo:${query.toLowerCase().trim()}`; + const cached = getCached(key); + if (cached) return c.json(cached); + + const url = `${OPEN_METEO_GEOCODING}/search?name=${encodeURIComponent(query)}&count=5&language=de&format=json`; + const res = await fetch(url); + if (!res.ok) return c.json({ error: 'Geocoding request failed' }, 502); + + const data = (await res.json()) as { + results?: Array<{ + name: string; + latitude: number; + longitude: number; + country: string; + admin1?: string; + }>; + }; + const results = (data.results ?? []).map((r) => ({ + name: r.name, + lat: r.latitude, + lon: r.longitude, + country: r.country, + admin1: r.admin1, + })); + + const payload = { results }; + setCache(key, payload, WEATHER_TTL); + return c.json(payload); +}); + +// ─── DWD Warnings ────────────────────────────────────────── + +const DWD_WARNINGS_URL = 'https://dwd.api.proxy.bund.dev/warnings/v2/current'; + +routes.post('/alerts', async (c) => { + const { lat, lon } = await c.req.json<{ lat: number; lon: number }>(); + if (lat == null || lon == null) return c.json({ error: 'lat and lon required' }, 400); + + const key = `alerts:${coordKey(lat, lon)}`; + const cached = getCached(key); + if (cached) return c.json(cached); + + try { + const res = await fetch(DWD_WARNINGS_URL, { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + // DWD sometimes returns non-JSON; fall back gracefully + return c.json({ alerts: [] }); + } + + const raw = await res.json(); + // DWD warnings are keyed by warning cell IDs — we filter by proximity + const alerts = extractNearbyAlerts(raw, lat, lon); + const payload = { alerts }; + setCache(key, payload, ALERTS_TTL); + return c.json(payload); + } catch { + // DWD endpoint can be unreliable — return empty alerts rather than erroring + return c.json({ alerts: [] }); + } +}); + +interface DwdWarning { + headline?: string; + description?: string; + severity?: string; + event?: string; + start?: number; + end?: number; + regionName?: string; + areaDesc?: string; +} + +function extractNearbyAlerts(raw: unknown, _lat: number, _lon: number) { + // The DWD API returns warnings grouped by region cells. + // For v1, we return all active warnings (nationwide). A finer spatial + // filter requires mapping Warnzell IDs to geo areas — planned for v2. + const alerts: Array<{ + id: string; + headline: string; + description: string; + severity: string; + event: string; + start: string; + end: string; + regionName: string; + }> = []; + + if (!raw || typeof raw !== 'object') return alerts; + + const warnings: Record = + (raw as Record>).warnings ?? {}; + let idx = 0; + for (const [, regionWarnings] of Object.entries(warnings)) { + if (!Array.isArray(regionWarnings)) continue; + for (const w of regionWarnings) { + alerts.push({ + id: `dwd-${idx++}`, + headline: w.headline ?? '', + description: w.description ?? '', + severity: mapDwdSeverity(w.severity), + event: w.event ?? '', + start: w.start ? new Date(w.start).toISOString() : '', + end: w.end ? new Date(w.end).toISOString() : '', + regionName: w.regionName ?? w.areaDesc ?? '', + }); + } + } + return alerts.slice(0, 50); // cap to avoid huge payloads +} + +function mapDwdSeverity(s?: string): string { + if (!s) return 'minor'; + const lower = s.toLowerCase(); + if (lower.includes('extreme')) return 'extreme'; + if (lower.includes('severe') || lower.includes('schwer')) return 'severe'; + if (lower.includes('moderate') || lower.includes('markant')) return 'moderate'; + return 'minor'; +} + +// ─── Rainbow.ai Nowcast ──────────────────────────────────── + +const RAINBOW_API_KEY = process.env.RAINBOW_API_KEY || ''; + +routes.post('/nowcast', async (c) => { + const { lat, lon } = await c.req.json<{ lat: number; lon: number }>(); + if (lat == null || lon == null) return c.json({ error: 'lat and lon required' }, 400); + + if (!RAINBOW_API_KEY) { + // Fallback: use Open-Meteo 15-min precipitation data as nowcast substitute + return await openMeteoNowcast(c, lat, lon); + } + + const key = `nowcast:${coordKey(lat, lon)}`; + const cached = getCached(key); + if (cached) return c.json(cached); + + try { + const url = `https://api.rainbow.ai/v2/nowcast?lat=${lat}&lon=${lon}`; + const res = await fetch(url, { + headers: { 'X-API-Key': RAINBOW_API_KEY, Accept: 'application/json' }, + }); + if (!res.ok) { + return await openMeteoNowcast(c, lat, lon); + } + + const data = await res.json(); + setCache(key, data, 5 * 60 * 1000); // 5min TTL for nowcast + return c.json(data); + } catch { + return await openMeteoNowcast(c, lat, lon); + } +}); + +async function openMeteoNowcast( + c: { json: (data: unknown, status?: number) => Response }, + lat: number, + lon: number +) { + const key = `nowcast-om:${coordKey(lat, lon)}`; + const cached = getCached(key); + if (cached) return c.json(cached); + + try { + const url = `${OPEN_METEO_BASE}/forecast?latitude=${lat}&longitude=${lon}&minutely_15=precipitation&models=icon_d2&timezone=auto&forecast_hours=4`; + const res = await fetch(url); + if (!res.ok) return c.json({ minutely: [], summary: 'Keine Niederschlagsdaten verfuegbar' }); + + const data = (await res.json()) as { + minutely_15?: { time?: string[]; precipitation?: number[] }; + }; + const times = data.minutely_15?.time ?? []; + const precips = data.minutely_15?.precipitation ?? []; + const minutely = times.map((t: string, i: number) => ({ + time: t, + precipitation: precips[i] ?? 0, + })); + + const hasRain = minutely.some((m: { precipitation: number }) => m.precipitation > 0); + const summary = hasRain ? 'Niederschlag erwartet' : 'Kein Niederschlag erwartet'; + const payload = { minutely, summary }; + setCache(key, payload, 5 * 60 * 1000); + return c.json(payload); + } catch { + return c.json({ minutely: [], summary: 'Keine Niederschlagsdaten verfuegbar' }); + } +} + +// ─── Radar Tile Info ─────────────────────────────────────── + +routes.get('/radar-tiles', (c) => { + // Return tile URL templates for the frontend to render on a map + if (RAINBOW_API_KEY) { + return c.json({ + provider: 'rainbow', + tileUrl: 'https://tilecache.rainbow.ai/v2/radar/{z}/{x}/{y}.png', + apiKey: RAINBOW_API_KEY, + attribution: 'Rainbow.ai', + }); + } + // Fallback: RainViewer free tiles (personal use) + return c.json({ + provider: 'rainviewer', + tileUrl: + 'https://tilecache.rainviewer.com/v2/radar/{ts}/{size}/{z}/{x}/{y}/{color}/{options}.png', + apiKey: null, + attribution: 'RainViewer', + infoUrl: 'https://api.rainviewer.com/public/weather-maps.json', + }); +}); + +export { routes as wetterRoutes }; diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 8f25274e0..a731a31b0 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -563,6 +563,24 @@ db.version(23).stores({ userContext: 'id', }); +// v25 — Wetter module: saved locations and user preferences. +db.version(25).stores({ + wetterLocations: 'id, isDefault, order', + wetterSettings: 'id', +}); + +// v26 — Library module: single-table media log with a `kind` discriminator. +// v24 + v25 are reserved for the wishes + wetter modules being developed +// in parallel; library jumps to v26 to avoid colliding with those. +// Index strategy: +// - kind indexes the tab filter (book / movie / series / comic) — hottest path. +// - status powers the "Läuft / Fertig / Geplant" filter strip. +// - completedAt gives the Jahresrückblick a cheap range scan of completed items. +// - isFavorite supports the favourites-only toggle without a full-table filter. +db.version(26).stores({ + libraryEntries: 'id, kind, status, completedAt, isFavorite', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 38ab55a0a..05a53553d 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -98,6 +98,8 @@ import { moodModuleConfig } from '$lib/modules/mood/module.config'; import { kontextModuleConfig } from '$lib/modules/kontext/module.config'; import { quizModuleConfig } from '$lib/modules/quiz/module.config'; import { profileModuleConfig } from '$lib/modules/profile/module.config'; +import { libraryModuleConfig } from '$lib/modules/library/module.config'; +import { wetterModuleConfig } from '$lib/modules/wetter/module.config'; import { aiModuleConfig } from '$lib/data/ai/module.config'; export const MODULE_CONFIGS: readonly ModuleConfig[] = [ @@ -152,6 +154,8 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ kontextModuleConfig, quizModuleConfig, profileModuleConfig, + libraryModuleConfig, + wetterModuleConfig, aiModuleConfig, ]; diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index 6b854d9d5..6359f8468 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -38,6 +38,8 @@ import { sleepTools } from '$lib/modules/sleep/tools'; import { mydayTools } from '$lib/modules/myday/tools'; import { goalsTools } from '$lib/modules/goals/tools'; import { moodTools } from '$lib/modules/mood/tools'; +import { wishesTools } from '$lib/modules/wishes/tools'; +import { wetterTools } from '$lib/modules/wetter/tools'; let initialized = false; @@ -77,5 +79,7 @@ export function initTools(): void { registerTools(mydayTools); registerTools(goalsTools); registerTools(moodTools); + registerTools(wishesTools); + registerTools(wetterTools); initialized = true; } diff --git a/apps/mana/apps/web/src/lib/modules/wetter/api.ts b/apps/mana/apps/web/src/lib/modules/wetter/api.ts new file mode 100644 index 000000000..713cc18c4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/api.ts @@ -0,0 +1,171 @@ +/** + * Wetter API client — talks to apps/api `/api/v1/wetter/*`. + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; +import type { + CurrentWeather, + HourlyForecast, + DailyForecast, + WeatherAlert, + RainNowcast, + GeocodingResult, +} from './types'; + +async function authHeader(): Promise> { + const token = await authStore.getValidToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function post(path: string, body: unknown): Promise { + const res = await fetch(`${getManaApiUrl()}/api/v1/wetter${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(await authHeader()) }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + let message = text; + try { + const parsed = JSON.parse(text) as { message?: string; error?: string }; + message = parsed.message ?? parsed.error ?? text; + } catch { + // text was not JSON + } + throw new Error(message || `wetter ${path} failed (${res.status})`); + } + return (await res.json()) as T; +} + +// ─── Open-Meteo Response Transformers ────────────────────── + +interface OpenMeteoCurrentResponse { + current?: { + temperature_2m?: number; + apparent_temperature?: number; + weather_code?: number; + relative_humidity_2m?: number; + surface_pressure?: number; + wind_speed_10m?: number; + wind_direction_10m?: number; + uv_index?: number; + precipitation?: number; + cloud_cover?: number; + visibility?: number; + is_day?: number; + }; +} + +interface OpenMeteoForecastResponse { + hourly?: { + time?: string[]; + temperature_2m?: number[]; + precipitation?: number[]; + precipitation_probability?: number[]; + weather_code?: number[]; + wind_speed_10m?: number[]; + wind_direction_10m?: number[]; + relative_humidity_2m?: number[]; + apparent_temperature?: number[]; + is_day?: number[]; + }; + daily?: { + time?: string[]; + temperature_2m_min?: number[]; + temperature_2m_max?: number[]; + weather_code?: number[]; + precipitation_sum?: number[]; + precipitation_probability_max?: number[]; + sunrise?: string[]; + sunset?: string[]; + uv_index_max?: number[]; + wind_speed_10m_max?: number[]; + wind_direction_10m_dominant?: number[]; + }; +} + +function transformCurrent(raw: OpenMeteoCurrentResponse): CurrentWeather { + const c = raw.current ?? {}; + return { + temperature: c.temperature_2m ?? 0, + feelsLike: c.apparent_temperature ?? 0, + weatherCode: c.weather_code ?? 0, + humidity: c.relative_humidity_2m ?? 0, + pressure: c.surface_pressure ?? 0, + windSpeed: c.wind_speed_10m ?? 0, + windDirection: c.wind_direction_10m ?? 0, + uvIndex: c.uv_index ?? 0, + precipitation: c.precipitation ?? 0, + cloudCover: c.cloud_cover ?? 0, + visibility: c.visibility ?? 0, + isDay: (c.is_day ?? 1) === 1, + }; +} + +function transformHourly(raw: OpenMeteoForecastResponse): HourlyForecast[] { + const h = raw.hourly ?? {}; + const times = h.time ?? []; + return times.map((t, i) => ({ + time: t, + temperature: h.temperature_2m?.[i] ?? 0, + precipitation: h.precipitation?.[i] ?? 0, + precipitationProbability: h.precipitation_probability?.[i] ?? 0, + weatherCode: h.weather_code?.[i] ?? 0, + windSpeed: h.wind_speed_10m?.[i] ?? 0, + windDirection: h.wind_direction_10m?.[i] ?? 0, + humidity: h.relative_humidity_2m?.[i] ?? 0, + feelsLike: h.apparent_temperature?.[i] ?? 0, + isDay: (h.is_day?.[i] ?? 1) === 1, + })); +} + +function transformDaily(raw: OpenMeteoForecastResponse): DailyForecast[] { + const d = raw.daily ?? {}; + const dates = d.time ?? []; + return dates.map((date, i) => ({ + date, + temperatureMin: d.temperature_2m_min?.[i] ?? 0, + temperatureMax: d.temperature_2m_max?.[i] ?? 0, + weatherCode: d.weather_code?.[i] ?? 0, + precipitationSum: d.precipitation_sum?.[i] ?? 0, + precipitationProbabilityMax: d.precipitation_probability_max?.[i] ?? 0, + sunrise: d.sunrise?.[i] ?? '', + sunset: d.sunset?.[i] ?? '', + uvIndexMax: d.uv_index_max?.[i] ?? 0, + windSpeedMax: d.wind_speed_10m_max?.[i] ?? 0, + windDirection: d.wind_direction_10m_dominant?.[i] ?? 0, + })); +} + +// ─── Public API ──────────────────────────────────────────── + +export async function getCurrentWeather(lat: number, lon: number): Promise { + const raw = await post('/current', { lat, lon }); + return transformCurrent(raw); +} + +export async function getForecast( + lat: number, + lon: number +): Promise<{ hourly: HourlyForecast[]; daily: DailyForecast[] }> { + const raw = await post('/forecast', { lat, lon }); + return { + hourly: transformHourly(raw), + daily: transformDaily(raw), + }; +} + +export async function getAlerts(lat: number, lon: number): Promise { + const raw = await post<{ alerts: WeatherAlert[] }>('/alerts', { lat, lon }); + return raw.alerts ?? []; +} + +export async function getNowcast(lat: number, lon: number): Promise { + return post('/nowcast', { lat, lon }); +} + +export async function geocode(query: string): Promise { + const raw = await post<{ results: GeocodingResult[] }>('/geocode', { query }); + return raw.results ?? []; +} diff --git a/apps/mana/apps/web/src/lib/modules/wetter/components/CurrentConditions.svelte b/apps/mana/apps/web/src/lib/modules/wetter/components/CurrentConditions.svelte new file mode 100644 index 000000000..2a5fdf78b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/components/CurrentConditions.svelte @@ -0,0 +1,118 @@ + + + +
+
{locationName}
+
+ {getWeatherIcon(current.weatherCode, current.isDay)} + {Math.round(current.temperature)}° +
+ {getWeatherLabel(current.weatherCode)} + Gefuehlt {Math.round(current.feelsLike)}° +
+
+
+
+ 💨 + {Math.round(current.windSpeed)} km/h + {windDirectionLabel(current.windDirection)} +
+
+ 💧 + {current.humidity}% + Feuchtigkeit +
+
+ 🌡 + {Math.round(current.pressure)} hPa + Druck +
+
+ ☀️ + {current.uvIndex} + UV-Index +
+
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/wetter/components/DailyForecast.svelte b/apps/mana/apps/web/src/lib/modules/wetter/components/DailyForecast.svelte new file mode 100644 index 000000000..b2c4a513e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/components/DailyForecast.svelte @@ -0,0 +1,133 @@ + + + +
+ +
+ {#each visibleDays as day, idx (day.date)} +
+ {dayLabel(day.date, idx)} + + {getWeatherIcon(day.weatherCode)} + + {#if day.precipitationProbabilityMax > 10} + {day.precipitationProbabilityMax}% + {:else} + + {/if} + {Math.round(day.temperatureMin)}° +
+
+
+ {Math.round(day.temperatureMax)}° +
+ {/each} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/wetter/components/HourlyForecast.svelte b/apps/mana/apps/web/src/lib/modules/wetter/components/HourlyForecast.svelte new file mode 100644 index 000000000..80340059d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/components/HourlyForecast.svelte @@ -0,0 +1,101 @@ + + + +
+ +
+ {#each visibleHours as hour (hour.time)} + {@const time = new Date(hour.time).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + })} + {@const isNow = Math.abs(Date.now() - new Date(hour.time).getTime()) < 30 * 60 * 1000} +
+ {isNow ? 'Jetzt' : time} + {getWeatherIcon(hour.weatherCode, hour.isDay)} + {Math.round(hour.temperature)}° + {#if hour.precipitationProbability > 0} + + {hour.precipitationProbability}% + + {/if} +
+ {/each} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/wetter/components/LocationPicker.svelte b/apps/mana/apps/web/src/lib/modules/wetter/components/LocationPicker.svelte new file mode 100644 index 000000000..9a5c29af0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/components/LocationPicker.svelte @@ -0,0 +1,272 @@ + + + +
+
+ {#if locations.length > 0} +
+ {#each locations as loc (loc.id)} + + {/each} +
+ {/if} +
+ + +
+
+ + {#if showSearch} +
+ + +
+ + {#if searchResults.length > 0} +
+ {#each searchResults as result} +
selectSearchResult(result)} + onkeydown={(e: KeyboardEvent) => { + if (e.key === 'Enter') selectSearchResult(result); + }} + > + {result.name} + + {result.admin1 ? `${result.admin1}, ` : ''}{result.country} + + +
+ {/each} +
+ {/if} + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/wetter/components/NowcastBar.svelte b/apps/mana/apps/web/src/lib/modules/wetter/components/NowcastBar.svelte new file mode 100644 index 000000000..e402a7d6b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/components/NowcastBar.svelte @@ -0,0 +1,97 @@ + + + +
+ +
{nowcast.summary}
+ {#if nowcast.minutely.length > 0} +
+ {#each nowcast.minutely as point (point.time)} + {@const height = Math.max(2, (point.precipitation / maxPrecip) * 100)} + {@const time = new Date(point.time).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + })} +
+
0} style:height="{height}%">
+
+ {/each} +
+
+ {new Date(nowcast.minutely[0].time).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + })} + {new Date(nowcast.minutely[nowcast.minutely.length - 1].time).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + })} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/wetter/components/WeatherAlerts.svelte b/apps/mana/apps/web/src/lib/modules/wetter/components/WeatherAlerts.svelte new file mode 100644 index 000000000..18313ffa5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/components/WeatherAlerts.svelte @@ -0,0 +1,105 @@ + + + +{#if alerts.length > 0} +
+ + {#each alerts.slice(0, 5) as alert (alert.id)} +
+
+ + {severityLabel(alert.severity)} + + {alert.event} +
+
{alert.headline}
+ {#if alert.regionName} +
{alert.regionName}
+ {/if} +
+ {/each} +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/wetter/index.ts b/apps/mana/apps/web/src/lib/modules/wetter/index.ts new file mode 100644 index 000000000..8054850d5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/index.ts @@ -0,0 +1,5 @@ +export { wetterModuleConfig } from './module.config'; +export { wetterTools } from './tools'; +export { weatherStore } from './stores/weather.svelte'; +export { locationsStore } from './stores/locations.svelte'; +export * from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/wetter/module.config.ts b/apps/mana/apps/web/src/lib/modules/wetter/module.config.ts new file mode 100644 index 000000000..ac2fd7fae --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/module.config.ts @@ -0,0 +1,9 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const wetterModuleConfig: ModuleConfig = { + appId: 'wetter', + tables: [ + { name: 'wetterLocations', syncName: 'locations' }, + { name: 'wetterSettings', syncName: 'settings' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/wetter/queries.ts b/apps/mana/apps/web/src/lib/modules/wetter/queries.ts new file mode 100644 index 000000000..67fdbd12b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/queries.ts @@ -0,0 +1,43 @@ +/** + * Wetter module read-side — Dexie liveQuery hooks for locations. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import type { WeatherLocation, WeatherSettings } from './types'; + +export function useLocations() { + return useLiveQueryWithDefault( + () => db.table('wetterLocations').orderBy('order').toArray(), + [] as WeatherLocation[] + ); +} + +export function useDefaultLocation() { + return useLiveQueryWithDefault( + () => db.table('wetterLocations').where('isDefault').equals(1).first(), + undefined as WeatherLocation | undefined + ); +} + +export function useSettings() { + return useLiveQueryWithDefault( + async () => { + const settings = await db.table('wetterSettings').get('default'); + return ( + settings ?? { + id: 'default', + temperatureUnit: 'celsius', + windSpeedUnit: 'kmh', + precipitationUnit: 'mm', + } + ); + }, + { + id: 'default', + temperatureUnit: 'celsius', + windSpeedUnit: 'kmh', + precipitationUnit: 'mm', + } as WeatherSettings + ); +} diff --git a/apps/mana/apps/web/src/lib/modules/wetter/stores/locations.svelte.ts b/apps/mana/apps/web/src/lib/modules/wetter/stores/locations.svelte.ts new file mode 100644 index 000000000..013be60c4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/stores/locations.svelte.ts @@ -0,0 +1,49 @@ +/** + * Weather locations store — CRUD for saved locations in Dexie. + * Locations sync across devices via mana-sync. + */ + +import { db } from '$lib/data/database'; +import type { WeatherLocation } from '../types'; + +const table = db.table('wetterLocations'); + +export const locationsStore = { + async addLocation(name: string, lat: number, lon: number): Promise { + const count = await table.count(); + const loc: WeatherLocation = { + id: crypto.randomUUID(), + name, + lat, + lon, + isDefault: count === 0, // first location becomes default + order: count, + createdAt: Date.now(), + }; + await table.add(loc); + return loc; + }, + + async removeLocation(id: string): Promise { + const loc = await table.get(id); + await table.delete(id); + // If we deleted the default, make the first remaining one default + if (loc?.isDefault) { + const first = await table.orderBy('order').first(); + if (first) { + await table.update(first.id, { isDefault: true }); + } + } + }, + + async setDefault(id: string): Promise { + // Unset all defaults, then set the new one + const all = await table.toArray(); + const updates = all.map((loc) => table.update(loc.id, { isDefault: loc.id === id })); + await Promise.all(updates); + }, + + async updateOrder(id: string, newOrder: number): Promise { + await table.update(id, { order: newOrder }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/wetter/stores/weather.svelte.ts b/apps/mana/apps/web/src/lib/modules/wetter/stores/weather.svelte.ts new file mode 100644 index 000000000..402ae08b7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/stores/weather.svelte.ts @@ -0,0 +1,94 @@ +/** + * Ephemeral weather data store. Lives in memory only — weather data + * is fetched fresh from the API. Auto-refreshes every 15 minutes. + */ + +import * as api from '../api'; +import type { WeatherData, RainNowcast } from '../types'; + +const REFRESH_INTERVAL = 15 * 60 * 1000; // 15 min + +function createStore() { + let weatherData = $state(null); + let nowcast = $state(null); + let loading = $state(false); + let error = $state(null); + let refreshTimer: ReturnType | null = null; + let lastCoords: { lat: number; lon: number } | null = null; + + async function fetchWeather(lat: number, lon: number, locationName?: string) { + loading = true; + error = null; + lastCoords = { lat, lon }; + + try { + const [current, forecast, alerts] = await Promise.all([ + api.getCurrentWeather(lat, lon), + api.getForecast(lat, lon), + api.getAlerts(lat, lon), + ]); + + weatherData = { + current, + hourly: forecast.hourly, + daily: forecast.daily, + alerts, + location: { name: locationName ?? `${lat.toFixed(2)}, ${lon.toFixed(2)}`, lat, lon }, + fetchedAt: Date.now(), + }; + + // Start auto-refresh + startAutoRefresh(); + } catch (e) { + error = e instanceof Error ? e.message : 'Wetterdaten konnten nicht geladen werden'; + } finally { + loading = false; + } + } + + async function fetchNowcast(lat: number, lon: number) { + try { + nowcast = await api.getNowcast(lat, lon); + } catch { + nowcast = { minutely: [], summary: 'Niederschlagsdaten nicht verfuegbar' }; + } + } + + function startAutoRefresh() { + stopAutoRefresh(); + refreshTimer = setInterval(() => { + if (lastCoords) { + const name = weatherData?.location.name; + fetchWeather(lastCoords.lat, lastCoords.lon, name); + fetchNowcast(lastCoords.lat, lastCoords.lon); + } + }, REFRESH_INTERVAL); + } + + function stopAutoRefresh() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } + + return { + get weatherData() { + return weatherData; + }, + get nowcast() { + return nowcast; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + fetchWeather, + fetchNowcast, + stopAutoRefresh, + }; +} + +export const weatherStore = createStore(); diff --git a/apps/mana/apps/web/src/lib/modules/wetter/tools.ts b/apps/mana/apps/web/src/lib/modules/wetter/tools.ts new file mode 100644 index 000000000..8e6fc7280 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/tools.ts @@ -0,0 +1,172 @@ +/** + * Wetter AI tools — expose weather data to the AI companion. + * Both tools are read-only (auto policy) and run during the reasoning loop. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import * as api from './api'; +import { getWeatherLabel, windDirectionLabel } from './weather-icons'; + +export const wetterTools: ModuleTool[] = [ + { + name: 'get_weather', + module: 'wetter', + description: + 'Gibt aktuelle Wetterbedingungen und 7-Tage-Vorhersage fuer einen Ort zurueck. Akzeptiert Ortsname (z.B. "Berlin") oder Koordinaten (z.B. "48.13,11.58").', + parameters: [ + { + name: 'location', + type: 'string', + description: 'Ortsname oder "lat,lon" Koordinaten', + required: true, + }, + ], + async execute(params) { + const location = String(params.location ?? '').trim(); + if (!location) { + return { success: false, message: 'location is required' }; + } + + let lat: number; + let lon: number; + let name: string; + + // Check if coordinates + const coordMatch = location.match(/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/); + if (coordMatch) { + lat = parseFloat(coordMatch[1]); + lon = parseFloat(coordMatch[2]); + name = `${lat}, ${lon}`; + } else { + // Geocode + const results = await api.geocode(location); + if (results.length === 0) { + return { success: false, message: `Ort "${location}" nicht gefunden` }; + } + lat = results[0].lat; + lon = results[0].lon; + name = results[0].admin1 ? `${results[0].name}, ${results[0].admin1}` : results[0].name; + } + + const [current, forecast, alerts] = await Promise.all([ + api.getCurrentWeather(lat, lon), + api.getForecast(lat, lon), + api.getAlerts(lat, lon), + ]); + + const lines = [ + `# Wetter fuer ${name}`, + '', + `## Aktuell`, + `${getWeatherLabel(current.weatherCode)} | ${current.temperature}°C (gefuehlt ${current.feelsLike}°C)`, + `Wind: ${current.windSpeed} km/h ${windDirectionLabel(current.windDirection)}`, + `Luftfeuchtigkeit: ${current.humidity}% | Druck: ${current.pressure} hPa`, + `UV-Index: ${current.uvIndex} | Niederschlag: ${current.precipitation} mm`, + '', + ]; + + if (alerts.length > 0) { + lines.push('## Wetterwarnungen'); + for (const a of alerts.slice(0, 5)) { + lines.push(`- **${a.severity.toUpperCase()}**: ${a.headline} (${a.regionName})`); + } + lines.push(''); + } + + lines.push('## 7-Tage-Vorhersage'); + for (const d of forecast.daily.slice(0, 7)) { + const day = new Date(d.date).toLocaleDateString('de-DE', { + weekday: 'short', + day: 'numeric', + month: 'short', + }); + lines.push( + `- ${day}: ${getWeatherLabel(d.weatherCode)}, ${d.temperatureMin}°–${d.temperatureMax}°C, Regen: ${d.precipitationSum}mm (${d.precipitationProbabilityMax}%)` + ); + } + + const context = lines.join('\n'); + return { + success: true, + message: `Wetter fuer ${name}: ${current.temperature}°C, ${getWeatherLabel(current.weatherCode)}`, + data: { context, location: { name, lat, lon } }, + }; + }, + }, + { + name: 'get_rain_forecast', + module: 'wetter', + description: + 'Gibt eine Minuten-Regenprognose (Nowcast) und aktive Wetterwarnungen fuer einen Ort zurueck. Nuetzlich um zu wissen ob es bald regnet.', + parameters: [ + { + name: 'location', + type: 'string', + description: 'Ortsname oder "lat,lon" Koordinaten', + required: true, + }, + ], + async execute(params) { + const location = String(params.location ?? '').trim(); + if (!location) { + return { success: false, message: 'location is required' }; + } + + let lat: number; + let lon: number; + let name: string; + + const coordMatch = location.match(/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/); + if (coordMatch) { + lat = parseFloat(coordMatch[1]); + lon = parseFloat(coordMatch[2]); + name = `${lat}, ${lon}`; + } else { + const results = await api.geocode(location); + if (results.length === 0) { + return { success: false, message: `Ort "${location}" nicht gefunden` }; + } + lat = results[0].lat; + lon = results[0].lon; + name = results[0].admin1 ? `${results[0].name}, ${results[0].admin1}` : results[0].name; + } + + const [nowcast, alerts] = await Promise.all([ + api.getNowcast(lat, lon), + api.getAlerts(lat, lon), + ]); + + const lines = [`# Regenprognose fuer ${name}`, '', `Zusammenfassung: ${nowcast.summary}`, '']; + + if (nowcast.minutely.length > 0) { + lines.push('## Niederschlag (naechste Stunden)'); + const withRain = nowcast.minutely.filter((m) => m.precipitation > 0); + if (withRain.length === 0) { + lines.push('Kein Niederschlag erwartet.'); + } else { + for (const m of withRain.slice(0, 12)) { + const t = new Date(m.time).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + }); + lines.push(`- ${t}: ${m.precipitation.toFixed(1)} mm`); + } + } + lines.push(''); + } + + if (alerts.length > 0) { + lines.push('## Aktive Warnungen'); + for (const a of alerts.slice(0, 5)) { + lines.push(`- **${a.severity.toUpperCase()}**: ${a.headline}`); + } + } + + return { + success: true, + message: nowcast.summary, + data: { context: lines.join('\n') }, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/wetter/types.ts b/apps/mana/apps/web/src/lib/modules/wetter/types.ts new file mode 100644 index 000000000..1cb6d597a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/types.ts @@ -0,0 +1,103 @@ +/** + * Wetter module types — weather data structures for Open-Meteo, + * DWD alerts, and Rainbow.ai nowcast. + */ + +// ─── Persisted (Dexie) ─────────────────────────────────────── + +export interface WeatherLocation { + id: string; + name: string; + lat: number; + lon: number; + isDefault: boolean; + order: number; + createdAt: number; +} + +export interface WeatherSettings { + id: string; + /** Temperature unit: 'celsius' | 'fahrenheit' */ + temperatureUnit: 'celsius' | 'fahrenheit'; + /** Wind speed unit */ + windSpeedUnit: 'kmh' | 'ms' | 'mph' | 'kn'; + /** Precipitation unit */ + precipitationUnit: 'mm' | 'inch'; +} + +// ─── API Response Types ────────────────────────────────────── + +export interface CurrentWeather { + temperature: number; + feelsLike: number; + weatherCode: number; + humidity: number; + pressure: number; + windSpeed: number; + windDirection: number; + uvIndex: number; + precipitation: number; + cloudCover: number; + visibility: number; + isDay: boolean; +} + +export interface HourlyForecast { + time: string; + temperature: number; + precipitation: number; + precipitationProbability: number; + weatherCode: number; + windSpeed: number; + windDirection: number; + humidity: number; + feelsLike: number; + isDay: boolean; +} + +export interface DailyForecast { + date: string; + temperatureMin: number; + temperatureMax: number; + weatherCode: number; + precipitationSum: number; + precipitationProbabilityMax: number; + sunrise: string; + sunset: string; + uvIndexMax: number; + windSpeedMax: number; + windDirection: number; +} + +export interface WeatherAlert { + id: string; + headline: string; + description: string; + severity: 'minor' | 'moderate' | 'severe' | 'extreme'; + event: string; + start: string; + end: string; + regionName: string; +} + +export interface RainNowcast { + minutely: { time: string; precipitation: number }[]; + summary: string; +} + +export interface WeatherData { + current: CurrentWeather; + hourly: HourlyForecast[]; + daily: DailyForecast[]; + alerts: WeatherAlert[]; + location: { name: string; lat: number; lon: number }; + fetchedAt: number; +} + +export interface GeocodingResult { + name: string; + lat: number; + lon: number; + country: string; + admin1?: string; +} diff --git a/apps/mana/apps/web/src/lib/modules/wetter/weather-icons.ts b/apps/mana/apps/web/src/lib/modules/wetter/weather-icons.ts new file mode 100644 index 000000000..8564cbc51 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/wetter/weather-icons.ts @@ -0,0 +1,104 @@ +/** + * WMO Weather Interpretation Codes (WW) mapped to labels and icons. + * Ref: https://open-meteo.com/en/docs#weathervariables + * + * Returns unicode weather symbols for lightweight rendering. + */ + +interface WeatherInfo { + label: string; + labelEn: string; + icon: string; + iconNight?: string; +} + +const WMO_CODES: Record = { + 0: { label: 'Klar', labelEn: 'Clear sky', icon: '\u2600\uFE0F', iconNight: '\uD83C\uDF19' }, + 1: { + label: 'Ueberwiegend klar', + labelEn: 'Mainly clear', + icon: '\uD83C\uDF24\uFE0F', + iconNight: '\uD83C\uDF19', + }, + 2: { + label: 'Teilweise bewoelkt', + labelEn: 'Partly cloudy', + icon: '\u26C5', + iconNight: '\uD83C\uDF19', + }, + 3: { label: 'Bedeckt', labelEn: 'Overcast', icon: '\u2601\uFE0F' }, + 45: { label: 'Nebel', labelEn: 'Fog', icon: '\uD83C\uDF2B\uFE0F' }, + 48: { label: 'Nebel mit Reif', labelEn: 'Depositing rime fog', icon: '\uD83C\uDF2B\uFE0F' }, + 51: { label: 'Leichter Nieselregen', labelEn: 'Light drizzle', icon: '\uD83C\uDF26\uFE0F' }, + 53: { label: 'Nieselregen', labelEn: 'Moderate drizzle', icon: '\uD83C\uDF26\uFE0F' }, + 55: { label: 'Starker Nieselregen', labelEn: 'Dense drizzle', icon: '\uD83C\uDF27\uFE0F' }, + 56: { + label: 'Gefrierender Nieselregen', + labelEn: 'Freezing light drizzle', + icon: '\uD83C\uDF27\uFE0F', + }, + 57: { + label: 'Starker gefrierender Nieselregen', + labelEn: 'Freezing dense drizzle', + icon: '\uD83C\uDF27\uFE0F', + }, + 61: { label: 'Leichter Regen', labelEn: 'Slight rain', icon: '\uD83C\uDF26\uFE0F' }, + 63: { label: 'Regen', labelEn: 'Moderate rain', icon: '\uD83C\uDF27\uFE0F' }, + 65: { label: 'Starker Regen', labelEn: 'Heavy rain', icon: '\uD83C\uDF27\uFE0F' }, + 66: { label: 'Gefrierender Regen', labelEn: 'Freezing light rain', icon: '\uD83C\uDF27\uFE0F' }, + 67: { + label: 'Starker gefrierender Regen', + labelEn: 'Freezing heavy rain', + icon: '\uD83C\uDF27\uFE0F', + }, + 71: { label: 'Leichter Schneefall', labelEn: 'Slight snow fall', icon: '\uD83C\uDF28\uFE0F' }, + 73: { label: 'Schneefall', labelEn: 'Moderate snow fall', icon: '\uD83C\uDF28\uFE0F' }, + 75: { label: 'Starker Schneefall', labelEn: 'Heavy snow fall', icon: '\uD83C\uDF28\uFE0F' }, + 77: { label: 'Schneegriesel', labelEn: 'Snow grains', icon: '\uD83C\uDF28\uFE0F' }, + 80: { label: 'Leichte Regenschauer', labelEn: 'Slight rain showers', icon: '\uD83C\uDF26\uFE0F' }, + 81: { label: 'Regenschauer', labelEn: 'Moderate rain showers', icon: '\uD83C\uDF27\uFE0F' }, + 82: { + label: 'Heftige Regenschauer', + labelEn: 'Violent rain showers', + icon: '\uD83C\uDF27\uFE0F', + }, + 85: { + label: 'Leichte Schneeschauer', + labelEn: 'Slight snow showers', + icon: '\uD83C\uDF28\uFE0F', + }, + 86: { label: 'Starke Schneeschauer', labelEn: 'Heavy snow showers', icon: '\uD83C\uDF28\uFE0F' }, + 95: { label: 'Gewitter', labelEn: 'Thunderstorm', icon: '\u26C8\uFE0F' }, + 96: { + label: 'Gewitter mit leichtem Hagel', + labelEn: 'Thunderstorm with slight hail', + icon: '\u26C8\uFE0F', + }, + 99: { + label: 'Gewitter mit starkem Hagel', + labelEn: 'Thunderstorm with heavy hail', + icon: '\u26C8\uFE0F', + }, +}; + +const FALLBACK: WeatherInfo = { label: 'Unbekannt', labelEn: 'Unknown', icon: '\u2753' }; + +export function getWeatherInfo(code: number): WeatherInfo { + return WMO_CODES[code] ?? FALLBACK; +} + +export function getWeatherIcon(code: number, isDay = true): string { + const info = getWeatherInfo(code); + return !isDay && info.iconNight ? info.iconNight : info.icon; +} + +export function getWeatherLabel(code: number): string { + return getWeatherInfo(code).label; +} + +/** Wind direction degrees to compass label */ +export function windDirectionLabel(deg: number): string { + const dirs = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW']; + const idx = Math.round(deg / 45) % 8; + return dirs[idx]; +} diff --git a/apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte b/apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte new file mode 100644 index 000000000..4ef593773 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte @@ -0,0 +1,175 @@ + + + + + Wetter - Mana + + +
+ + + {#if weatherStore.loading && !weatherStore.weatherData} +
+ 🌤 + Wetterdaten werden geladen... +
+ {:else if weatherStore.error && !weatherStore.weatherData} +
+ {weatherStore.error} + {#if selectedLat != null && selectedLon != null} + + {/if} +
+ {:else if weatherStore.weatherData} + {@const data = weatherStore.weatherData} + + + + + + {#if weatherStore.nowcast} + + {/if} + + {#if data.hourly.length > 0} + + {/if} + + {#if data.daily.length > 0} + + {/if} + + {#if weatherStore.loading} +
Aktualisierung...
+ {/if} + {/if} +
+ + diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 94a9091b6..cdb42dd51 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -854,6 +854,38 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ defaultPolicy: 'auto', parameters: [], }, + + // ── Wetter ─────────────────────────────────────────────────── + { + name: 'get_weather', + module: 'wetter', + description: + 'Gibt aktuelle Wetterbedingungen und 7-Tage-Vorhersage fuer einen Ort zurueck. Akzeptiert Ortsname oder Koordinaten.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'location', + type: 'string', + description: 'Ortsname (z.B. "Berlin") oder "lat,lon" Koordinaten', + required: true, + }, + ], + }, + { + name: 'get_rain_forecast', + module: 'wetter', + description: + 'Gibt eine Minuten-Regenprognose (Nowcast) und aktive Wetterwarnungen fuer einen Ort zurueck.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'location', + type: 'string', + description: 'Ortsname oder "lat,lon" Koordinaten', + required: true, + }, + ], + }, ]; // ═══════════════════════════════════════════════════════════════ diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 26001c0a1..24c3c44f6 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -217,6 +217,11 @@ export const APP_ICONS = { goals: svgToDataUrl( `` ), + wetter: svgToDataUrl( + // Sun partially behind cloud with rain drops — weather / forecast. + // Sky-blue gradient for the weather theme. + `` + ), } as const; export type AppIconId = keyof typeof APP_ICONS; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index dc595052b..7fe5c0ea9 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -969,6 +969,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'guest', }, + { + id: 'wetter', + name: 'Wetter', + description: { + de: 'Wetter & Regenradar', + en: 'Weather & Rain Radar', + }, + longDescription: { + de: 'Aktuelle Wetterdaten, Vorhersage und Regenradar fuer die DACH-Region. DWD-Warnungen und Minuten-Niederschlagsprognose.', + en: 'Current weather, forecast, and rain radar for the DACH region. DWD alerts and minute-level precipitation nowcast.', + }, + icon: APP_ICONS.wetter, + color: '#38bdf8', + comingSoon: false, + status: 'development', + requiredTier: 'guest', + }, ]; /**