/* WeatherMate — app root */
const DEFAULT_CITY = { id: "thunderbay", name: "Thunder Bay", admin: "Ontario", country: "Canada", lat: 48.3809, lon: -89.2477 };
const COND_BY_LABEL = { Clear: "clear", "Partly Cloudy": "partly", Cloudy: "cloudy", Fog: "fog", Rain: "rain", Snow: "snow", Thunderstorm: "thunder" };
const CODE_BY_COND = { clear: 0, partly: 2, cloudy: 3, fog: 45, drizzle: 53, rain: 63, snow: 73, thunder: 95 };
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"style": "classic",
"units": "°C",
"animations": true,
"timeOfDay": "Auto",
"forceWeather": "Auto"
}/*EDITMODE-END*/;
/* ---- pull to refresh hook (touch) ---- */
function usePullToRefresh(onRefresh) {
const [height, setHeight] = React.useState(0);
const [spinning, setSpinning] = React.useState(false);
const startY = React.useRef(null);
const hRef = React.useRef(0);
const THRESH = 62;
hRef.current = height;
const bind = (scrollRef) => ({
onTouchStart: (e) => {
if (!spinning && scrollRef.current && scrollRef.current.scrollTop <= 0) startY.current = e.touches[0].clientY;
},
onTouchMove: (e) => {
if (startY.current == null) return;
const dy = e.touches[0].clientY - startY.current;
if (dy > 0 && scrollRef.current && scrollRef.current.scrollTop <= 0) {
setHeight(Math.min(96, dy * 0.5));
} else if (dy <= 0) {
setHeight(0);
}
},
onTouchEnd: async () => {
if (startY.current == null) return;
startY.current = null;
if (hRef.current >= THRESH && !spinning) {
setSpinning(true); setHeight(54);
try { await onRefresh(); } catch (e) {}
setSpinning(false); setHeight(0);
} else { setHeight(0); }
},
});
return { height, spinning, bind, zoneRef: React.useRef(null) };
}
function Loader({ theme }) {
return (
);
}
function UnitToggle({ unit, onToggle }) {
return (
);
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const unit = t.units === "°F" ? "f" : "c";
const [saved, setSaved] = React.useState(() => {
try { const s = JSON.parse(localStorage.getItem("wm_cities")); if (s && s.length) return s; } catch (e) {}
return [DEFAULT_CITY];
});
const [activeId, setActiveId] = React.useState(() => {
try { return localStorage.getItem("wm_active") || saved[0].id; } catch (e) { return saved[0].id; }
});
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [sample, setSample] = React.useState(false);
const [view, setView] = React.useState("home");
const [forecastMode, setForecastMode] = React.useState("hourly");
const [clock, setClock] = React.useState("");
const activeCity = saved.find(c => c.id === activeId) || saved[0];
React.useEffect(() => { try { localStorage.setItem("wm_cities", JSON.stringify(saved)); } catch (e) {} }, [saved]);
React.useEffect(() => { try { localStorage.setItem("wm_active", activeId); } catch (e) {} }, [activeId]);
const loadWeather = React.useCallback(async (city) => {
setLoading(true);
try {
const d = await WX.fetchWeather(city.lat, city.lon);
setData(d); setSample(false);
} catch (e) {
setData(WX.sampleData()); setSample(true);
}
setLoading(false);
}, []);
React.useEffect(() => { if (activeCity) loadWeather(activeCity); }, [activeId]);
// clock at city local time
React.useEffect(() => {
if (!data) return;
const tick = () => {
const now = new Date();
const utc = now.getTime() + now.getTimezoneOffset() * 60000;
const ct = new Date(utc + (data.utcOffsetSec || 0) * 1000);
let h = ct.getHours(); const m = ct.getMinutes();
const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12;
setClock(`${h}:${String(m).padStart(2, "0")}`);
};
tick(); const id = setInterval(tick, 10000);
return () => clearInterval(id);
}, [data]);
const ptr = usePullToRefresh(() => loadWeather(activeCity));
// ---- effective display (apply tweaks overrides) ----
const baseCond = data ? WX.conditionOf(data.current.code) : "clear";
const forcedCond = t.forceWeather === "Auto" ? null : COND_BY_LABEL[t.forceWeather];
const cond = forcedCond || baseCond;
const isDay = !data ? false : (t.timeOfDay === "Auto" ? data.current.isDay : t.timeOfDay === "Day");
const theme = WX.themeFor(cond, isDay);
const animate = t.animations;
const displayData = React.useMemo(() => {
if (!data) return null;
if (!forcedCond && t.timeOfDay === "Auto") return data;
const code = forcedCond ? CODE_BY_COND[forcedCond] : data.current.code;
const dayv = t.timeOfDay === "Auto" ? data.current.isDay : t.timeOfDay === "Day";
return { ...data, current: { ...data.current, code, isDay: dayv } };
}, [data, forcedCond, t.timeOfDay]);
// ---- city management ----
const addCity = (r) => {
const city = { id: String(r.id), name: r.name, admin: r.admin, country: r.country, lat: r.lat, lon: r.lon };
setSaved(prev => prev.find(c => c.id === city.id) ? prev : [...prev, city]);
setActiveId(city.id); setView("home");
};
const selectCity = (c) => { setActiveId(c.id); setView("home"); };
const removeCity = (c) => {
setSaved(prev => {
const next = prev.filter(x => x.id !== c.id);
if (c.id === activeId && next.length) setActiveId(next[0].id);
return next.length ? next : [DEFAULT_CITY];
});
};
return (
{sample &&
Demo data · live feed unavailable
}
{view === "home" && (
{!displayData ? : (
)}
setTweak("units", unit === "c" ? "°F" : "°C")} />
setView("search")}
onList={() => setForecastMode(m => m === "hourly" ? "weekly" : "hourly")}
onHome={() => setView("home")} onRadar={() => setView("radar")}
onPrefs={() => setView("prefs")} />
)}
{view === "home" && loading && displayData && (
)}
{view === "search" && (
setView("home")} />
)}
{view === "radar" && (
setView("home")} />
)}
setTweak("style", v)} />
setTweak("units", v)} />
setTweak("animations", v)} />
setTweak("timeOfDay", v)} />
setTweak("forceWeather", v)} />
);
}
ReactDOM.createRoot(document.getElementById("root")).render();