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 {
        // Restore the last saved observation and forecast before trying the network.
        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}&current=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 {
            /* Forecast and weather events remain available without desktop delivery. */
          }
        }
        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 />);