mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01: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 { 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}...`);
|
||||||
|
|
|
||||||
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',
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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',
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue