A dashboard for Malaysia's Government Open API - weather, fuel prices, economic statistics and public transport, in one page.
Live: https://mygov.faizalmzain.com
No build step, no framework, no npm dependencies at runtime. The whole app is a single HTML file plus a service worker, served by a Cloudflare Worker that also proxies the reverse-geocoding, GTFS archive, flight-board and live-bus calls.
The same data is available to AI coding agents through an official MCP plugin -
mfaizalzain/mygov-mcp - exposing
weather, fuel prices, CPI/OpenDOSM, GTFS transit and live Rapid bus positions as
seven read-only tools.
Published for Claude Code (@claude-community marketplace) and Codex/ChatGPT
(universal plugin directory). No API key, same rate-limit rules as the app.
| Section | Source | Contents |
|---|---|---|
| Weather | MET Malaysia | 7-day forecast for all 360 locations, severe-weather warnings, recent earthquakes |
| Fuel & Households | Ministry of Finance | Weekly RON95 / RON97 / diesel ceiling prices, household income |
| Economy | DOSM (OpenDOSM), EPF | Headline vs core CPI by expenditure division, year-on-year inflation by state, unemployment, quarterly real GDP, latest EPF dividend |
| Finance | Bank Negara Malaysia, PayNet | Exchange rates vs key currencies (daily or monthly), interest rates by bank type, daily FPX payment value and volume |
| Vehicles & Ridership | JPJ, KTMB | Monthly new-vehicle registrations stacked by fuel type (the EV adoption curve), daily KTMB ridership per service |
| Population | DOSM (OpenDOSM) | National population 1970-present, ethnic composition |
| Health | Ministry of Health | Daily blood donations with blood-type split, organ pledges, PeKa B40 screenings |
| Postcodes | Pos Malaysia | Searchable postcode → city → state reference |
| Transport | KTMB, Prasarana, Malaysia Airports | GTFS schedules - route/stop search, nearest stops, busiest routes, LRT/MRT metro line diagram; live arrivals & departures board for 13 airports (FIDS) |
| Live | KTMB, Prasarana | KTMB GTFS-Realtime trains + 800+ live Rapid KL buses from Prasarana's official kiosk feed on a clustered live map, with route chips (click to filter) and tooltips |
| Trend Radar | News + Sebenarnya.my | Top-10 hot issues in Malaysia, clustered daily by Gemini, with Sebenarnya fact-check status |
A daily scoop of Malaysia's hot issues and trends, fed by:
- News RSS: MalayMail, Free Malaysia Today, Utusan
- Fact-check feed: Sebenarnya.my (MCMC) - used to verify/debunk circulating claims
The collector clusters ~100 raw signals into a ranked top-10 issue list (BM titles,
categories, cross-source counts) using the Gemini API (gemini-flash-latest),
then writes public/radar.json. Each issue carries a fact-check status
(verified_claim / debunked / no_check_found) matched deterministically
against Sebenarnya posts, plus clickable source URLs.
GitHub Actions (collect_radar.yml) Hermes cron (digest, 10 PM MYT)
daily 9 AM MYT (or manual dispatch) reads live radar.json
python3 tools/collect_radar.py sends top-5 to Telegram
GOOGLE_API_KEY secret → Gemini cluster (no collecting - read-only)
commit + push radar.json
Cloudflare auto-deploy → mygov.faizalmzain.com/radar.json
- Collector:
tools/collect_radar.py- RSS fetch, Gemini clustering with JSON-rescue parsing, deterministic Sebenarnya fact-check matching, URL backfill. Falls back to keyword clustering if the Gemini call fails (never crashes). - Repo secret required:
GOOGLE_API_KEY(Gemini API key) - without it the collector degrades to keyword clustering in CI. - Workflow:
.github/workflows/collect_radar.yml- scheduled 01:00 UTC (9 AM MYT) + manualworkflow_dispatch, commits asgithub-actions[bot]withcontents: write. - The dashboard fetches
/radar.json(same-origin) and renders issue cards.
Two collectors pre-fetch everything that changes at most daily, so visitors spend zero API budget on it. Both run as GitHub Actions and commit a static JSON the dashboard reads same-origin (served from the Cloudflare edge):
| Collector | Workflow | Writes | Contents |
|---|---|---|---|
| KKM health | collect_health.yml, daily 00:30 UTC |
public/health.json |
blood donations (3y), organ pledges, PeKa B40 |
| Slow data | collect_slow.yml, 00:30 + 12:30 UTC |
public/slow.json |
fuel, finance, mobility, economy, population |
-
Every collector finishes with
tools/embed_seo.py, which (a) injects the current headline values (fuel, USD/MYR, CPI, blood donations, top radar issues) into the<noscript>fallback ofindex.htmlbetween the<!-- SEO:SNAP -->markers, so crawlers that never run JS still see fresh numbers on first pass, and (b) writespublic/feed.xml, an RSS 2.0 feed of the Trend Radar issues. Both are idempotent - they only rewrite when the content actually changes, so collector commits stay clean. -
tools/collect_health.pyandtools/collect_slow.pyfetch from the data.gov.my API with 4 attempts and 5s/10s/15s backoff (a transient failure in unattended CI must not cost a whole day of freshness), then write the data in the exact compact shapes the browser loaders build ({t0, n, keys, series}etc. - 596 KB for slow.json vs ~5 MB of raw rows). -
The loaders (
loadHealth,loadFuel,loadFinance,loadMobility,loadEconomy,loadPopulation) try the static file first viareadSlow(), and only fall back to the live API when the file is missing or more than ~6 days old (e.g. on plain static hosting without the Action). -
Net effect: a cold visit costs 3 API requests (weather) plus the real-time feeds - everything else is static.
interestratesis the one series currently stale upstream (last published 2025-12); the collector captures whatever the source publishes.
public/
index.html entire app - markup, design system, logic
sw.js service worker (offline shell + API fallback)
manifest.webmanifest PWA manifest
icons/ generated PNGs (192, 512, apple-touch)
vendor/ self-hosted Chart.js, Leaflet (SRI-pinned)
src/
index.js Worker: static assets + /api/reverse, /api/gtfs, /api/fids, /api/rapid
tools/
make-icons.mjs regenerates the icons (no image dependencies)
collect_radar.py Trend Radar collector (see above)
collect_health.py KKM health collector → public/health.json
collect_slow.py slow-data collector → public/slow.json
embed_seo.py injects live values into index.html + writes feed.xml
wrangler.jsonc
The Worker serves public/ through the ASSETS binding and owns four routes,
/api/reverse, /api/gtfs, /api/fids and /api/rapid. Everything else is
static.
Static assets are served by Cloudflare's asset router without invoking the Worker, so response headers for them come from
public/_headers, not fromsrc/index.js.
Three of the four routes exist because the upstream API cannot be called from the browser; the fourth exists because the browser could not render 800+ live markers efficiently. The weather section can centre itself on wherever you are, which needs reverse geocoding. That call cannot be made from the browser:
nominatim.openstreetmap.orgreturns noaccess-control-allow-originheader, so a direct browser request is blocked by CORS.- Nominatim's usage policy requires a descriptive
User-Agent. Browsers forbid scripts from setting that header, so only a server-side call can comply.
/api/reverse does the call server-side with a proper User-Agent, rounds
coordinates to ~1 km before forwarding (so nearby visitors share a cache entry
and precise locations are never stored), and caches results at the edge for a
day. If the proxy is unavailable the UI degrades to a manual location search
rather than breaking.
The second route, /api/gtfs, exists for a different reason. Upstream
/gtfs-static/* answers 302 to an S3 bucket, so the browser has to pass
CORS on the redirect target. That is fragile - any network layer that
intercepts that hop surfaces it as an opaque CORS failure the page cannot
distinguish from being offline. Doing the hop server-side removes the
cross-origin redirect entirely and lets the ZIP be edge-cached for 6 hours, so
the government API sees a handful of requests a day rather than one 8 MB
download per visitor. The client still falls back to fetching the API directly
if the proxy is absent, so the page works on plain static hosting too.
The third route, /api/fids, backs the live flight board. Malaysia Airports'
FIDS endpoint (api.myairports.com.my) requires an x-api-key header and
answers without CORS headers, so browsers cannot call it directly. The Worker
injects the key (from the FIDS_API_KEY variable), adds
Access-Control-Allow-Origin: *, slims the response to the fields the board
renders, and edge-caches it for 60 seconds so the airport API sees one request
per minute per board, not one per visitor.
The fourth route, /api/rapid, backs the Live section's Rapid KL buses. The
data.gov.my GTFS-RT feed for Prasarana is frequently empty even mid-service,
but the operator's own kiosk (myrapidbus.prasarana.com.my/kiosk) shows 800+
live buses, streamed from a socket.io server. socket.io's engine.io POLLING
transport is plain HTTP, so the Worker consumes it without a websocket client:
open the session, connect, emit onFts-reload, poll - the response frame
carries base64(gzip(JSON)) which the Worker decompresses, slims to the fields
the map renders, and edge-caches for ~25 s so every visitor shares one
upstream poll. The page aggregates those buses into route chips and grid
clusters on the map (dissolving into individual markers as you zoom), so 800+
points never freeze the browser.
- CSP with no
'unsafe-eval',object-src 'none',frame-ancestors 'none', and aconnect-srcallowlist, plus HSTS,nosniff,X-Frame-Options, a restrictivePermissions-Policy(onlygeolocation, same-origin) andstrict-origin-when-cross-originreferrer policy. All inpublic/_headers. - Chart.js and Leaflet are pinned and SRI-checked
(
integrity="sha384-…"), so altered bytes are refused rather than executed. - Both API routes validate input against a strict allowlist and never interpolate user input into an upstream URL, and they reject cross-origin callers - they exist for this page, not as a public proxy.
- No secrets, tokens or account identifiers are in the repo; the app needs
none.
.dev.varsand.wrangler/are gitignored. - No analytics. The page connects to exactly three hosts: itself (static assets
and
/api/*),api.data.gov.my(every dataset, health included) andtile.openstreetmap.org(map tiles in the Live section). Every library is self-hosted. The "Buy me a coffee" button is a plain link, not their tracking widget. Earthquake and live-vehicle cards include an outbound Google Maps link - a normal link the reader chooses to follow, not a request the page makes.
Notes that cost time to discover - all verified against live responses.
A trailing slash is required. GET /weather/forecast?limit=1 responds
301 to /weather/forecast/?limit=1. Every URL the app builds ends in /, so
no request wastes a redirect hop against the rate limit.
Rate limit: 4 requests per minute, per API family (weather,
data-catalogue, opendosm, gtfs-static, gtfs-realtime). The app:
- fetches each family once per session, strictly sequentially, ~1.2 s apart;
- never lets two calls to the same family land within 3 s - bursts trip the limiter even when the per-minute count is legal;
- enforces a rolling 4-per-60 s token bucket per family;
- caches results in memory +
localStoragefor 15 minutes; - never polls. Refresh is manual.
- retries transient failures once (network blips and 5xx get a 1.5 s / 2.5 s pause, then a second attempt); 429 and 404 are not retried - the first is a quota state a retry won't fix, the second is permanent. A section that still fails shows an error box with a manual Retry button, keeping any stale content on screen.
Total cold load: 3 requests - weather 3 - plus the real-time feeds
(transport GTFS + live vehicles, which must stay live) and the static
health.json / slow.json (same-origin, produced by the daily collectors,
no API budget spent). GitHub is not called at all. Only weather + fuel (fuel
reads slow.json) are fetched on first paint; everything else is lazy-loaded
as its section scrolls into view, so the first screen renders before the
slowest datasets arrive.
Dataset ids that exist (many plausible ones do not - an unknown id returns
404 with {"status_code":404,...}):
data-catalogue:fuelprice,hh_income,exchangerates,interestrates,poskod- plusarrivals,passports,birthsandmnha, which exist but are frozen (see Data freshness below), andblood_donations,organ_pledges,pekab40_screenings, which the KKM collector uses (see above)opendosm:cpi_core,cpi_headline,lfs_month,gdp_qtr_real,population_malaysia- Not found:
electrictariff,watertariff,interestrate,unemployment,cpi_2d,opr
Gotchas in the catalogue datasets (all verified against live responses):
interestrateshas nooprseries - the rate codes are lending and deposit rates (br,alr,sr,fdr_*,wabr…). The Finance section charts the commercial base rate (bank=commercial, rate=br) instead, which tracks the OPR, and says so in the UI.exchangeratespublishes each month five times (start/low/high/avg/end); filter toindicator=avgfor a single monthly series.poskodhas nodatecolumn, sosort=-datefails on it (columns arecity,state,postcode).population_malaysiais published in thousands of people, one row per year × sex × ethnicity × age band. Only onefilteris allowed per request, so narrow tofilter=overall@age(which drops ~95% of the rows) and split by sex and ethnicity in the browser.
Date-range filters use <YYYY-MM-DD>@<date_column> syntax, e.g.
?id=fuelprice&date_start=2021-01-01@date.
Server-side filtering works and is worth using:
?id=fuelprice&filter=level@series_type returns only the price levels,
halving the payload. Without it you also get change_weekly delta rows
interleaved on the same dates - charting the raw response draws a sawtooth
through zero.
Locations are keyed by location_id, not name. The forecast feed has 360
distinct ids but only ~284 distinct names: "Perlis" exists as a state
(St001), a district (Ds002) and a town (Tn001), each with its own
forecast. Keying by name silently merges them. Prefixes are St state,
Ds district, Tn town, Dv division, Rc recreation.
MET free text arrives with HTML entities already encoded (&), so it
must be decoded before escaping or readers see the raw entity. The warnings
endpoint also carries "No Advisory" all-clear notices with null validity
dates - new Date(null) is the epoch, which renders a bogus
"valid 01 Jan 1970". Both are handled.
GTFS static ships as ZIP. routes.txt column order differs between
agencies, so CSV is parsed by header name. For the bus feeds only
routes/trips/stops/agency are inflated - Prasarana's stop_times.txt
alone is 5.6 MB of the 8.8 MB archive and is never decompressed. The rail feed
(gtfs_rapid_rail_kl.zip) is the exception: its stop_times.txt is small
enough to read, which is how the LRT/MRT metro diagram gets the ordered
station sequence per line. It also carries the official route colors
(route_color) used to draw each line.
The rail feed is served by the same /gtfs-static/prasarana endpoint with
category=rapid-rail-kl - 187 stations across 8 lines (Ampang, Sri Petaling,
Kelana Jaya, Shah Alam, MRT Kajang, MRT Putrajaya, Monorail, BRT Sunway).
GTFS realtime (KTMB) legitimately returns zero vehicles outside service
hours. That is an empty state, not an error - the UI says so and shows the
feed's own timestamp as proof it responded. Rapid KL buses do not use the
GTFS-RT feed (it is frequently empty even mid-service); they come from
Prasarana's kiosk feed via /api/rapid instead - see the Worker section above.
Three things that would normally pull in a library:
- ZIP reading - a central-directory reader using the platform's
DecompressionStream('deflate-raw'). No JSZip. - GTFS-Realtime protobuf - a ~90-line wire-format reader (varint /
length-delimited / fixed32) covering just the
VehiclePositionsubset (used for KTMB trains; Rapid KL buses come through/api/rapidas plain JSON). No protobufjs, no.protofile. - Icons -
tools/make-icons.mjsrenders the PNGs with a hand-written encoder (zlib+ CRC32), supersampled 4× for clean edges.
Chart.js and Leaflet are the only runtime dependencies. They live in
public/vendor/ (self-hosted with subresource integrity, so there is no CDN
to go down and the app works fully offline) and are precached by the service
worker.
Several datasets are one JSON object per category per day - blood donations
alone are five keys over three years. denseDaily() collapses each key to a
start day plus a flat array of values, which is roughly a fifth the size and
keeps these sections inside the localStorage budget the other eight share.
Seven-day moving averages are computed at render time rather than cached, for
the same reason.
All preferences are stored in localStorage only - nothing leaves the device:
- Theme - dark, light, or follow the system, plus a persisted choice.
- Text size - a large-text mode for readability.
- Language - English or Bahasa Melayu for the interface. Weather warnings
use the API's native
_bmfields when available, and Malay forecast phrases are mapped to English when the interface language is English.
The live maps use OpenStreetMap tiles (© OpenStreetMap contributors), with a
CSS filter to keep them dark in dark mode. The img-src CSP allows
tile.openstreetmap.org for exactly that purpose.
Two layers, deliberately different:
- App (
localStorage, 15 min TTL) - drives freshness. Holds compacted view models, not raw responses: forecast strings are dictionary-compressed, and GTFS is reduced to counts plus stop coordinates before storage. A full cold load is a few hundred KB, well under quota, andcacheSetdegrades to memory-only if the quota is hit anyway. - Service worker - drives offline. Cache-first for the shell; API responses are cached on success and served only when the network fails.
The service worker deliberately does not use stale-while-revalidate for
api.data.gov.my. SWR fires a background revalidation on every cache hit,
which would silently double request volume against a 4/min limit and push the
app into 429. The app's 15-minute TTL is the "revalidate" half, moved into a
rate-limit-aware layer.
When changing a loader's return shape, bump
CK(themygov.cache.vN.prefix) inindex.html. Otherwise a visitor's 15-minute-old cache is replayed into a renderer that expects different fields. Old prefixes are purged automatically on load.
npx wrangler devServes on http://localhost:8788 with /api/reverse, /api/gtfs and
/api/fids all working locally - wrangler dev picks up the FIDS_API_KEY
variable declared in wrangler.jsonc automatically.
Regenerate icons after changing the palette:
node tools/make-icons.mjsThe site deploys automatically: this GitHub repo is connected to the
mygov Worker via Cloudflare's Workers Git integration (Workers & Pages →
mygov → Settings → Builds & Deployments). Every push to main triggers a new
production deploy - no workflow file, secrets, or manual steps required.
For manual deploys (e.g. testing a branch before pushing):
npx wrangler deployThe custom domain is attached in the Cloudflare dashboard under
Workers & Pages → mygov → Settings → Domains & Routes, which creates the
mygov CNAME on faizalmzain.com automatically. No secrets are required; the
only variable is FIDS_API_KEY (a public key, declared as a var in
wrangler.jsonc so wrangler deploy pushes it with the code).
A dataset earns its place by being current, not by being long. Deep history is welcome - a 1970-2026 population series is more useful than a short one - but a series whose last update is years old has nothing to compare against, and charting it invites readers to mistake stale numbers for today's.
So: the pill in each section header shows "data as of <date>" - the
newest row that section actually renders, not when we fetched it - and appends
"LOADERS[id].asOf(data); a section that returns nothing (a static
lookup, a live feed) keeps the old "updated HH:MM" fetch time instead.
Datasets that had stopped updating entirely were removed rather than captioned:
| Removed | Last update |
|---|---|
arrivals, passports (the whole Tourism section) |
2024-10-01 |
births |
2023-07-31 |
mnha (national health accounts) |
2022-01-01 |
population_state |
2023-01-01 |
poskod is exempt: it is a static reference table, not a time series, so it
has no publication date that can go stale. It defines no asOf, so its pill
keeps showing the fetch time.
Into an existing section - add one request to the relevant loader in
index.html, reduce it to a compact array, and render it. Mind the 4/min cap:
weather is the only family still hit live (3 of 4); the data-catalogue
and opendosm families are served by the daily collectors' static files, so a
new series there should be added to tools/collect_slow.py instead of the
browser loader - that keeps the visitor path static.
const x = await request("data-catalogue", "/data-catalogue", { id: "YOUR_ID" });As a new section - two registrations, and the rest is automatic:
SECTIONS.push({ id:"myid", label:"My Data", icon:"🧭", family:"data-catalogue" })
META.myid = { title, desc, how, eps:[...] }
LOADERS.myid = { load, render, after, asOf }
// after(data) feeds the top KPI row.
// asOf(data) returns the newest data date the section renders, for the
// "data as of …" pill. Omit it and the pill shows the fetch time.Nav entry, scroll-spy, skeleton, error/retry, caching and throttling are all
wired from those (new sections load eagerly; LAZY in index.html marks the
scroll-triggered ones). Browse available ids at
developer.data.gov.my.
Skip link, :focus-visible outlines throughout, aria-labels on icon-only
controls, keyboard-operable sortable table headers (Enter/Space),
aria-current on the active nav item, aria-sort on sorted columns, and
real <button>s for selectable weather rows with an aria-live region
announcing location changes. prefers-reduced-motion is honoured - it disables
chart animations, the KPI count-up and smooth scrolling. Every chart has a
"View data table" alternative for users who can't use tooltips. Body text is
#e8ecf4 on #0a0c10 (~16:1 contrast); the dimmest supporting text stays
above 4.5:1 in both themes.
Code: MIT. Data © Government of Malaysia, via the Open API - MET Malaysia, MOF, DOSM, KTMB and Prasarana. Reverse geocoding © OpenStreetMap contributors (ODbL).