# Weather and Crop Advisory Widget

> A concept project with a working demo: a widget that fetches a real 7-day forecast from the Open-Meteo API for any point on a map and turns it into crop advice for wheat, cotton, rice or sugarcane. It covers irrigation from ET0 and rain, heat and frost days and hourly spray windows, explains every rule, and keeps working offline from a cache.

- Author: Sami Ullah (https://samiullah.tech/about)
- Type: Concept project
- Role: Full build
- Stack: TypeScript, React.js, Next.js, Leaflet, zod, Open-Meteo API
- Year: 2026
- Updated: 2026-11-13
- Canonical: https://samiullah.tech/work/crop-advisory-widget

## Overview

Farmers do not need a forecast table. They need to know whether to irrigate this week, whether tomorrow morning is safe for spraying, and whether a heat wave is coming during flowering. A weather API gives the raw numbers; the useful part is the translation.

This concept does that translation in the open. The [live demo](/lab/crop-advisory-widget) takes a point you click on a map, fetches a real forecast from Open-Meteo, and produces advice for a chosen crop and growth stage. Every advice card has a "why?" that shows the numbers and thresholds it used. It is a small, public version of the [geospatial](/skills/geospatial) advisory work I do in my job.

## My role

I built all of it: the API client with validation, timeout, cache and fallback; the rule engine; the map picker; the charts and this write-up. I chose the crop coefficients from FAO-56, a public reference, and wrote the other thresholds as simple demo rules, labelled as such in the widget.

## The problem

Turning weather into advice has three hard parts:

- **Water is a balance, not a rain forecast.** "No rain this week" means something very different for wheat just sown in November and for cotton at full canopy in June. The crop's water use depends on the weather (reference evapotranspiration, ET0) and on the crop and its stage (the crop coefficient, Kc).
- **Spraying depends on hours, not days.** A day can have a calm, dry morning and a windy, showery afternoon. Daily summaries hide the window.
- **Networks fail.** Rural connections drop, APIs time out, and the user still opens the widget. Showing a blank card or a spinner forever is the worst outcome.

On top of that, advice has to be trusted. A red badge with no explanation is easy to ignore.

## Approach & architecture

```flow
# Request path
Map click -> cache lookup (0.01° cell, 1 h) -> Open-Meteo /v1/forecast -> zod validation -> normalised Forecast
Open-Meteo error or timeout -> stale cache -> labelled synthetic sample
```

```flow
# Advice
Forecast + crop + stage -> water balance (Kc × ET0 − effective rain) -> irrigation card
Forecast -> heat, frost, heavy rain thresholds -> cards
Hourly wind, rain, temperature -> spray rating per hour -> windows of 2 h or more
```

The client and the rules are separate modules with no React in them. `getForecast` takes its dependencies (fetch, clock, cache reader and writer) as arguments, so the same function can run in a Next.js route handler with a server cache, or be tested with fixtures:

```ts
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 };
}
```

The water balance is FAO-56's crop coefficient method in its simplest form. Light showers under 2 mm are ignored and 80 percent of heavier rain is counted:

```ts
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) };
  });
}
```

Spray windows rate each daylight hour as good, marginal or not advised. Wind under 3 km/h is marginal, not good, because calm air often means a temperature inversion and drift.

## Key challenges

- **Never trusting the response.** Open-Meteo returns `null` for missing values and `{ "error": true, "reason": "…" }` with a 400 for bad input. The client validates the body with zod and turns both into a clear error message rather than a crash in a chart.
- **Fallback that tells the truth.** The status line always says where the numbers came from: live, fresh cache, stale cache with its time, or a synthetic sample in red. "Simulate offline" lets you see each case.
- **Race conditions.** Clicking three towns quickly starts three requests. Each request gets an id and only the latest one may update the screen.
- **Advice not conveyed by colour alone.** Every card has a text label (Act, Watch, OK) and an icon, and every spray cell has a symbol as well as a colour.
- **Local time.** Spray windows and "today" use the forecast location's time zone from the API (`timezone=auto`), not the visitor's.

## Tech stack

- **Demo:** TypeScript, [React](/skills/react) and [Next.js](/skills/nextjs), Leaflet with OpenStreetMap tiles for picking a location, zod for response validation, the Open-Meteo forecast API, hand-written SVG charts.
- **Production design:** the same modules behind a Next.js route handler with a shared cache (Redis or the Next.js data cache, one hour), field locations and sowing dates from the farm database, and rules reviewed by agronomists.

## Results

- A working widget on real weather data for any point you pick, with advice for four crops and three growth stages.
- Each advice card shows the numbers behind it, including ET0, Kc, effective rain and soil moisture where Open-Meteo provides it.
- The widget keeps working with the network off, using the last cached forecast or a clearly labelled sample.
- The request log shows every cache hit, request, error and fallback.

The thresholds are demo rules, and I have not validated the advice against field outcomes. This is a concept build without real users.

## Try the demo

[Open the crop advisory demo](/lab/crop-advisory-widget). Things to try:

1. Click **Multan**, choose **Cotton** and **Mid-season**. Compare the irrigation card with **Wheat** at the **Initial** stage for the same place.
2. Open "why?" on the irrigation card to see Kc, ET0 and effective rain.
3. Look at the spray grid and hover a ✕ to see which rule blocked that hour.
4. Tick **Simulate offline**, then click a new town and then one you already loaded. The new town falls back to the labelled sample; the one you loaded comes from the cache with no request at all.

## What I would do next

- Move the API call into a Next.js route handler with a shared cache and add fixture-based unit tests for every rule.
- Derive the growth stage from the sowing date and growing degree days instead of asking for it.
- Add Urdu labels with right-to-left layout and an embeddable iframe route.
- Have the thresholds reviewed against public provincial agriculture guidance and cite the source for each.
