wayfarer-weather/components/WeatherApp.tsx
2026-10-31 21:50:28 -05:00

335 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<Settings>;
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<Settings>(DEFAULT_SETTINGS);
const [loaded, setLoaded] = useState(false);
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [report, setReport] = useState<WeatherReport | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const update = (changes: Partial<Settings>) => 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);
const savedIsAvailable = list.some((p) => p.id === saved.provider);
const fallback = list[0]?.id ?? saved.provider;
setSettings({ ...saved, provider: savedIsAvailable ? 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");
return (
<main
className={[
"mx-auto flex w-full max-w-[880px] flex-col gap-5",
// --gutter is reused by the forecast strip to bleed to the screen edge
"[--gutter:max(16px,env(safe-area-inset-left))] px-(--gutter)",
"pt-[max(20px,env(safe-area-inset-top))] pb-[max(40px,env(safe-area-inset-bottom))]",
].join(" ")}
>
{/* Top station bar */}
<header className="flex items-center justify-between gap-3 border-b border-gold-dim/50 pb-3.5">
<div className="flex min-w-0 items-center gap-3.5">
<div className="flex items-center gap-2.5">
{place ? (
<button
type="button"
onClick={goHome}
title="Return to Wayfarer home"
aria-label="Return to Wayfarer home"
className="cursor-pointer"
>
<CompassStar />
</button>
) : (
<CompassStar />
)}
<div className="flex flex-col">
{place ? (
<button
type="button"
onClick={goHome}
title="Return to Wayfarer home"
className="cursor-pointer text-left font-display text-lg leading-none font-bold tracking-[0.28em] text-gold uppercase transition-colors hover:text-gold-bright sm:text-2xl"
>
Wayfarer
</button>
) : (
<h1 className="font-display text-lg leading-none font-bold tracking-[0.28em] text-gold uppercase sm:text-2xl">
Wayfarer
</h1>
)}
<a
href="https://caydens.dev"
target="_blank"
rel="noopener"
className="group/author mt-1 w-fit font-pixel text-[0.68rem] leading-none tracking-[0.04em] text-stone transition-colors hover:text-gold-bright"
>
by <span className="text-gold transition-colors group-hover/author:text-gold-bright">Cayden Codes</span>
</a>
</div>
</div>
<span className="hidden border-l border-gold-dim/60 pl-3.5 text-xs text-stone sm:inline">
Weather for the road ahead
</span>
</div>
<div className="flex items-center gap-2.5">
{place && (
<button
type="button"
onClick={goHome}
className="min-h-9 cursor-pointer border border-gold-dim bg-panel/60 px-2.5 font-pixel text-[0.72rem] text-stone transition-colors hover:border-gold hover:text-gold-bright"
>
Home
</button>
)}
<div className="flex divide-x divide-gold-dim border border-gold-dim bg-panel/60" role="group" aria-label="Temperature units">
{(["imperial", "metric"] as const).map((u) => (
<button
key={u}
type="button"
aria-pressed={settings.units === u}
onClick={() => update({ units: u })}
className="min-h-9 min-w-11 cursor-pointer px-2.5 font-pixel text-[0.8rem] text-stone transition-colors hover:not-aria-pressed:text-ink aria-pressed:bg-gold aria-pressed:text-black"
>
{u === "imperial" ? "°F" : "°C"}
</button>
))}
</div>
</div>
</header>
{/* Search & recent waypoints */}
<div className="flex flex-col gap-2.5">
<PlaceSearch inputRef={searchInputRef} onSelect={selectPlace} geocoderUsesOpenWeather={hasOpenWeather} />
{settings.recentPlaces.length > 0 && (
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none]" aria-label="Recent waypoints">
<span className={`${pixelLabel} flex-none`}>Recent</span>
{settings.recentPlaces.map((item) => {
const isCurrent = place ? samePlace(place, item) : false;
return (
<div
key={`${item.lat.toFixed(2)},${item.lon.toFixed(2)}`}
className={`flex flex-none items-center border text-xs transition-colors ${
isCurrent
? "border-gold bg-gold/12 text-gold-bright"
: "border-gold-dim/70 bg-panel/60 text-stone hover:border-gold/60 hover:text-ink"
}`}
>
<button
type="button"
onClick={() => selectPlace(item)}
className="min-h-7 cursor-pointer px-2.5 py-1 font-pixel text-[0.68rem]"
>
{placeLabel(item)}
</button>
<button
type="button"
aria-label={`Remove ${placeLabel(item)} from recent`}
onClick={() => removeRecentPlace(item)}
className="min-h-7 cursor-pointer pr-2 pl-1 font-pixel text-[0.68rem] opacity-60 hover:text-gold-bright hover:opacity-100"
>
×
</button>
</div>
);
})}
</div>
)}
</div>
{providers.length > 0 && (
<ProviderPicker providers={providers} selected={settings.provider} onChange={(id) => update({ provider: id })} />
)}
{/* Homepage landing state when no place is selected */}
{!place && (
<HomeLanding
onSelectPlace={selectPlace}
onFocusSearch={() => searchInputRef.current?.focus()}
activeProviderLabel={activeProvider?.label}
/>
)}
{status === "error" && error && (
<Slot as="section" tone="danger" innerClassName="flex flex-wrap items-center justify-between gap-4 p-6">
<p>{error}</p>
<button type="button" className={outlineButton} onClick={() => update({ place: place ? { ...place } : null })}>
Try again
</button>
</Slot>
)}
{/* Initial loading frame sits ABOVE radar so the radar doesn't jump down once forecast arrives */}
{place && status === "loading" && !report && (
<Slot as="section" className="[--cut:22px]" innerClassName="flex items-center gap-5 p-6 sm:px-8 sm:py-8">
<SkyIcon condition="clear" size={56} className="flex-none animate-float motion-reduce:animate-none" />
<div className="flex flex-col gap-1">
<p className="font-display text-lg text-gold-bright">Reading the skies above {placeLabel(place)}…</p>
<p className={pixelLabel}>Gathering current conditions, hourly horizon, and local alerts</p>
</div>
</Slot>
)}
{/* Keep the last report on screen while a new one loads, just dimmed */}
{report && place && status !== "error" && (
<div className={status === "loading" ? "opacity-50 transition-opacity" : undefined} aria-busy={status === "loading"}>
<WeatherView
report={report}
place={place}
units={settings.units}
forecastView={settings.forecastView}
onForecastViewChange={(view) => update({ forecastView: view })}
providerLabel={reportProvider?.label ?? report.provider}
maxDaysOnPlan={reportProvider?.forecastDays ?? report.daily.length}
/>
</div>
)}
{/* The radar doesn't depend on the forecast, so it shows as soon as there's a place */}
{place && <RadarPanel place={place} timeZone={report?.timeZone ?? null} hasOpenWeather={hasOpenWeather} />}
<SiteFooter report={report} activeProvider={settings.provider} hasOpenWeather={hasOpenWeather} />
</main>
);
}
/** Four-point gold compass emblem in the station header. */
function CompassStar() {
return (
<svg width={20} height={20} viewBox="0 0 20 20" fill="none" aria-hidden="true" className="flex-none text-gold">
<path d="M10 1L12.3 7.7L19 10L12.3 12.3L10 19L7.7 12.3L1 10L7.7 7.7L10 1Z" stroke="currentColor" strokeWidth="1.2" fill="var(--color-panel)" />
<circle cx="10" cy="10" r="1.6" fill="var(--color-gold-bright)" />
</svg>
);
}