"use client"; import { useEffect, useRef, useState } from "react"; import { fetchProviders, fetchWeather } from "@/lib/client-api"; import { placeLabel, type Units } from "@/lib/display"; import { outlineButton, pixelLabel } from "@/lib/styles"; import type { Place, ProviderId, ProviderInfo, WeatherReport } from "@/lib/weather/types"; import type { ForecastView } from "./forecast/ForecastPanel"; import { HomeLanding } from "./HomeLanding"; import { SkyIcon } from "./PixelArt"; import { PlaceSearch } from "./PlaceSearch"; import { ProviderPicker } from "./ProviderPicker"; import { RadarPanel } from "./radar/RadarPanel"; import { SiteFooter } from "./SiteFooter"; import { Slot } from "./Slot"; import { WeatherView } from "./WeatherView"; /** Settings that survive a page reload (stored in this browser only). */ interface Settings { provider: ProviderId; units: Units; place: Place | null; recentPlaces: Place[]; forecastView: ForecastView; } const STORAGE_KEY = "wayfarer:settings"; const MAX_RECENT_PLACES = 5; const DEFAULT_SETTINGS: Settings = { provider: "openweather", units: "imperial", place: null, recentPlaces: [], forecastView: "hourly", }; function samePlace(a: Place, b: Place): boolean { return Math.abs(a.lat - b.lat) < 0.01 && Math.abs(a.lon - b.lon) < 0.01; } function loadSettings(): Settings { try { const saved = localStorage.getItem(STORAGE_KEY); if (!saved) return DEFAULT_SETTINGS; const parsed = JSON.parse(saved) as Partial; return { ...DEFAULT_SETTINGS, ...parsed, recentPlaces: Array.isArray(parsed.recentPlaces) ? parsed.recentPlaces.slice(0, MAX_RECENT_PLACES) : [], }; } catch { return DEFAULT_SETTINGS; // private mode or blocked storage — just use defaults } } function saveSettings(settings: Settings) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch { /* not critical */ } } type Status = "idle" | "loading" | "ready" | "error"; export default function WeatherApp() { const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [loaded, setLoaded] = useState(false); const [providers, setProviders] = useState([]); const [report, setReport] = useState(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); const searchInputRef = useRef(null); const update = (changes: Partial) => setSettings((prev) => ({ ...prev, ...changes })); function selectPlace(nextPlace: Place) { setSettings((prev) => { const filtered = prev.recentPlaces.filter((p) => !samePlace(p, nextPlace)); return { ...prev, place: nextPlace, recentPlaces: [nextPlace, ...filtered].slice(0, MAX_RECENT_PLACES), }; }); } function removeRecentPlace(target: Place) { setSettings((prev) => ({ ...prev, recentPlaces: prev.recentPlaces.filter((p) => !samePlace(p, target)), })); } function goHome() { setReport(null); setStatus("idle"); setError(null); update({ place: null }); } // 1. On first load: restore saved settings and find out which providers have keys. useEffect(() => { const saved = loadSettings(); fetchProviders() .then((list) => { setProviders(list); // Prefer OpenWeather if its key was just added and user hasn't explicitly saved a choice; // otherwise keep saved provider if ready, or fall back to the first ready source. const savedIsReady = list.some((p) => p.id === saved.provider && p.ready); const fallback = list.find((p) => p.ready)?.id ?? saved.provider; setSettings({ ...saved, provider: savedIsReady ? saved.provider : fallback }); }) .catch(() => setSettings(saved)) .finally(() => setLoaded(true)); }, []); // 2. Remember settings whenever they change. useEffect(() => { if (loaded) saveSettings(settings); }, [settings, loaded]); // 3. Fetch weather whenever the place or provider changes. const { place, provider } = settings; useEffect(() => { if (!loaded || !place) return; const controller = new AbortController(); setStatus("loading"); setError(null); fetchWeather(provider, place, controller.signal) .then((data) => { setReport(data); setStatus("ready"); }) .catch((err: Error) => { if (controller.signal.aborted) return; setError(err.message); setStatus("error"); }); return () => controller.abort(); }, [loaded, place, provider]); const activeProvider = providers.find((p) => p.id === settings.provider); const reportProvider = providers.find((p) => p.id === report?.provider); const hasOpenWeather = providers.some((p) => p.id === "openweather" && p.ready); return (
{/* Top station bar */}
{place ? ( ) : ( )}
{place ? ( ) : (

Wayfarer

)} by Cayden Codes
Weather for the road ahead
{place && ( )}
{(["imperial", "metric"] as const).map((u) => ( ))}
{/* Search & recent waypoints */}
{settings.recentPlaces.length > 0 && (
Recent {settings.recentPlaces.map((item) => { const isCurrent = place ? samePlace(place, item) : false; return (
); })}
)}
{providers.length > 0 && ( update({ provider: id })} /> )} {/* Homepage landing state when no place is selected */} {!place && ( searchInputRef.current?.focus()} activeProviderLabel={activeProvider?.label} /> )} {status === "error" && error && (

{error}

)} {/* Initial loading frame sits ABOVE radar so the radar doesn't jump down once forecast arrives */} {place && status === "loading" && !report && (

Reading the skies above {placeLabel(place)}…

Gathering current conditions, hourly horizon, and local alerts

)} {/* Keep the last report on screen while a new one loads, just dimmed */} {report && place && status !== "error" && (
update({ forecastView: view })} providerLabel={reportProvider?.label ?? report.provider} maxDaysOnPlan={reportProvider?.forecastDays ?? report.daily.length} />
)} {/* The radar doesn't depend on the forecast, so it shows as soon as there's a place */} {place && }
); } /** Four-point gold compass emblem in the station header. */ function CompassStar() { return ( ); }