···296296 - Uses free 5 Day / 3 Hour Forecast API (aggregated to daily)
297297 - Requires `OPENWEATHER_API_KEY` in `.env` (free API key from openweathermap.org)
298298 - Station selection dropdown grouped by Austrian states (Bundesländer)
299299+ - Uses `FORECAST_STATION_ID` as default station if no station selected
299300 - Last selected station is saved in localStorage
301301+- **Analyse (Analysis)**: Forecast accuracy analysis comparing predictions with actual weather data.
302302+ - Compares stored forecasts from all 4 sources with real-time measurements
303303+ - Shows Mean Absolute Error (MAE) and Root Mean Square Error (RMSE) for temperature, precipitation, and wind
304304+ - Daily comparison details with error highlighting
305305+ - Configurable time range (7-60 days) and station selection
306306+ - Automatically stores forecasts daily via server background poller (runs on startup + every 24h)
307307+ - Station ID configured via `FORECAST_STATION_ID` environment variable (default: 11035 - Wien)
308308+ - Same station ID is used for all forecast sources (Geosphere, Meteoblue, Open-Meteo, OpenWeatherMap)
300309 - Color-coded display: red for max temperature, blue for min temperature and precipitation
301310302311### Backend Realtime Processing
···487496488497```ts
489498const rt = await fetch('/api/rt/last').then(r => r.json());
499499+```
500500+501501+### Forecast Storage & Analysis
502502+503503+Forecasts are automatically stored daily by the server background poller (configured via `FORECAST_STATION_ID` in `.env`).
504504+505505+- Manually store forecasts for a station (POST)
506506+507507+```bash
508508+curl -X POST 'http://localhost:3000/api/forecast/store' \
509509+ -H 'Content-Type: application/json' \
510510+ -d '{"stationId": "11035"}'
511511+```
512512+513513+- Compare forecasts with actual data (GET)
514514+515515+```bash
516516+curl 'http://localhost:3000/api/forecast/compare?stationId=11035&days=30'
517517+curl 'http://localhost:3000/api/forecast/compare?stationId=11230&days=7'
518518+```
519519+520520+Response format for comparison:
521521+522522+```json
523523+{
524524+ "stationId": "11035",
525525+ "days": 30,
526526+ "data": {
527527+ "dailyComparisons": [
528528+ {
529529+ "date": "2025-01-15",
530530+ "actual": {
531531+ "tempMin": -2.3,
532532+ "tempMax": 4.1,
533533+ "precipitation": 0.0,
534534+ "windSpeed": 12.5
535535+ },
536536+ "forecasts": {
537537+ "geosphere": {
538538+ "tempMin": -1.8,
539539+ "tempMax": 3.9,
540540+ "precipitation": 0.2,
541541+ "windSpeed": 11.2
542542+ },
543543+ "openweather": {
544544+ "tempMin": -2.1,
545545+ "tempMax": 4.3,
546546+ "precipitation": 0.0,
547547+ "windSpeed": 13.1
548548+ }
549549+ },
550550+ "errors": {
551551+ "geosphere": {
552552+ "tempMinError": 0.5,
553553+ "tempMaxError": 0.2,
554554+ "precipitationError": 0.2,
555555+ "windSpeedError": 1.3
556556+ },
557557+ "openweather": {
558558+ "tempMinError": 0.2,
559559+ "tempMaxError": 0.2,
560560+ "precipitationError": 0.0,
561561+ "windSpeedError": 0.6
562562+ }
563563+ }
564564+ }
565565+ ],
566566+ "accuracyStats": {
567567+ "geosphere": {
568568+ "sampleSize": 25,
569569+ "tempMin": { "mae": 0.8, "rmse": 1.1 },
570570+ "tempMax": { "mae": 0.7, "rmse": 0.9 },
571571+ "precipitation": { "mae": 0.3, "rmse": 0.5 },
572572+ "windSpeed": { "mae": 2.1, "rmse": 2.8 }
573573+ },
574574+ "openweather": {
575575+ "sampleSize": 25,
576576+ "tempMin": { "mae": 0.6, "rmse": 0.8 },
577577+ "tempMax": { "mae": 0.5, "rmse": 0.7 },
578578+ "precipitation": { "mae": 0.2, "rmse": 0.4 },
579579+ "windSpeed": { "mae": 1.8, "rmse": 2.3 }
580580+ }
581581+ }
582582+ },
583583+ "generated": "2025-01-16T10:30:00.000Z"
584584+}
490585```
491586492587### Data (CSV/DuckDB-backed)
+6
env.example
···1414## Meteoblue API Key for 7-14 day forecast (FREE - get key at https://www.meteoblue.com/en/weather-api)
1515## Register for free, confirm non-commercial use, and get instant access
1616METEOBLUE_API_KEY=your_api_key_here
1717+1818+## Forecast Station ID for daily automatic forecast storage (default: 11035 - Wien Hohe Warte)
1919+## This station is used for all forecast sources (Geosphere, Meteoblue, Open-Meteo, OpenWeatherMap)
2020+## Find station IDs at: https://dataset.api.hub.geosphere.at/v1/station/current/tawes-v1-10min/metadata
2121+## Popular stations: 11035 (Wien), 11120 (Innsbruck), 11320 (Graz), 11150 (Salzburg), 11240 (Klagenfurt)
2222+FORECAST_STATION_ID=11035
+12
src/app/api/config/forecast-station/route.ts
···11+import { NextResponse } from "next/server";
22+33+export const runtime = "nodejs";
44+55+/**
66+ * API route to get the default forecast station ID from environment
77+ * GET /api/config/forecast-station
88+ */
99+export async function GET() {
1010+ const stationId = process.env.FORECAST_STATION_ID || "11035";
1111+ return NextResponse.json({ stationId });
1212+}
+203
src/app/api/forecast/compare/route.ts
···11+import { NextResponse } from "next/server";
22+import { getDuckConn } from "@/lib/db/duckdb";
33+44+export const runtime = "nodejs";
55+66+/**
77+ * API route to compare stored forecasts with actual weather data
88+ * GET /api/forecast/compare?stationId=11035&days=30
99+ *
1010+ * Returns accuracy analysis for each forecast source
1111+ */
1212+export async function GET(req: Request) {
1313+ try {
1414+ const { searchParams } = new URL(req.url);
1515+ const stationId = searchParams.get("stationId");
1616+ const days = parseInt(searchParams.get("days") || "30");
1717+1818+ if (!stationId) {
1919+ return NextResponse.json({ error: "stationId parameter is required" }, { status: 400 });
2020+ }
2121+2222+ const conn = await getDuckConn();
2323+2424+ // Get comparison data for the last N days
2525+ const comparisonData = await getForecastComparison(conn, stationId, days);
2626+2727+ return NextResponse.json({
2828+ stationId,
2929+ days,
3030+ data: comparisonData,
3131+ generated: new Date().toISOString()
3232+ });
3333+3434+ } catch (error: any) {
3535+ console.error("Forecast comparison error:", error);
3636+ return NextResponse.json({ error: error.message }, { status: 500 });
3737+ }
3838+}
3939+4040+/**
4141+ * Get forecast vs actual data comparison
4242+ */
4343+async function getForecastComparison(conn: any, stationId: string, days: number) {
4444+ // Get actual weather data from the main table
4545+ const actualQuery = `
4646+ SELECT
4747+ DATE(time) as date,
4848+ MIN(CAST(CASE WHEN tempf LIKE '%[^0-9.]%' THEN NULL ELSE tempf END AS DOUBLE)) as temp_min_f,
4949+ MAX(CAST(CASE WHEN tempf LIKE '%[^0-9.]%' THEN NULL ELSE tempf END AS DOUBLE)) as temp_max_f,
5050+ SUM(CAST(CASE WHEN rain_rate_in LIKE '%[^0-9.]%' THEN NULL ELSE rain_rate_in END AS DOUBLE)) as precipitation_in,
5151+ AVG(CAST(CASE WHEN windspeedmph LIKE '%[^0-9.]%' THEN NULL ELSE windspeedmph END AS DOUBLE)) as wind_speed_mph
5252+ FROM weather_data
5353+ WHERE station_id = ?
5454+ AND DATE(time) >= DATE('now', '-${days} days')
5555+ AND DATE(time) < DATE('now')
5656+ GROUP BY DATE(time)
5757+ ORDER BY date DESC
5858+ `;
5959+6060+ const actualData = await conn.all(actualQuery, [stationId]);
6161+6262+ // Convert units to match forecast units (°C, mm, km/h)
6363+ const actualDataConverted = actualData.map((row: any) => ({
6464+ date: row.date,
6565+ tempMin: row.temp_min_f !== null ? (row.temp_min_f - 32) * 5/9 : null,
6666+ tempMax: row.temp_max_f !== null ? (row.temp_max_f - 32) * 5/9 : null,
6767+ precipitation: row.precipitation_in !== null ? row.precipitation_in * 25.4 : null,
6868+ windSpeed: row.wind_speed_mph !== null ? row.wind_speed_mph * 1.60934 : null
6969+ }));
7070+7171+ // Get stored forecasts for comparison
7272+ const forecastQuery = `
7373+ SELECT
7474+ forecast_date,
7575+ source,
7676+ temp_min,
7777+ temp_max,
7878+ precipitation,
7979+ wind_speed
8080+ FROM forecasts
8181+ WHERE station_id = ?
8282+ AND forecast_date >= DATE('now', '-${days} days')
8383+ AND forecast_date < DATE('now')
8484+ ORDER BY forecast_date DESC, source
8585+ `;
8686+8787+ const forecastData = await conn.all(forecastQuery, [stationId]);
8888+8989+ // Group forecasts by date and source
9090+ const forecastsByDate: Record<string, Record<string, any>> = {};
9191+9292+ forecastData.forEach((row: any) => {
9393+ const date = row.forecast_date;
9494+ if (!forecastsByDate[date]) {
9595+ forecastsByDate[date] = {};
9696+ }
9797+ forecastsByDate[date][row.source] = row;
9898+ });
9999+100100+ // Compare actual vs forecast data
101101+ const comparisons = [];
102102+103103+ for (const actual of actualDataConverted) {
104104+ const date = actual.date;
105105+ const forecasts = forecastsByDate[date];
106106+107107+ if (forecasts) {
108108+ const comparison: any = {
109109+ date,
110110+ actual: {
111111+ tempMin: actual.tempMin,
112112+ tempMax: actual.tempMax,
113113+ precipitation: actual.precipitation,
114114+ windSpeed: actual.windSpeed
115115+ },
116116+ forecasts: {},
117117+ errors: {}
118118+ };
119119+120120+ // Compare each forecast source
121121+ ['geosphere', 'openweather', 'meteoblue', 'openmeteo'].forEach(source => {
122122+ if (forecasts[source]) {
123123+ const forecast = forecasts[source];
124124+ comparison.forecasts[source] = {
125125+ tempMin: forecast.temp_min,
126126+ tempMax: forecast.temp_max,
127127+ precipitation: forecast.precipitation,
128128+ windSpeed: forecast.wind_speed
129129+ };
130130+131131+ // Calculate errors
132132+ comparison.errors[source] = {
133133+ tempMinError: calculateError(actual.tempMin, forecast.temp_min),
134134+ tempMaxError: calculateError(actual.tempMax, forecast.temp_max),
135135+ precipitationError: calculateError(actual.precipitation, forecast.precipitation),
136136+ windSpeedError: calculateError(actual.windSpeed, forecast.wind_speed)
137137+ };
138138+ }
139139+ });
140140+141141+ comparisons.push(comparison);
142142+ }
143143+ }
144144+145145+ // Calculate overall accuracy statistics
146146+ const accuracyStats = calculateAccuracyStats(comparisons);
147147+148148+ return {
149149+ dailyComparisons: comparisons,
150150+ accuracyStats
151151+ };
152152+}
153153+154154+/**
155155+ * Calculate error between actual and forecast values
156156+ */
157157+function calculateError(actual: number | null, forecast: number | null): number | null {
158158+ if (actual === null || forecast === null) return null;
159159+ return Math.abs(actual - forecast);
160160+}
161161+162162+/**
163163+ * Calculate overall accuracy statistics for each forecast source
164164+ */
165165+function calculateAccuracyStats(comparisons: any[]) {
166166+ const sources = ['geosphere', 'openweather', 'meteoblue', 'openmeteo'];
167167+ const stats: Record<string, any> = {};
168168+169169+ sources.forEach(source => {
170170+ const errors = comparisons
171171+ .map(c => c.errors[source])
172172+ .filter(e => e !== undefined);
173173+174174+ if (errors.length > 0) {
175175+ const tempMinErrors = errors.map(e => e.tempMinError).filter(e => e !== null);
176176+ const tempMaxErrors = errors.map(e => e.tempMaxError).filter(e => e !== null);
177177+ const precipitationErrors = errors.map(e => e.precipitationError).filter(e => e !== null);
178178+ const windSpeedErrors = errors.map(e => e.windSpeedError).filter(e => e !== null);
179179+180180+ stats[source] = {
181181+ sampleSize: errors.length,
182182+ tempMin: {
183183+ mae: tempMinErrors.length > 0 ? tempMinErrors.reduce((sum, e) => sum + e, 0) / tempMinErrors.length : null,
184184+ rmse: tempMinErrors.length > 0 ? Math.sqrt(tempMinErrors.reduce((sum, e) => sum + e*e, 0) / tempMinErrors.length) : null
185185+ },
186186+ tempMax: {
187187+ mae: tempMaxErrors.length > 0 ? tempMaxErrors.reduce((sum, e) => sum + e, 0) / tempMaxErrors.length : null,
188188+ rmse: tempMaxErrors.length > 0 ? Math.sqrt(tempMaxErrors.reduce((sum, e) => sum + e*e, 0) / tempMaxErrors.length) : null
189189+ },
190190+ precipitation: {
191191+ mae: precipitationErrors.length > 0 ? precipitationErrors.reduce((sum, e) => sum + e, 0) / precipitationErrors.length : null,
192192+ rmse: precipitationErrors.length > 0 ? Math.sqrt(precipitationErrors.reduce((sum, e) => sum + e*e, 0) / precipitationErrors.length) : null
193193+ },
194194+ windSpeed: {
195195+ mae: windSpeedErrors.length > 0 ? windSpeedErrors.reduce((sum, e) => sum + e, 0) / windSpeedErrors.length : null,
196196+ rmse: windSpeedErrors.length > 0 ? Math.sqrt(windSpeedErrors.reduce((sum, e) => sum + e*e, 0) / windSpeedErrors.length) : null
197197+ }
198198+ };
199199+ }
200200+ });
201201+202202+ return stats;
203203+}
+170
src/app/api/forecast/store/route.ts
···11+import { NextResponse } from "next/server";
22+import { getDuckConn } from "@/lib/db/duckdb";
33+44+export const runtime = "nodejs";
55+66+/**
77+ * API route to store forecast data from all sources daily
88+ * POST /api/forecast/store
99+ * Body: { stationId: string }
1010+ *
1111+ * This endpoint fetches forecasts from all 4 sources and stores them in DuckDB
1212+ * for later comparison with actual weather data
1313+ */
1414+export async function POST(req: Request) {
1515+ try {
1616+ const { stationId } = await req.json();
1717+1818+ if (!stationId) {
1919+ return NextResponse.json({ error: "stationId is required" }, { status: 400 });
2020+ }
2121+2222+ // Fetch forecasts from all 4 sources
2323+ const forecastPromises = [
2424+ fetchForecastData('forecast', stationId),
2525+ fetchForecastData('openweather', stationId),
2626+ fetchForecastData('meteoblue', stationId),
2727+ fetchForecastData('openmeteo', stationId)
2828+ ];
2929+3030+ const [geosphereData, openweatherData, meteoblueData, openmeteoData] = await Promise.allSettled(forecastPromises);
3131+3232+ // Store in DuckDB
3333+ const conn = await getDuckConn();
3434+ const storageDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
3535+3636+ // Create forecast table if not exists
3737+ await conn.run(`
3838+ CREATE TABLE IF NOT EXISTS forecasts (
3939+ id INTEGER PRIMARY KEY,
4040+ storage_date DATE NOT NULL,
4141+ station_id VARCHAR(50) NOT NULL,
4242+ forecast_date DATE NOT NULL,
4343+ source VARCHAR(20) NOT NULL,
4444+ temp_min DOUBLE,
4545+ temp_max DOUBLE,
4646+ precipitation DOUBLE,
4747+ wind_speed DOUBLE,
4848+ wind_gust DOUBLE,
4949+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
5050+ UNIQUE(storage_date, station_id, forecast_date, source)
5151+ )
5252+ `);
5353+5454+ // Insert forecast data from each source
5555+ const insertPromises = [];
5656+5757+ // Process Geosphere forecast
5858+ if (geosphereData.status === 'fulfilled' && geosphereData.value.forecast) {
5959+ const dailyData = aggregateHourlyToDaily(geosphereData.value.forecast);
6060+ for (const day of dailyData) {
6161+ insertPromises.push(
6262+ conn.run(`
6363+ INSERT OR REPLACE INTO forecasts
6464+ (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed)
6565+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
6666+ `, [storageDate, stationId, day.date, 'geosphere', day.tempMin, day.tempMax, day.precipitation, day.windSpeed])
6767+ );
6868+ }
6969+ }
7070+7171+ // Process OpenWeatherMap forecast
7272+ if (openweatherData.status === 'fulfilled' && openweatherData.value.forecast) {
7373+ for (const day of openweatherData.value.forecast) {
7474+ insertPromises.push(
7575+ conn.run(`
7676+ INSERT OR REPLACE INTO forecasts
7777+ (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed, wind_gust)
7878+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
7979+ `, [storageDate, stationId, day.date, 'openweather', day.tempMin, day.tempMax, day.precipitation, day.windSpeed, day.windGust])
8080+ );
8181+ }
8282+ }
8383+8484+ // Process Meteoblue forecast
8585+ if (meteoblueData.status === 'fulfilled' && meteoblueData.value.forecast) {
8686+ for (const day of meteoblueData.value.forecast) {
8787+ insertPromises.push(
8888+ conn.run(`
8989+ INSERT OR REPLACE INTO forecasts
9090+ (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed, wind_gust)
9191+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9292+ `, [storageDate, stationId, day.date, 'meteoblue', day.tempMin, day.tempMax, day.precipitation, day.windSpeed, day.windGust])
9393+ );
9494+ }
9595+ }
9696+9797+ // Process Open-Meteo forecast
9898+ if (openmeteoData.status === 'fulfilled' && openmeteoData.value.forecast) {
9999+ for (const day of openmeteoData.value.forecast) {
100100+ insertPromises.push(
101101+ conn.run(`
102102+ INSERT OR REPLACE INTO forecasts
103103+ (storage_date, station_id, forecast_date, source, temp_min, temp_max, precipitation, wind_speed, wind_gust)
104104+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
105105+ `, [storageDate, stationId, day.date, 'openmeteo', day.tempMin, day.tempMax, day.precipitation, day.windSpeed, day.windGust])
106106+ );
107107+ }
108108+ }
109109+110110+ await Promise.all(insertPromises);
111111+112112+ return NextResponse.json({
113113+ success: true,
114114+ message: `Stored forecasts for station ${stationId} on ${storageDate}`,
115115+ sources: {
116116+ geosphere: geosphereData.status === 'fulfilled',
117117+ openweather: openweatherData.status === 'fulfilled',
118118+ meteoblue: meteoblueData.status === 'fulfilled',
119119+ openmeteo: openmeteoData.status === 'fulfilled'
120120+ }
121121+ });
122122+123123+ } catch (error: any) {
124124+ console.error("Forecast storage error:", error);
125125+ return NextResponse.json({ error: error.message }, { status: 500 });
126126+ }
127127+}
128128+129129+/**
130130+ * Fetch forecast data from existing forecast API
131131+ */
132132+async function fetchForecastData(action: string, stationId: string) {
133133+ const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
134134+ const response = await fetch(`${baseUrl}/api/forecast?action=${action}&stationId=${stationId}`);
135135+136136+ if (!response.ok) {
137137+ throw new Error(`Failed to fetch ${action} forecast: ${response.statusText}`);
138138+ }
139139+140140+ return response.json();
141141+}
142142+143143+/**
144144+ * Aggregate hourly forecast data to daily values (for Geosphere)
145145+ */
146146+function aggregateHourlyToDaily(hourlyData: any[]): any[] {
147147+ const dailyMap: Record<string, any[]> = {};
148148+149149+ hourlyData.forEach(item => {
150150+ const date = new Date(item.time).toISOString().split('T')[0];
151151+ if (!dailyMap[date]) {
152152+ dailyMap[date] = [];
153153+ }
154154+ dailyMap[date].push(item);
155155+ });
156156+157157+ return Object.entries(dailyMap).map(([date, items]) => {
158158+ const temps = items.map(i => i.temperature).filter(t => t !== null);
159159+ const precipitations = items.map(i => i.precipitation).filter(p => p !== null);
160160+ const windSpeeds = items.map(i => i.windSpeed).filter(w => w !== null);
161161+162162+ return {
163163+ date,
164164+ tempMin: temps.length > 0 ? Math.min(...temps) : null,
165165+ tempMax: temps.length > 0 ? Math.max(...temps) : null,
166166+ precipitation: precipitations.length > 0 ? precipitations.reduce((sum, p) => sum + p, 0) : 0,
167167+ windSpeed: windSpeeds.length > 0 ? windSpeeds.reduce((sum, w) => sum + w, 0) / windSpeeds.length : null
168168+ };
169169+ });
170170+}
+12-3
src/app/page.tsx
···66import Gauges from "@/components/Gauges";
77import Statistics from "@/components/Statistics";
88import Forecast from "@/components/Forecast";
99+import ForecastAnalysis from "@/components/ForecastAnalysis";
910import { useTranslation } from "react-i18next";
1011import LanguageSwitcher from "@/components/LanguageSwitcher";
1112import { RealtimeProvider } from "@/contexts/RealtimeContext";
···1516 * It provides a tabbed interface to switch between different views:
1617 * - Realtime: A list of current sensor readings.
1718 * - Graphics: A set of gauges and visual displays for current data.
1919+ * - Forecast: 7-day weather forecast from multiple APIs.
2020+ * - Analysis: Forecast accuracy analysis comparing predictions with actual data.
1821 * - Saved: A dashboard for viewing historical data with charts.
1922 * - Statistics: Statistical analysis of historical data.
2020- * - Forecast: 7-day weather forecast from Geosphere API.
2123 *
2224 * @returns The Home page component.
2325 */
2426export default function Home() {
2527 const { t } = useTranslation();
2626- const [tab, setTab] = useState<"rt" | "gfx" | "stored" | "stats" | "forecast">("rt");
2828+ const [tab, setTab] = useState<"rt" | "gfx" | "stored" | "stats" | "forecast" | "analysis">("rt");
2729 return (
2830 <RealtimeProvider>
2931 <div className="min-h-screen w-full bg-gray-50 dark:bg-neutral-950 text-gray-900 dark:text-gray-100 p-4 sm:p-6">
···4850 {t("tabs.forecast", "Forecast")}
4951 </button>
5052 <button
5353+ className={`px-3 py-2 text-sm font-medium rounded-t ${tab === "analysis" ? "bg-white dark:bg-neutral-900 border border-b-0 border-gray-200 dark:border-neutral-800" : "text-gray-600 hover:text-gray-900"}`}
5454+ onClick={() => setTab("analysis")}
5555+ >
5656+ {t("tabs.analysis", "Analysis")}
5757+ </button>
5858+ <button
5159 className={`px-3 py-2 text-sm font-medium rounded-t ${tab === "stored" ? "bg-white dark:bg-neutral-900 border border-b-0 border-gray-200 dark:border-neutral-800" : "text-gray-600 hover:text-gray-900"}`}
5260 onClick={() => setTab("stored")}
5361 >
···6573 <div className="rounded-b border border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
6674 {tab === "rt" && <Realtime />}
6775 {tab === "gfx" && <Gauges />}
7676+ {tab === "forecast" && <Forecast />}
7777+ {tab === "analysis" && <ForecastAnalysis />}
6878 {tab === "stored" && <Dashboard />}
6979 {tab === "stats" && <Statistics />}
7070- {tab === "forecast" && <Forecast />}
7180 </div>
7281 </div>
7382 </div>
+20-5
src/components/Forecast.tsx
···156156 const data = await response.json();
157157 setStations(data.stations);
158158159159- // Load last selected station from localStorage
159159+ // Load last selected station from localStorage, or use default from env
160160 const lastSelected = localStorage.getItem("forecastStation");
161161- if (lastSelected && data.stations) {
162162- // Verify the station still exists
161161+ let stationToUse = lastSelected;
162162+163163+ // If no station in localStorage, fetch default from server
164164+ if (!stationToUse) {
165165+ try {
166166+ const configResponse = await fetch("/api/config/forecast-station");
167167+ if (configResponse.ok) {
168168+ const configData = await configResponse.json();
169169+ stationToUse = configData.stationId;
170170+ }
171171+ } catch (err) {
172172+ console.error("Error loading default station:", err);
173173+ }
174174+ }
175175+176176+ if (stationToUse && data.stations) {
177177+ // Verify the station exists
163178 let stationExists = false;
164179 for (const state in data.stations) {
165165- if (data.stations[state].some((station: Station) => station.id === lastSelected)) {
180180+ if (data.stations[state].some((station: Station) => station.id === stationToUse)) {
166181 stationExists = true;
167182 break;
168183 }
169184 }
170185 if (stationExists) {
171171- setSelectedStation(lastSelected);
186186+ setSelectedStation(stationToUse);
172187 }
173188 }
174189 } catch (err) {