Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save garg-aayush/aae13309e3c8c8ab42499b2eb9987b7c to your computer and use it in GitHub Desktop.

Select an option

Save garg-aayush/aae13309e3c8c8ab42499b2eb9987b7c to your computer and use it in GitHub Desktop.
weather-stage-3: data source consolidation and migrating the live cron from GitHub Actions to a Cloudflare Worker (Claude Code conversation)
▐▛███▜▌ Claude Code v2.1.123
▝▜█████▛▘ Opus 4.7 · Claude Max
▘▘ ▝▝ ~/repos/garg-aayush.github.io
❯ Now, we have a working dashbard for the weather. You also understand how, I
am pulling the date for live feed that is updated every 15-30 mins and the
data that is fetched for 24hr/7d/24d feed. We need to search online and
decide on the data sources we are using. Currently, we are using data sources
from two different APIs.
Given our usecase and the number of calls we are making per minute/hr. We
need to use and find the best single source to fetch the weather data keeping
in mind that the most important criteria for us is the authentecity and
currentness of weather dataset specific to Indian cities.
⎿  ⧉ Selected 1 lines from Untitled-1 in Cursor
⏺ I'll research current options and check our call volume before recommending.
Let me look at the cities config and search for current Indian weather data
source options.
Read 1 file (ctrl+o to expand)
⏺ Now let me research the data source options. We have 20 Indian cities, ~40 API
calls every 15 min (2 APIs × 20 cities) + ~40 calls/day for the historical
aggregates.
⏺ Web Search("best weather API India IMD CPCB official data 2026 free tier")
⎿  Did 1 search in 7s
⏺ Web Search("Open-Meteo vs OpenWeatherMap vs Tomorrow.io accuracy India 2026")
⎿  Did 1 search in 8s
⏺ Web Search("WAQI World Air Quality Index API rate limits free tier 2026")
⎿  Did 1 search in 6s
⏺ Web Search("IMD India Meteorological Department public API mausam.imd.gov.in
developer access")
⎿  Did 1 search in 11s
⏺ Web Search("Open-Meteo data sources India IMD GFS ECMWF models which national
weather service")
⎿  Did 1 search in 6s
⏺ Web Search("CPCB air quality API India real-time PM2.5 cpcbccr.com developer")
⎿  Did 1 search in 8s
⏺ Findings & recommendation
Current call volume (20 cities): ~80 calls per 15 min cron (40 weather + 40
AQI) ≈ 320/hr. Daily cron adds ~40/day. This is trivial for any free tier —
volume is not the constraint here. Authenticity is.
Authoritative sources for India (the gold standard)
Provider: IMD (mausam.imd.gov.in)
Authority for: Indian weather (official)
Public API?: No — requires IP whitelisting + nodal officer approval. Won't
work
from GitHub Actions runners (rotating IPs).
────────────────────────────────────────
Provider: CPCB (airquality.cpcb.gov.in)
Authority for: Indian AQI (official)
Public API?: No public developer API. Only unofficial scrapers.
So both true Indian government sources are effectively closed. Every viable
option is either a global model or a CPCB aggregator.
Realistic candidates
Option: Open-Meteo (current)
Weather: ECMWF IFS 9 km + GFS + DWD
AQI: CAMS (European model)
Indian provenance: Indirect — global models, no IMD ingest
Notes: Free, no key, generous limits. Best free weather quality. AQI is
modeled, not station-measured.
────────────────────────────────────────
Option: WAQI / aqicn.org (current)
Weather: None
AQI: Aggregates directly from CPCB CAAQMS stations
Indian provenance: Yes — closest you can get to official CPCB AQI
Notes: Free token, 1000 req/sec. ToS forbids resale.
────────────────────────────────────────
Option: OpenWeatherMap
Weather: OK
AQI: OK
Indian provenance: None
Notes: 100k/mo free. Middling for India, no IMD source.
────────────────────────────────────────
Option: Tomorrow.io / Visual Crossing / Meteomatics
Weather: Excellent
AQI: Excellent
Indian provenance: None
Notes: Paid; overkill for 320/hr.
────────────────────────────────────────
Option: IndianAPI.in
Weather: Claims IMD-sourced
AQI: Limited
Indian provenance: Unverified — likely scrapes IMD
Notes: Third-party reseller; opaque provenance.
────────────────────────────────────────
Option: Tropical Tidbits / Meteologix
Weather: Display only
AQI: —
Indian provenance: —
Notes: Not APIs.
Recommendation: keep the two-source split
A single source that gives you authoritative Indian weather and authoritative
Indian AQI does not exist as a public API. The current architecture is
actually the right one for India:
- Weather → Open-Meteo. ECMWF IFS at 9 km is the best free global model for
the Indian subcontinent; IMD itself uses GFS/IFS as inputs to its NWP chain.
You're not getting "less" by avoiding IMD — you're getting the same upstream
physics, just without the IMD post-processing layer.
- AQI → WAQI. This is the important one. WAQI's India network is sourced
directly from CPCB CAAQMS stations (the same ones feeding
airquality.cpcb.gov.in). Functionally it is the official CPCB reading, just
with a developer API in front. Open-Meteo's CAMS AQI is a satellite/model
product and will diverge from what CPCB publishes on the news.
If you collapsed to Open-Meteo only, you'd lose ground-truth AQI — the worst
trade for an India-focused dashboard, because AQI is the metric where
ground-station data and modeled data diverge most (especially in winter NCR).
Optional improvements (if you want to push further)
1. Add a CPCB cross-check for the live tile only: scrape app.cpcbccr.com once
per cron, fall back to WAQI if blocked. Adds maintenance burden — only worth
it if you ever see WAQI/CPCB diverge meaningfully.
2. Document the AQI source clearly on the page (you already note that
live=WAQI/CPCB, history=Open-Meteo CAMS — keep that disclaimer).
3. Consider the ECMWF IFS-only mode in Open-Meteo (models=ecmwf_ifs09) for
forecasts; for current conditions the default best-match is fine.
Bottom line: consolidating to one API would be a downgrade for India. The
two-API setup isn't a wart — it's the correct shape, because the two
government authorities (IMD for weather, CPCB for air) are administratively
separate in India too.
Sources
- IMD APIs (mausam.imd.gov.in) — IP-whitelist requirement
- CPCB CCR portal — no public developer API
- Open-Meteo docs & data sources — ECMWF/GFS/DWD ingest
- Open-Meteo ECMWF API — 9 km IFS
- WAQI / aqicn.org API — 1000 req/sec, CPCB-station-backed in India
- Visual Crossing comparison
- Meteomatics best-weather-APIs
- IndianAPI.in weather — third-party, IMD-claimed
✻ Cogitated for 1m 39s
❯ So does it mean currently we are fetching AQI data from WAQI and all other
weather data from OpenMeteo?
⏺ Yes, exactly. Here's the split as it stands today:
Live cron (every 15 min) → scripts/fetch-india-weather.mjs
- Open-Meteo Forecast API → temperature, humidity, wind, weather code (the
values shown on the marker pills and live tile)
- WAQI → AQI number + CPCB station name (the value shown in the live tile and
used to color the AQI bars in the leaderboard)
Daily cron (02:00 IST) → scripts/fetch-india-weather-daily.mjs
- Open-Meteo Forecast API → 30 days of hourly temp + humidity, aggregated to
per-day min/max/mean
- Open-Meteo Air Quality API (CAMS, US-AQI scale) → 30 days of hourly AQI,
aggregated the same way
The asymmetry to be aware of (already documented in CLAUDE.md and noted on the
page): the live AQI tile is WAQI/CPCB, but the 7d/30d AQI bars are Open-Meteo
CAMS. They're on the same scale (US AQI) but different methodologies — CPCB
is ground stations, CAMS is satellite+model — so the live number won't always
line up with where the most-recent historical bar lands. WAQI's historical
endpoint is paywalled even with a token, which is why the history view falls
back to CAMS.
Want me to verify by re-reading the two fetcher scripts?
✻ Baked for 11s
❯ How are we fetching the temperature and humidity every 15 minutes? From where
is it being fetched?
Read 1 file (ctrl+o to expand)
⏺ Here's the precise answer, straight from scripts/fetch-india-weather.mjs:
Temperature & humidity — every 15 min
Source: Open-Meteo Forecast API (api.open-meteo.com/v1/forecast)
How: One single HTTP call per cron run for all 20 cities. The fetcher
(fetchOpenMeteo, lines 81–107) joins every city's lat/lon into comma-separated
lists and hits one URL like:
https://api.open-meteo.com/v1/forecast
?latitude=28.6139,28.4595,28.6692,... (20 lats)
&longitude=77.2090,77.0266,77.4538,... (20 lons)
&current=temperature_2m,apparent_temperature,relative_humidity_2m,uv_index
&timezone=Asia/Kolkata
Open-Meteo returns an array of 20 current blocks, one per coordinate pair. We
pick temperature_2m, apparent_temperature, relative_humidity_2m, and uv_index
from each (lines 96–101).
What Open-Meteo gives us under the hood: their "best match" model selection,
which for India means primarily ECMWF IFS (9 km) plus GFS as a blend,
downscaled to the requested coordinate. It is not station data — it's a model
nowcast at that lat/lon.
Where it goes
1. Live tile / map markers → written to weather.json and pushed to the data
branch.
2. 24h chart history → the same temperature + humidity values (plus a CAMS
US-AQI value from a second Open-Meteo call to the air-quality API) are
appended as a point with t = now() to history-<city>.json, with anything older
than 24h trimmed (lines 283–301).
Total Open-Meteo call cost per cron run
- 1 call for current weather (all 20 cities batched)
- 1 call for hourly US-AQI (all 20 cities batched, only when writing history)
So 2 Open-Meteo calls every 15 min ≈ 192/day, well under their non-commercial
10k/day soft limit. WAQI is the one that goes per-city (1 bounds call + 1 feed
call per city = 40 WAQI calls per run), because WAQI has no multi-coord
endpoint.
✻ Baked for 22s
❯ So this implies the temperature and humidity, whether it's per 15 minutes or
24 hours or 7 days or 30 days, it's coming from one source. The only change
we have here is AQI, which for 15 minutes comes from WAQI and for 24 hours, 7
days, and 30 days is coming from OpenMeteo.
⏺ Correct, exactly that. To restate cleanly:
┌─────────────┬──────────┬────────┬────────────┬─────────────────────────┐
│ Metric │ Live (15 │ 24h │ 7d / 30d │ Source │
│ │ min) │ chart │ charts │ │
├─────────────┼──────────┼────────┼────────────┼─────────────────────────┤
│ Temperature │ ✓ │ ✓ │ ✓ │ Open-Meteo Forecast │
│ │ │ │ │ (single source) │
├─────────────┼──────────┼────────┼────────────┼─────────────────────────┤
│ Humidity │ ✓ │ ✓ │ ✓ │ Open-Meteo Forecast │
│ │ │ │ │ (single source) │
├─────────────┼──────────┼────────┼────────────┼─────────────────────────┤
│ AQI │ ✓ │ │ │ WAQI / CPCB stations │
├─────────────┼──────────┼────────┼────────────┼─────────────────────────┤
│ AQI │ │ ✓ │ ✓ │ Open-Meteo Air Quality │
│ │ │ │ │ (CAMS, US AQI) │
└─────────────┴──────────┴────────┴────────────┴─────────────────────────┘
One subtlety worth keeping in mind: because the live AQI tile is WAQI/CPCB but
the rightmost point on the 24h AQI chart is Open-Meteo CAMS, those two
numbers can disagree on the same screen — they're measuring the same thing
through different instruments (ground stations vs satellite+model).
Temperature and humidity have no such split, so they'll always be internally
consistent across the live tile and all three chart ranges.
The reason for the AQI split (already in CLAUDE.md): WAQI's historical
endpoint is paywalled even with a token, so we can't backfill 24h/7d/30d from
it — Open-Meteo CAMS is the only free historical AQI source.
✻ Sautéed for 14s
❯ And now I understand why 24-hour AQI looks so different. Is there a
workaround here? It seems to me the readings are quite different for WAQI and
OpenMeteo cams.
⏺ Yes — and the cleanest workaround costs zero extra API calls. The root cause
is structural: WAQI/CPCB is regulatory ground stations (PM2.5/PM10 measured
directly), Open-Meteo CAMS is a global chemistry model at ~40 km resolution
that systematically under-predicts Indian pollution episodes (crop burning
plumes, traffic, point sources don't show up in coarse-grid models, and Indian
surface obs are under-weighted in the assimilation). For NCR in winter the
gap can easily be 150 AQI points.
Ranked workarounds
1. Build our own WAQI history by accumulating the live readings (recommended).
We're already polling WAQI every 15 min for the live tile. Just also write
that value into history-<id>.json as a second field (e.g. aqi_waqi alongside
the existing aqi). After 24h of cron runs the 24h chart can switch to plotting
WAQI, perfectly matching the live tile. Cost: zero new API calls. Tradeoff: a
24-hour warm-up after deploy where the chart is sparse, and the historical
7d/30d is harder (see below).
2. For 7d/30d: keep CAMS, but add a clear disclaimer.
The daily aggregator currently rebuilds the 30-day window from Open-Meteo each
run, which is what gives you the self-healing-against-missed-runs property.
If you switch to accumulating WAQI for 7d/30d too, you give that up and eat a
30-day warm-up. The pragmatic compromise: live + 24h are WAQI (consistent on
screen), 7d/30d stays CAMS as "regional trend" with a one-line note. The
visual jolt then only happens when the user changes range, not within one
view.
3. (Optional, later) Add a WAQI-based daily aggregator.
Once you've accumulated 30+ days of 15-min WAQI points, a small script can
roll them into per-IST-day min/max/mean and replace CAMS for 7d/30d entirely.
You lose self-healing but gain end-to-end CPCB consistency. Worth doing only
after the 1+2 fix has been live for a month and proven.
Not recommended:
- Show both lines on the chart — visually noisy, doesn't actually fix
anything.
- Bias-correct CAMS to WAQI — statistically unsound; the bias varies by season
and region.
- Scrape cpcbccr.com directly — brittle, no API contract, will break.
- WAQI paid tier — overkill for a portfolio dashboard.
Recommendation
Do #1 + #2 as a single change: start writing aqi_waqi into history points now
(free), flip the 24h chart to read it after 24h, leave 7d/30d on CAMS with a
footnote. That eliminates the most jarring divergence (live tile vs. 24h
chart, which sit literally side-by-side on the page) without adding API load
or losing the daily aggregator's self-healing.
Want me to draft the change?
✻ Churned for 46s
❯ Yes please draft the changes for #1 and #2
Read 3 files, listed 1 directory (ctrl+o to expand)
⏺ I'll plan the change as three commits on a feature branch, then make the
edits. Let me set up tasks and create the branch.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout -b
weather-aqi-waqi-history)
⎿  Switched to a new branch 'weather-aqi-waqi-history'
⏺ Commit 1 — fetcher writes aqi_waqi into history points
⏺ Update(scripts/fetch-india-weather.mjs)
⎿  Added 11 lines, removed 2 lines
14 // provided, the script reads the existing per-city history-<id
>.json files and
15 // appends a new point to points_24h (used for the 24h chart vi
ew). The 7d / 30d
16 // chart views are owned by scripts/fetch-india-weather-daily.m
js and are not
17 -// touched here. The chart-friendly AQI value is pulled from Op
-en-Meteo Air
18 -// Quality so the historical series has a single, backfillable
-source.
17 +// touched here.
18 +//
19 +// Two AQI values are written into each point:
20 +// - aqi : Open-Meteo Air Quality (CAMS, US AQI). Backfi
+llable, model-based,
21 +// used as the rendered series until the rolling
+ window has enough
22 +// aqi_waqi points to take over (and as the fall
+back if WAQI is
23 +// blocked).
24 +// - aqi_waqi : WAQI / CPCB-station average. The same value s
+hown on the live
25 +// tile, but accumulated over 24h so the chart c
+an match the live
26 +// number once the window is full. Free WAQI has
+ no historical
27 +// endpoint, so this series only grows forward f
+rom rollout.
28
29 import { readFileSync, writeFileSync, existsSync, mkdirSync } f
rom 'node:fs';
30 import { fileURLToPath } from 'node:url';
⏺ Update(scripts/fetch-india-weather.mjs)
⎿  Added 7 lines, removed 1 line
197
198 // History helpers -------------------------------------------
-----------------
199
200 +const HISTORY_SOURCE = {
201 + weather: 'open-meteo',
202 + aqi: 'open-meteo-air-quality',
203 + aqi_waqi: 'waqi-cpcb',
204 +};
205 +
206 function emptyHistory(city) {
207 return {
208 city: city.id,
209 name: city.name,
210 generated_at: new Date().toISOString(),
205 - source: { weather: 'open-meteo', aqi: 'open-meteo-air-qual
-ity' },
211 + source: { ...HISTORY_SOURCE },
212 points_24h: [],
213 };
214 }
⏺ Update(scripts/fetch-india-weather.mjs)
⎿  Added 1 line, removed 1 line
223 city: city.id,
224 name: city.name,
225 generated_at: parsed.generated_at || new Date().toISOStr
ing(),
226 - source: parsed.source || { weather: 'open-meteo', aqi: '
-open-meteo-air-quality' },
226 + source: { ...HISTORY_SOURCE, ...(parsed.source || {}) },
227 points_24h: Array.isArray(parsed.points_24h) ? parsed.po
ints_24h : [],
228 };
229 } catch (err) {
⏺ Now the actual point write — pull WAQI value from results[i]:
⏺ Update(scripts/fetch-india-weather.mjs)
⎿  Added 4 lines
301 for (let i = 0; i < cities.length; i++) {
302 const city = cities[i];
303 const w = weatherList[i];
304 + const waqiValue = results[i] && results[i].aqi && result
+s[i].aqi.value != null
305 + ? results[i].aqi.value
306 + : null;
307 const point = {
308 t: new Date(nowMs).toISOString(),
309 temp: w && w.temperature_c != null ? w.temperature_c :
null,
310 humidity: w && w.humidity_pct != null ? w.humidity_pct
: null,
311 aqi: omAqiList[i],
312 + aqi_waqi: waqiValue,
313 };
314 const prior = readHistory(HISTORY_IN, city);
315 const next = updateHistory(prior, point, nowMs);
⏺ Bash(node --check /Users/aayushgarg/repos/garg-aayush.github.io/scripts/fetch-
india-weather.mjs && echo OK)
⎿  OK
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add
scripts/fetch-india-weather.mjs && git -C
/Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(ca…)
⎿  ok 1 file changed, 23 insertions(+), 4 deletions(-)
ok weather
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io log --oneline -3)
⎿  f3bbf65 Weather: record WAQI/CPCB AQI in 24h history points
a1c50ba CLAUDE.md: document weather page reset control and chart-tick
override (#68)
… +2 lines (ctrl+o to expand)
⏺ Commit 2 — client prefers WAQI for 24h, dynamic source label, updated footnote
⏺ Update(india-weather.qmd)
⎿  Added 1 line, removed 1 line
59
60 <div class="iw-charts">
61 <figure class="iw-chart-card">
62 - <figcaption>Air Quality Index <span class="iw-chart-sou
-rce">(US AQI · Open-Meteo)</span></figcaption>
62 + <figcaption>Air Quality Index <span id="iw-aqi-source"
+class="iw-chart-source">(US AQI · Open-Meteo)</span></figcaptio
+n>
63 <div id="iw-chart-aqi" class="iw-chart"></div>
64 </figure>
65 <figure class="iw-chart-card">
⏺ Update(india-weather.qmd)
⎿  Added 5 lines, removed 2 lines
78 Live tile: weather from <a href="https://open-meteo.com/" r
el="noopener">Open-Meteo</a>,
79 AQI from <a href="https://aqicn.org/" rel="noopener">WAQI</
a> (CPCB-station average) on the
80 <a href="https://cpcb.nic.in/" rel="noopener">Indian CPCB</
a> band scale.
81 - History charts use Open-Meteo for both weather and US AQI;
-the chart's AQI scale is therefore
82 - different from the live tile. Data refreshes every 15 minut
-es.
81 + History charts: temperature and humidity are always Open-Me
+teo. The 24h AQI series matches
82 + the live tile (CPCB stations via WAQI, accumulated forward
+from rollout); the 7d / 30d AQI
83 + series uses Open-Meteo (CAMS, US AQI) because WAQI's histor
+ical endpoint is paywalled. The
84 + two AQI sources are on different scales, so 7d / 30d bars w
+ill not always agree with the
85 + live CPCB number. Data refreshes every 15 minutes.
86 </p>
87 </div>
88 ```
⏺ Now the JS — add the source-label updater and switch the 24h AQI key based on
data presence:
⏺ Update(static/india-weather/india-weather.js)
⎿  Added 13 lines
33 const elHistoryCity = document.getElementById('iw-history-cit
y');
34 const elHistoryStatus = document.getElementById('iw-history-s
tatus');
35 const elRangeBtns = document.querySelectorAll('.iw-range-btn'
);
36 + const elAqiSource = document.getElementById('iw-aqi-source');
37
38 + // The AQI chart caption changes between ranges: 24h reads CP
+CB stations via
39 + // WAQI when the rolling window has accumulated them, otherwi
+se Open-Meteo's
40 + // CAMS series. 7d / 30d are always CAMS (WAQI history is pay
+walled).
41 + const AQI_SOURCE_LABEL = {
42 + waqi: '(CPCB stations · WAQI)',
43 + cams: '(US AQI · Open-Meteo)',
44 + };
45 + function setAqiSourceLabel(kind) {
46 + if (!elAqiSource) return;
47 + elAqiSource.textContent = AQI_SOURCE_LABEL[kind] || AQI_SOU
+RCE_LABEL.cams;
48 + }
49 +
50 // Default camera, also used by the reset-view control to fly
back home.
51 const HOME_VIEW = { center: [80.0, 22.5], zoom: 3.8 };
52
⏺ Update(static/india-weather/india-weather.js)
⎿  Added 8 lines, removed 1 line
734 if (activeRange === '24h') {
735 const hourly = hourlyCache.get(cityId);
736 const points = (hourly && Array.isArray(hourly.points_24
h)) ? hourly.points_24h : [];
737 + // Prefer WAQI/CPCB stations (matches the live tile) onc
+e the rolling
738 + // window has accumulated them. Falls back to CAMS durin
+g the 24h
739 + // warm-up after rollout, or if WAQI was blocked across
+the window.
740 + const hasWaqi = points.some(p => p && p.aqi_waqi != null
+ && Number.isFinite(p.aqi_waqi));
741 + const aqiKey = hasWaqi ? 'aqi_waqi' : 'aqi';
742 + setAqiSourceLabel(hasWaqi ? 'waqi' : 'cams');
743 renderLineChart('aqi', 'iw-chart-aqi', 'AQI',
'#75A8D9',
738 - v => Math.round(v), points, 'aqi');
744 + v => Math.round(v), points, aqiKey);
745 renderLineChart('temp', 'iw-chart-temp', 'Temp',
'#E8A87C',
746 v => v.toFixed(1) + '°', points, 'temp');
747 renderLineChart('humidity', 'iw-chart-humidity', 'Humidi
ty', '#7CC4A1',
...
752 const daily = dailyCache.get(cityId);
753 const days = daySliceForRange(daily, activeRange);
754
755 + setAqiSourceLabel('cams');
756 renderAqiBarChart('iw-chart-aqi', days);
757
758 renderBandChart('temp', 'iw-chart-temp', 'Temp °C',
⏺ Bash(node --check /Users/aayushgarg/repos/garg-aayush.github.io/static/india-w
eather/india-weather.js && echo OK)
⎿  OK
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add
india-weather.qmd static/india-weather/india-weather.js && git -C
/Users/aayushgarg/repos/garg-aayush.g…)
⎿  ok 2 files changed, 27 insertions(+), 4 deletions(-)
ok weather
⏺ Commit 3 — CLAUDE.md doc update
⏺ Update(CLAUDE.md)
⎿  Added 3 lines, removed 2 lines
106 - Visitor count never affects API call volume because all fetc
hing happens server-side on the cron schedule.
107
108 ### History data model
109 -- `history-<id>.json` holds the 24h trailing window only: `poi
-nts_24h` at 15-min cadence. Point shape: `{ t: ISO-UTC, temp:
-°C, humidity: %, aqi: US-AQI }`.
109 +- `history-<id>.json` holds the 24h trailing window only: `poi
+nts_24h` at 15-min cadence. Point shape: `{ t: ISO-UTC, temp:
+°C, humidity: %, aqi: US-AQI, aqi_waqi: CPCB-AQI }`. Both AQI
+fields are written every run: `aqi` is Open-Meteo CAMS (model-
+based, US AQI scale) and is backfillable; `aqi_waqi` is the sa
+me WAQI/CPCB-station value shown on the live tile, accumulated
+ forward only.
110 - `daily-<id>.json` holds the last 30 complete IST days as `da
ys: [...]`. Each entry: `{ date: "YYYY-MM-DD", temp_min, temp_
max, temp_mean, humidity_min, humidity_max, humidity_mean, aqi
_min, aqi_max, aqi_mean }`. The 7d view slices the last 7 days
; 30d uses all 30. The in-progress IST day is excluded.
111 -- The chart's AQI series uses Open-Meteo Air Quality (US AQI s
-cale) because WAQI's historical endpoint is paywalled even wit
-h a token. The live tile / leaderboard still shows WAQI's CPCB
--station reading. The two AQI numbers are on different scales
-and will not match exactly; this is documented inline on the p
-age.
111 +- **24h AQI chart:** prefers `aqi_waqi` (CPCB stations via WAQ
+I) when the rolling window has accumulated it, so the chart ag
+rees with the live tile. Falls back to `aqi` (CAMS) during the
+ 24h warm-up after rollout, or when WAQI was blocked across th
+e entire window. The chart's caption-source label flips betwee
+n "(CPCB stations · WAQI)" and "(US AQI · Open-Meteo)" based o
+n which series is being plotted.
112 +- **7d / 30d AQI charts:** still use Open-Meteo CAMS only, bec
+ause WAQI's historical endpoint is paywalled even with a token
+. The numbers there are on the US AQI scale and will not alway
+s agree with the live CPCB number; this is documented inline o
+n the page.
113 - 7d / 30d x-axis ticks are *not* uPlot's auto-generated split
s. The bars sit at noon IST (06:30 UTC) per day; uPlot's defau
lts place ticks at midnight UTC, which slips ~6.5h to the left
of every bar. `dayAxisConfig(t, days)` overrides `splits` to
return our actual per-day timestamps (stride 1 for 7d, stride
5 for 30d). If you change the chart, keep this override or the
labels will visibly drift again.
114 - Map camera is locked to India: `minZoom: 3.8` matches the in
itial zoom and `maxBounds: [[67, 5.5], [98, 37.5]]` clips pann
ing to roughly the country's bbox. There is also a custom rese
t-view control (top-right, just below the +/- group) that flie
s back to `[80, 22.5]` zoom 3.8 and closes any open marker pop
up.
115 - 7d / 30d AQI bars are colored by the day's mean US AQI on th
e standard EPA category scale (Good / Moderate / USG / Unhealt
hy / Very Unhealthy / Hazardous). Temp and humidity for those
views render as a min/max band with a daily-mean line on top.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add CLAUDE.md && git
-C /Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(cat <<'EOF'
CLAUDE.md: d…)
⎿  ok 1 file changed, 3 insertions(+), 2 deletions(-)
ok weather
6c032b9 CLAUDE.md: document aqi_waqi field and 24h source-prefer behav
… +7 lines (ctrl+o to expand)
⏺ All three commits landed on weather-aqi-waqi-history. Summary of what's
drafted:
Commit 1 — f3bbf65 (fetcher)
scripts/fetch-india-weather.mjs: every 15-min history point now carries both
aqi (CAMS, backfillable) and aqi_waqi (WAQI/CPCB, accumulated forward). Source
metadata updated, with read-merge so existing files on the data branch
upgrade in place.
Commit 2 — b8c2c9f (client + page)
- static/india-weather/india-weather.js: 24h chart plots aqi_waqi when at
least one window point has it; falls back to aqi until the warm-up fills.
Source label under the AQI caption flips dynamically per range.
- india-weather.qmd: added id="iw-aqi-source" so JS can rewrite the caption;
footnote rewritten to spell out the 24h-vs-7d/30d split.
Commit 3 — 6c032b9 (docs)
CLAUDE.md: history shape documents the new aqi_waqi field and the
24h-prefers-WAQI behaviour.
What you'll see after this ships
- Now → +24h: 24h chart still on CAMS (warm-up). Caption reads "(US AQI ·
Open-Meteo)". Live tile and 24h chart still disagree, same as today.
- +24h onward: 24h chart switches to WAQI/CPCB. Caption flips to "(CPCB
stations · WAQI)". Live tile and 24h chart now agree on every screen.
- 7d / 30d: unchanged. Caption stays "(US AQI · Open-Meteo)". Disclaimer in
the footnote.
Things I deliberately didn't do
- Didn't touch history-*.sample.json fixtures. ?local dev will fall through to
aqi until cron regenerates them — harmless. Happy to add a aqi_waqi field to
one or two for offline-dev parity if you want.
- Didn't preview locally with quarto preview; the JS passes node --check but I
can't validate Mapbox + uPlot rendering from here. Worth eyeballing before
merge.
Want me to push and open a PR, or hold for review first?
✻ Churned for 3m 52s
❯ What is the flipped criteria to WAQI CPCB, given we are running a cron job on
Github which is flaky and at times it runs at half an hour, 45 minutes,
etc.? How would you patch that data?
⎿  ⧉ Selected 1 lines from Untitled-1 in Cursor
⏺ Honestly, the criterion I shipped is too eager and you've spotted exactly why.
The flaw in what I wrote
const hasWaqi = points.some(p => p && p.aqi_waqi != null &&
Number.isFinite(p.aqi_waqi));
Any single WAQI point flips the whole chart. So the moment the first cron run
lands a WAQI value, the chart switches to aqi_waqi while 95+ of the 96 window
slots still only have aqi (CAMS). Result: a 24h chart with one dot on the
right and a sea of nulls. That's worse than what we have today.
Better criterion — coverage + recency
const total = points.length;
const withWaqi = points.filter(p => p && p.aqi_waqi != null &&
Number.isFinite(p.aqi_waqi)).length;
const coverage = total > 0 ? withWaqi / total : 0;
const latest = points[points.length - 1];
const recentHasWaqi = !!(latest && latest.aqi_waqi != null);
const useWaqi = coverage >= 0.75 && recentHasWaqi;
Two thresholds, each guarding a different failure mode:
- Coverage ≥ 75 % — kills premature flips during the warm-up and auto-reverts
to CAMS if WAQI dies for a multi-hour stretch.
- Most recent point has WAQI — kills the case where WAQI was healthy for 18h
then went dark; you don't want a CPCB-labelled chart whose right edge is 6h
old.
Cron drift (45-min gaps, occasional skipped run) doesn't break this because
both checks are proportional to whatever points the window actually has — not
"expected 96, got 60, panic." A sparser-than-usual window with 60 WAQI points
out of 60 still reads 100 % coverage and flips correctly.
Patching genuine gaps
Two regimes, by gap duration:
1. Short gaps (≤ ~30 min, i.e. one or two missed runs): linear-interpolate
aqi_waqi from the bracketing neighbours at render time. AQI doesn't move fast
enough at 15-min cadence for this to lie meaningfully — a station-measured 280
either side of a missing slot is almost certainly ~280 in the gap.
2. Long gaps (> 30 min): leave the points as null. uPlot's spanGaps: false
already draws those as breaks in the line, which is the honest signal: "we
don't know." Filling a 3-hour gap with a straight line is fabrication.
Implementing this is ~30 lines: a fillShortGapsByTime(points, key, maxGapMs)
pass before pointsToLine(), plus the 4-line criterion swap above.
Want me to push this as a follow-up commit on the same branch? It's small
enough that it doesn't deserve a separate PR, and it fixes the bug before the
warm-up period even starts.
✻ Brewed for 58s
❯ Okay, explain in very simple 2-3 lines what exactly this change will be and
how many points will be visible in 24hr AQI.
⏺ The change: wait until WAQI fills ≥75 % of the 24h window and the most recent
run has it, before flipping the chart from CAMS to WAQI; linear-interpolate
any 1–2 missed runs in between, leave longer gaps as breaks.
Points visible in 24h AQI: at full 15-min cadence, ~96 points. With realistic
GitHub cron drift, ~70–96 points. The chart only flips to WAQI once at least
~72 of those carry a CPCB reading.
✻ Churned for 4s
❯ What if we change this crown job to a five-minute cadence? That way, we are
going to have at least, say, 50 or 100 points, like 70 points.
⏺ Doable on GitHub (5 min is the cron minimum), and free-tier minutes / API
limits aren't blockers — but it doesn't actually buy you what you want, for
one core reason:
WAQI/CPCB stations report hourly. A 5-min poll just records the same station
value 11 times in a row until the station updates, so the "extra" 200+ points
are duplicates. The chart turns into a visible staircase (12 identical dots,
then a step, then 12 more) and the warm-up to 75% coverage still takes ~18h of
wall-clock time. Open-Meteo's current endpoint refreshes faster than that but
still not every 5 min. You'd 3× your history file sizes, hit GitHub's
higher-frequency cron drift more often, and gain effectively zero new
information.
My recommendation: stay at 15 min, or go to 10 min if you want it to feel
fresher without wasting calls. 5 min only makes sense if WAQI/CPCB starts
publishing sub-hourly, which they don't.
✻ Worked for 38s
❯ At what frequency WAQI/CPCB publishes the data
⏺ Let me verify rather than guess.
⏺ Web Search("CPCB CAAQMS air quality station update frequency how often hourly
real-time India")
⎿  Did 1 search in 4s
⏺ Web Search("WAQI aqicn.org data update frequency how often refresh station
readings")
⎿  Did 1 search in 8s
⏺ Verified — both publish on roughly the same cadence as our cron:
- CPCB CAAQMS portal: refreshes every 15 minutes; underlying station data is
hourly-averaged. (CPCB real-time data)
- WAQI (aqicn): standard interval is hourly averages; for stations that submit
faster (10–30 min) WAQI republishes whenever the upstream feed updates. (WAQI
data feed templates)
So WAQI's Indian feed effectively refreshes ~15 min (CPCB-bound). Our 15-min
cron is already perfectly matched — it's the natural cadence. 5 min = 3 polls
per upstream refresh (mostly duplicate values), 10 min = risk of occasional
misalignment with CPCB's cycle. 15 min stays the right answer.
✻ Brewed for 27s
❯ I mean, 15 minutes work fine, but what I am seeing seems to me the cron job
is only running once every hour.
⏺ Let me check the actual workflow config and recent run cadence.
Read 1 file (ctrl+o to expand)
⏺ Bash(gh run list --workflow=india-weather-data.yml --limit 30 --json
startedAt,status,conclusion,event 2>&1 | head -200)
⎿  [
{
"conclusion": "success",
… +38 lines (ctrl+o to expand)
⏺ You're right — I checked the actual run history and the effective cadence is
roughly 30 to 80 minutes, not 15.
Last 13 scheduled runs in UTC, with the gap to the previous one:
┌─────────┬────────┐
│ Started │ Gap │
├─────────┼────────┤
│ 03:18 │ 82 min │
├─────────┼────────┤
│ 01:56 │ 47 min │
├─────────┼────────┤
│ 01:09 │ 65 min │
├─────────┼────────┤
│ 00:04 │ 30 min │
├─────────┼────────┤
│ 23:34 │ 28 min │
├─────────┼────────┤
│ 23:06 │ 31 min │
├─────────┼────────┤
│ 22:35 │ 28 min │
├─────────┼────────┤
│ 22:07 │ 27 min │
├─────────┼────────┤
│ 21:40 │ 26 min │
├─────────┼────────┤
│ 21:14 │ 30 min │
├─────────┼────────┤
│ 20:44 │ 42 min │
├─────────┼────────┤
│ 20:02 │ 44 min │
├─────────┼────────┤
│ 19:18 │ 34 min │
└─────────┴────────┘
Average ~40 min, never actually hitting 15. That averages to ~36 points/24h,
not 96.
Why
The workflow config is fine (*/15 * * * *). This is GitHub Actions scheduler
drift — well-documented behaviour on free-tier hosted runners. From GitHub's
own docs: "the schedule event can be delayed during periods of high loads of
GitHub Actions workflow runs. High load times include the start of every
hour." */15 runs are the most affected because every 4th tick lands on a
top-of-hour spike. There's no setting that fixes this — it's the trade-off for
free hosted scheduling.
Realistic fixes
1. External pinger triggering workflow_dispatch (recommended). Sign up for a
free cron service (cron-job.org, EasyCron, UptimeRobot's free tier) and have
it call GitHub's POST /repos/{owner}/{repo}/actions/workflows/{id}/dispatches
API every 15 min with a fine-grained PAT. The trigger fires on time; only the
runner cold-start (~30-60s) adds variance. Practical end-to-end cadence:
~15-17 min, very consistent. Cost: zero.
2. Move the fetch off Actions entirely. Cloudflare Workers has a free cron
trigger (100k req/day) that fires within seconds of schedule. The Worker can
fetch Open-Meteo + WAQI and push the result to the data branch via GitHub's
REST API. More setup, but you'd get genuine 15-min (or even 5-min if you
wanted) cadence forever. This is what most "live" GitHub Pages dashboards end
up doing.
3. Accept it and adjust the warm-up math. At ~36 points/24h, the 75% coverage
threshold for the WAQI flip translates to ~27 points needed, which still
arrives in roughly 18 hours of wall-clock time — same answer as before, just
denser-or-sparser. So our planned change still works; just the chart will be
visibly sparser than I claimed.
If you want low-effort and high-reliability, option 1 is the clear winner.
Want me to draft what the pinger setup looks like?
✻ Crunched for 43s
❯ Lets go ahead with option 2, I would recommend lets commit and push the
current changes and start fresh with Cloudflare option, I like that.
⏺ Let me push the branch and open the PR.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io push -u origin
weather-aqi-waqi-history 2>&1)
⎿  ok weather-aqi-waqi-history
⏺ Bash(gh pr create --title "Weather: prefer WAQI/CPCB for the 24h AQI chart"
--body "$(cat <<'EOF'
## Summary…)
⎿  ok created #69 https://github.com/garg-aayush/garg-aayush.github.io/pu
ll/69
⏺ PR opened: #69 (3 commits, regular-merge ready per CLAUDE.md).
Cloudflare Workers plan — sketch before I start
Before I write code I want to align on the architecture, since this is a
non-trivial migration. Here's what I'm picturing:
Architecture
- One Cloudflare Worker with a Cron Trigger at */15 * * * *. (Free tier: 100k
req/day, our load is ~5k/day. Cron triggers are free up to 5 schedules per
Worker.)
- The Worker's scheduled() handler does what fetch-india-weather.mjs does
today, but talks to the GitHub Git Data API instead of the filesystem:
a. fetch Open-Meteo + WAQI in-memory
b. read current data branch tree via GitHub API (one call with recursive=1)
c. mutate weather.json and each history-<id>.json in-memory; carry forward
daily-*.json untouched
d. write a single commit via blobs → tree → commit → ref update
- The fetch/parse logic itself is mostly portable as-is (already pure ESM,
already uses fetch + AbortController, no fs dependency outside the I/O
wrappers).
- Leave the daily aggregator on GitHub Actions. It runs once a day; cron drift
of 30-60 min is irrelevant for 30-day aggregates. Migrating it would be churn
for no gain. Worker handles the 15-min beat only.
- Once the Worker has been live and proven for ~24-48h, retire
.github/workflows/india-weather-data.yml.
Secrets
- WAQI_TOKEN → wrangler secret put on the Worker.
- A new fine-grained GitHub PAT (Contents:Write on this repo only) → wrangler
secret put.
Concurrency with the daily cron
- Worker reads the latest tree fresh each run and includes the existing
daily-*.json blobs in its new commit (same pattern as today's workflow). On
the rare collision (~0.3 %/day) one of the two pushes will fail and we retry —
easy.
Repo layout
- New worker/ directory with wrangler.toml, src/index.mjs, and a small lib/
shared with the existing scripts so we don't fork the fetch logic.
Three things I need from you before starting
1. Cloudflare account. Do you already have one (with Workers enabled), or do
you need to set one up? I can't create it for you.
2. Wrangler CLI. Is wrangler already on this machine, or do I need to add an
install step? (npm i -g wrangler)
3. Branch name preference. I'd suggest weather-cloudflare-cron — confirm or
override.
Once those are sorted I'll branch off master (after #69 merges) and stage this
in commits: (1) Worker skeleton + wrangler config, (2) port the fetch logic +
GitHub commit flow, (3) docs + retirement of the Actions workflow.
Sound right? Anything you'd change about the plan?
✻ Crunched for 1m 17s
❯ 1. I just created the cloudflare account
2. YOu need to install it
3. I am good with your suggested branch name
⏺ Let me check git state and install wrangler. The Cloudflare branch should be
cut after #69 lands so it inherits the new fetcher contract — I'll flag that.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io status -sb && echo
--- && node --version && npm --version)
⎿  ## weather-aqi-waqi-history...origin/weather-aqi-waqi-history
?? .claude/commands/
?? .claude/settings.local.json
… +5 lines (ctrl+o to expand)
⏺ Bash(which wrangler 2>&1; wrangler --version 2>&1 | head -3) ⎿  wrangler not found
(eval):1: command not found: wrangler
⏺ Bash(npm config get prefix)
⎿  /Users/aayushgarg/.nvm/versions/node/v22.14.0
⏺ Bash(npm install -g wrangler 2>&1 | tail -20)
⎿  added 35 packages in 13s
7 packages are looking for funding
run `npm fund` for details
⏺ Bash(wrangler --version)
⎿  4.86.0
⏺ Wrangler 4.86.0 installed. Now I need three things from you before I create
the branch and start writing the Worker:
1. Log in to Cloudflare interactively
Wrangler login opens a browser flow that I can't drive. Please run this in
your prompt:
! wrangler login
(The ! runs it in this session so the result lands here.) This will open
dash.cloudflare.com in your browser, you click Allow, and wrangler stores an
OAuth token in ~/.wrangler/.
2. Confirm the branch base
PR #69 is open but not merged. The Cloudflare Worker will essentially port the
new fetcher (with the aqi_waqi field), so it logically depends on #69. Two
options — your call:
- (a) Merge #69 first, then I cut weather-cloudflare-cron off master. Cleaner
history. Recommended.
- (b) I cut weather-cloudflare-cron off weather-aqi-waqi-history now, and we
deal with whatever rebase is needed when one merges before the other.
3. Create a fine-grained GitHub PAT (we'll need it later, not yet)
When the Worker code is ready you'll need a Personal Access Token that the
Worker uses to commit to the data branch. Settings:
github.com/settings/personal-access-tokens → Fine-grained tokens → New →
Repository access: only this repo → Permissions: Contents: Read and write +
Metadata: Read-only. We'll add it via wrangler secret put GITHUB_TOKEN once
the Worker exists. Don't generate it yet — just so you know it's coming.
Once you've done #1 (login) and answered #2 (branch base), I'll start.
✻ Baked for 1m 10s
❯ 1. You have the OAuth token in ~/.wrangler
2. Go with option a
⏺ Bash(wrangler whoami 2>&1 | tail -10)
⎿  - secrets_store (write)
- artifacts (write)
- flagship (write)
… +7 lines (ctrl+o to expand)
⏺ Bash(gh pr merge 69 --merge --delete-branch 2>&1)
⎿  From github.com:garg-aayush/garg-aayush.github.io
* branch master -> FETCH_HEAD
a1c50ba..60ae737 master -> origin/master
… +8 lines (ctrl+o to expand)
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout master &&
git -C /Users/aayushgarg/repos/garg-aayush.github.io pull --ff-only && git
-C /Users/aay…)
⎿  Already on 'master'
Your branch is up to date with 'origin/master'.
ok (up-to-date)
… +9 lines (ctrl+o to expand)
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io log --oneline
--graph --all -10)
⎿  * 6c032b9 CLAUDE.md: document aqi_waqi field and 24h source-prefer beh
aviour
* b8c2c9f Weather: prefer WAQI/CPCB for the 24h AQI chart
… +9 lines (ctrl+o to expand)
⏺ Master is at 6c032b9, all 3 commits preserved (GitHub fast-forwarded since the
history was linear — same effect as regular merge for our purposes). Now I'll
cut the Cloudflare branch.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout -b
weather-cloudflare-cron)
⎿  Switched to a new branch 'weather-cloudflare-cron'
⏺ Branch cut. I'll start with the skeleton — wrangler config + entry point +
package.json — so the Worker structure is in place before I port logic.
⏺ Bash(mkdir -p /Users/aayushgarg/repos/garg-aayush.github.io/worker/src)
⎿  Done
⏺ Write(worker/package.json)
⎿  Wrote 15 lines to worker/package.json
2 "name": "india-weather-worker",
3 "private": true,
4 "version": "0.0.0",
5 "type": "module",
6 "scripts": {
7 "dev": "wrangler dev",
8 "deploy": "wrangler deploy",
9 "tail": "wrangler tail",
10 "trigger": "curl -s 'http://localhost:8787/__scheduled?cron=
*/15+*+*+*+*'"
… +6 lines (ctrl+o to expand)
⏺ Write(worker/wrangler.toml)
⎿  Wrote 23 lines to worker/wrangler.toml
2 main = "src/index.mjs"
3 compatibility_date = "2026-04-01"
4
5 # Cron Triggers run in UTC. */15 here is the actual cadence the W
orker sees;
riggers fireHub Actions' best-effort scheduler, Cloudflare Cron T
7 # within seconds of the schedule. This is the whole reason for th
e Worker.
8 [triggers]
9 crons = ["*/15 * * * *"]
… +19 lines (ctrl+o to expand)
⏺ Write(worker/src/index.mjs)
⎿  Wrote 29 lines to worker/src/index.mjs
2 // Cloudflare Worker: India Weather live fetcher.
3 // Replaces .github/workflows/india-weather-data.yml. Cron Trigge
4 // schedule (within seconds), so a real */15 cadence here gives u
s ~96 history
5 // points per 24h instead of the ~36 we get from Actions' drifted
6 //cheduler.
7 // Daily aggregates (daily-*.json) are still owned by
ay, whereb/workflows/india-weather-daily.yml — that runs once a d
9 // Actions cron drift is harmless, and migrating it would be chur
n for no gain.
… +31 lines (ctrl+o to expand)
⏺ Write(worker/.gitignore)
⎿  Wrote 4 lines to worker/.gitignore
1 node_modules/
2 .wrangler/
3 .dev.vars
4 .dev.vars.*
⏺ Bash(cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && npm install
2>&1 | tail -5)
⎿  7 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
⏺ Bash(npx wrangler deploy --dry-run --outdir=.wrangler/dryrun 2>&1 | tail -25)
⎿  npm error Missing script: "wrangler"
npm error
npm error To see a list of scripts, run:
… +3 lines (ctrl+o to expand)
⏺ Bash(npx wrangler deploy --dry-run --outdir=.wrangler/dryrun 2>&1 | tail -25)
⎿  npm error Missing script: "wrangler"
npm error
npm error To see a list of scripts, run:
… +3 lines (ctrl+o to expand)
⏺ Bash(npx wrangler deploy --dry-run --outdir=.wrangler/dryrun 2>&1 | tail -30)
⎿  npm error Missing script: "wrangler"
npm error
npm error To see a list of scripts, run:
… +3 lines (ctrl+o to expand)
⏺ Bash(pwd && /Users/aayushgarg/.nvm/versions/node/v22.14.0/bin/wrangler deploy
--dry-run --outdir=.wrangler/dryrun 2>&1 | tail -30)
⎿  /Users/aayushgarg/repos/garg-aayush.github.io/worker
⛅️ wrangler 4.86.0
… +17 lines (ctrl+o to expand)
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add worker/ && git
-C /Users/aayushgarg/repos/garg-aayush.github.io status)
⎿  ok 5 files changed, 1575 insertions(+)
* weather-cloudflare-cron
+ Staged: 5 files
… +11 lines (ctrl+o to expand)
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(cat
<<'EOF'
Worker: bootstrap india-weather Cloudflare Worker skeleton…)
⎿  ok weather
⏺ Now porting the fetch logic to the Worker:
⏺ Write(worker/src/fetch-weather.mjs)
⎿  Wrote 256 lines to worker/src/fetch-weather.mjs
d. Mirrorstch + transform pipeline for the live India weather fee
ncy: prior/fetch-india-weather.mjs but with no filesystem depende
3 // history comes in as an object, new history goes out as an obje
4 // caller (worker/src/index.mjs) decides where they live.
5 //
city missing in this file do not throw on per-source failures: a
ntact. Theps its weather, a global Open-Meteo outage leaves AQI i
8 // only way buildWeatherUpdate() rejects is if Promise.all() itse
9 // which the inner fetchers should prevent by catching their own
errors.
… +281 lines (ctrl+o to expand)
⏺ Bash(node --check
/Users/aayushgarg/repos/garg-aayush.github.io/worker/src/fetch-weather.mjs
&& echo OK)
⎿  OK
⏺ Update(worker/wrangler.toml)
⎿  Removed 7 lines
14 GITHUB_OWNER = "garg-aayush"
15 GITHUB_REPO = "garg-aayush.github.io"
16 DATA_BRANCH = "data"
17 -
18 -# Bundle the canonical cities.json from the site into the Worke
-r so we have a
19 -# single source of truth and don't pay an extra HTTP round-trip
- per run.
20 -[[rules]]
21 -type = "Data"
22 -globs = ["**/cities.json"]
23 -fallthrough = true
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add worker/ && git
-C /Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(cat <<'EOF'
Worker: port f…)
⎿  ok 2 files changed, 256 insertions(+), 7 deletions(-)
ok weather
⏺ Now the GitHub Git Data API client:
⏺ Write(worker/src/github.mjs)
⎿  Wrote 174 lines to worker/src/github.mjs
2 // each run rewrites the branch with a single parentless commit.
3 // the retired Actions workflow used to do via `git push --force
`, just done
5 // from a Cloudflare Worker via the REST API.
6 // Two entry points:
son content,aBranch() - read current state (parses history-*.j
ly) carries daily-*.json forward by SHA on
9 // commitDataBranch() - upload blobs, build tree, write parent
10 // force-update the ref. Creates the bran
ch if missing.
… +202 lines (ctrl+o to expand)
⏺ Bash(node --check
/Users/aayushgarg/repos/garg-aayush.github.io/worker/src/github.mjs &&
echo OK)
⎿  OK
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add
worker/src/github.mjs && git -C
/Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(cat <<'EOF'…)
⎿  ok 1 file changed, 174 insertions(+)
ok weather
⏺ Now wire it all together:
⏺ Write(worker/src/index.mjs)
⎿  Added 91 lines, removed 10 lines
6 //
7 // Daily aggregates (daily-*.json) are still owned by
8 // .github/workflows/india-weather-daily.yml — that runs once
a day, where
9 -// Actions cron drift is harmless, and migrating it would be c
-hurn for no gain.
9 +// Actions cron drift is harmless. The Worker carries those fi
+les forward
10 +// untouched so the daily cron can keep owning them.
11
12 +import cities from '../../static/india-weather/cities.json';
13 +import { buildWeatherUpdate } from './fetch-weather.mjs';
14 +import { readDataBranch, commitDataBranch } from './github.mjs
+';
15 +
16 +function requireEnv(env, key) {
17 + const v = env[key];
18 + if (!v || typeof v !== 'string') {
19 + throw new Error('Missing required env/secret: ' + key);
20 + }
21 + return v;
22 +}
23 +
24 +async function runOnce(env) {
25 + const githubToken = requireEnv(env, 'GITHUB_TOKEN');
26 + const owner = requireEnv(env, 'GITHUB_OWNER');
27 + const repo = requireEnv(env, 'GITHUB_REPO');
28 + const branch = requireEnv(env, 'DATA_BRANCH');
29 + const waqiToken = env.WAQI_TOKEN || ''; // optional; missing
+ means AQI null
30 +
31 + // Read prior state from the data branch first so we have hi
+story to append
32 + // to. Done before the upstream fetches because if GitHub is
+ down we want
33 + // to fail fast rather than waste WAQI quota.
34 + const prior = await readDataBranch({ token: githubToken, own
+er, repo, branch });
35 +
36 + // Fetch upstream + merge. Pure function; no side effects.
37 + const { weather, history } = await buildWeatherUpdate(
38 + cities,
39 + waqiToken,
40 + prior.historyByCity
41 + );
42 +
43 + // Single commit replacing the branch (parentless), carrying
+ daily files
44 + // forward by SHA so the daily Actions cron is not stomped o
+n.
45 + const commitMessage = 'chore(data): update weather.json '
46 + + new Date().toISOString().replace(/\.\d+Z$/, 'Z');
47 +
48 + const result = await commitDataBranch({
49 + token: githubToken,
50 + owner, repo, branch,
51 + weather,
52 + history,
53 + dailyEntries: prior.dailyEntries,
54 + message: commitMessage,
55 + });
56 +
57 + return {
58 + cities: cities.length,
59 + daily_carried: prior.dailyEntries.length,
60 + commit: result.commitSha,
61 + branch_existed: prior.exists,
62 + };
63 +}
64 +
65 export default {
12 - // Cron Trigger entry point. event.scheduledTime is the wall
--clock UTC ms
13 - // the Worker was *scheduled* to run (not when it actually s
-tarted).
66 + // Cron Trigger entry point. Cloudflare passes scheduledTime
+ in ms.
67 async scheduled(event, env, ctx) {
68 const startedAt = Date.now();
69 const tickIso = new Date(event.scheduledTime).toISOString(
);
17 -
70 try {
19 - // Fetch + commit pipeline lands in the next commit.
20 - console.log(JSON.stringify({ msg: 'tick', tick: tickIso,
- status: 'skeleton' }));
71 + const result = await runOnce(env);
72 + console.log(JSON.stringify({
73 + msg: 'tick_ok',
74 + tick: tickIso,
75 + ms: Date.now() - startedAt,
76 + ...result,
77 + }));
78 } catch (err) {
79 const message = err && err.message ? err.message : Strin
g(err);
23 - console.error(JSON.stringify({ msg: 'tick_failed', tick:
- tickIso, error: message }));
24 - throw err; // surface to Cloudflare so failed runs are v
-isible in dashboard
25 - } finally {
26 - console.log(JSON.stringify({ msg: 'tick_done', tick: tic
-kIso, ms: Date.now() - startedAt }));
80 + console.error(JSON.stringify({
81 + msg: 'tick_failed',
82 + tick: tickIso,
83 + ms: Date.now() - startedAt,
84 + error: message,
85 + }));
86 + // Surface failures to the Cloudflare dashboard. Next ti
+ck will retry.
87 + throw err;
88 }
89 },
90 +
91 + // Manual trigger for local dev (`wrangler dev`) and on-dema
+nd re-runs.
92 + // Production cron does not call this path.
93 + async fetch(request, env, ctx) {
94 + const url = new URL(request.url);
95 + if (url.pathname === '/__trigger') {
96 + const expected = env.TRIGGER_SECRET;
97 + const provided = url.searchParams.get('key') || '';
98 + if (expected && provided !== expected) {
99 + return new Response('forbidden\n', { status: 403 });
100 + }
101 + try {
102 + const result = await runOnce(env);
103 + return Response.json({ ok: true, ...result });
104 + } catch (err) {
105 + return Response.json({ ok: false, error: err && err.me
+ssage ? err.message : String(err) }, { status: 500 });
106 + }
107 + }
108 + return new Response('india-weather worker. Cron-driven. PO
+ST /__trigger?key=… for manual run.\n');
109 + },
110 };
⏺ Bash(node --check
/Users/aayushgarg/repos/garg-aayush.github.io/worker/src/index.mjs 2>&1)
⎿  (No output)
⏺ Bash(/Users/aayushgarg/.nvm/versions/node/v22.14.0/bin/wrangler deploy
--dry-run --outdir=.wrangler/dryrun 2>&1 | tail -25)
⎿   ⛅️ wrangler 4.86.0
───────────────────
Total Upload: 16.54 KiB / gzip: 5.21 KiB
… +11 lines (ctrl+o to expand)
⏺ Bundle is 16.5 KiB with cities.json embedded. Wrangler resolves the
cross-directory JSON import natively.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add
worker/src/index.mjs && git -C
/Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(cat <<'EOF'
W…)
⎿  ok 1 file changed, 91 insertions(+), 10 deletions(-)
ok weather
⏺ Per the project's "no docs unless asked" rule, I'll skip the README file and
hand off setup steps in chat below. Let me push the branch as a draft PR so
you can review the code, then walk you through deploy.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io push -u origin
weather-cloudflare-cron 2>&1 && echo --- && git -C
/Users/aayushgarg/repos/garg-aayush.githu…)
⎿  ok weather-cloudflare-cron
---
cfe3c35 Worker: wire scheduled() handler end-to-end
… +4 lines (ctrl+o to expand)
⏺ Bash(gh pr create --draft --title "Weather: migrate live cron to Cloudflare
Worker" --body "$(cat <<'EOF'
## Summary…)
⎿  ok created #70 https://github.com/garg-aayush/garg-aayush.github.io/pu
ll/70
⏺ Draft PR opened: #70. Four commits, ~600 lines, bundle is 16.5 KiB.
What you need to do next (manual steps I can't do for you)
1. Create a fine-grained GitHub PAT for the Worker
Go to github.com/settings/personal-access-tokens → Fine-grained tokens →
Generate new token:
- Token name: india-weather-worker
- Expiration: 1 year (or less if you prefer)
- Repository access: Only select repositories → garg-aayush.github.io
- Repository permissions: Contents: Read and write, Metadata: Read-only
(auto-selected)
- Click Generate token and copy the value (github_pat_...). You'll only see it
once.
2. Push the two secrets to the Worker
In your terminal, run these via ! wrangler ... so the prompts work
interactively:
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
put WAQI_TOKEN
→ paste your WAQI token, hit Enter.
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
put GITHUB_TOKEN
→ paste the PAT from step 1, hit Enter.
3. Test locally before deploying
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler dev
--local
This starts the Worker on localhost:8787. In another terminal, trigger one
run:
curl 'http://localhost:8787/__trigger'
You should see a JSON response like
{"ok":true,"cities":20,"daily_carried":N,"commit":"...","branch_existed":true}
and the data branch on GitHub should update.
▎ ⚠️ This will overwrite the live data branch. Daily files are carried
▎ forward, so the AQI history charts won't break, but every existing 24h
▎ history file will be replaced with whatever the Worker computes. Acceptable
▎ since the Actions cron writes the same shape, but worth knowing before you
▎ click.
4. Deploy to Cloudflare
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler deploy
Wrangler prints the deployment URL and confirms the cron trigger is
registered. First scheduled tick fires within ~15 min.
5. Watch it run
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler tail
You'll see structured JSON log lines like
{"msg":"tick_ok","tick":"…","ms":4823,"cities":20,...} for each cron
invocation.
What I'll do once you confirm it's working
- Mark PR #70 ready for review and merge it.
- Open a small follow-up PR that retires
.github/workflows/india-weather-data.yml and updates CLAUDE.md to point at the
Worker.
- Optionally: open another small PR for the WAQI flip criterion tightening
(the ≥75% coverage + recency change we discussed).
Let me know when steps 1-3 are done, or if anything throws an error.
✻ Brewed for 9m 57s
※ recap: We're migrating the India weather 15-min cron from GitHub Actions (drifts to 30-80 min) to a Cloudflare Worker; PR #70 is up with the Worker code ready to deploy. Next: create a fine-grained GitHub PAT and run wrangler secret put for WAQI_TOKEN and GITHUB_TOKEN. (disable recaps in /config)
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
put REDACTED_GITHUB_PAT
REDACTED_TOKEN_FRAGMENT
⎿   ⛅️ wrangler 4.86.0
───────────────────
🌀 Creating the secret for the Worker "india-weather"
… +6 lines (ctrl+o to expand)
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
put REDACTED_WAQI_TOKEN
⎿   ⛅️ wrangler 4.86.0
───────────────────
🌀 Creating the secret for the Worker "india-weather"
✨ Success! Uploaded secret REDACTED_WAQI_TOKEN
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler dev
--local
⎿  Interrupted · What should Claude do instead?
❯ I get this curl 'http://localhost:8787/__trigger'
─╯
{"ok":false,"error":"Missing required env/secret: GITHUB_TOKEN"}%
⏺ 🚨 Stop — security issue, act now
Two things went wrong, one of them serious. The wrangler secret put command
takes the secret name as the argument, then prompts you for the value. You
passed the values as the names, which means:
1. Your GitHub PAT is now exposed — its full value REDACTED_GITHUB_PAT
REDACTED_TOKEN_FRAGMENT is in your
shell history, in this Claude transcript, and stored as a secret name in
Cloudflare's dashboard. Anyone seeing any of those can read+write your repo.
2. Your WAQI token REDACTED_WAQI_TOKEN is similarly
exposed.
3. The Worker is looking for secrets named GITHUB_TOKEN and WAQI_TOKEN, which
don't exist (hence the error).
Do these in order, right now:
Step 1 — Revoke the GitHub PAT immediately.
Go to https://github.com/settings/personal-access-tokens, find the
india-weather-worker token, click it, and hit Revoke. Do this first; the rest
can wait a minute.
Step 2 — Delete the wrongly-named Cloudflare secrets (the values are exposed
via the names):
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
delete REDACTED_GITHUB_PAT
i2XREDACTED_TOKEN_FRAGMENT
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
delete REDACTED_WAQI_TOKEN
Step 3 — Decide about the WAQI token.
WAQI tokens don't have self-service rotation. If you want to be safe, email
them via aqicn.org/contact/ to request a new one and disable the old one. If
you'd rather just live with it (the worst-case is someone exhausts the 1000
req/sec rate limit on your account), you can reuse it — your call.
Step 4 — Generate a NEW GitHub PAT with the same settings as before:
- Repo: only garg-aayush.github.io
- Permissions: Contents Read+write, Metadata Read-only
Step 5 — Add the secrets with the CORRECT names. The command is wrangler
secret put NAME, and it will then prompt you for the value (hidden):
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
put GITHUB_TOKEN
→ When it prompts Enter a secret value:, paste the new PAT, hit Enter.
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
put WAQI_TOKEN
→ When it prompts, paste the WAQI token, hit Enter.
Step 6 — Re-run the trigger:
! curl 'http://localhost:8787/__trigger'
(Make sure wrangler dev --local is still running in the other terminal.)
---
One more thing for the future
When pasting a secret in this session, never type the value into the chat or
as a CLI argument. The safe patterns are:
- Interactive prompt: wrangler secret put NAME then paste value into the
hidden prompt
- Stdin pipe: pbpaste | wrangler secret put NAME (if you've copied it to
clipboard)
- File: wrangler secret put NAME < /tmp/secret.txt && rm /tmp/secret.txt
The ! command prefix in Claude Code echoes both the command line and its
stdout into the transcript, so anything visible on your terminal becomes part
of the conversation history.
Tell me when revoke + new PAT + correct secrets are in place, and we'll
resume.
✻ Crunched for 38s
※ recap: Migrating the India weather live cron from GitHub Actions to a Cloudflare Worker (PR #70). You exposed a GitHub PAT and the WAQI token by passing them as secret names in wrangler. Next: revoke the PAT at github.com/settings/personal-access-tokens. (disable recaps in /config)
❯ I still see curl 'http://localhost:8787/__trigger'
─╯
{"ok":false,"error":"Missing required env/secret: GITHUB_TOKEN"}%
⎿  ⧉ Selected 1 lines from ../Untitled-1 in Cursor
⏺ Different issue — wrangler dev --local doesn't use the secrets you uploaded
with wrangler secret put. Those go to the deployed Worker. Local dev needs a
.dev.vars file alongside wrangler.toml.
Fix
1. Create worker/.dev.vars with the two tokens. Don't ask me to write it —
type it yourself so the values don't land in this transcript again:
GITHUB_TOKEN=<paste your new PAT>
WAQI_TOKEN=<paste your WAQI token>
.dev.vars is already in the .gitignore I added, so it won't get committed.
2. Restart wrangler dev (Ctrl-C in the terminal running it, then re-run). It
reads .dev.vars only at startup.
3. Re-trigger:
! curl 'http://localhost:8787/__trigger'
While you're at it, sanity-check the deployed secrets
The wrong-named secrets were created on the deployed Worker. Confirm cleanup
landed and that the right names exist:
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
list
You should see exactly two secrets: GITHUB_TOKEN and WAQI_TOKEN. If you still
see entries starting with github_pat_… or 5ddb0b24…, delete them — they're
storing token values in plaintext as their names.
✻ Churned for 16s
❯ It is asking me this question.
? Would you like to register a workers.dev subdomain now? › (Y/n)
⏺ Say Y. It's free, gives you india-weather.<your-account>.workers.dev as a
public URL, and lets you hit /__trigger from anywhere if cron ever fails. Cron
Triggers themselves don't need it, but the public route is useful as a manual
escape hatch.
Important: lock down /__trigger before any public deploy
The code I wrote treats TRIGGER_SECRET as optional — if unset, /__trigger is
wide open and anyone who guesses your subdomain can fire Worker runs (worst
case: exhaust your WAQI rate limit). Set one right after you finish the
current step:
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && openssl rand -hex
24
Copy that output, then:
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
put TRIGGER_SECRET
Paste the random hex when prompted. After that, manual triggers must include
?key=<hex> or they get 403 forbidden.
Then back to the original flow — answer the subdomain prompt with Y and let it
finish.
✻ Sautéed for 22s
❯ DOne
⏺ Now try the trigger and show me the output:
! curl 'http://localhost:8787/__trigger'
Also confirm secrets are clean (only the right names should appear, no
github_pat_… or 5ddb… entries):
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
list
If /__trigger returns {"ok":true,...}, also check the data branch on GitHub —
there should be a fresh commit from github-actions[bot] (or your PAT's user)
within the last minute.
✻ Crunched for 11s
❯ Yes
⏺ Let me verify on my end too.
⏺ Bash(gh api repos/garg-aayush/garg-aayush.github.io/commits/data --jq '{sha:
.sha, msg: .commit.message, author: .commit.author.name, date:
.commit.author.date}')
⎿  {
"author": "Aayush Garg",
"date": "2026-04-29T04:24:53Z",
… +3 lines (ctrl+o to expand)
⏺ Bash(gh api
repos/garg-aayush/garg-aayush.github.io/contents/weather.json?ref=data
--jq '.sha' && gh api
repos/garg-aayush/garg-aayush.github.io/contents/history-del…)
⎿  Error: Exit code 1
(eval):1: no matches found:
repos/garg-aayush/garg-aayush.github.io/contents/weather.json?ref=data
⏺ Local Worker run committed successfully — 3fb414a7 from 30 seconds ago, with
the expected commit message format. Let me peek inside to confirm aqi_waqi is
landing:
⏺ Bash(gh api 'repos/garg-aayush/garg-aayush.github.io/contents/history-delhi.js
on?ref=data' --jq '.content' | base64 -d | python3 -c 'import json,sys;
d=json.load(sys…)
⎿  source: {'weather': 'open-meteo', 'aqi': 'open-meteo-air-quality', 'aq
i_waqi': 'waqi-cpcb'}
points: 29
… +2 lines (ctrl+o to expand)
⏺ End-to-end working. Latest Delhi point shows both AQI sources side-by-side:
{ "t": "2026-04-29T04:24:49Z", "temp": 33.3, "humidity": 32, "aqi": 293,
"aqi_waqi": 95 }
That divergence is exactly what we predicted: CAMS model says 293 (very
unhealthy), CPCB stations say 95 (satisfactory). The whole reason we did this
PR.
Two more steps before we're done
1. Confirm Cloudflare secrets are clean. Show me:
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler secret
list
Should show exactly three: GITHUB_TOKEN, WAQI_TOKEN, TRIGGER_SECRET. If you
see the github_pat_… or 5ddb… entries, delete them now.
2. Deploy to production:
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler deploy
This registers the cron trigger on Cloudflare's edge. Wrangler prints the
deployed URL and confirms Schedule: */15 * * * *. The first scheduled tick
fires at the next 15-min boundary (04:30, 04:45, etc. UTC).
Then in a separate terminal, watch the first tick land:
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler tail
You'll see a {"msg":"tick_ok",...} log line within seconds of each */15
boundary. That's the proof the cron is firing on time, not Actions-drifted.
Once you've seen at least one scheduled tick succeed, we're ready to retire
the Actions workflow and merge PR #70.
✻ Worked for 46s
❯ I see this error Successfully created tail, expires at 2026-04-29T10:29:01Z
Connected to india-weather, waiting for logs...
"*/15 * * * *" @ 4/29/2026, 10:00:51 AM - Exception Thrown
(error) WAQI feed failed for kolkata : Too many subrequests by single
Worker invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for kanpur : Too many subrequests by single Worker
invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for bhopal : Too many subrequests by single Worker
invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for patna : Too many subrequests by single Worker
invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for lucknow : Too many subrequests by single
Worker invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for nagpur : Too many subrequests by single Worker
invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for mumbai : Too many subrequests by single Worker
invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for chennai : Too many subrequests by single
Worker invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for coimbatore : Too many subrequests by single
Worker invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for bengaluru : Too many subrequests by single
Worker invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for ahmedabad : Too many subrequests by single
Worker invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error) WAQI feed failed for hyderabad : Too many subrequests by single
Worker invocation. To configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
(error)
{"msg":"tick_failed","tick":"2026-04-29T04:30:51.000Z","ms":4839,"error":"Too
many subrequests by single Worker invocation. To configure this limit, refer
to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits"}
✘ [ERROR] Error: Too many subrequests by single Worker invocation. To
configure this limit, refer to
https://developers.cloudflare.com/workers/wrangler/configuration/#limits
at ghFetch (index.js:371:19)
at index.js:443:24
at Array.map (<anonymous>)
at commitDataBranch (index.js:442:55)
at runOnce (index.js:513:24)
at async Object.scheduled (index.js:537:22)
⏺ Cloudflare Workers free tier caps at 50 subrequests per invocation. We're
doing ~89 (2 Open-Meteo + 40 WAQI + 23 GitHub reads + 24 GitHub writes). Local
dev (Miniflare) doesn't enforce the limit, which is why /__trigger worked but
the deployed cron blew up.
Three fixes drop us to ~46 subrequests with safe headroom:
1. Skip the WAQI dominant-pollutant feed call (saves 20). Tradeoff: popup
loses the "Dominant: PM2.5" line. Acceptable for now; can revisit when we go
to Workers Paid ($5/mo, 1000-subrequest cap).
2. Embed file content directly in the tree creation (saves 21). GitHub's POST
/git/trees accepts content inline, so we skip the per-blob upload step
entirely.
3. Read tree by branch name in one call (saves 2). GET
/git/trees/{branch}?recursive=1 replaces ref → commit → tree.
⏺ Update(worker/src/fetch-weather.mjs)
⎿  Added 6 lines, removed 16 lines
134
135 const avg = Math.round(stations.reduce((sum, s) => sum + s.a
qi, 0) / stations.length);
136
137 - // Dominant pollutant: pull from the worst station's full fe
-ed. One extra
138 - // call per city; cheap when we run all cities in parallel.
139 - let dominant = null;
140 - const worst = stations.reduce((a, b) => (a.aqi >= b.aqi ? a
-: b));
141 - try {
142 - const feedUrl = 'https://api.waqi.info/feed/@' + worst.uid
- + '/'
143 - + '?token=' + encodeURIComponent(token);
144 - const feed = await safeFetch(feedUrl);
145 - if (feed && feed.status === 'ok' && feed.data) {
146 - dominant = feed.data.dominentpol || null;
147 - }
148 - } catch (err) {
149 - console.error('WAQI feed failed for', city.id, ':', err &&
- err.message ? err.message : err);
150 - }
151 -
137 + // Dominant-pollutant lookup is intentionally omitted here.
+It would cost one
138 + // extra WAQI call per city (20 cities = 20 subrequests) and
+ Cloudflare's
139 + // free tier caps Workers at 50 subrequests per invocation.
+The popup's
140 + // "Dominant" line just stays blank under the Worker; revisi
+t if we ever go
141 + // to Workers Paid ($5/mo, 1000-subrequest cap).
142 return {
143 value: avg,
144 band: cpcbBand(avg),
155 - dominant_pollutant: dominant,
145 + dominant_pollutant: null,
146 station_count: stations.length,
147 };
148 }
⏺ Now the GitHub client — collapse reads + embed content in tree:
⏺ Update(worker/src/github.mjs)
⎿  Added 7 lines, removed 8 lines
58 // exists - false if the branch does not exist (first
-ever run)
59 // historyByCity - { [cityId]: parsedHistoryJson } for each
history-*.json
60 // dailyEntries - array of tree entries for daily-*.json (c
arry forward by SHA)
61 +//
62 +// Subrequest count: 1 (tree by branch name) + N history blob r
+eads. The
63 +// trees endpoint accepts a branch name in place of a SHA, whic
+h lets us
64 +// skip the otherwise-required ref + commit lookups and saves t
+wo calls
65 +// against Cloudflare's 50-subrequest per-invocation cap.
66 export async function readDataBranch({ token, owner, repo, bran
ch }) {
62 - let ref;
67 + let tree;
68 try {
64 - ref = await ghFetch(`/repos/${owner}/${repo}/git/ref/heads/
-${branch}`, { token });
69 + tree = await ghFetch(`/repos/${owner}/${repo}/git/trees/${b
+ranch}?recursive=1`, { token });
70 } catch (err) {
71 if (err.status === 404) {
72 return { exists: false, historyByCity: {}, dailyEntries:
[] };
73 }
74 throw err;
75 }
71 - const commitSha = ref.object.sha;
72 -
73 - const commit = await ghFetch(`/repos/${owner}/${repo}/git/com
-mits/${commitSha}`, { token });
74 - const treeSha = commit.tree.sha;
75 -
76 - const tree = await ghFetch(`/repos/${owner}/${repo}/git/trees
-/${treeSha}`, { token });
76 const entries = (tree.tree || []).filter(e => e.type === 'blo
b');
77
78 const dailyEntries = entries.filter(e => /^daily-.*\.json$/.t
est(e.path));
⏺ Update(worker/src/github.mjs)
⎿  Added 16 lines, removed 20 lines
102 token, owner, repo, branch,
103 weather, history, dailyEntries, message,
104 }) {
105 - // 1. Upload weather.json + every history-<id>.json as fresh
- blobs in parallel.
106 - const blobInputs = [
107 - { path: 'weather.json', content: JSON.stringify(weather, n
-ull, 2) + '\n' },
105 + // 1. Build the new tree from scratch (no base_tree). New fi
+les are passed
106 + // inline via `content`; GitHub creates the blobs as a si
+de-effect of
107 + // creating the tree, which saves us 21 individual blob P
+OSTs (and keeps
108 + // us inside Cloudflare's 50-subrequest cap on the free t
+ier). Daily
109 + // files are carried forward by SHA. Any other paths on t
+he branch are
110 + // deliberately dropped — the data branch only contains t
+hese three
111 + // file families.
112 + const treeEntries = [
113 + {
114 + path: 'weather.json',
115 + mode: '100644',
116 + type: 'blob',
117 + content: JSON.stringify(weather, null, 2) + '\n',
118 + },
119 ...Object.entries(history).map(([cityId, h]) => ({
120 path: 'history-' + cityId + '.json',
121 + mode: '100644',
122 + type: 'blob',
123 content: JSON.stringify(h) + '\n',
124 })),
112 - ];
113 -
114 - const newBlobEntries = await Promise.all(blobInputs.map(asyn
-c ({ path, content }) => {
115 - const blob = await ghFetch(`/repos/${owner}/${repo}/git/bl
-obs`, {
116 - token,
117 - method: 'POST',
118 - body: { content, encoding: 'utf-8' },
119 - });
120 - return { path, mode: '100644', type: 'blob', sha: blob.sha
- };
121 - }));
122 -
123 - // 2. Build the new tree from scratch (no base_tree). New bl
-obs + daily
124 - // carry-forwards. We deliberately drop any other paths t
-hat may have
125 - // been on the branch — the data branch only contains the
-se three
126 - // file families.
127 - const treeEntries = [
128 - ...newBlobEntries,
125 ...dailyEntries.map(e => ({
126 path: e.path,
127 mode: e.mode || '100644',
Read 1 file (ctrl+o to expand)
⏺ Update(worker/src/github.mjs)
⎿  Added 2 lines, removed 2 lines
136 body: { tree: treeEntries },
137 });
138
139 - // 3. Parentless commit. The ref will point at this and orph
-an all prior
139 + // 2. Parentless commit. The ref will point at this and orph
+an all prior
140 // commits, which Git GCs eventually. Branch stays at one
commit.
141 const commit = await ghFetch(`/repos/${owner}/${repo}/git/co
mmits`, {
142 token,
...
144 body: { message, tree: tree.sha, parents: [] },
145 });
146
147 - // 4. Force-update the ref. If the branch doesn't exist (fir
-st run), create
147 + // 3. Force-update the ref. If the branch doesn't exist (fir
+st run), create
148 // it via POST /git/refs instead.
149 let ref;
150 try {
⏺ Bash(node --check
/Users/aayushgarg/repos/garg-aayush.github.io/worker/src/github.mjs &&
node --check
/Users/aayushgarg/repos/garg-aayush.github.io/worker/src/fetch-…)
⎿   ⛅️ wrangler 4.86.0
───────────────────
Total Upload: 15.57 KiB / gzip: 5.01 KiB
… +11 lines (ctrl+o to expand)
⏺ Bundle clean and shrunk. Let me commit:
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add worker/src/ &&
git -C /Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(cat
<<'EOF'
Worker: cu…)
⎿  ok 2 files changed, 31 insertions(+), 46 deletions(-)
ok weather
ok weather-cloudflare-cron
⏺ Pushed to PR #70. Now redeploy and watch the next tick:
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler deploy
! cd /Users/aayushgarg/repos/garg-aayush.github.io/worker && wrangler tail
Next */15 tick should land around 04:45 UTC and log {"msg":"tick_ok",...}
instead of the subrequest exception.
One head's-up about the budget
We're now at 46 / 50 subrequests with 4 to spare. Each new city you add costs
2 more (one WAQI bounds + one history blob read), so we'd hit the cap at 22
cities. Two long-term options if you ever add more:
1. Workers Paid ($5/mo) raises the cap to 1000 — also lets us bring back the
dominant-pollutant feature.
2. Cloudflare KV — store the rolling history in KV (1 read/write per run,
replacing 20 GitHub blob reads). Free tier 100k reads/day, easily covers our
load.
Both are future work. For now, 20 cities at 15-min cadence works.
✻ Worked for 3m 7s
※ recap: We're migrating the India Weather live cron from GitHub Actions to a Cloudflare Worker so the 15-min cadence is real. Just pushed a fix for the Cloudflare 50-subrequest cap; next action is to run wrangler deploy and watch the next tick land in wrangler tail. (disable recaps in /config)
❯ Yup it works
⏺ Let me verify the deployed cron is committing cleanly:
⏺ Bash(gh api repos/garg-aayush/garg-aayush.github.io/commits/data --jq '{sha:
.sha, msg: .commit.message, date: .commit.author.date}' && echo --- && gh
api 'repos/gar…)
⎿  {
"date": "2026-04-29T04:45:54Z",
"msg": "chore(data): update weather.json 2026-04-29T04:45:53Z",
… +6 lines (ctrl+o to expand)
⏺ Verified: deployed cron ran at 04:45:52 UTC (2 sec after the */15 boundary —
perfectly punctual), Delhi history grew to 31 points with both aqi: 293 and
aqi_waqi: 96 recorded. Cron drift is gone.
Marking PR #70 ready:
⏺ Bash(gh pr ready 70 && gh pr view 70 --json title,state,isDraft --jq .)
⎿  ✓ Pull request garg-aayush/garg-aayush.github.io#70 is marked as "read
y for review"
{
… +4 lines (ctrl+o to expand)
⏺ Where we are now
- Worker is live, firing at every */15 boundary, ~2 sec latency.
- Actions cron is also still running every ~30-80 min, writing to the same
data branch. They race-clobber each other but it's harmless — both produce the
same shape, the loser just loses one history point.
- PR #70 ready for review/merge.
Two follow-ups to close out
1. Disable the Actions cron (now or after a soak period — your call).
Lowest-risk way is to drop the schedule: trigger from
.github/workflows/india-weather-data.yml and keep workflow_dispatch only, so
you can still trigger it manually if the Worker ever needs to be benched. Want
me to open that PR now, or wait 24-48h?
2. Tighten the WAQI flip criterion in the client (the ≥75 % coverage + recency
change we discussed earlier). Independent of the Worker. Small follow-up PR
whenever.
Tell me which you want first and I'll set it up.
✻ Cogitated for 55s
❯ Please go ahead with both the follow-ups.
⏺ I'll merge #70 first, then open the two follow-up PRs back-to-back.
⏺ Bash(gh pr merge 70 --merge --delete-branch && git -C
/Users/aayushgarg/repos/garg-aayush.github.io checkout master && git -C
/Users/aayushgarg/repos/garg-aayush.git…)
⎿  From github.com:garg-aayush/garg-aayush.github.io
* branch master -> FETCH_HEAD
60ae737..a6536da master -> origin/master
… +18 lines (ctrl+o to expand)
⏺ Follow-up #1 — retire Actions cron + update CLAUDE.md
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout -b
weather-retire-actions-cron)
⎿  Switched to a new branch 'weather-retire-actions-cron'
Searched for 1 pattern, read 1 file (ctrl+o to expand)
⏺ Update(.github/workflows/india-weather-data.yml)
⎿  Added 6 lines, removed 3 lines
1 -name: India Weather Data
1 +name: India Weather Data (fallback)
2
3 +# The 15-min live cron is now owned by the Cloudflare Worker in
+ worker/,
4 +# which fires on schedule (within seconds) instead of suffering
+ Actions'
5 +# 30-80 min cron drift. This workflow is kept as a manual fallb
+ack only —
6 +# `workflow_dispatch` lets us force-refresh the data branch fro
+m the Actions
7 +# UI if the Worker is ever benched (token rotation, account iss
+ue, etc.).
8 on:
4 - schedule:
5 - - cron: "*/15 * * * *"
9 workflow_dispatch:
10
11 permissions:
⏺ Now CLAUDE.md updates — top-level CI/CD section first:
⏺ Update(CLAUDE.md)
⎿  Added 5 lines, removed 4 lines
14 ## CI/CD
15 - Pushes to `master` trigger `.github/workflows/quarto-publish.
yml`
16 - Workflow runs `quarto render`, then a `sed` step substitutes
any `__MAPBOX_TOKEN__` placeholder in `_site/` from the `MAPBOX
_TOKEN` repo secret, then publishes `_site/` to `gh-pages` via
`quarto-dev/quarto-actions/publish@v2` with `render: false`
17 -- Two cron workflows feed the orphan `data` branch (force-push,
- single-commit):
18 - - `.github/workflows/india-weather-data.yml` runs every 15 mi
-nutes (Open-Meteo + WAQI live snapshot + 24h trailing window).
19 - - `.github/workflows/india-weather-daily.yml` runs once per d
-ay at 02:00 IST and rewrites 30-day per-city daily aggregates f
-rom Open-Meteo (forecast API + air-quality API).
20 - - Both workflows share the concurrency group `india-weather-d
-ata-publish` and each carries forward the other's files when fo
-rce-pushing, so they cannot race-clobber each other. See "Live
-Data Pages" below.
17 +- Two crons feed the orphan `data` branch (force-push, single-c
+ommit):
18 + - **Cloudflare Worker** in `worker/` runs every 15 minutes (O
+pen-Meteo + WAQI live snapshot + 24h trailing window). Cron Tri
+ggers fire on schedule, unlike Actions which drifted 30-80 min.
+ Owns `weather.json` and `history-*.json`.
19 + - `.github/workflows/india-weather-daily.yml` runs once per d
+ay at 02:00 IST and rewrites 30-day per-city daily aggregates f
+rom Open-Meteo (forecast API + air-quality API). Owns `daily-*.
+json`.
20 + - Each writer carries the other's files forward when force-pu
+shing the orphan commit, so they cannot race-clobber. See "Live
+ Data Pages" below.
21 + - `.github/workflows/india-weather-data.yml` is kept as a `wo
+rkflow_dispatch`-only fallback — usable from the Actions UI if
+the Worker is ever benched.
22
23 ## Architecture
24
⏺ Now the "Files (India Weather)" + Architecture sections:
⏺ Update(CLAUDE.md)
⎿  Added 6 lines, removed 5 lines
94 - `static/india-weather/weather.sample.json` — hand-authored f
ixture used as a fallback when the remote data branch is unrea
chable
95 - `static/india-weather/history-<id>.sample.json` — per-city 2
4h fallbacks for ?local dev / data-branch outages
96 - `static/india-weather/daily-<id>.sample.json` — per-city 30-
day daily-aggregate fallbacks
97 -- `scripts/fetch-india-weather.mjs` — pure-Node ESM fetcher (n
-o deps, uses built-in `fetch` and `AbortController`); writes t
-he live `weather.json` and a 24h-only rolling `history-<id>.js
-on` per city when given `--history-in` / `--history-out`
97 +- `worker/` — Cloudflare Worker that owns the 15-min live cron
+. `src/fetch-weather.mjs` is the pure-functions Open-Meteo + W
+AQI pipeline; `src/github.mjs` is the Git Data API client (rea
+ds tree by branch, embeds blob content inline in `POST /git/tr
+ees`, parentless commit + force ref-update); `src/index.mjs` w
+ires it together in `scheduled()` and exposes a `/__trigger` e
+ndpoint guarded by `TRIGGER_SECRET` for manual runs. Bundles `
+static/india-weather/cities.json` cross-directory so there's a
+ single source of truth.
98 +- `scripts/fetch-india-weather.mjs` — pure-Node ESM fetcher; w
+as the live cron's body when Actions owned it. Now only used b
+y the fallback workflow if it's manually dispatched.
99 - `scripts/fetch-india-weather-daily.mjs` — pure-Node ESM dail
y-aggregate fetcher; rewrites the 30-day window from Open-Mete
o each run (no incremental append, missed runs self-heal)
99 -- `.github/workflows/india-weather-data.yml` — `*/15` cron + `
-workflow_dispatch`; owns `weather.json` and `history-*.json`
100 -- `.github/workflows/india-weather-daily.yml` — `30 20 * * *`
-cron (= 02:00 IST) + `workflow_dispatch`; owns `daily-*.json`
100 +- `.github/workflows/india-weather-data.yml` — `workflow_dispa
+tch`-only fallback (the `schedule:` trigger was removed when t
+he Worker took over). Still writes `weather.json` and `history
+-*.json` if manually dispatched.
101 +- `.github/workflows/india-weather-daily.yml` — `30 20 * * *`
+cron (= 02:00 IST) + `workflow_dispatch`; owns `daily-*.json`.
+ Stays on Actions: runs once a day where cron drift is harmles
+s.
102
103 ### Architecture
103 -- The 15-min cron checks out `master`, runs the fetcher (which
- calls Open-Meteo for weather and WAQI for AQI), updates the r
-olling 24h history, then publishes the resulting `weather.json
-` + `history-*.json` as a single-commit force-push to the orph
-an `data` branch — preserving the daily cron's `daily-*.json`
-files when it does so.
104 +- The Worker fires every 15 minutes via Cloudflare Cron Trigge
+r (within seconds of schedule, no drift). On each tick: read t
+he data branch tree by name (one call), pull each `history-*.j
+son` blob, fetch Open-Meteo + WAQI in parallel, append a new p
+oint, and write a single parentless commit via `POST /git/tree
+s` with file content inlined — keeping the data branch at exac
+tly one commit and matching the orphan-style push the retired
+Actions cron used. Daily files are carried forward by SHA so t
+he daily cron is never stomped on. Total subrequest count is ~
+46/run, deliberately under Cloudflare's free-tier 50 cap.
105 - The daily cron checks out `master`, runs the daily-aggregate
fetcher (Open-Meteo Forecast API + Air Quality API with `past
_days=30`, `timezone=Asia/Kolkata`), aggregates hourly data in
to per-IST-day min/max/mean for temp, humidity, and US AQI, an
d force-pushes the resulting `daily-*.json` files alongside th
e preserved `weather.json` + `history-*.json`.
105 -- Both workflows share concurrency group `india-weather-data-p
-ublish` so they serialize and never race-clobber the orphan fo
-rce-push.
106 +- The Worker and the daily Actions cron each carry the other's
+ files forward by SHA when they push, so neither clobbers the
+other. They are not in a shared concurrency group (Worker is o
+n Cloudflare, Actions on GitHub) — collision risk is ~0.3 %/da
+y and `force: true` ref updates resolve harmlessly when it doe
+s happen.
107 - The published page reads `weather.json`, `history-<id>.json`
, and `daily-<id>.json` from `https://raw.githubusercontent.co
m/garg-aayush/garg-aayush.github.io/data/`. If a fetch fails,
the JS falls back to the bundled `*.sample.json` files. Per-ci
ty history files are loaded lazily on city/range selection.
108 - Visitor count never affects API call volume because all fetc
hing happens server-side on the cron schedule.
109
⏺ Now Token handling and the cron-drift caveat:
⏺ Update(CLAUDE.md)
⎿  Added 4 lines, removed 3 lines
118 - No bootstrap workflow is required: each daily cron run rebui
lds the entire 30-day window from Open-Meteo, so missed runs s
elf-heal automatically.
119
120 ### Token handling
121 -- **No tokens in source, ever.** Both `MAPBOX_TOKEN` and `WAQI
-_TOKEN` live as GitHub repo Secrets.
122 -- `WAQI_TOKEN` is consumed inside the cron workflow only; neve
-r reaches the client.
121 +- **No tokens in source, ever.** `MAPBOX_TOKEN` lives as a Git
+Hub repo Secret; `WAQI_TOKEN` and `GITHUB_TOKEN` (a fine-grain
+ed PAT scoped to this repo, Contents:Write) live as Cloudflare
+ Worker secrets set via `wrangler secret put`. `TRIGGER_SECRET
+` is also a Worker secret guarding the manual `/__trigger` end
+point.
122 +- `WAQI_TOKEN` is consumed inside the Worker only; never reach
+es the client. The fallback Actions workflow still reads it fr
+om GitHub Secrets if manually dispatched.
123 - `MAPBOX_TOKEN` must reach the client to render the map. The
repo source has only a `__MAPBOX_TOKEN__` placeholder in `indi
a-weather.js`. The publish workflow's `sed` step replaces it a
fter `quarto render` and before pushing to `gh-pages`. Local d
ev falls back to `localStorage.iwMapboxToken` so the token nev
er touches a shell history or dotfile either.
124 - Mapbox URL allowlist on the token: `aayushgarg.dev`, `garg-a
ayush.github.io`, `localhost`. Bare hostnames only; Mapbox doe
s not accept wildcards or path suffixes.
125
126 ### Known caveats
127 -- **GitHub Actions cron drift.** Scheduled runs are best-effor
-t. The very first scheduled tick after a workflow is registere
-d often does not fire for 30 to 60 minutes, and `*/15` runs ca
-n be delayed or dropped under load. The page handles this grac
-efully (the client shows a relative `Updated Nm ago` timestamp
- from `generated_at`), and `workflow_dispatch` is available fo
-r manual force-refresh.
127 - **`raw.githubusercontent.com` Fastly cache.** Responses are
cached for ~5 minutes, and Fastly does NOT vary the cache by q
uery string. The current Refresh button cache-busts with `?t=D
ate.now()` but Fastly ignores it; the button effectively only
re-renders the existing cached payload. Worst-case staleness i
s therefore cron interval (15 min) plus Fastly TTL (5 min) ≈ 2
0 min. Acceptable for portfolio purposes; switch to jsDelivr i
f a tighter SLA is ever needed.
128 +- **Cloudflare Workers free-tier subrequest cap.** 50 subreque
+sts per Worker invocation. We sit at ~46 (2 Open-Meteo + 20 WA
+QI + 1 tree read + 20 history blob reads + 3 GitHub writes). A
+dding cities costs 2 each; we hit the cap at 22 cities. Mitiga
+tion paths if it ever bites: Workers Paid ($5/mo, 1000-subrequ
+est cap), or move history reads into Cloudflare KV (one batche
+d read replaces 20 GitHub blob reads).
129 +- **WAQI dominant pollutant is dropped under the Worker.** The
+ popup's "Dominant: PM2.5" line stays blank. Reinstating it co
+sts +20 subrequests (one WAQI feed call per city) and would pu
+sh us over the free-tier cap; revisit when/if we go to Workers
+ Paid.
130 - **GitHub secret scanning.** Push protection flags any `pk.*`
Mapbox token landing in any branch (including `gh-pages`). Th
e first push after rotating the Mapbox token will be blocked o
nce and require a click-through unblock URL. Pasting a `pk.*`
directly into source instead of the placeholder will be perman
ently blocked.
131 - **Mapbox token type.** Use `pk.*` (public, scoped read-only)
. Never put an `sk.*` token anywhere near the client.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io diff --stat && echo
--- && git -C /Users/aayushgarg/repos/garg-aayush.github.io add CLAUDE.md
.github/workf…)
⎿  .github/workflows/india-weather-data.yml | 9 ++++++---
CLAUDE.md | 27 +++++++++++++++--------
----
… +5 lines (ctrl+o to expand)
⏺ Bash(gh pr create --title "Weather: retire Actions live cron, document Worker
handover" --body "$(cat <<'EOF'
## Summary…)
⎿  ok created #71 https://github.com/garg-aayush/garg-aayush.github.io/pu
ll/71
⏺ PR #71 up. Now follow-up #2 — WAQI flip criterion.
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout master &&
git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout -b
weather-aqi-flip-covera…)
⎿  Switched to branch 'master'
Your branch is up to date with 'origin/master'.
Switched to a new branch 'weather-aqi-flip-coverage'
Searched for 1 pattern (ctrl+o to expand)
⏺ Update(static/india-weather/india-weather.js)
⎿  Added 18 lines, removed 5 lines
735 const hourly = hourlyCache.get(cityId);
736 const points = (hourly && Array.isArray(hourly.points_24
h)) ? hourly.points_24h : [];
737 // Prefer WAQI/CPCB stations (matches the live tile) onc
e the rolling
738 - // window has accumulated them. Falls back to CAMS durin
-g the 24h
739 - // warm-up after rollout, or if WAQI was blocked across
-the window.
740 - const hasWaqi = points.some(p => p && p.aqi_waqi != null
- && Number.isFinite(p.aqi_waqi));
741 - const aqiKey = hasWaqi ? 'aqi_waqi' : 'aqi';
742 - setAqiSourceLabel(hasWaqi ? 'waqi' : 'cams');
738 + // window has enough of them. Two guards:
739 + // coverage >= 75% - kills premature flips during the
+ warm-up. The
740 + // old "any single point" rule woul
+d flip on the
741 + // first WAQI run and show one dot
+with 95 nulls
742 + // around it. Also auto-reverts to
+CAMS if WAQI
743 + // stays dead for hours and the pro
+portion drops.
744 + // recent point - kills the case where WAQI was he
+althy for 18h
745 + // then died; without this we'd lab
+el the chart
746 + // "CPCB" while its right edge is h
+ours stale.
747 + // Falls back to CAMS when either guard fails.
748 + const total = points.length;
749 + const withWaqi = points.filter(p => p && p.aqi_waqi != n
+ull && Number.isFinite(p.aqi_waqi)).length;
750 + const coverage = total > 0 ? withWaqi / total : 0;
751 + const latest = points[points.length - 1];
752 + const recentHasWaqi = !!(latest && latest.aqi_waqi != nu
+ll && Number.isFinite(latest.aqi_waqi));
753 + const useWaqi = coverage >= 0.75 && recentHasWaqi;
754 + const aqiKey = useWaqi ? 'aqi_waqi' : 'aqi';
755 + setAqiSourceLabel(useWaqi ? 'waqi' : 'cams');
756 renderLineChart('aqi', 'iw-chart-aqi', 'AQI',
'#75A8D9',
757 v => Math.round(v), points, aqiKey);
758 renderLineChart('temp', 'iw-chart-temp', 'Temp',
'#E8A87C',
⏺ Bash(node --check /Users/aayushgarg/repos/garg-aayush.github.io/static/india-w
eather/india-weather.js && echo OK && git -C
/Users/aayushgarg/repos/garg-aayush.github…)
⎿  OK
ok 1 file changed, 18 insertions(+), 5 deletions(-)
ok weather
ok weather-aqi-flip-coverage
⏺ Bash(gh pr create --title "Weather: tighten 24h AQI flip criterion (coverage +
recency)" --body "$(cat <<'EOF'
## Summary…)
⎿  ok created #72 https://github.com/garg-aayush/garg-aayush.github.io/pu
ll/72
⏺ Both follow-ups up:
- PR #71 — Retire Actions live cron, document Worker handover. Removes the
*/15 schedule from the Actions workflow, keeps it as a workflow_dispatch
fallback, rewrites CLAUDE.md's Live Data Pages section to describe the Worker.
- PR #72 — Tighten 24h AQI flip criterion. Replaces "any single point flips"
with "≥75 % coverage AND latest point has WAQI". One-file, ~18 lines.
Independent of each other — merge in either order. Both small enough for
squash-merge per CLAUDE.md.
When you merge them, the live cron is fully on Cloudflare and the chart will
start showing the WAQI/CPCB series ~18 h after the next deploy (which happens
automatically when #72 merges and the publish workflow runs).
✻ Worked for 4m 25s
❯ Please merge too
⏺ Bash(gh pr merge 71 --squash --delete-branch && gh pr merge 72 --squash
--delete-branch)
⎿  From github.com:garg-aayush/garg-aayush.github.io
* branch master -> FETCH_HEAD
a6536da..aa81b96 master -> origin/master
… +7 lines (ctrl+o to expand)
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout master &&
git -C /Users/aayushgarg/repos/garg-aayush.github.io pull --ff-only && git
-C /Users/aay…)
⎿  Already on 'master'
Your branch is up to date with 'origin/master'.
ok (up-to-date)
… +7 lines (ctrl+o to expand)
⏺ Both merged into master. The publish workflow will rebuild and ship the new
client to gh-pages shortly.
Final state
- Live cron: Cloudflare Worker, real */15 cadence, ~46 subrequests, deployed.
- Daily aggregator: unchanged (Actions, once a day).
- Actions live cron: disabled — kept as a workflow_dispatch fallback.
- 24h AQI chart: will switch from CAMS to WAQI/CPCB once the rolling window
has ≥75 % WAQI coverage AND the latest point is WAQI. At 15-min cadence,
that's ~18 h from now.
Two open improvements for some other day, if you ever want them:
- Bring back the dominant-pollutant feed (needs Workers Paid or KV caching).
- Linear interpolate 1-2 missed-run gaps in the WAQI series.
✻ Cogitated for 36s
❯ Just add it as To-Do for future in some markdown file
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout -b
add-todo-file)
⎿  Switched to a new branch 'add-todo-file'
⏺ Write(TODO.md)
⎿  Wrote 17 lines to TODO.md
2 # TODO
3 Deferred work that is not blocking but worth picking up if the r
4 elevant area is touched again.
5 ## India Weather
6
8 Each city's marker popup used to show "Dominant: PM2.5 · 5 stati
ons". The Cloudflare Worker dropped the per-city WAQI feed call
0-subrequest cap. The popup currently has no Dominant line.ier 5
9
10 Two ways to reinstate:
… +22 lines (ctrl+o to expand)
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add TODO.md && git
-C /Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(cat <<'EOF'
Add TODO.md fo…)
⎿  ok 1 file changed, 17 insertions(+)
ok add-tod
ok add-todo-file
… +10 lines (ctrl+o to expand)
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout master &&
git -C /Users/aayushgarg/repos/garg-aayush.github.io pull --ff-only 2>&1 |
tail -3)
⎿  Already on 'master'
Your branch is up to date with 'origin/master'.
ok (up-to-date)
⏺ Done. PR #73 merged. TODO.md now lives at the repo root with both deferred
items and the context for why each was deferred.
✻ Worked for 1m 7s
※ recap: Migrated India Weather's live cron from drift-prone GitHub Actions to a Cloudflare Worker firing every real 15 min, plus tightened the WAQI flip rule and recorded follow-ups in TODO.md. Next: just watch the chart flip from CAMS to WAQI in roughly 18 hours.
❯ Do we need to update the Claude.md or is it already updated?
⏺ Let me check what's currently in CLAUDE.md vs. what's changed.
Searched for 1 pattern, read 1 file (ctrl+o to expand)
⏺ Two small bits drifted:
1. Line 113 still says "prefers aqi_waqi when the rolling window has
accumulated it" — accurate before PR #72 but no longer captures the 75
%-coverage + recency rule.
2. TODO.md isn't mentioned anywhere.
Both worth fixing. Quick PR:
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io checkout -b
update-claude-md-postworker)
⎿  Switched to a new branch 'update-claude-md-postworker'
⏺ Update(CLAUDE.md)
⎿  Added 1 line, removed 1 line
110 ### History data model
111 - `history-<id>.json` holds the 24h trailing window only: `poi
nts_24h` at 15-min cadence. Point shape: `{ t: ISO-UTC, temp:
°C, humidity: %, aqi: US-AQI, aqi_waqi: CPCB-AQI }`. Both AQI
fields are written every run: `aqi` is Open-Meteo CAMS (model-
based, US AQI scale) and is backfillable; `aqi_waqi` is the sa
me WAQI/CPCB-station value shown on the live tile, accumulated
forward only.
112 - `daily-<id>.json` holds the last 30 complete IST days as `da
ys: [...]`. Each entry: `{ date: "YYYY-MM-DD", temp_min, temp_
max, temp_mean, humidity_min, humidity_max, humidity_mean, aqi
_min, aqi_max, aqi_mean }`. The 7d view slices the last 7 days
; 30d uses all 30. The in-progress IST day is excluded.
113 -- **24h AQI chart:** prefers `aqi_waqi` (CPCB stations via WAQ
-I) when the rolling window has accumulated it, so the chart ag
-rees with the live tile. Falls back to `aqi` (CAMS) during the
- 24h warm-up after rollout, or when WAQI was blocked across th
-e entire window. The chart's caption-source label flips betwee
-n "(CPCB stations · WAQI)" and "(US AQI · Open-Meteo)" based o
-n which series is being plotted.
113 +- **24h AQI chart:** prefers `aqi_waqi` (CPCB stations via WAQ
+I) when **both** guards pass: ≥75 % of the rolling 24h window
+has `aqi_waqi`, AND the most recent point has `aqi_waqi`. The
+coverage guard kills premature flips during the warm-up; the r
+ecency guard kills the case where WAQI was healthy then died m
+id-day. Either guard failing falls the chart back to `aqi` (CA
+MS). The chart's caption-source label flips between "(CPCB sta
+tions · WAQI)" and "(US AQI · Open-Meteo)" based on which seri
+es is being plotted.
114 - **7d / 30d AQI charts:** still use Open-Meteo CAMS only, bec
ause WAQI's historical endpoint is paywalled even with a token
. The numbers there are on the US AQI scale and will not alway
s agree with the live CPCB number; this is documented inline o
n the page.
115 - 7d / 30d x-axis ticks are *not* uPlot's auto-generated split
s. The bars sit at noon IST (06:30 UTC) per day; uPlot's defau
lts place ticks at midnight UTC, which slips ~6.5h to the left
of every bar. `dayAxisConfig(t, days)` overrides `splits` to
return our actual per-day timestamps (stride 1 for 7d, stride
5 for 30d). If you change the chart, keep this override or the
labels will visibly drift again.
116 - Map camera is locked to India: `minZoom: 3.8` matches the in
itial zoom and `maxBounds: [[67, 5.5], [98, 37.5]]` clips pann
ing to roughly the country's bbox. There is also a custom rese
t-view control (top-right, just below the +/- group) that flie
s back to `[80, 22.5]` zoom 3.8 and closes any open marker pop
up.
⏺ Update(CLAUDE.md)
⎿  Added 1 line
67 - **Blog post images** are stored in `static/img/blog-YYYY-MM-D
D/` matching the post date (e.g., post `2026-01-23-expert-itera
tion` images in `static/img/blog-2026-01-23/`)
68 - Code execution is frozen (`freeze: true`), rendered outputs a
re cached, not re-executed on each build
69 - External links open in new tabs (configured globally in `_qua
rto.yml`)
70 +- **`TODO.md` at repo root** captures deferred work that isn't
+blocking but is worth picking up if the relevant area is touche
+d again. Add to it (with context for *why* something was deferr
+ed) when shipping a change that consciously leaves an improveme
+nt on the table.
71
72 ## Blog Posts
73
⏺ Bash(git -C /Users/aayushgarg/repos/garg-aayush.github.io add CLAUDE.md && git
-C /Users/aayushgarg/repos/garg-aayush.github.io commit -m "$(cat <<'EOF'
CLAUDE.md: p…)
⎿  ok 1 file changed, 2 insertions(+), 1 deletion(-)
ok update-
ok update-claude-md-postworker
… +12 lines (ctrl+o to expand)
⏺ Done. PR #74 merged. CLAUDE.md now accurately describes the flip rule and
points at TODO.md.
✻ Churned for 1m 25s
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment