feat(wetter): add weather module with Open-Meteo, DWD alerts, and rain nowcast

New module providing weather data for the DACH region via three sources:
- Open-Meteo (DWD ICON-D2 model) for current conditions and 7-day forecast
- DWD warnings endpoint for severe weather alerts
- Rainbow.ai / Open-Meteo fallback for minute-level rain nowcast

Includes API proxy with in-memory caching, Svelte 5 UI with location
picker, hourly/daily forecast, alert cards, and precipitation bar chart.
Two AI tools (get_weather, get_rain_forecast) enable the companion to
answer weather questions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 03:46:15 +02:00
parent 20aeccfaca
commit 62aac6dfdb
24 changed files with 2179 additions and 0 deletions

View file

@ -38,6 +38,7 @@ import { tracesRoutes } from './modules/traces/routes';
import { presiRoutes } from './modules/presi/routes'; import { presiRoutes } from './modules/presi/routes';
import { researchRoutes } from './modules/research/routes'; import { researchRoutes } from './modules/research/routes';
import { whoRoutes } from './modules/who/routes'; import { whoRoutes } from './modules/who/routes';
import { wetterRoutes } from './modules/wetter/routes';
const PORT = parseInt(process.env.PORT || '3060', 10); const PORT = parseInt(process.env.PORT || '3060', 10);
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','); 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/presi', presiRoutes);
app.route('/api/v1/research', researchRoutes); app.route('/api/v1/research', researchRoutes);
app.route('/api/v1/who', whoRoutes); app.route('/api/v1/who', whoRoutes);
app.route('/api/v1/wetter', wetterRoutes);
// ─── Server Info ──────────────────────────────────────────── // ─── Server Info ────────────────────────────────────────────
console.log(`mana-api starting on port ${PORT}...`); console.log(`mana-api starting on port ${PORT}...`);

View file

