Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · Leaflet

Weather and Crop Advisory Widget Demo

Choose a location in Pakistan on the map, pick wheat, cotton, rice or sugarcane and a growth stage, and the widget fetches a real 7-day forecast from Open-Meteo. It turns it into plain advice (irrigate, heat stress, frost, spray windows) and shows the numbers behind every line. Switch on offline mode to see the cache and fallback.

Read the case study →

Interactive demo

samiullah.tech/lab/crop-advisory-widget

Sargodha 32.083, 72.671

the rules (demo rules, not agronomic advice)
  • Crop water use ETc = Kc × ET0. Kc per crop and stage is from FAO-56 Table 12. ET0 is Open-Meteo's FAO Penman-Monteith reference evapotranspiration.
  • Effective rain = 80% of any day with at least 2 mm; lighter showers are ignored.
  • Irrigate when the 7-day deficit reaches 25 mm (or 10 mm with soil below 0.2 m³/m³); plan one above 10 mm; hold off when soil is above 0.38 m³/m³. Paddy rice is treated as flooded.
  • Heat stress: daily max at or above the crop threshold. Frost: min at or below 2 °C. Heavy rain: 20 mm or more in a day.
  • Spray: 6:00–19:00, wind 3–15 km/h, rain chance under 30%, no rain in the next 4 hours, at most 30 °C.

Weather data by Open-Meteo.com (CC BY 4.0). Forecasts are cached in your browser for one hour per location.

## How it works

openmeteo.ts builds the request, applies an 8-second timeout with AbortController, and validates the JSON with zod before anything reads it. Forecasts are cached per 0.01° cell for an hour. If the request fails, the last cached forecast is used and labelled with its age; with no cache, a synthetic sample is shown with a red label.

advisory.ts is pure functions. Crop water use is ETc = Kc × ET0 with Kc from FAO-56, minus effective rain, accumulated into a running deficit. Heat and frost are threshold counts. Every daylight hour is rated for spraying from wind, rain chance, rain in the next four hours and temperature.

Each advice card returns the numbers it used, shown under "why?", so a farmer or agronomist can check the reasoning instead of trusting a colour.

## View source

The code running in the window above, exactly as deployed.

app/lab/crop-advisory-widget/advisory.ts233 lines
/**
 * Rule-based crop advisories from a 7-day forecast.
 * Every rule is a pure function that returns the numbers it used, so the UI can show "why".
 *
 * Crop coefficients (Kc) are from FAO Irrigation and Drainage Paper 56, Table 12.
 * Heat, frost, wind and rain thresholds are simple demo rules, not agronomic advice.
 */
import type { Day, Forecast, Hour } from "./openmeteo";

export type CropId = "wheat" | "cotton" | "rice" | "sugarcane";
export type Stage = "initial" | "mid" | "late";

export type CropRules = {
  label: string;
  kc: Record<Stage, number>;
  /** Daily max temperature at or above which the crop is heat-stressed (most sensitive stage). */
  heatC: number;
  heatNote: string;
  season: string;
};

export const CROPS: Record<CropId, CropRules> = {
  wheat: { label: "Wheat", kc: { initial: 0.4, mid: 1.15, late: 0.4 }, heatC: 32, heatNote: "grain filling suffers above about 32 °C", season: "Rabi, sown Nov, harvested Apr" },
  cotton: { label: "Cotton", kc: { initial: 0.35, mid: 1.18, late: 0.6 }, heatC: 40, heatNote: "flowers and young bolls shed above about 40 °C", season: "Kharif, sown Apr–May" },
  rice: { label: "Rice", kc: { initial: 1.05, mid: 1.2, late: 0.75 }, heatC: 35, heatNote: "pollen fails at flowering above about 35 °C", season: "Kharif, transplanted Jun–Jul" },
  sugarcane: { label: "Sugarcane", kc: { initial: 0.4, mid: 1.25, late: 0.75 }, heatC: 38, heatNote: "growth slows sharply above about 38 °C", season: "planted Feb–Mar or Sep–Oct" },
};

export const STAGES: Record<Stage, string> = { initial: "Initial (establishment)", mid: "Mid-season (full canopy)", late: "Late season (ripening)" };

export const RULES = {
  /** Rain below this per day is assumed to wet only the canopy and evaporate. */
  minEffectiveRainMm: 2,
  /** Share of a day's rain that reaches the root zone (simple fixed-ratio method). */
  effectiveRainShare: 0.8,
  irrigateNowMm: 25,
  planIrrigationMm: 10,
  frostC: 2,
  heavyRainMm: 20,
  spray: { minWind: 3, maxWind: 15, marginalWind: 20, maxRainProb: 30, marginalRainProb: 50, dryHoursAfter: 4, maxTemp: 30, firstHour: 6, lastHour: 18 },
  soilDry: 0.2,
  soilWet: 0.38,
};

export type Level = "action" | "watch" | "ok";
export type Advice = { id: string; level: Level; title: string; detail: string; why: string };

/* ------------------------------------------------------------------ */
/* Water balance                                                       */
/* ------------------------------------------------------------------ */

export type BalanceDay = { date: string; et0: number; etc: number; rain: number; effRain: number; deficit: number; cumulative: number };

export function waterBalance(days: Day[], kc: number): BalanceDay[] {
  let cumulative = 0;
  return days.map((d) => {
    const et0 = d.et0 ?? 0;
    const etc = et0 * kc;
    const effRain = d.rain >= RULES.minEffectiveRainMm ? d.rain * RULES.effectiveRainShare : 0;
    const deficit = etc - effRain;
    cumulative = Math.max(0, cumulative + deficit);
    return { date: d.date, et0, etc: r1(etc), rain: d.rain, effRain: r1(effRain), deficit: r1(deficit), cumulative: r1(cumulative) };
  });
}

const r1 = (n: number) => Math.round(n * 10) / 10;

/* ------------------------------------------------------------------ */
/* Spray windows                                                       */
/* ------------------------------------------------------------------ */

