mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
20aeccfaca
commit
62aac6dfdb
24 changed files with 2179 additions and 0 deletions
|
|
@ -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}...`);
|
||||
|
|
|
|||
346
apps/api/src/modules/wetter/routes.ts
Normal file
346
apps/api/src/modules/wetter/routes.ts
Normal 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}¤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<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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
171
apps/mana/apps/web/src/lib/modules/wetter/api.ts
Normal file
171
apps/mana/apps/web/src/lib/modules/wetter/api.ts
Normal 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 ?? [];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
5
apps/mana/apps/web/src/lib/modules/wetter/index.ts
Normal file
5
apps/mana/apps/web/src/lib/modules/wetter/index.ts
Normal 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';
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
43
apps/mana/apps/web/src/lib/modules/wetter/queries.ts
Normal file
43
apps/mana/apps/web/src/lib/modules/wetter/queries.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
172
apps/mana/apps/web/src/lib/modules/wetter/tools.ts
Normal file
172
apps/mana/apps/web/src/lib/modules/wetter/tools.ts
Normal 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') },
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
103
apps/mana/apps/web/src/lib/modules/wetter/types.ts
Normal file
103
apps/mana/apps/web/src/lib/modules/wetter/types.ts
Normal 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;
|
||||
}
|
||||
104
apps/mana/apps/web/src/lib/modules/wetter/weather-icons.ts
Normal file
104
apps/mana/apps/web/src/lib/modules/wetter/weather-icons.ts
Normal 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];
|
||||
}
|
||||
175
apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte
Normal file
175
apps/mana/apps/web/src/routes/(app)/wetter/+page.svelte
Normal 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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -217,6 +217,11 @@ export const APP_ICONS = {
|
|||
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>`
|
||||
),
|
||||
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;
|
||||
|
||||
export type AppIconId = keyof typeof APP_ICONS;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue