Weather
index.jsx 532 lines
Files · 9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Page,
Header,
Button,
Input,
Row,
EmptyState,
Modal,
} from "../../shared/ui.jsx";
import { ConfirmDelete } from "../../shared/forms.jsx";
import { useStore, inserted, displayTime } from "../../shared/data.js";
import { conditionText, setupWeather, loadWeather, saveWeather, updateLocation } from "./weather.js";
import "./style.css";
const L = window.lattice;
const presets = [
{ name: "New York", lat: 40.7128, lon: -74.006 },
{ name: "London", lat: 51.5074, lon: -0.1278 },
{ name: "Tokyo", lat: 35.6762, lon: 139.6503 },
{ name: "Sydney", lat: -33.8688, lon: 151.2093 },
{ name: "Paris", lat: 48.8566, lon: 2.3522 },
{ name: "San Francisco", lat: 37.7749, lon: -122.4194 },
{ name: "Berlin", lat: 52.52, lon: 13.405 },
{ name: "Dubai", lat: 25.2048, lon: 55.2708 },
];
const number = (value, suffix = "") =>
value == null ? "—" : `${Math.round(value)}${suffix}`;
function LocationForm({ record, locations, onSave, onClose, busy, error }) {
const [form, setForm] = useState(record || { name: "", lat: "", lon: "" }),
[validation, setValidation] = useState("");
return (
<Modal
title={record ? "Edit location" : "Add location"}
onClose={busy ? undefined : onClose}
>
<form
onSubmit={async (event) => {
event.preventDefault();
const lat = Number(form.lat),
lon = Number(form.lon),
name = form.name.trim();
if (
!name ||
String(form.lat).trim() === "" ||
String(form.lon).trim() === "" ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
setValidation(
"Enter a name, latitude from −90 to 90, and longitude from −180 to 180.",
);
return;
}
if (
locations.some(
(loc) =>
loc.id !== record?.id && loc.lat === lat && loc.lon === lon,
)
) {
setValidation("That location is already saved.");
return;
}
await onSave({ name, lat, lon });
}}
>
{!record && (
<div className="ui-field">
<label htmlFor="weather-preset">City preset</label>
<select
id="weather-preset"
value=""
onChange={(e) => {
if (e.target.value) setForm(presets[Number(e.target.value)]);
}}
>
<option value="">Choose a city, or enter coordinates</option>
{presets.map((city, index) => (
<option key={city.name} value={index}>
{city.name}
</option>
))}
</select>
</div>
)}
<Input
label="Name"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<div className="weather-coordinate-fields">
<Input
label="Latitude"
type="number"
min="-90"
max="90"
step="any"
required
value={form.lat}
onChange={(e) => setForm({ ...form, lat: e.target.value })}
/>
<Input
label="Longitude"
type="number"
min="-180"
max="180"
step="any"
required
value={form.lon}
onChange={(e) => setForm({ ...form, lon: e.target.value })}
/>
</div>
{(validation || error) && (
<p role="alert" className="ui-error-text">
{validation || error}
</p>
)}
<div className="ui-actions" style={{ justifyContent: "flex-end" }}>
<Button onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={busy}>
Save location
</Button>
</div>
</form>
</Modal>
);
}
function App() {
const [activeId, setActiveId] = useState(null),
[data, setData] = useState({}),
[history, setHistory] = useState([]),
[loading, setLoading] = useState({}),
[errors, setErrors] = useState({});
const [editing, setEditing] = useState(null),
[deleting, setDeleting] = useState(null);
const attempted = useRef(new Set()),
inFlight = useRef(new Set()),
latestData = useRef(data),
activeRef = useRef(activeId);
latestData.current = data;
activeRef.current = activeId;
const store = useStore({
setup: async () => {
await setupWeather(L);
if (!(await L.kv.get("weather_initialized"))) {
if (!(await L.db.query("SELECT id FROM locations LIMIT 1")).length)
await L.db.exec(
"INSERT INTO locations (name,lat,lon) VALUES (?,?,?)",
["New York", 40.7128, -74.006],
);
await L.kv.set("weather_initialized", true);
}
},
load: () =>
L.db.query("SELECT * FROM locations ORDER BY name COLLATE NOCASE,id"),
});
const locations = store.rows,
active = locations.find((loc) => loc.id === activeId),
weather = active && data[activeId];
async function loadHistory(id) {
const rows = await L.db.query(
"SELECT * FROM weather_log WHERE location_id=? ORDER BY fetched_at DESC,id DESC LIMIT 20",
[id],
);
if (activeRef.current === id) setHistory(rows);
}
async function refresh(loc) {
if (inFlight.current.has(loc.id)) return;
inFlight.current.add(loc.id);
attempted.current.add(loc.id);
setLoading((prev) => ({ ...prev, [loc.id]: true }));
await store.act(async () => {
try {
if (!latestData.current[loc.id]) {
const saved = await loadWeather(L, loc.id);
if (saved) setData((prev) => ({ ...prev, [loc.id]: saved }));
}
const response = await L.net.fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${loc.lat}&longitude=${loc.lon}¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto&forecast_days=5`,
);
if (response.status !== 200)
throw Error(`Weather service returned ${response.status}`);
const payload = typeof response.body === "string"
? JSON.parse(response.body) : response.body;
const next = await saveWeather(L, loc.id, payload);
const cur = payload.current, now = next.current.fetched_at,
condition = next.current.condition;
setData((prev) => ({ ...prev, [loc.id]: next }));
await L.events.emit("weather-updated", {
location: loc.name,
temperature_c: cur.temperature_2m,
feels_like_c: cur.apparent_temperature ?? null,
humidity_pct: cur.relative_humidity_2m,
wind_speed_kmh: cur.wind_speed_10m,
condition,
uv_index: cur.uv_index ?? null,
fetched_at: now,
});
if (cur.weather_code >= 65) {
const message = `${condition} in ${loc.name}: ${Math.round(cur.temperature_2m)}°C, wind ${cur.wind_speed_10m} km/h`;
await L.events.emit("weather-alert", {
location: loc.name,
alert_type: condition,
message,
temperature_c: cur.temperature_2m,
condition,
triggered_at: now,
});
try {
await L.notify.send(condition, message);
} catch {
}
}
setErrors((prev) => ({ ...prev, [loc.id]: "" }));
} catch (error) {
setErrors((prev) => ({
...prev,
[loc.id]: error.message || String(error),
}));
}
await loadHistory(loc.id);
});
inFlight.current.delete(loc.id);
setLoading((prev) => ({ ...prev, [loc.id]: false }));
}
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
useEffect(() => {
if (store.ready && !locations.some((loc) => loc.id === activeId))
setActiveId(locations[0]?.id ?? null);
}, [store.ready, locations, activeId]);
useEffect(() => {
if (store.ready)
locations.forEach((loc) => {
if (!attempted.current.has(loc.id)) refreshRef.current(loc);
});
}, [store.ready, locations]);
useEffect(() => {
if (!store.ready) return;
const timer = setInterval(
() => locations.forEach((loc) => refreshRef.current(loc)),
10 * 60 * 1000,
);
return () => clearInterval(timer);
}, [store.ready, locations]);
useEffect(() => {
setHistory([]);
if (activeId && store.ready) store.act(() => loadHistory(activeId));
}, [activeId, store.ready]);
async function saveLocation(form) {
let id = editing?.id;
if (
await store.act(async () => {
if (id) {
await updateLocation(L, editing, form);
} else
id = (
await inserted(
"locations",
"INSERT INTO locations (name,lat,lon) VALUES (?,?,?)",
[form.name, form.lat, form.lon],
)
).id;
})
) {
attempted.current.delete(id);
setData((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setActiveId(id);
setEditing(null);
refreshRef.current({ ...form, id });
}
}
return (
<Page className="weather-page">
<Header
title="Weather"
subtitle="Open-Meteo · Celsius"
actions={
<>
<Button
onClick={() =>
locations.forEach((loc) => refreshRef.current(loc))
}
disabled={
!locations.length || Object.values(loading).some(Boolean)
}
>
Refresh all
</Button>
<Button variant="primary" onClick={() => setEditing("new")}>
Add location
</Button>
</>
}
/>
{store.error && (
<p role="alert" className="ui-error-text">
{store.error}{" "}
<Button onClick={() => store.act(async () => {})}>Retry</Button>
</p>
)}
<div className="ui-toolbar" role="group" aria-label="Saved locations">
{locations.map((loc) => (
<Button
key={loc.id}
variant={loc.id === activeId ? "primary" : "secondary"}
aria-pressed={loc.id === activeId}
onClick={() => setActiveId(loc.id)}
>
{loc.name}
</Button>
))}
</div>
{!store.ready ? (
<EmptyState
title={
store.error ? "Locations could not be loaded" : "Loading weather…"
}
/>
) : !locations.length ? (
<div className="ui-panel">
<EmptyState
title="No locations"
action={
<Button onClick={() => setEditing("new")}>Add location</Button>
}
/>
</div>
) : active ? (
<>
<div className="ui-panel weather-current">
<div className="ui-header">
<div>
<h2>{active.name}</h2>
<p className="ui-muted">
{active.lat.toFixed(2)}°, {active.lon.toFixed(2)}°
</p>
</div>
<div className="ui-actions">
<Button
onClick={() => refreshRef.current(active)}
disabled={loading[activeId]}
>
{loading[activeId] ? "Refreshing…" : "Refresh"}
</Button>
<Button variant="ghost" onClick={() => setEditing(active)}>
Edit
</Button>
<Button variant="ghost" onClick={() => setDeleting(active)}>
Remove
</Button>
</div>
</div>
{errors[activeId] && (
<p
className="ui-error-text"
role="alert"
style={{ marginBottom: 16 }}
>
{errors[activeId]}.{" "}
{weather
? "Showing the last saved forecast."
: "Try refreshing again."}
</p>
)}
{weather ? (
<>
<div className="weather-temperature">
{number(weather.current.temperature_2m, "°")}
<span>
{" "}
{weather.current.condition ||
conditionText(weather.current.weather_code)}
</span>
</div>
<p className="ui-muted">
Feels like{" "}
{number(weather.current.apparent_temperature, "°C")}
</p>
<div className="weather-stats">
{[
[
"Humidity",
number(weather.current.relative_humidity_2m, "%"),
],
["Wind", number(weather.current.wind_speed_10m, " km/h")],
[
"Precipitation",
weather.current.precipitation == null
? "—"
: `${weather.current.precipitation} mm`,
],
].map(([title, value]) => (
<div key={title}>
<p className="ui-muted">{title}</p>
<strong>{value}</strong>
</div>
))}
</div>
<p className="ui-muted">
Updated {displayTime(weather.current.fetched_at)}
</p>
</>
) : (
<EmptyState
title={
loading[activeId]
? "Loading forecast…"
: "Forecast unavailable"
}
/>
)}
</div>
{weather?.daily?.time?.length > 0 && (
<section className="ui-panel weather-forecast">
<h2>5-day forecast</h2>
<div className="weather-days">
{weather.daily.time.map((date, index) => (
<div key={date}>
<h3>
{new Date(date + "T12:00:00").toLocaleDateString([], {
weekday: "short",
day: "numeric",
})}
</h3>
<p className="ui-muted">
{conditionText(weather.daily.weather_code?.[index])}
</p>
<strong>
{number(weather.daily.temperature_2m_max?.[index], "°")}
</strong>
<span className="ui-muted">
{" "}
/ {number(weather.daily.temperature_2m_min?.[index], "°")}
</span>
<p className="ui-muted">
{weather.daily.precipitation_sum?.[index] ?? "—"} mm
</p>
</div>
))}
</div>
</section>
)}
{history.length > 0 && (
<section className="ui-panel" style={{ marginTop: 20 }}>
<div style={{ padding: "18px 20px" }}>
<h2>Recent readings</h2>
</div>
{history.slice(0, 5).map((row) => (
<Row
key={row.id}
title={`${number(row.temperature_c, "°C")} · ${row.condition}`}
subtitle={displayTime(row.fetched_at)}
actions={
<span className="ui-muted">
{number(row.wind_speed_kmh, " km/h")} wind
</span>
}
/>
))}
{history.length > 5 && (
<details style={{ padding: 18 }}>
<summary className="ui-muted">
{history.length - 5} more readings
</summary>
{history.slice(5).map((row) => (
<Row
key={row.id}
title={`${number(row.temperature_c, "°C")} · ${row.condition}`}
subtitle={displayTime(row.fetched_at)}
/>
))}
</details>
)}
</section>
)}
</>
) : null}
{editing && (
<LocationForm
record={editing === "new" ? null : editing}
locations={locations}
onSave={saveLocation}
onClose={() => setEditing(null)}
busy={store.busy}
error={store.error}
/>
)}
{deleting && (
<ConfirmDelete
title="Remove location?"
onClose={() => setDeleting(null)}
busy={store.busy}
error={store.error}
onConfirm={async () => {
const id = deleting.id;
if (
await store.act(async () => {
await L.db.exec("DELETE FROM weather_log WHERE location_id=?", [
id,
]);
await L.db.exec("DELETE FROM forecasts WHERE location_id=?", [id]);
await L.db.exec("DELETE FROM locations WHERE id=?", [id]);
})
) {
setDeleting(null);
setData((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
}}
>
{deleting.name} and its weather history will be removed.
</ConfirmDelete>
)}
</Page>
);
}
createRoot(document.getElementById("root")).render(<App />);