export type SprayRating = "good" | "marginal" | "no";
export type SprayCell = { time: string; hour: number; rating: SprayRating; reason: string };
export type SprayWindow = { date: string; from: string; to: string; hours: number };

export function rateSprayHour(hours: Hour[], i: number): SprayCell {
  const h = hours[i]!;
  const s = RULES.spray;
  const hour = Number(h.time.slice(11, 13));
  const next = hours.slice(i, i + s.dryHoursAfter);
  const rainSoon = next.some((x) => (x.rain ?? 0) >= 0.1);
  const wind = h.wind ?? 0;
  const prob = h.rainProb ?? 0;
  const temp = h.temp ?? 0;
  if (rainSoon) return { time: h.time, hour, rating: "no", reason: `rain within ${s.dryHoursAfter} h` };
  if (wind > s.marginalWind) return { time: h.time, hour, rating: "no", reason: `wind ${wind} km/h` };
  if (prob >= s.marginalRainProb) return { time: h.time, hour, rating: "no", reason: `rain chance ${prob}%` };
  if (wind < s.minWind) return { time: h.time, hour, rating: "marginal", reason: `calm (${wind} km/h): inversion and drift risk` };
  if (wind > s.maxWind) return { time: h.time, hour, rating: "marginal", reason: `wind ${wind} km/h` };
  if (prob >= s.maxRainProb) return { time: h.time, hour, rating: "marginal", reason: `rain chance ${prob}%` };
  if (temp > s.maxTemp) return { time: h.time, hour, rating: "marginal", reason: `hot (${temp} °C): fast evaporation` };
  return { time: h.time, hour, rating: "good", reason: `wind ${wind} km/h, rain chance ${prob}%, ${temp} °C` };
}

/** Daylight hours only, grouped by date. */
export function sprayGrid(hours: Hour[]): Array<{ date: string; cells: SprayCell[] }> {
  const byDate = new Map<string, SprayCell[]>();
  hours.forEach((h, i) => {
    const hour = Number(h.time.slice(11, 13));
    if (hour < RULES.spray.firstHour || hour > RULES.spray.lastHour) return;
    const date = h.time.slice(0, 10);
    byDate.set(date, [...(byDate.get(date) ?? []), rateSprayHour(hours, i)]);
  });
  return [...byDate.entries()].map(([date, cells]) => ({ date, cells }));
}

export function sprayWindows(grid: Array<{ date: string; cells: SprayCell[] }>, minHours = 2): SprayWindow[] {
  const out: SprayWindow[] = [];
  for (const { date, cells } of grid) {
    let run: SprayCell[] = [];
    const flush = () => {
      if (run.length >= minHours) out.push({ date, from: run[0]!.time.slice(11), to: `${String(run[run.length - 1]!.hour + 1).padStart(2, "0")}:00`, hours: run.length });
      run = [];
    };
    for (const c of cells) {
      if (c.rating === "good") run.push(c);
      else flush();
    }
    flush();
  }
  return out;
}

/* ------------------------------------------------------------------ */
/* Advisories                                                          */
/* ------------------------------------------------------------------ */

const dayName = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" });

export function advise(f: Forecast, crop: CropId, stage: Stage, todayHour = 0): Advice[] {
  const rules = CROPS[crop];
  const kc = rules.kc[stage];
  const out: Advice[] = [];
  const bal = waterBalance(f.days, kc);
  const etc = bal.reduce((s, d) => s + d.etc, 0);
  const eff = bal.reduce((s, d) => s + d.effRain, 0);
  const rain = f.days.reduce((s, d) => s + d.rain, 0);
  const deficit = r1(etc - eff);
  const soilNow = f.hours.find((h) => Number(h.time.slice(11, 13)) >= todayHour && h.soil !== null)?.soil ?? null;
  const soilTxt = soilNow === null ? "soil moisture not available here" : `soil moisture at 9–27 cm is ${soilNow.toFixed(2)} m³/m³`;
  const why = `7-day ETc = Kc ${kc} × ET0 ${r1(etc / kc)} mm = ${r1(etc)} mm; rain ${r1(rain)} mm, effective ${r1(eff)} mm; ${soilTxt}.`;

  // Irrigation
  if (crop === "rice" && stage !== "late") {
    out.push({
      id: "irrigation",
      level: deficit > RULES.planIrrigationMm ? "action" : "ok",
      title: deficit > RULES.planIrrigationMm ? `Top up standing water (~${Math.round(deficit)} mm over 7 days)` : "Standing water should hold this week",
      detail: "Paddy rice is kept flooded until a couple of weeks before harvest, so the deficit is the water to add, not a trigger.",
      why,
    });
  } else if (soilNow !== null && soilNow >= RULES.soilWet) {
    out.push({ id: "irrigation", level: "ok", title: "No irrigation: soil is wet", detail: `Soil is near field capacity; watering now risks waterlogging.`, why });
  } else if (deficit >= RULES.irrigateNowMm || (soilNow !== null && soilNow < RULES.soilDry && deficit >= RULES.planIrrigationMm)) {
    const day = bal.find((d) => d.cumulative >= RULES.irrigateNowMm);
    out.push({
      id: "irrigation",
      level: "action",
      title: `Irrigate about ${Math.round(deficit)} mm${day ? ` by ${dayName(day.date)}` : " this week"}`,
      detail: `The crop will use more water than rain supplies. ${day ? `The running deficit passes ${RULES.irrigateNowMm} mm on ${dayName(day.date)}.` : ""}`.trim(),
      why,
    });
  } else if (deficit >= RULES.planIrrigationMm) {
    out.push({ id: "irrigation", level: "watch", title: `Plan an irrigation (deficit ~${Math.round(deficit)} mm)`, detail: "Not urgent yet. Check again in 2–3 days.", why });
  } else {
    out.push({ id: "irrigation", level: "ok", title: "No irrigation needed this week", detail: `The 7-day water need left after rain (${Math.max(0, deficit)} mm) is under ${RULES.planIrrigationMm} mm.`, why });
  }

  // Heat stress
  const hot = f.days.filter((d) => (d.tmax ?? -99) >= rules.heatC);
  out.push(
    hot.length
      ? {
          id: "heat",
          level: hot.length >= 3 ? "action" : "watch",
          title: `${hot.length} heat-stress day${hot.length > 1 ? "s" : ""} ahead`,
          detail: `${rules.label}: ${rules.heatNote}. Irrigate the evening before hot days; avoid spraying in the afternoon.`,
          why: `Days with max ≥ ${rules.heatC} °C: ${hot.map((d) => `${dayName(d.date)} ${d.tmax} °C`).join(", ")}.`,
        }
      : {
          id: "heat",
          level: "ok",
          title: "No heat stress expected",
          detail: `${rules.label}: ${rules.heatNote}.`,
          why: `Highest max this week: ${Math.max(...f.days.map((d) => d.tmax ?? -99))} °C (threshold ${rules.heatC} °C).`,
        },
  );

  // Frost
  const cold = f.days.filter((d) => d.tmin !== null && d.tmin <= RULES.frostC);
  if (cold.length)
    out.push({
      id: "frost",
      level: "action",
      title: `Frost risk on ${cold.map((d) => dayName(d.date)).join(", ")}`,
      detail: "A light irrigation the day before keeps soil warmer overnight. Do not spray before a frosty night.",
      why: `Min temperature ≤ ${RULES.frostC} °C: ${cold.map((d) => `${d.tmin} °C`).join(", ")}.`,
    });

  // Heavy rain
  const wet = f.days.filter((d) => d.rain >= RULES.heavyRainMm);
  if (wet.length)
    out.push({
      id: "rain",
      level: "watch",
      title: `Heavy rain on ${wet.map((d) => dayName(d.date)).join(", ")}`,
      detail: "Hold irrigation and fertiliser before it, and clear field drains.",
      why: `Daily rain ≥ ${RULES.heavyRainMm} mm: ${wet.map((d) => `${d.rain} mm`).join(", ")}.`,
    });

  // Spray windows
  const windows = sprayWindows(sprayGrid(f.hours.filter((h) => h.time >= `${f.days[0]?.date}T${String(todayHour).padStart(2, "0")}`)));
  const s = RULES.spray;
  const sprayWhy = `Good hour: ${s.firstHour}:00–${s.lastHour + 1}:00, wind ${s.minWind}–${s.maxWind} km/h, rain chance < ${s.maxRainProb}%, no rain for ${s.dryHoursAfter} h after, ≤ ${s.maxTemp} °C. Windows need ≥ 2 good hours in a row.`;
  out.push(
    windows.length
      ? {
          id: "spray",
          level: "ok",
          title: `Next spray window: ${dayName(windows[0]!.date)} ${windows[0]!.from}–${windows[0]!.to}`,
          detail: `${windows.length} window${windows.length > 1 ? "s" : ""} this week. ${windows
            .slice(1, 4)
            .map((w) => `${dayName(w.date)} ${w.from}–${w.to}`)
            .join(", ")}`.trim(),
          why: sprayWhy,
        }
      : { id: "spray", level: "watch", title: "No good spray window this week", detail: "Wind or rain rules out every daylight slot of two hours or more.", why: sprayWhy },
  );

  const order: Record<Level, number> = { action: 0, watch: 1, ok: 2 };
  return out.sort((a, b) => order[a.level] - order[b.level]);
}
app/lab/crop-advisory-widget/openmeteo.ts201 lines
/**
 * Open-Meteo forecast client with validation, timeout, cache and fallback.
 * Weather data by Open-Meteo.com (CC BY 4.0). No API key; free for non-commercial use.
 *
 * In the production design this runs in a Next.js route handler and the cache is
 * shared (Redis or the fetch cache). Here the cache is the visitor's localStorage.
 */
import { z } from "zod";
import { mulberry32 } from "@/components/lab/geo/geo";

export const API = "https://api.open-meteo.com/v1/forecast";
export const DAILY = [
  "temperature_2m_max",
  "temperature_2m_min",
  "precipitation_sum",
  "precipitation_probability_max",
  "et0_fao_evapotranspiration",
  "wind_speed_10m_max",
] as const;
export const HOURLY = ["temperature_2m", "precipitation", "precipitation_probability", "wind_speed_10m", "soil_moisture_9_to_27cm"] as const;

export function buildUrl(lat: number, lng: number): string {
  const q = new URLSearchParams({
    latitude: lat.toFixed(3),
    longitude: lng.toFixed(3),
    daily: DAILY.join(","),
    hourly: HOURLY.join(","),
    timezone: "auto",
    forecast_days: "7",
    wind_speed_unit: "kmh",
  });
  return `${API}?${q}`;
}

/* ------------------------------------------------------------------ */
/* Response validation                                                 */
/* ------------------------------------------------------------------ */

const nums = z.array(z.number().nullable());
const responseSchema = z.object({
  latitude: z.number(),
  longitude: z.number(),
  timezone: z.string(),
  elevation: z.number().optional(),
  daily: z.object({
    time: z.array(z.string()),
    temperature_2m_max: nums,
    temperature_2m_min: nums,
    precipitation_sum: nums,
    precipitation_probability_max: nums.optional(),
    et0_fao_evapotranspiration: nums,
    wind_speed_10m_max: nums,
  }),
  hourly: z.object({
    time: z.array(z.string()),
    temperature_2m: nums,
    precipitation: nums,
    precipitation_probability: nums.optional(),
    wind_speed_10m: nums,
    soil_moisture_9_to_27cm: nums.optional(),
  }),
});
const errorSchema = z.object({ error: z.literal(true), reason: z.string() });

export type Day = { date: string; tmax: number | null; tmin: number | null; rain: number; rainProb: number | null; et0: number | null; windMax: number | null };
export type Hour = { time: string; temp: number | null; rain: number | null; rainProb: number | null; wind: number | null; soil: number | null };
export type Forecast = { lat: number; lng: number; timezone: string; elevation: number | null; days: Day[]; hours: Hour[] };