@ -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<T> {
data: T;
expiresAt: number;
}
const cache = new Map<string, CacheEntry<unknown>>();
function getCached<T>(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<T>(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}&current=${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<string, DwdWarning[]> =
(raw as Record<string, Record<string, DwdWarning[]>>).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 };

View file

@ -563,6 +563,24 @@ db.version(23).stores({
userContext: 'id', 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 Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module // toSyncName() and fromSyncName() are now derived from per-module

View file

@ -98,6 +98,8 @@ import { moodModuleConfig } from '$lib/modules/mood/module.config';
import { kontextModuleConfig } from '$lib/modules/kontext/module.config'; import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
import { quizModuleConfig } from '$lib/modules/quiz/module.config'; import { quizModuleConfig } from '$lib/modules/quiz/module.config';
import { profileModuleConfig } from '$lib/modules/profile/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'; import { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -152,6 +154,8 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
kontextModuleConfig, kontextModuleConfig,
quizModuleConfig, quizModuleConfig,
profileModuleConfig, profileModuleConfig,
libraryModuleConfig,
wetterModuleConfig,
aiModuleConfig, aiModuleConfig,
]; ];

View file

@ -38,6 +38,8 @@ import { sleepTools } from '$lib/modules/sleep/tools';
import { mydayTools } from '$lib/modules/myday/tools'; import { mydayTools } from '$lib/modules/myday/tools';
import { goalsTools } from '$lib/modules/goals/tools'; import { goalsTools } from '$lib/modules/goals/tools';
import { moodTools } from '$lib/modules/mood/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; let initialized = false;
@ -77,5 +79,7 @@ export function initTools(): void {
registerTools(mydayTools); registerTools(mydayTools);
registerTools(goalsTools); registerTools(goalsTools);
registerTools(moodTools); registerTools(moodTools);
registerTools(wishesTools);
registerTools(wetterTools);
initialized = true; initialized = true;
} }

View file

@ -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<Record<string, string>> {
const token = await authStore.getValidToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function post<T>(path: string, body: unknown): Promise<T> {
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<CurrentWeather> {
const raw = await post<OpenMeteoCurrentResponse>('/current', { lat, lon });
return transformCurrent(raw);
}
export async function getForecast(
lat: number,
lon: number
): Promise<{ hourly: HourlyForecast[]; daily: DailyForecast[] }> {
const raw = await post<OpenMeteoForecastResponse>('/forecast', { lat, lon });
return {
hourly: transformHourly(raw),
daily: transformDaily(raw),
};
}
export async function getAlerts(lat: number, lon: number): Promise<WeatherAlert[]> {
const raw = await post<{ alerts: WeatherAlert[] }>('/alerts', { lat, lon });
return raw.alerts ?? [];
}
export async function getNowcast(lat: number, lon: number): Promise<RainNowcast> {
return post<RainNowcast>('/nowcast', { lat, lon });
}
export async function geocode(query: string): Promise<GeocodingResult[]> {
const raw = await post<{ results: GeocodingResult[] }>('/geocode', { query });
return raw.results ?? [];
}

View file

@ -0,0 +1,118 @@
<!--
Current weather conditions card — shows temperature, conditions,
wind, humidity, pressure, UV index.
-->
<script lang="ts">
import type { CurrentWeather } from '../types';
import { getWeatherIcon, getWeatherLabel, windDirectionLabel } from '../weather-icons';
interface Props {
current: CurrentWeather;
locationName: string;
}
let { current, locationName }: Props = $props();
</script>
<div class="current-card">
<div class="location-name">{locationName}</div>
<div class="main-row">
<span class="weather-icon">{getWeatherIcon(current.weatherCode, current.isDay)}</span>
<span class="temperature">{Math.round(current.temperature)}°</span>
<div class="condition-info">
<span class="condition-label">{getWeatherLabel(current.weatherCode)}</span>
<span class="feels-like">Gefuehlt {Math.round(current.feelsLike)}°</span>
</div>
</div>
<div class="detail-grid">
<div class="detail">
<span class="detail-icon">💨</span>
<span class="detail-val">{Math.round(current.windSpeed)} km/h</span>
<span class="detail-lbl">{windDirectionLabel(current.windDirection)}</span>
</div>
<div class="detail">
<span class="detail-icon">💧</span>
<span class="detail-val">{current.humidity}%</span>
<span class="detail-lbl">Feuchtigkeit</span>
</div>
<div class="detail">
<span class="detail-icon">🌡</span>
<span class="detail-val">{Math.round(current.pressure)} hPa</span>
<span class="detail-lbl">Druck</span>
</div>
<div class="detail">
<span class="detail-icon">☀️</span>
<span class="detail-val">{current.uvIndex}</span>
<span class="detail-lbl">UV-Index</span>
</div>
</div>
</div>
<style>
.current-card {
background: var(--card-bg, rgba(255, 255, 255, 0.06));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 16px;
padding: 20px;
}
.location-name {
font-size: 0.85rem;
color: var(--text-secondary, #9ca3af);
margin-bottom: 8px;
}
.main-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.weather-icon {
font-size: 3rem;
line-height: 1;
}
.temperature {
font-size: 3.5rem;
font-weight: 300;
line-height: 1;
color: var(--text-primary, #f3f4f6);
}
.condition-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.condition-label {
font-size: 1rem;
color: var(--text-primary, #f3f4f6);
}
.feels-like {
font-size: 0.85rem;
color: var(--text-secondary, #9ca3af);
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 8px;
}
.detail {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
border-radius: 8px;
background: var(--card-bg-hover, rgba(255, 255, 255, 0.04));
}
.detail-icon {
font-size: 1.1rem;
}
.detail-val {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary, #f3f4f6);
}
.detail-lbl {
font-size: 0.7rem;
color: var(--text-secondary, #9ca3af);
}
</style>

View file

@ -0,0 +1,133 @@
<!--
7-day daily forecast — shows min/max temp, weather icon, precipitation.
-->
<script lang="ts">
import type { DailyForecast } from '../types';
import { getWeatherIcon, getWeatherLabel } from '../weather-icons';
interface Props {
days: DailyForecast[];
}
let { days }: Props = $props();
let visibleDays = $derived(days.slice(0, 7));
function dayLabel(dateStr: string, idx: number): string {
if (idx === 0) return 'Heute';
if (idx === 1) return 'Morgen';
return new Date(dateStr).toLocaleDateString('de-DE', { weekday: 'short' });
}
function tempBarStyle(min: number, max: number): string {
// Normalize to a 0-100 range for visual bar, based on typical DACH range
const rangeMin = -10;
const rangeMax = 40;
const left = Math.max(0, ((min - rangeMin) / (rangeMax - rangeMin)) * 100);
const right = Math.min(100, ((max - rangeMin) / (rangeMax - rangeMin)) * 100);
return `left: ${left}%; width: ${Math.max(right - left, 5)}%`;
}
</script>
<div class="daily-section">
<span class="section-label">7-Tage-Vorhersage</span>
<div class="daily-list">
{#each visibleDays as day, idx (day.date)}
<div class="day-row">
<span class="day-name">{dayLabel(day.date, idx)}</span>
<span class="day-icon" title={getWeatherLabel(day.weatherCode)}>
{getWeatherIcon(day.weatherCode)}
</span>
{#if day.precipitationProbabilityMax > 10}
<span class="day-precip">{day.precipitationProbabilityMax}%</span>
{:else}
<span class="day-precip"></span>
{/if}
<span class="day-temp-min">{Math.round(day.temperatureMin)}°</span>
<div class="temp-bar-track">
<div
class="temp-bar-fill"
style={tempBarStyle(day.temperatureMin, day.temperatureMax)}
></div>
</div>
<span class="day-temp-max">{Math.round(day.temperatureMax)}°</span>
</div>
{/each}
</div>
</div>
<style>
.daily-section {
background: var(--card-bg, rgba(255, 255, 255, 0.06));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 16px;
padding: 16px;
}
.section-label {
font-size: 0.8rem;
color: var(--text-secondary, #9ca3af);
text-transform: uppercase;
letter-spacing: 0.05em;
display: block;
margin-bottom: 12px;
}
.daily-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.day-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.day-name {
width: 56px;
font-size: 0.85rem;
color: var(--text-primary, #f3f4f6);
flex-shrink: 0;
}
.day-icon {
font-size: 1.1rem;
width: 28px;
text-align: center;
flex-shrink: 0;
}
.day-precip {
width: 32px;
font-size: 0.7rem;
color: #38bdf8;
text-align: right;
flex-shrink: 0;
}
.day-temp-min {
width: 28px;
font-size: 0.8rem;
color: var(--text-secondary, #9ca3af);
text-align: right;
flex-shrink: 0;
}
.temp-bar-track {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--card-bg-hover, rgba(255, 255, 255, 0.06));
position: relative;
min-width: 60px;
}
.temp-bar-fill {
position: absolute;
top: 0;
height: 100%;
border-radius: 2px;
background: linear-gradient(to right, #38bdf8, #f59e0b);
}
.day-temp-max {
width: 28px;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-primary, #f3f4f6);
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,101 @@
<!--
Horizontal scrolling hourly forecast — next 24 hours.
-->
<script lang="ts">
import type { HourlyForecast } from '../types';
import { getWeatherIcon } from '../weather-icons';
interface Props {
hours: HourlyForecast[];
}
let { hours }: Props = $props();
let visibleHours = $derived(hours.slice(0, 24));
</script>
<div class="hourly-section">
<span class="section-label">Stundenvorhersage</span>
<div class="hourly-scroll">
{#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}
<div class="hour-item" class:now={isNow}>
<span class="hour-time">{isNow ? 'Jetzt' : time}</span>
<span class="hour-icon">{getWeatherIcon(hour.weatherCode, hour.isDay)}</span>
<span class="hour-temp">{Math.round(hour.temperature)}°</span>
{#if hour.precipitationProbability > 0}
<span class="hour-precip">
{hour.precipitationProbability}%
</span>
{/if}
</div>
{/each}
</div>
</div>
<style>
.hourly-section {
background: var(--card-bg, rgba(255, 255, 255, 0.06));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 16px;
padding: 16px;
}
.section-label {
font-size: 0.8rem;
color: var(--text-secondary, #9ca3af);
text-transform: uppercase;
letter-spacing: 0.05em;
display: block;
margin-bottom: 12px;
}
.hourly-scroll {
display: flex;
gap: 4px;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px;
}
.hourly-scroll::-webkit-scrollbar {
height: 4px;
}
.hourly-scroll::-webkit-scrollbar-thumb {
background: var(--border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 2px;
}
.hour-item {
scroll-snap-align: start;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 56px;
padding: 8px 6px;
border-radius: 10px;
flex-shrink: 0;
}
.hour-item.now {
background: var(--accent-subtle, rgba(56, 189, 248, 0.15));
}
.hour-time {
font-size: 0.7rem;
color: var(--text-secondary, #9ca3af);
white-space: nowrap;
}
.hour-icon {
font-size: 1.2rem;
}
.hour-temp {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary, #f3f4f6);
}
.hour-precip {
font-size: 0.65rem;
color: #38bdf8;
}
</style>

View file

@ -0,0 +1,272 @@
<!--
Location picker — dropdown of saved locations + GPS + search.
-->
<script lang="ts">
import type { WeatherLocation, GeocodingResult } from '../types';
import { geocode } from '../api';
interface Props {
locations: WeatherLocation[];
selectedLat: number | null;
selectedLon: number | null;
onSelect: (lat: number, lon: number, name: string) => void;
onSave: (name: string, lat: number, lon: number) => void;
}
let { locations, selectedLat, selectedLon, onSelect, onSave }: Props = $props();
let searching = $state(false);
let searchQuery = $state('');
let searchResults = $state<GeocodingResult[]>([]);
let showSearch = $state(false);
let locating = $state(false);
async function onSearch(e: Event) {
e.preventDefault();
if (searchQuery.trim().length < 2) return;
searching = true;
try {
searchResults = await geocode(searchQuery.trim());
} catch {
searchResults = [];
} finally {
searching = false;
}
}
function selectSearchResult(result: GeocodingResult) {
const name = result.admin1 ? `${result.name}, ${result.admin1}` : result.name;
onSelect(result.lat, result.lon, name);
showSearch = false;
searchQuery = '';
searchResults = [];
}
async function useGps() {
if (!navigator.geolocation) return;
locating = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
onSelect(pos.coords.latitude, pos.coords.longitude, 'Aktueller Standort');
locating = false;
},
() => {
locating = false;
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
function isSelected(loc: WeatherLocation): boolean {
if (selectedLat == null || selectedLon == null) return false;
return Math.abs(loc.lat - selectedLat) < 0.01 && Math.abs(loc.lon - selectedLon) < 0.01;
}
</script>
<div class="location-picker">
<div class="picker-row">
{#if locations.length > 0}
<div class="location-chips">
{#each locations as loc (loc.id)}
<button
class="loc-chip"
class:active={isSelected(loc)}
onclick={() => onSelect(loc.lat, loc.lon, loc.name)}
>
{loc.name}
</button>
{/each}
</div>
{/if}
<div class="picker-actions">
<button class="action-btn" onclick={useGps} disabled={locating} title="Aktueller Standort">
{locating ? '...' : '📍'}
</button>
<button class="action-btn" onclick={() => (showSearch = !showSearch)} title="Ort suchen">
🔍
</button>
</div>
</div>
{#if showSearch}
<form class="search-form" onsubmit={onSearch}>
<input
class="search-input"
type="text"
placeholder="Stadt suchen..."
bind:value={searchQuery}
/>
<button class="search-btn" type="submit" disabled={searching}>
{searching ? '...' : 'Suchen'}
</button>
</form>
{#if searchResults.length > 0}
<div class="search-results">
{#each searchResults as result}
<div
class="result-item"
role="button"
tabindex="0"
onclick={() => selectSearchResult(result)}
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter') selectSearchResult(result);
}}
>
<span class="result-name">{result.name}</span>
<span class="result-detail">
{result.admin1 ? `${result.admin1}, ` : ''}{result.country}
</span>
<button
class="save-btn"
title="Ort speichern"
onclick={(e: MouseEvent) => {
e.stopPropagation();
const name = result.admin1 ? `${result.name}, ${result.admin1}` : result.name;
onSave(name, result.lat, result.lon);
selectSearchResult(result);
}}
>
+
</button>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.location-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.picker-row {
display: flex;
align-items: center;
gap: 8px;
}
.location-chips {
display: flex;
gap: 4px;
flex-wrap: wrap;
flex: 1;
}
.loc-chip {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.8rem;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
background: var(--card-bg, rgba(255, 255, 255, 0.06));
color: var(--text-secondary, #9ca3af);
cursor: pointer;
transition: all 0.15s ease;
}
.loc-chip.active {
background: var(--accent-subtle, rgba(56, 189, 248, 0.15));
color: #38bdf8;
border-color: rgba(56, 189, 248, 0.3);
}
.picker-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.action-btn {
width: 36px;
height: 36px;
border-radius: 18px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
background: var(--card-bg, rgba(255, 255, 255, 0.06));
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn:hover {
background: var(--card-bg-hover, rgba(255, 255, 255, 0.1));
}
.search-form {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
background: var(--card-bg, rgba(255, 255, 255, 0.06));
color: var(--text-primary, #f3f4f6);
font-size: 0.85rem;
outline: none;
}
.search-input:focus {
border-color: #38bdf8;
}
.search-btn {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: #38bdf8;
color: #0c1221;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
}
.search-btn:disabled {
opacity: 0.5;
}
.search-results {
display: flex;
flex-direction: column;
gap: 2px;
background: var(--card-bg, rgba(255, 255, 255, 0.06));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 10px;
overflow: hidden;
}
.result-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: none;
background: none;
color: var(--text-primary, #f3f4f6);
cursor: pointer;
text-align: left;
width: 100%;
}
.result-item:hover {
background: var(--card-bg-hover, rgba(255, 255, 255, 0.06));
}
.result-name {
font-size: 0.85rem;
font-weight: 500;
}
.result-detail {
font-size: 0.75rem;
color: var(--text-secondary, #9ca3af);
flex: 1;
}
.save-btn {
width: 24px;
height: 24px;
border-radius: 12px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.15));
background: none;
color: var(--text-secondary, #9ca3af);
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.save-btn:hover {
background: var(--accent-subtle, rgba(56, 189, 248, 0.15));
color: #38bdf8;
}
</style>

View file

@ -0,0 +1,97 @@
<!--
Rain nowcast — bar chart showing minute-level precipitation forecast.
-->
<script lang="ts">
import type { RainNowcast } from '../types';
interface Props {
nowcast: RainNowcast;
}
let { nowcast }: Props = $props();
let maxPrecip = $derived(Math.max(0.5, ...nowcast.minutely.map((m) => m.precipitation)));
</script>
<div class="nowcast-section">
<span class="section-label">Niederschlagsprognose</span>
<div class="nowcast-summary">{nowcast.summary}</div>
{#if nowcast.minutely.length > 0}
<div class="nowcast-chart">
{#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',
})}
<div class="bar-wrapper" title="{time}: {point.precipitation.toFixed(1)} mm">
<div class="bar" class:has-rain={point.precipitation > 0} style:height="{height}%"></div>
</div>
{/each}
</div>
<div class="nowcast-time-labels">
<span
>{new Date(nowcast.minutely[0].time).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}</span
>
<span
>{new Date(nowcast.minutely[nowcast.minutely.length - 1].time).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}</span
>
</div>
{/if}
</div>
<style>
.nowcast-section {
background: var(--card-bg, rgba(255, 255, 255, 0.06));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 16px;
padding: 16px;
}
.section-label {
font-size: 0.8rem;
color: var(--text-secondary, #9ca3af);
text-transform: uppercase;
letter-spacing: 0.05em;
display: block;
margin-bottom: 4px;
}
.nowcast-summary {
font-size: 0.85rem;
color: var(--text-primary, #f3f4f6);
margin-bottom: 12px;
}
.nowcast-chart {
display: flex;
align-items: flex-end;
gap: 1px;
height: 60px;
}
.bar-wrapper {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
}
.bar {
width: 100%;
border-radius: 1px 1px 0 0;
background: var(--card-bg-hover, rgba(255, 255, 255, 0.1));
transition: height 0.2s ease;
}
.bar.has-rain {
background: #38bdf8;
}
.nowcast-time-labels {
display: flex;
justify-content: space-between;
margin-top: 4px;
font-size: 0.65rem;
color: var(--text-tertiary, #6b7280);
}
</style>

View file

@ -0,0 +1,105 @@
<!--
DWD weather alerts — shows active warnings with severity color coding.
-->
<script lang="ts">
import type { WeatherAlert } from '../types';
interface Props {
alerts: WeatherAlert[];
}
let { alerts }: Props = $props();
function severityColor(severity: string): string {
switch (severity) {
case 'extreme':
return '#dc2626';
case 'severe':
return '#ea580c';
case 'moderate':
return '#f59e0b';
default:
return '#eab308';
}
}
function severityLabel(severity: string): string {
switch (severity) {
case 'extreme':
return 'Extrem';
case 'severe':
return 'Schwer';
case 'moderate':
return 'Markant';
default:
return 'Gering';
}
}
</script>
{#if alerts.length > 0}
<div class="alerts-section">
<span class="section-label">Wetterwarnungen</span>
{#each alerts.slice(0, 5) as alert (alert.id)}
<div class="alert-card" style:border-left-color={severityColor(alert.severity)}>
<div class="alert-header">
<span class="alert-severity" style:color={severityColor(alert.severity)}>
{severityLabel(alert.severity)}
</span>
<span class="alert-event">{alert.event}</span>
</div>
<div class="alert-headline">{alert.headline}</div>
{#if alert.regionName}
<div class="alert-region">{alert.regionName}</div>
{/if}
</div>
{/each}
</div>
{/if}
<style>
.alerts-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-label {
font-size: 0.8rem;
color: var(--text-secondary, #9ca3af);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.alert-card {
background: var(--card-bg, rgba(255, 255, 255, 0.06));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-left: 3px solid;
border-radius: 10px;
padding: 12px;
}
.alert-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.alert-severity {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.alert-event {
font-size: 0.8rem;
color: var(--text-secondary, #9ca3af);
}
.alert-headline {
font-size: 0.85rem;
color: var(--text-primary, #f3f4f6);
line-height: 1.4;
}
.alert-region {
font-size: 0.75rem;
color: var(--text-tertiary, #6b7280);
margin-top: 4px;
}
</style>

View file

@ -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';

View file

@ -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' },
],
};

View file

@ -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<WeatherLocation>('wetterLocations').orderBy('order').toArray(),
[] as WeatherLocation[]
);
}
export function useDefaultLocation() {
return useLiveQueryWithDefault(
() => db.table<WeatherLocation>('wetterLocations').where('isDefault').equals(1).first(),
undefined as WeatherLocation | undefined
);
}
export function useSettings() {
return useLiveQueryWithDefault(
async () => {
const settings = await db.table<WeatherSettings>('wetterSettings').get('default');
return (
settings ?? {
id: 'default',
temperatureUnit: 'celsius',
windSpeedUnit: 'kmh',
precipitationUnit: 'mm',
}
);
},
{
id: 'default',
temperatureUnit: 'celsius',
windSpeedUnit: 'kmh',
precipitationUnit: 'mm',
} as WeatherSettings
);
}

View file

@ -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<WeatherLocation>('wetterLocations');
export const locationsStore = {
async addLocation(name: string, lat: number, lon: number): Promise<WeatherLocation> {
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<void> {
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<void> {
// 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<void> {
await table.update(id, { order: newOrder });
},
};

View file

@ -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<WeatherData | null>(null);
let nowcast = $state<RainNowcast | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let refreshTimer: ReturnType<typeof setInterval> | 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();

View file

@ -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') },
};
},
},
];

View file

@ -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;
}

View file

@ -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<number, WeatherInfo> = {
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];
}

View file

@ -0,0 +1,175 @@
<!--
/wetter — Weather dashboard with current conditions, hourly/daily
forecast, DWD alerts, and rain nowcast. Data from Open-Meteo,
DWD, and Rainbow.ai via the mana-api proxy.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { weatherStore } from '$lib/modules/wetter/stores/weather.svelte';
import { locationsStore } from '$lib/modules/wetter/stores/locations.svelte';
import { useLocations } from '$lib/modules/wetter/queries';
import CurrentConditions from '$lib/modules/wetter/components/CurrentConditions.svelte';
import HourlyForecast from '$lib/modules/wetter/components/HourlyForecast.svelte';
import DailyForecast from '$lib/modules/wetter/components/DailyForecast.svelte';
import WeatherAlerts from '$lib/modules/wetter/components/WeatherAlerts.svelte';
import NowcastBar from '$lib/modules/wetter/components/NowcastBar.svelte';
import LocationPicker from '$lib/modules/wetter/components/LocationPicker.svelte';
const locationsQuery = useLocations();
let locations = $derived(locationsQuery.value);
let selectedLat = $state<number | null>(null);
let selectedLon = $state<number | null>(null);
function selectLocation(lat: number, lon: number, name: string) {
selectedLat = lat;
selectedLon = lon;
weatherStore.fetchWeather(lat, lon, name);
weatherStore.fetchNowcast(lat, lon);
}
async function saveLocation(name: string, lat: number, lon: number) {
await locationsStore.addLocation(name, lat, lon);
}
// On mount: use default location, or first saved, or GPS
onMount(() => {
const defaultLoc = locations.find((l) => l.isDefault) ?? locations[0];
if (defaultLoc) {
selectLocation(defaultLoc.lat, defaultLoc.lon, defaultLoc.name);
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
selectLocation(pos.coords.latitude, pos.coords.longitude, 'Aktueller Standort');
},
() => {
// Default to Berlin if no GPS
selectLocation(52.52, 13.41, 'Berlin');
},
{ enableHighAccuracy: true, timeout: 5000 }
);
} else {
selectLocation(52.52, 13.41, 'Berlin');
}
});
onDestroy(() => {
weatherStore.stopAutoRefresh();
});
</script>
<svelte:head>
<title>Wetter - Mana</title>
</svelte:head>
<div class="wetter-view">
<LocationPicker
{locations}
{selectedLat}
{selectedLon}
onSelect={selectLocation}
onSave={saveLocation}
/>
{#if weatherStore.loading && !weatherStore.weatherData}
<div class="loading-state">
<span class="loading-icon">🌤</span>
<span class="loading-text">Wetterdaten werden geladen...</span>
</div>
{:else if weatherStore.error && !weatherStore.weatherData}
<div class="error-state">
<span class="error-text">{weatherStore.error}</span>
{#if selectedLat != null && selectedLon != null}
<button
class="retry-btn"
onclick={() => selectLocation(selectedLat!, selectedLon!, 'Erneut versuchen')}
>
Erneut versuchen
</button>
{/if}
</div>
{:else if weatherStore.weatherData}
{@const data = weatherStore.weatherData}
<CurrentConditions current={data.current} locationName={data.location.name} />
<WeatherAlerts alerts={data.alerts} />
{#if weatherStore.nowcast}
<NowcastBar nowcast={weatherStore.nowcast} />
{/if}
{#if data.hourly.length > 0}
<HourlyForecast hours={data.hourly} />
{/if}
{#if data.daily.length > 0}
<DailyForecast days={data.daily} />
{/if}
{#if weatherStore.loading}
<div class="refresh-indicator">Aktualisierung...</div>
{/if}
{/if}
</div>
<style>
.wetter-view {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 600px;
margin: 0 auto;
padding: 16px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
}
.loading-icon {
font-size: 3rem;
animation: pulse 1.5s ease-in-out infinite;
}
.loading-text {
font-size: 0.9rem;
color: var(--text-secondary, #9ca3af);
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
}
.error-text {
font-size: 0.9rem;
color: #ef4444;
text-align: center;
}
.retry-btn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
background: var(--card-bg, rgba(255, 255, 255, 0.06));
color: var(--text-primary, #f3f4f6);
font-size: 0.85rem;
cursor: pointer;
}
.refresh-indicator {
text-align: center;
font-size: 0.75rem;
color: var(--text-tertiary, #6b7280);
padding: 4px;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>

View file

@ -854,6 +854,38 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
defaultPolicy: 'auto', defaultPolicy: 'auto',
parameters: [], 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,
},
],
},
]; ];
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════

View file

@ -217,6 +217,11 @@ export const APP_ICONS = {
goals: svgToDataUrl( goals: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10B981"/><stop offset="100%" style="stop-color:#059669"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gl)"/><circle cx="50" cy="46" r="24" fill="none" stroke="white" stroke-width="3.5" opacity="0.4"/><circle cx="50" cy="46" r="16" fill="none" stroke="white" stroke-width="3" opacity="0.6"/><circle cx="50" cy="46" r="8" fill="none" stroke="white" stroke-width="2.5" opacity="0.8"/><circle cx="50" cy="46" r="3" fill="white"/><rect x="28" y="78" width="44" height="4" rx="2" fill="white" fill-opacity="0.4"/></svg>` `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="gl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10B981"/><stop offset="100%" style="stop-color:#059669"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#gl)"/><circle cx="50" cy="46" r="24" fill="none" stroke="white" stroke-width="3.5" opacity="0.4"/><circle cx="50" cy="46" r="16" fill="none" stroke="white" stroke-width="3" opacity="0.6"/><circle cx="50" cy="46" r="8" fill="none" stroke="white" stroke-width="2.5" opacity="0.8"/><circle cx="50" cy="46" r="3" fill="white"/><rect x="28" y="78" width="44" height="4" rx="2" fill="white" fill-opacity="0.4"/></svg>`
), ),
wetter: svgToDataUrl(
// Sun partially behind cloud with rain drops — weather / forecast.
// Sky-blue gradient for the weather theme.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="wt" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#38bdf8"/><stop offset="100%" style="stop-color:#0284c7"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#wt)"/><circle cx="62" cy="32" r="14" fill="white" fill-opacity="0.9"/><line x1="62" y1="12" x2="62" y2="18" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="62" y1="46" x2="62" y2="52" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/><line x1="42" y1="32" x2="48" y2="32" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="76" y1="32" x2="82" y2="32" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="48" y1="18" x2="52" y2="22" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/><line x1="76" y1="18" x2="72" y2="22" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/><path d="M28 56a14 14 0 0 1 14-14h0a14 14 0 0 1 13 9 10 10 0 0 1 11 10 10 10 0 0 1-10 10H30a10 10 0 0 1-10-10 10 10 0 0 1 8-5z" fill="white" fill-opacity="0.95"/><line x1="34" y1="76" x2="30" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="46" y1="76" x2="42" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/><line x1="58" y1="76" x2="54" y2="84" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/></svg>`
),
} as const; } as const;
export type AppIconId = keyof typeof APP_ICONS; export type AppIconId = keyof typeof APP_ICONS;

View file

@ -969,6 +969,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development', status: 'development',
requiredTier: 'guest', 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',
},
]; ];
/** /**