export function normalise(json: unknown): Forecast {
  const r = responseSchema.parse(json);
  const d = r.daily;
  const h = r.hourly;
  return {
    lat: r.latitude,
    lng: r.longitude,
    timezone: r.timezone,
    elevation: r.elevation ?? null,
    days: d.time.map((date, i) => ({
      date,
      tmax: d.temperature_2m_max[i] ?? null,
      tmin: d.temperature_2m_min[i] ?? null,
      rain: d.precipitation_sum[i] ?? 0,
      rainProb: d.precipitation_probability_max?.[i] ?? null,
      et0: d.et0_fao_evapotranspiration[i] ?? null,
      windMax: d.wind_speed_10m_max[i] ?? null,
    })),
    hours: h.time.map((time, i) => ({
      time,
      temp: h.temperature_2m[i] ?? null,
      rain: h.precipitation[i] ?? null,
      rainProb: h.precipitation_probability?.[i] ?? null,
      wind: h.wind_speed_10m[i] ?? null,
      soil: h.soil_moisture_9_to_27cm?.[i] ?? null,
    })),
  };
}

/* ------------------------------------------------------------------ */
/* Fetch with cache and fallback                                       */
/* ------------------------------------------------------------------ */

export type Source = "live" | "cache" | "stale-cache" | "sample";
export type CacheEntry = { key: string; fetchedAt: number; forecast: Forecast };
export type Outcome = { forecast: Forecast; source: Source; fetchedAt: number | null; error: string | null; log: string[] };

/** Forecasts are cached per ~1 km cell (2 decimals) for an hour, like a revalidate: 3600 route. */
export const FRESH_MS = 60 * 60 * 1000;
export const cacheKey = (lat: number, lng: number) => `${lat.toFixed(2)},${lng.toFixed(2)}`;

export type Deps = {
  fetch: typeof fetch;
  now: number;
  readCache: (key: string) => CacheEntry | null;
  writeCache: (entry: CacheEntry) => void;
  /** Pretend the network is down (demo switch). */
  offline?: boolean;
  timeoutMs?: number;
  force?: boolean;
};

export async function getForecast(lat: number, lng: number, deps: Deps): Promise<Outcome> {
  const key = cacheKey(lat, lng);
  const url = buildUrl(lat, lng);
  const log: string[] = [];
  const cached = deps.readCache(key);

  if (cached && !deps.force && deps.now - cached.fetchedAt < FRESH_MS) {
    log.push(`cache HIT ${key} (age ${Math.round((deps.now - cached.fetchedAt) / 60000)} min), no request`);
    return { forecast: cached.forecast, source: "cache", fetchedAt: cached.fetchedAt, error: null, log };
  }

  log.push(`GET ${url.replace(API, "/v1/forecast")}`);
  let error: string;
  try {
    if (deps.offline) throw new TypeError("Failed to fetch (offline mode)");
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), deps.timeoutMs ?? 8000);
    const res = await deps.fetch(url, { signal: ctrl.signal }).finally(() => clearTimeout(timer));
    const body: unknown = await res.json().catch(() => null);
    if (!res.ok) {
      const reason = errorSchema.safeParse(body).data?.reason ?? res.statusText;
      throw new Error(`${res.status} ${reason}`);
    }
    const forecast = normalise(body);
    deps.writeCache({ key, fetchedAt: deps.now, forecast });
    log.push(`200 OK · ${forecast.days.length} days, ${forecast.hours.length} hours · ${forecast.timezone} · cached as ${key}`);
    return { forecast, source: "live", fetchedAt: deps.now, error: null, log };
  } catch (e) {
    error =
      e instanceof DOMException && e.name === "AbortError"
        ? `timed out after ${(deps.timeoutMs ?? 8000) / 1000} s`
        : e instanceof z.ZodError
          ? `unexpected response shape (${e.issues[0]?.path.join(".")})`
          : e instanceof Error
            ? e.message
            : "unknown error";
    log.push(`ERR ${error}`);
  }

  if (cached) {
    log.push(`fallback → stale cache from ${new Date(cached.fetchedAt).toISOString().slice(0, 16).replace("T", " ")} UTC`);
    return { forecast: cached.forecast, source: "stale-cache", fetchedAt: cached.fetchedAt, error, log };
  }
  log.push("fallback → built-in synthetic sample");
  return { forecast: sampleForecast(lat, lng, deps.now), source: "sample", fetchedAt: null, error, log };
}

/* ------------------------------------------------------------------ */
/* Synthetic sample (last-resort fallback, clearly labelled in the UI) */
/* ------------------------------------------------------------------ */

export function sampleForecast(lat: number, lng: number, now: number): Forecast {
  const rnd = mulberry32(Math.round(lat * 100) * 1000 + Math.round(lng * 100));
  const start = new Date(now);
  const days: Day[] = [];
  const hours: Hour[] = [];
  for (let d = 0; d < 7; d++) {
    const date = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate() + d)).toISOString().slice(0, 10);
    const tmax = Math.round((28 + rnd() * 8) * 10) / 10;
    const tmin = Math.round((tmax - 11 - rnd() * 4) * 10) / 10;
    const wet = d === 3 || d === 4;
    const rain = wet ? Math.round(rnd() * 14 * 10) / 10 : 0;
    let windMax = 0;
    for (let hr = 0; hr < 24; hr++) {
      const temp = Math.round((tmin + (tmax - tmin) * Math.max(0, Math.sin(((hr - 6) / 24) * 2 * Math.PI))) * 10) / 10;
      const wind = Math.round((4 + 10 * Math.max(0, Math.sin(((hr - 8) / 24) * 2 * Math.PI)) + rnd() * 6) * 10) / 10;
      windMax = Math.max(windMax, wind);
      hours.push({
        time: `${date}T${String(hr).padStart(2, "0")}:00`,
        temp,
        rain: wet && hr >= 14 && hr <= 18 ? Math.round((rain / 5) * 10) / 10 : 0,
        rainProb: wet ? 60 + Math.round(rnd() * 30) : Math.round(rnd() * 15),
        wind,
        soil: Math.round((0.24 - d * 0.008 + (wet ? 0.04 : 0)) * 1000) / 1000,
      });
    }
    days.push({ date, tmax, tmin, rain, rainProb: wet ? 80 : 10, et0: Math.round((3 + rnd() * 2.5) * 100) / 100, windMax });
  }
  return { lat, lng, timezone: "Asia/Karachi", elevation: null, days, hours };
}
app/lab/crop-advisory-widget/Demo.tsx524 lines
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type * as Leaflet from "leaflet";
import { MAP_CLASS, useLeafletMap } from "@/components/lab/geo/useLeafletMap";
import { getForecast, type CacheEntry, type Outcome } from "./openmeteo";
import { CROPS, RULES, STAGES, advise, sprayGrid, waterBalance, type CropId, type Level, type Stage } from "./advisory";

const KEY = "lab:crop-advisory-widget:v1";
const MAX_CACHE = 8;

type Loc = { lat: number; lng: number; label: string };
type Settings = { loc: Loc; crop: CropId; stage: Stage };
type Stored = { settings: Settings; cache: CacheEntry[] };

const PRESETS: Loc[] = [
  { lat: 32.083, lng: 72.671, label: "Sargodha" },
  { lat: 31.418, lng: 73.079, label: "Faisalabad" },
  { lat: 30.157, lng: 71.525, label: "Multan" },
  { lat: 29.395, lng: 71.683, label: "Bahawalpur" },
  { lat: 30.808, lng: 73.446, label: "Okara" },
  { lat: 27.558, lng: 68.212, label: "Larkana" },
];
const DEFAULTS: Settings = { loc: PRESETS[0]!, crop: "wheat", stage: "initial" };

const btn =
  "inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-elev px-3 py-1.5 font-mono text-xs hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-40";
const LEVEL: Record<Level, { label: string; icon: string; cls: string }> = {
  action: { label: "Act", icon: "!", cls: "border-danger/50 bg-danger/10 text-danger" },
  watch: { label: "Watch", icon: "~", cls: "border-warn-fg/40 bg-warn-bg text-warn-fg" },
  ok: { label: "OK", icon: "✓", cls: "border-accent/40 bg-accent-soft text-accent" },
};

function readStore(): Stored | null {
  try {
    const raw = localStorage.getItem(KEY);
    return raw ? (JSON.parse(raw) as Stored) : null;
  } catch {
    return null;
  }
}
function writeStore(s: Stored) {
  try {
    localStorage.setItem(KEY, JSON.stringify(s));
  } catch {
    /* storage full or blocked: the widget still works, just without a cache */
  }
}

const dayLabel = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", timeZone: "UTC" });
const localHour = (tz: string) => {
  try {
    return Number(new Intl.DateTimeFormat("en-GB", { timeZone: tz, hour: "2-digit", hourCycle: "h23" }).format(new Date()));
  } catch {
    return new Date().getHours();
  }
};
const fmtTime = (ms: number) => new Date(ms).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" });

export default function Demo() {
  const { ref, ctx } = useLeafletMap({ center: [30.6, 71.8], zoom: 6 });
  const [settings, setSettings] = useState<Settings>(DEFAULTS);
  const [loaded, setLoaded] = useState(false);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [loading, setLoading] = useState(false);
  const [offline, setOffline] = useState(false);
  const [log, setLog] = useState<Array<{ id: number; text: string }>>([]);
  const [geoMsg, setGeoMsg] = useState<string | null>(null);
  const [hour, setHour] = useState(0);
  const seq = useRef(0);
  const reqId = useRef(0);

  const push = useCallback((lines: string[]) => {
    setLog((l) => [...lines.map((text) => ({ id: seq.current++, text })), ...l].slice(0, 40));
  }, []);

  useEffect(() => {
    const s = readStore();
    if (s?.settings?.loc) setSettings({ ...DEFAULTS, ...s.settings });
    setLoaded(true);
  }, []);

  useEffect(() => {
    if (!loaded) return;
    const s = readStore();
    writeStore({ settings, cache: s?.cache ?? [] });
  }, [settings, loaded]);

  const load = useCallback(
    async (loc: Loc, force = false) => {
      const id = ++reqId.current;
      setLoading(true);
      const res = await getForecast(loc.lat, loc.lng, {
        fetch: (...a) => fetch(...a),
        now: Date.now(),
        offline,
        force,
        readCache: (key) => readStore()?.cache.find((c) => c.key === key) ?? null,
        writeCache: (entry) => {
          const s = readStore();
          const cache = [entry, ...(s?.cache ?? []).filter((c) => c.key !== entry.key)].slice(0, MAX_CACHE);
          writeStore({ settings: s?.settings ?? DEFAULTS, cache });
        },
      });
      if (id !== reqId.current) return; // a newer request superseded this one
      push(res.log.slice().reverse());
      setOutcome(res);
      setHour(localHour(res.forecast.timezone));
      setLoading(false);
    },
    [offline, push],
  );

  useEffect(() => {
    if (loaded) void load(settings.loc);
    // Reload only when the location changes (or offline mode is toggled), not on crop changes.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loaded, settings.loc.lat, settings.loc.lng, offline]);

  /* ---------------- map ---------------- */
  useEffect(() => {
    if (!ctx) return;
    const onClick = (e: Leaflet.LeafletMouseEvent) =>
      setSettings((s) => ({ ...s, loc: { lat: Number(e.latlng.lat.toFixed(3)), lng: Number(e.latlng.lng.toFixed(3)), label: "Picked on map" } }));
    ctx.map.on("click", onClick);
    return () => {
      ctx.map.off("click", onClick);
    };
  }, [ctx]);

  useEffect(() => {
    if (!ctx) return;
    const m = ctx.L.circleMarker([settings.loc.lat, settings.loc.lng], { radius: 8, color: "#fff", weight: 2, fillColor: "#2563eb", fillOpacity: 1 })
      .bindTooltip(`${settings.loc.label} · ${settings.loc.lat.toFixed(3)}, ${settings.loc.lng.toFixed(3)}`)
      .addTo(ctx.map);
    return () => {
      m.remove();
    };
  }, [ctx, settings.loc]);

  function locate() {
    if (!("geolocation" in navigator)) return setGeoMsg("No geolocation in this browser. Click the map instead.");
    navigator.geolocation.getCurrentPosition(
      (p) => {
        setGeoMsg(null);
        const loc = { lat: Number(p.coords.latitude.toFixed(3)), lng: Number(p.coords.longitude.toFixed(3)), label: "My location" };
        setSettings((s) => ({ ...s, loc }));
        ctx?.map.setView([loc.lat, loc.lng], 8);
      },
      () => setGeoMsg("Location unavailable or denied. Click the map or pick a town."),
      { timeout: 8000, maximumAge: 600_000 },
    );
  }

  function reset() {
    try {
      localStorage.removeItem(KEY);
    } catch {
      /* ignore */
    }
    setOffline(false);
    setSettings({ ...DEFAULTS, loc: { ...DEFAULTS.loc } });
    setLog([]);
    ctx?.map.setView([30.6, 71.8], 6);
  }

  /* ---------------- derived ---------------- */
  const f = outcome?.forecast ?? null;
  const rules = CROPS[settings.crop];
  const kc = rules.kc[settings.stage];
  const advice = useMemo(() => (f ? advise(f, settings.crop, settings.stage, hour) : []), [f, settings.crop, settings.stage, hour]);
  const balance = useMemo(() => (f ? waterBalance(f.days, kc) : []), [f, kc]);
  const grid = useMemo(() => (f ? sprayGrid(f.hours) : []), [f]);

  const sourceBadge = !outcome
    ? null
    : {
        live: { text: `Live · Open-Meteo · fetched ${outcome.fetchedAt ? fmtTime(outcome.fetchedAt) : ""}`, cls: "text-accent" },
        cache: { text: `Cached forecast from ${outcome.fetchedAt ? fmtTime(outcome.fetchedAt) : ""} (fresh for 1 h)`, cls: "text-accent-2" },
        "stale-cache": { text: `Offline: showing the last forecast, fetched ${outcome.fetchedAt ? fmtTime(outcome.fetchedAt) : ""}`, cls: "text-warn-fg" },
        sample: { text: "Offline and nothing cached: showing a built-in SYNTHETIC sample, not real weather", cls: "text-danger" },
      }[outcome.source];

  return (
    <div>
      <div className="grid lg:grid-cols-[22rem_1fr]">
        {/* ---------------- controls ---------------- */}
        <aside className="space-y-4 border-b border-border bg-bg-elev p-4 lg:border-r lg:border-b-0">
          <div ref={ref} className={`${MAP_CLASS} h-56 rounded-md border border-border`} role="application" aria-label="Click the map to choose a farm location" />
          <div>
            <p className="text-xs text-fg-muted">Click the map, or pick a town:</p>
            <div className="mt-1.5 flex flex-wrap gap-1.5">
              {PRESETS.map((p) => (
                <button
                  key={p.label}
                  type="button"
                  aria-pressed={settings.loc.label === p.label}
                  className={`${btn} ${settings.loc.label === p.label ? "border-accent text-accent" : ""}`}
                  onClick={() => {
                    setSettings((s) => ({ ...s, loc: p }));
                    ctx?.map.setView([p.lat, p.lng], 7);
                  }}
                >
                  {p.label}
                </button>
              ))}
              <button type="button" className={btn} onClick={locate}>
                ◎ My location
              </button>
            </div>
            {geoMsg && <p className="mt-1.5 text-xs text-warn-fg">{geoMsg}</p>}
          </div>
          <div className="grid grid-cols-2 gap-3 text-xs">
            <label className="block">
              <span className="text-fg-muted">Crop</span>
              <select
                value={settings.crop}
                onChange={(e) => setSettings((s) => ({ ...s, crop: e.target.value as CropId }))}
                className="mt-1 block w-full rounded-md border border-border bg-bg px-2 py-1.5 text-sm"
              >
                {(Object.keys(CROPS) as CropId[]).map((c) => (
                  <option key={c} value={c}>
                    {CROPS[c].label}
                  </option>
                ))}
              </select>
            </label>
            <label className="block">
              <span className="text-fg-muted">Growth stage</span>
              <select
                value={settings.stage}
                onChange={(e) => setSettings((s) => ({ ...s, stage: e.target.value as Stage }))}
                className="mt-1 block w-full rounded-md border border-border bg-bg px-2 py-1.5 text-sm"
              >
                {(Object.keys(STAGES) as Stage[]).map((s) => (
                  <option key={s} value={s}>
                    {STAGES[s]}
                  </option>
                ))}
              </select>
            </label>
          </div>
          <p className="text-xs text-fg-muted">
            {rules.label} ({rules.season}) · Kc {kc} · heat threshold {rules.heatC} °C
          </p>
          <div className="flex flex-wrap items-center gap-2">
            <button type="button" className={btn} onClick={() => load(settings.loc, true)} disabled={loading}>
              ↻ Refresh
            </button>
            <label className="flex items-center gap-1.5 text-xs">
              <input type="checkbox" checked={offline} onChange={(e) => setOffline(e.target.checked)} className="accent-[var(--accent)]" />
              Simulate offline
            </label>
            <button type="button" className={`${btn} ml-auto`} onClick={reset}>
              Reset demo
            </button>
          </div>

          <section aria-label="Request log">
            <h3 className="font-mono text-xs font-semibold">request log</h3>
            <ol role="log" aria-live="polite" className="mt-1.5 h-40 overflow-y-auto rounded-md border border-[#27272a] bg-[#0b0b0d] p-2 font-mono text-[11px] leading-relaxed break-all">
              {log.length === 0 && <li className="text-[#71717a]">$ waiting…</li>}
              {log.map((l) => (
                <li
                  key={l.id}
                  className={
                    l.text.startsWith("ERR")
                      ? "text-[#f87171]"
                      : l.text.startsWith("fallback")
                        ? "text-[#fbbf24]"
                        : l.text.startsWith("200") || l.text.startsWith("cache")
                          ? "text-[#4ade80]"
                          : "text-[#a1a1aa]"
                  }
                >
                  {l.text}
                </li>
              ))}
            </ol>
          </section>
        </aside>

        {/* ---------------- results ---------------- */}
        <div className="min-w-0 space-y-5 p-4">
          <div className="flex flex-wrap items-baseline justify-between gap-2">
            <h3 className="text-lg font-semibold">
              {settings.loc.label}{" "}
              <span className="font-mono text-xs font-normal text-fg-muted">
                {settings.loc.lat.toFixed(3)}, {settings.loc.lng.toFixed(3)}
                {f?.elevation != null ? ` · ${Math.round(f.elevation)} m` : ""}
              </span>
            </h3>
            <p role="status" aria-live="polite" className={`font-mono text-xs ${loading ? "text-fg-muted" : (sourceBadge?.cls ?? "")}`}>
              {loading ? "Loading forecast…" : sourceBadge?.text}
            </p>
          </div>
          {outcome?.error && !loading && (
            <p role="alert" className="rounded-md border border-warn-fg/40 bg-warn-bg px-3 py-2 text-xs text-warn-fg">
              Open-Meteo request failed: {outcome.error}. {outcome.source === "sample" ? "The numbers below are a synthetic sample." : "The advice below uses the cached forecast."}
            </p>
          )}

          {!f && (
            <div className="space-y-2" aria-hidden="true">
              {[0, 1, 2].map((i) => (
                <div key={i} className="h-16 animate-pulse rounded-md bg-bg-muted" />
              ))}
            </div>
          )}

          {f && (
            <>
              <section aria-labelledby="adv-h">
                <h4 id="adv-h" className="font-mono text-sm font-semibold">
                  advice for {rules.label.toLowerCase()} · {STAGES[settings.stage].toLowerCase()}
                </h4>
                <ul className="mt-2 grid gap-2 md:grid-cols-2">
                  {advice.map((a) => (
                    <li key={a.id} className="rounded-md border border-border bg-bg-elev p-3">
                      <p className="flex items-start gap-2">
                        <span className={`mt-0.5 inline-flex shrink-0 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-semibold ${LEVEL[a.level].cls}`}>
                          <span aria-hidden="true">{LEVEL[a.level].icon}</span>
                          {LEVEL[a.level].label}
                        </span>
                        <span className="text-sm font-medium">{a.title}</span>
                      </p>
                      {a.detail && <p className="mt-1 text-xs text-fg-muted">{a.detail}</p>}
                      <details className="mt-1.5">
                        <summary className="cursor-pointer font-mono text-[11px] text-accent-2">why?</summary>
                        <p className="mt-1 font-mono text-[11px] text-fg-muted">{a.why}</p>
                      </details>
                    </li>
                  ))}
                </ul>
              </section>

              <section aria-labelledby="fc-h" className="overflow-x-auto">
                <h4 id="fc-h" className="font-mono text-sm font-semibold">
                  7-day forecast
                </h4>
                <table className="mt-2 w-full min-w-[34rem] text-left text-xs">
                  <caption className="sr-only">Daily forecast and crop water use</caption>
                  <thead className="font-mono text-fg-muted">
                    <tr className="border-b border-border">
                      {["day", "max / min °C", "rain mm", "chance", "ET0 mm", `ETc mm (×${kc})`, "wind max km/h"].map((h) => (
                        <th key={h} className="py-1.5 pr-3 font-normal">
                          {h}
                        </th>
                      ))}
                    </tr>
                  </thead>
                  <tbody className="font-mono">
                    {f.days.map((d, i) => (
                      <tr key={d.date} className="border-b border-border">
                        <td className="py-1.5 pr-3 font-sans">{dayLabel(d.date)}</td>
                        <td className="py-1.5 pr-3">
                          <span className={(d.tmax ?? 0) >= rules.heatC ? "font-semibold text-danger" : ""}>{d.tmax ?? "—"}</span>
                          {(d.tmax ?? 0) >= rules.heatC && <span className="sr-only"> (heat stress)</span>} /{" "}
                          <span className={d.tmin !== null && d.tmin <= RULES.frostC ? "font-semibold text-accent-2" : ""}>{d.tmin ?? "—"}</span>
                        </td>
                        <td className="py-1.5 pr-3">{d.rain.toFixed(1)}</td>
                        <td className="py-1.5 pr-3">{d.rainProb === null ? "—" : `${d.rainProb}%`}</td>
                        <td className="py-1.5 pr-3">{d.et0?.toFixed(1) ?? "—"}</td>
                        <td className="py-1.5 pr-3">{balance[i]?.etc.toFixed(1)}</td>
                        <td className="py-1.5 pr-3">{d.windMax ?? "—"}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </section>

              <div className="grid gap-5 xl:grid-cols-2">
                <section aria-labelledby="wb-h">
                  <h4 id="wb-h" className="font-mono text-sm font-semibold">
                    water balance
                  </h4>
                  <WaterChart balance={balance} />
                  <p className="flex flex-wrap gap-3 text-[11px] text-fg-muted">
                    <span className="flex items-center gap-1">
                      <span className="inline-block size-2.5 rounded-sm bg-accent-2" aria-hidden="true" /> crop use ETc
                    </span>
                    <span className="flex items-center gap-1">
                      <span className="inline-block size-2.5 rounded-sm bg-accent" aria-hidden="true" /> effective rain
                    </span>
                    <span className="flex items-center gap-1">
                      <span className="inline-block h-0.5 w-3 bg-danger" aria-hidden="true" /> running deficit
                    </span>
                  </p>
                </section>

                <section aria-labelledby="sp-h" className="overflow-x-auto">
                  <h4 id="sp-h" className="font-mono text-sm font-semibold">
                    spray windows (local time)
                  </h4>
                  <table className="mt-2 font-mono text-[10px]">
                    <caption className="sr-only">Spraying suitability per daylight hour: ✓ good, ~ marginal, ✕ not advised</caption>
                    <thead>
                      <tr>
                        <th className="pr-2 font-normal text-fg-muted">
                          <span className="sr-only">day</span>
                        </th>
                        {grid[0]?.cells.map((c) => (
                          <th key={c.hour} className="w-6 font-normal text-fg-muted">
                            {String(c.hour).padStart(2, "0")}
                          </th>
                        ))}
                      </tr>
                    </thead>
                    <tbody>
                      {grid.map((row) => (
                        <tr key={row.date}>
                          <th scope="row" className="pr-2 text-left font-sans font-normal whitespace-nowrap">
                            {dayLabel(row.date)}
                          </th>
                          {row.cells.map((c) => (
                            <td key={c.time} className="p-0.5">
                              <span
                                title={`${c.time.replace("T", " ")}: ${c.rating} (${c.reason})`}
                                className={`flex size-5 items-center justify-center rounded-sm ${
                                  c.rating === "good" ? "bg-accent text-accent-fg" : c.rating === "marginal" ? "bg-warn-bg text-warn-fg" : "bg-bg-muted text-fg-muted"
                                }`}
                              >
                                {c.rating === "good" ? "✓" : c.rating === "marginal" ? "~" : "✕"}
                                <span className="sr-only">{c.reason}</span>
                              </span>
                            </td>
                          ))}
                        </tr>
                      ))}
                    </tbody>
                  </table>
                  <p className="mt-1 text-[11px] text-fg-muted">Hover a cell for the reason. ✓ good · ~ marginal · ✕ not advised.</p>
                </section>
              </div>
            </>
          )}

          <details className="rounded-md border border-border p-3 text-xs">
            <summary className="cursor-pointer font-mono font-semibold hover:text-accent">the rules (demo rules, not agronomic advice)</summary>
            <ul className="mt-2 list-disc space-y-1 pl-5 text-fg-muted">
              <li>
                Crop water use ETc = Kc × ET0. Kc per crop and stage is from FAO-56 Table 12. ET0 is Open-Meteo&apos;s FAO Penman-Monteith
                reference evapotranspiration.
              </li>
              <li>
                Effective rain = {RULES.effectiveRainShare * 100}% of any day with at least {RULES.minEffectiveRainMm} mm; lighter showers are
                ignored.
              </li>
              <li>
                Irrigate when the 7-day deficit reaches {RULES.irrigateNowMm} mm (or {RULES.planIrrigationMm} mm with soil below{" "}
                {RULES.soilDry} m³/m³); plan one above {RULES.planIrrigationMm} mm; hold off when soil is above {RULES.soilWet} m³/m³. Paddy rice
                is treated as flooded.
              </li>
              <li>Heat stress: daily max at or above the crop threshold. Frost: min at or below {RULES.frostC} °C. Heavy rain: {RULES.heavyRainMm} mm or more in a day.</li>
              <li>
                Spray: {RULES.spray.firstHour}:00–{RULES.spray.lastHour + 1}:00, wind {RULES.spray.minWind}–{RULES.spray.maxWind} km/h, rain
                chance under {RULES.spray.maxRainProb}%, no rain in the next {RULES.spray.dryHoursAfter} hours, at most {RULES.spray.maxTemp} °C.
              </li>
            </ul>
          </details>

          <p className="text-[11px] text-fg-muted">
            Weather data by{" "}
            <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer" className="underline hover:text-accent">
              Open-Meteo.com
            </a>{" "}
            (CC BY 4.0). Forecasts are cached in your browser for one hour per location.
          </p>
        </div>
      </div>
    </div>
  );
}

function WaterChart({ balance }: { balance: ReturnType<typeof waterBalance> }) {
  const W = 360;
  const H = 150;
  const m = { l: 28, r: 8, t: 10, b: 20 };
  const max = Math.max(5, ...balance.map((b) => Math.max(b.etc, b.effRain)), ...balance.map((b) => b.cumulative));
  const y = (v: number) => m.t + (1 - v / max) * (H - m.t - m.b);
  const slot = (W - m.l - m.r) / Math.max(1, balance.length);
  const line = balance.map((b, i) => `${i ? "L" : "M"}${(m.l + slot * (i + 0.5)).toFixed(1)},${y(b.cumulative).toFixed(1)}`).join("");
  return (
    <svg
      viewBox={`0 0 ${W} ${H}`}
      className="mt-2 h-auto w-full"
      role="img"
      aria-label={`Daily crop water use and effective rain in mm. Running deficit reaches ${balance[balance.length - 1]?.cumulative ?? 0} mm after 7 days.`}
    >
      {[0, 0.5, 1].map((t) => (
        <g key={t}>
          <line x1={m.l} x2={W - m.r} y1={y(max * t)} y2={y(max * t)} stroke="var(--border)" />
          <text x={m.l - 4} y={y(max * t) + 3} textAnchor="end" fontSize={9} fill="var(--fg-muted)">
            {Math.round(max * t)}
          </text>
        </g>
      ))}
      {balance.map((b, i) => {
        const x0 = m.l + slot * i + slot * 0.18;
        const bw = slot * 0.3;
        return (
          <g key={b.date}>
            <rect x={x0} y={y(b.etc)} width={bw} height={y(0) - y(b.etc)} fill="var(--accent-2)" rx={1}>
              <title>{`${b.date}: ETc ${b.etc} mm`}</title>
            </rect>
            <rect x={x0 + bw + 1} y={y(b.effRain)} width={bw} height={y(0) - y(b.effRain)} fill="var(--accent)" rx={1}>
              <title>{`${b.date}: effective rain ${b.effRain} mm`}</title>
            </rect>
            <text x={m.l + slot * (i + 0.5)} y={H - 6} textAnchor="middle" fontSize={9} fill="var(--fg-muted)">
              {dayLabel(b.date).split(" ")[0]}
            </text>
          </g>
        );
      })}
      <path d={line} fill="none" stroke="var(--danger)" strokeWidth={2} />
      {balance.map((b, i) => (
        <circle key={b.date} cx={m.l + slot * (i + 0.5)} cy={y(b.cumulative)} r={2.5} fill="var(--danger)">
          <title>{`${b.date}: running deficit ${b.cumulative} mm`}</title>
        </circle>
      ))}
    </svg>
  );
}