From 4542eab710efb7c83f07a17d6d97f81b8661f48e Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:06 +0530 Subject: [PATCH 01/21] docs(status): record history and service-update design --- .../plans/2026-10-12-status-history-design.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/plans/2026-10-12-status-history-design.md diff --git a/docs/plans/2026-10-12-status-history-design.md b/docs/plans/2026-10-12-status-history-design.md new file mode 100644 index 0000000..dd07983 --- /dev/null +++ b/docs/plans/2026-10-12-status-history-design.md @@ -0,0 +1,25 @@ +# Status history and service update + +Date: 2026-10-12. Extends Status with a daily closed-station history chart and a baked corporate service-update notice. + +## Decisions + +- History stores **count plus station codes and names** for each Asia/Kolkata calendar day. +- Retention is **complete** in `data/en/status-history.json`. The chart range control is **dynamic**: All days always; last week / month / year only once history reaches that far; years present in the pack always listed. +- Layout: two top panes (lookup + live board), then a **centered** history card and **centered** service-update card under them. +- Chart columns are fixed width with visible date labels; they do not stretch full viewport. Tap a day opens a **dialog** with that day’s closed stations. +- A day’s station list is the **union** of every successful bake that day. +- **Service update** sits under the history card. Omit it when the bake has no usable text. +- Still static: bake into `data/en/status.json` and `data/en/status-history.json`. No R2, Worker, or live call from the app. +- Collector may send official `Origin` / `Referer` for `corporate/service-update/` as in `curls/service-status`. The app does not. + +## Files + +| File | Role | +| --- | --- | +| `data/en/status.json` | Live closed map, `fetchedAt`, optional `serviceUpdate` | +| `data/en/status-history.json` | `{ "days": [ { "date", "count", "stations": [{ "code", "name" }] } ] }` | + +## Bake + +`python3 src/lib/offline/build_status.py` still packs linepage closed phrases. It also fetches service-update, strips HTML to plain text, merges today’s union into history, and writes only files that changed. GitHub Actions opens a PR when either file differs from `main`. From 59ea1d33642be9e13873e0ad36c3e156b7b1b8ee Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:06 +0530 Subject: [PATCH 02/21] feat(status): bake daily history and service update --- src/lib/offline/build_status.py | 235 ++++++++++++++++++++++++++++++-- 1 file changed, 227 insertions(+), 8 deletions(-) diff --git a/src/lib/offline/build_status.py b/src/lib/offline/build_status.py index d6de4e0..bf66936 100644 --- a/src/lib/offline/build_status.py +++ b/src/lib/offline/build_status.py @@ -1,19 +1,24 @@ #!/usr/bin/env python3 -"""Pack closed-station status from metro_line notes and linepage phrases.""" +"""Pack closed-station status, daily history, and corporate service update.""" from __future__ import annotations import argparse +import html as html_lib import json +import re import sys import urllib.error import urllib.request from datetime import datetime +from html.parser import HTMLParser from pathlib import Path from zoneinfo import ZoneInfo ROOT = Path(__file__).resolve().parents[3] OUT = ROOT / "data" / "en" / "status.json" +HISTORY_OUT = ROOT / "data" / "en" / "status-history.json" +STATIONS_DIR = ROOT / "data" / "en" / "stations" BASE = "https://backend.delhimetrorail.com/api/v2/en" HEADERS = { "Accept": "application/json", @@ -21,6 +26,7 @@ "Referer": "https://delhimetrorail.com/", "User-Agent": "detro-status-bake", } +DATE_RE = re.compile(r"Dated:\s*(\d{1,2}\.\d{1,2}\.\d{4})", re.I) def main() -> int: @@ -28,23 +34,60 @@ def main() -> int: parser.add_argument("--from-dir", type=Path, help="Folder of saved line_list, metro/{code}.json, linepage/{code}.json") args = parser.parse_args() try: - fetched_at = datetime.now(ZoneInfo("Asia/Kolkata")).strftime("%Y-%m-%dT%H:%M:%S.000") + now = datetime.now(ZoneInfo("Asia/Kolkata")) + fetched_at = now.strftime("%Y-%m-%dT%H:%M:%S.000") + day = now.strftime("%Y-%m-%d") lines = read_dir(args.from_dir) if args.from_dir else fetch_live() pack = pack_status(fetched_at, lines) + service = ( + read_service_file(args.from_dir / "service-update.json", fetched_at) + if args.from_dir + else fetch_service_update(fetched_at) + ) + names = load_station_names() previous = read_pack(OUT) - if previous is not None and previous.get("stations") == pack["stations"]: - kept = previous.get("fetchedAt") or "previous stamp" - print(f"unchanged {len(pack['stations'])} closed stations (kept {kept})", flush=True) - return 0 + previous_history = read_history(HISTORY_OUT) + history = merge_history(previous_history, day, pack["stations"], names) + + out = {"fetchedAt": fetched_at, "stations": pack["stations"]} + if service: + out["serviceUpdate"] = service + if previous is not None and previous.get("stations") == out["stations"]: + out["fetchedAt"] = previous.get("fetchedAt") or fetched_at + + status_changed = previous is None or normalize_status(previous) != normalize_status(out) + history_changed = previous_history != history + OUT.parent.mkdir(parents=True, exist_ok=True) - OUT.write_text(json.dumps(pack, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") - print(f"wrote {len(pack['stations'])} closed stations at {fetched_at}", flush=True) + if status_changed: + OUT.write_text(json.dumps(out, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"wrote {len(out['stations'])} closed stations at {out['fetchedAt']}", flush=True) + else: + print(f"unchanged {len(out['stations'])} closed stations (kept {out['fetchedAt']})", flush=True) + + if history_changed: + HISTORY_OUT.write_text( + json.dumps(history, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + today = next((row for row in history["days"] if row["date"] == day), None) + count = today["count"] if today else 0 + print(f"wrote history through {day} ({count} closed that day)", flush=True) + else: + print(f"history unchanged through {day}", flush=True) return 0 except Exception as error: print(f"status bake failed: {error}", file=sys.stderr) return 1 +def normalize_status(pack: dict) -> dict: + return { + "stations": pack.get("stations") or {}, + "serviceUpdate": pack.get("serviceUpdate"), + } + + def read_pack(path: Path) -> dict | None: if not path.exists(): return None @@ -55,6 +98,85 @@ def read_pack(path: Path) -> dict | None: return data if isinstance(data, dict) else None +def read_history(path: Path) -> dict: + raw = read_pack(path) + if not raw or not isinstance(raw.get("days"), list): + return {"days": []} + days = [] + for row in raw["days"]: + parsed = read_history_day(row) + if parsed: + days.append(parsed) + days.sort(key=lambda item: item["date"]) + return {"days": days} + + +def read_history_day(row: object) -> dict | None: + if not isinstance(row, dict): + return None + date = row.get("date") + if not isinstance(date, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): + return None + stations = [] + raw_stations = row.get("stations") + if isinstance(raw_stations, list): + for item in raw_stations: + if not isinstance(item, dict): + continue + code = item.get("code") + name = item.get("name") + if isinstance(code, str) and code and isinstance(name, str) and name: + stations.append({"code": code, "name": name}) + stations = unique_stations(stations) + return {"date": date, "count": len(stations), "stations": stations} + + +def merge_history(history: dict, day: str, closed: dict, names: dict[str, str]) -> dict: + by_code: dict[str, str] = {} + existing = next((row for row in history.get("days", []) if row.get("date") == day), None) + if existing: + for item in existing.get("stations", []): + by_code[item["code"]] = item["name"] + for code in closed: + by_code[code] = names.get(code) or by_code.get(code) or code + stations = unique_stations([{"code": code, "name": name} for code, name in by_code.items()]) + days = [row for row in history.get("days", []) if row.get("date") != day] + if stations: + days.append({"date": day, "count": len(stations), "stations": stations}) + days.sort(key=lambda item: item["date"]) + return {"days": days} + + +def unique_stations(rows: list[dict]) -> list[dict]: + seen: dict[str, str] = {} + for row in rows: + code = row["code"] + if code not in seen: + seen[code] = row["name"] + return [{"code": code, "name": seen[code]} for code in sorted(seen)] + + +def load_station_names() -> dict[str, str]: + names: dict[str, str] = {} + if not STATIONS_DIR.exists(): + return names + for path in sorted(STATIONS_DIR.glob("*.json")): + try: + rows = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(rows, list): + continue + for row in rows: + if not isinstance(row, dict): + continue + code = row.get("station_code") + name = row.get("station_name") + if isinstance(code, str) and code and isinstance(name, str) and name: + names[code] = name + return names + + def fetch_live() -> list[dict]: line_list = get_json(f"{BASE}/line_list") if not isinstance(line_list, list): @@ -76,6 +198,103 @@ def fetch_live() -> list[dict]: return rows +def fetch_service_update(fetched_at: str) -> dict | None: + payload = get_json(f"{BASE}/corporate/service-update/") + return read_service_payload(payload, fetched_at) + + +def read_service_file(path: Path, fetched_at: str) -> dict | None: + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return read_service_payload(payload, fetched_at) + + +def read_service_payload(payload: object, fetched_at: str) -> dict | None: + row = None + if isinstance(payload, list): + row = next((item for item in payload if isinstance(item, dict)), None) + elif isinstance(payload, dict): + row = payload + if not row: + return None + title = clean_text(str(row.get("title") or "Service update")) + text = plain_from_html(str(row.get("content") or "")) + if not text: + return None + dated_match = DATE_RE.search(text) or DATE_RE.search(str(row.get("content") or "")) + dated = dated_match.group(1) if dated_match else None + return { + "title": title or "Service update", + "text": text, + "dated": dated, + "fetchedAt": fetched_at, + } + + +def plain_from_html(raw: str) -> str: + parser = _TextExtractor() + parser.feed(raw) + parser.close() + text = html_lib.unescape(parser.text()) + text = re.sub(r"[ \t]+\n", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + text = re.sub(r"[ \t]{2,}", " ", text) + lines = [] + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + lower = stripped.lower() + if lower in {"back", "delhimetro"}: + continue + if lower == "service update" and (not lines or lines[-1].lower() == "service update"): + continue + lines.append(stripped) + while lines and lines[0].lower() == "service update": + lines.pop(0) + return "\n".join(lines).strip() + + +class _TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._chunks: list[str] = [] + self._skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + classes = " ".join(value for key, value in attrs if key == "class" and value).lower() + if tag in {"script", "style"} or "d-none" in classes.split(): + self._skip_depth += 1 + return + if self._skip_depth: + self._skip_depth += 1 + return + if tag in {"p", "div", "br", "h1", "h2", "h3", "h4", "li", "tr"}: + self._chunks.append("\n") + + def handle_endtag(self, tag: str) -> None: + if self._skip_depth: + self._skip_depth -= 1 + return + if tag in {"p", "div", "h1", "h2", "h3", "h4", "li", "tr"}: + self._chunks.append("\n") + + def handle_data(self, data: str) -> None: + if not self._skip_depth and data: + self._chunks.append(data) + + def text(self) -> str: + return "".join(self._chunks) + + +def clean_text(value: str) -> str: + return " ".join(value.split()).strip() + + def read_dir(folder: Path) -> list[dict]: line_list = json.loads((folder / "line_list.json").read_text(encoding="utf-8")) rows = [] From 4382216562164063a89159abf90f73764ad4d0dc Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 03/21] chore(status): add packed closure history --- data/en/status-history.json | 220 ++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 data/en/status-history.json diff --git a/data/en/status-history.json b/data/en/status-history.json new file mode 100644 index 0000000..1643c3e --- /dev/null +++ b/data/en/status-history.json @@ -0,0 +1,220 @@ +{ + "days": [ + { + "count": 45, + "date": "2026-10-10", + "stations": [ + { + "code": "AHRM", + "name": "ASHRAM" + }, + { + "code": "AZU", + "name": "AZADPUR" + }, + { + "code": "BIMR", + "name": "HAIDERPUR BADLI MOR" + }, + { + "code": "BRKR", + "name": "BARAKHAMBA ROAD" + }, + { + "code": "CTST", + "name": "CENTRAL SECRETARIAT" + }, + { + "code": "CWBR", + "name": "CHAWRI BAZAR" + }, + { + "code": "DLIG", + "name": "DELHI GATE" + }, + { + "code": "GTBR", + "name": "GURU TEG BAHADUR NAGAR" + }, + { + "code": "HKS", + "name": "HAUZ KHAS" + }, + { + "code": "IDPT", + "name": "INDRAPRASTHA" + }, + { + "code": "ILOK", + "name": "INDERLOK" + }, + { + "code": "INA", + "name": "DILLI HAAT - INA" + }, + { + "code": "ITO", + "name": "ITO" + }, + { + "code": "JAMD", + "name": "JAMA MASJID" + }, + { + "code": "JANR", + "name": "JAMIA MILLIA ISLAMIA" + }, + { + "code": "JB", + "name": "JOR BAGH" + }, + { + "code": "JFRB", + "name": "JAFRABAD" + }, + { + "code": "JGPA", + "name": "JANGPURA" + }, + { + "code": "JGPI", + "name": "JAHANGIRPURI" + }, + { + "code": "JNPH", + "name": "JANPATH" + }, + { + "code": "JW", + "name": "JHANDEWALAN" + }, + { + "code": "KB", + "name": "KAROL BAGH" + }, + { + "code": "KG", + "name": "KASHMERE GATE" + }, + { + "code": "KJKS", + "name": "KHAJURI KHAS" + }, + { + "code": "KM", + "name": "KHAN MARKET" + }, + { + "code": "LJPN", + "name": "LAJPAT NAGAR" + }, + { + "code": "LKM", + "name": "LOK KALYAN MARG" + }, + { + "code": "MDHS", + "name": "MANDI HOUSE" + }, + { + "code": "MKPR", + "name": "MAJLIS PARK" + }, + { + "code": "MUPR", + "name": "MAUJPUR-BABARPUR" + }, + { + "code": "NDI", + "name": "NEW DELHI (Yellow & Airport Line)" + }, + { + "code": "NSHP", + "name": "NETAJI SUBHASH PLACE" + }, + { + "code": "PTCK", + "name": "PATEL CHOWK" + }, + { + "code": "PTMD", + "name": "SUPREME COURT" + }, + { + "code": "RCK", + "name": "RAJIV CHOWK" + }, + { + "code": "RI", + "name": "RITHALA" + }, + { + "code": "RKAM", + "name": "RAMAKRISHNA ASHRAM MARG" + }, + { + "code": "RP", + "name": "RAJENDRA PLACE" + }, + { + "code": "SHD", + "name": "SHAHDARA" + }, + { + "code": "SJSU", + "name": "SHIVAJI STADIUM" + }, + { + "code": "SLAP", + "name": "SEELAMPUR" + }, + { + "code": "UDB", + "name": "SEVA TEERTH (earlier UDYOG BHAWAN))" + }, + { + "code": "VW", + "name": "VISHWAVIDYALAYA" + }, + { + "code": "WC", + "name": "WELCOME" + }, + { + "code": "YMVR", + "name": "YAMUNA VIHAR" + } + ] + }, + { + "count": 6, + "date": "2026-10-12", + "stations": [ + { + "code": "BRKR", + "name": "BARAKHAMBA ROAD" + }, + { + "code": "CTST", + "name": "CENTRAL SECRETARIAT" + }, + { + "code": "JNPH", + "name": "JANPATH" + }, + { + "code": "MDHS", + "name": "MANDI HOUSE" + }, + { + "code": "PTCK", + "name": "PATEL CHOWK" + }, + { + "code": "RCK", + "name": "RAJIV CHOWK" + } + ] + } + ] +} From 4d28d331028561d9b5a17812f8dea6ada0122c10 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 04/21] chore(status): bake closures and service update --- data/en/status.json | 74 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/data/en/status.json b/data/en/status.json index 6aaa921..6dd958f 100644 --- a/data/en/status.json +++ b/data/en/status.json @@ -1,4 +1,74 @@ { - "fetchedAt": "2026-10-12T00:06:41.000", - "stations": {} + "fetchedAt": "2026-10-12T08:10:01.000", + "serviceUpdate": { + "dated": "12.10.2026", + "fetchedAt": "2026-10-12T08:33:04.000", + "text": "New Delhi, Dated: 12.10.2026\nDue to security reasons, the entry and exit at the following metro stations will remain closed from 06:00 AM till 04:00 PM on 12th October, 2026. Further the situation will be reviewed by the security agencies and necessary updates will be shared accordingly. However, interchange facility will remain available at all the concerned metro stations.\n1. Patel Chowk\n2. Janpath\n3. Rajiv Chowk\n4. Mandi House\n5. Central Secretariat\n6. Barakhamba Road", + "title": "SERVICE UPDATE" + }, + "stations": { + "BRKR": { + "end": "12-Oct-2026 06:00PM", + "lineCodes": [ + "LN3" + ], + "note": "Due to security reasons, Barakhambha Road metro Station is closed till further instructions.", + "phrase": "Station Closed", + "start": "12-Oct-2026 06:00AM", + "title": "Service Update" + }, + "CTST": { + "end": "12-Oct-2026 06:00PM", + "lineCodes": [ + "LN2", + "LN6" + ], + "note": "Due to security reasons, Central Secretariat Metro Station is closed till further instructions.\r\n\r\nHowever, Interchange facility is available at Central Secretariat .", + "phrase": "Station Closed", + "start": "12-Oct-2026 06:00AM", + "title": "Service Update" + }, + "JNPH": { + "end": "12-Oct-2026 06:00PM", + "lineCodes": [ + "LN6" + ], + "note": "Due to security reasons, Janpath metro Station is closed till further instructions.", + "phrase": "Station Closed", + "start": "12-Oct-2026 06:00AM", + "title": "Service Update" + }, + "MDHS": { + "end": "12-Oct-2026 06:00PM", + "lineCodes": [ + "LN3", + "LN6" + ], + "note": "Due to security reasons, Mandi House Metro Station is closed till further instructions.\r\n\r\nHowever, Interchange facility is available at Mandi House .", + "phrase": "Station Closed", + "start": "12-Oct-2026 06:00AM", + "title": "Service Update" + }, + "PTCK": { + "end": "12-Oct-2026 06:00PM", + "lineCodes": [ + "LN2" + ], + "note": "Due to security reasons, Patel Chowk Station is closed till further instructions.", + "phrase": "Station Closed", + "start": "12-Oct-2026 06:00AM", + "title": "Service Update" + }, + "RCK": { + "end": "12-Oct-2026 06:00PM", + "lineCodes": [ + "LN2", + "LN3" + ], + "note": "Due to security reasons, Rajiv Chowk Metro Station is closed till further instructions.\r\n\r\nHowever, Interchange facility is available at Rajiv Chowk .", + "phrase": "Station Closed", + "start": "12-Oct-2026 06:00AM", + "title": "Service Update" + } + } } From 67c7aeec4b642ba715b9cbcc523d881066723bd1 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 05/21] chore(status): ship history in the bake workflow --- .github/workflows/status.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/status.yml b/.github/workflows/status.yml index cd903e6..0588371 100644 --- a/.github/workflows/status.yml +++ b/.github/workflows/status.yml @@ -1,9 +1,9 @@ name: Status on: - # Asia/Kolkata wall times: 06:00, 14:00, 20:00. Cron is UTC (IST = UTC+5:30). + # Asia/Kolkata wall times: 08:00, 14:00, 20:00. Cron is UTC (IST = UTC+5:30). schedule: - - cron: "30 0,8,14 * * *" + - cron: "30 2,8,14 * * *" workflow_dispatch: concurrency: @@ -32,18 +32,20 @@ jobs: echo "STATUS_BAKE_TOKEN is missing. Add a classic PAT with repo scope as that secret." >&2 exit 1 fi - if git diff --quiet -- data/en/status.json; then + if git diff --quiet -- data/en/status.json data/en/status-history.json; then echo "status pack unchanged" exit 0 fi cp data/en/status.json /tmp/status.json + cp data/en/status-history.json /tmp/status-history.json branch="chore/status-bake" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git fetch origin main git checkout -B "$branch" origin/main cp /tmp/status.json data/en/status.json - git add data/en/status.json + cp /tmp/status-history.json data/en/status-history.json + git add data/en/status.json data/en/status-history.json if git diff --cached --quiet; then echo "status pack unchanged on main" exit 0 @@ -60,11 +62,12 @@ jobs: --title "chore(status): bake station closures" \ --body "$(cat <<'EOF' ## Summary - - Refresh `data/en/status.json` from the DMRC bake + - Refresh `data/en/status.json` and `data/en/status-history.json` from the DMRC bake ## Test plan - [ ] Status pack loads after merge - [ ] Closed list matches the bake + - [ ] History chart reflects the day EOF )") From 89a9a6f685cdbba8bd7a7a8d81d9cd2c471e6e11 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 06/21] feat(status): add service update pack fields --- src/lib/status/pack.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/status/pack.ts b/src/lib/status/pack.ts index c3e786e..166b557 100644 --- a/src/lib/status/pack.ts +++ b/src/lib/status/pack.ts @@ -9,9 +9,17 @@ export type StatusStation = { lineCodes: string[] } +export type ServiceUpdate = { + title: string + text: string + dated: string | null + fetchedAt: string +} + export type StatusPack = { fetchedAt: string stations: Record + serviceUpdate?: ServiceUpdate | null } export type StatusLineInput = { From b8a9fe31ee58f6ebc12b9764a6b68a4f5aef8f1c Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 07/21] feat(status): add history read and chart helpers --- src/lib/status/history.ts | 153 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/lib/status/history.ts diff --git a/src/lib/status/history.ts b/src/lib/status/history.ts new file mode 100644 index 0000000..7631f82 --- /dev/null +++ b/src/lib/status/history.ts @@ -0,0 +1,153 @@ +export type HistoryStation = { code: string; name: string } + +export type HistoryDay = { + date: string + count: number + stations: HistoryStation[] +} + +export type StatusHistory = { + days: HistoryDay[] +} + +export type HistoryRange = "all" | "week" | "month" | "year" | number + +export type HistoryRangeOption = + | { value: "all"; labelKey: "all" } + | { value: "week"; labelKey: "week" } + | { value: "month"; labelKey: "month" } + | { value: "year"; labelKey: "year" } + | { value: number; labelKey: "calendar"; year: number } + +export function readHistory(raw: unknown): StatusHistory { + if (!raw || typeof raw !== "object") return { days: [] } + const row = raw as { days?: unknown } + if (!Array.isArray(row.days)) return { days: [] } + const days: HistoryDay[] = [] + for (const item of row.days) { + const day = readDay(item) + if (day) days.push(day) + } + days.sort((a, b) => a.date.localeCompare(b.date)) + return { days } +} + +export function historyYears(history: StatusHistory): number[] { + const years = new Set() + for (const day of history.days) { + const year = Number(day.date.slice(0, 4)) + if (Number.isFinite(year)) years.add(year) + } + return [...years].sort((a, b) => b - a) +} + +/** Only ranges the packed history can meaningfully support. */ +export function historyRangeOptions(history: StatusHistory, today = todayStamp()): HistoryRangeOption[] { + if (history.days.length === 0) return [] + const earliest = history.days[0]?.date + if (!earliest) return [] + const years = historyYears(history) + const options: HistoryRangeOption[] = [{ value: "all", labelKey: "all" }] + if (earliest <= shiftStamp(today, -6)) options.push({ value: "week", labelKey: "week" }) + if (earliest <= shiftStamp(today, -29)) options.push({ value: "month", labelKey: "month" }) + if (earliest <= shiftStamp(today, -365)) options.push({ value: "year", labelKey: "year" }) + for (const year of years) options.push({ value: year, labelKey: "calendar", year }) + return options +} + +export function formatHistoryDate(stamp: string): string { + const date = parseStamp(stamp) + if (!date) return stamp + return new Intl.DateTimeFormat("en-GB", { + timeZone: "UTC", + day: "numeric", + month: "short", + }).format(date) +} + +export function filterHistory( + history: StatusHistory, + range: HistoryRange, + today = todayStamp(), +): HistoryDay[] { + if (range === "all") return [...history.days] + if (typeof range === "number") { + const prefix = String(range) + return history.days.filter((day) => day.date.startsWith(prefix)) + } + const end = parseStamp(today) + if (!end) return [...history.days] + const start = new Date(end) + if (range === "week") start.setUTCDate(start.getUTCDate() - 6) + else if (range === "month") start.setUTCDate(start.getUTCDate() - 29) + else start.setUTCFullYear(start.getUTCFullYear() - 1) + const from = stampOf(start) + return history.days.filter((day) => day.date >= from && day.date <= today) +} + +export function chartSeries( + history: StatusHistory, + range: HistoryRange, + today = todayStamp(), +): Array<{ date: string; count: number }> { + const filtered = filterHistory(history, range, today) + if (range === "all" || typeof range === "number") { + return filtered.map((day) => ({ date: day.date, count: day.count })) + } + const byDate = new Map(history.days.map((day) => [day.date, day.count])) + const end = parseStamp(today) + if (!end) return filtered.map((day) => ({ date: day.date, count: day.count })) + const start = new Date(end) + if (range === "week") start.setUTCDate(start.getUTCDate() - 6) + else if (range === "month") start.setUTCDate(start.getUTCDate() - 29) + else start.setUTCFullYear(start.getUTCFullYear() - 1) + const series: Array<{ date: string; count: number }> = [] + for (let cursor = new Date(start); cursor.getTime() <= end.getTime(); cursor.setUTCDate(cursor.getUTCDate() + 1)) { + const date = stampOf(cursor) + series.push({ date, count: byDate.get(date) ?? 0 }) + } + return series +} + +export function todayStamp(now = new Date()): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Kolkata", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now) +} + +export function shiftStamp(stamp: string, dayDelta: number): string { + const date = parseStamp(stamp) + if (!date) return stamp + date.setUTCDate(date.getUTCDate() + dayDelta) + return stampOf(date) +} + +function readDay(raw: unknown): HistoryDay | null { + if (!raw || typeof raw !== "object") return null + const row = raw as { date?: unknown; stations?: unknown } + if (typeof row.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(row.date)) return null + const stations: HistoryStation[] = [] + if (Array.isArray(row.stations)) { + for (const item of row.stations) { + if (!item || typeof item !== "object") continue + const stop = item as { code?: unknown; name?: unknown } + if (typeof stop.code !== "string" || !stop.code) continue + if (typeof stop.name !== "string" || !stop.name) continue + stations.push({ code: stop.code, name: stop.name }) + } + } + stations.sort((a, b) => a.code.localeCompare(b.code)) + return { date: row.date, count: stations.length, stations } +} + +function parseStamp(stamp: string): Date | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(stamp)) return null + return new Date(`${stamp}T00:00:00.000Z`) +} + +function stampOf(date: Date): string { + return date.toISOString().slice(0, 10) +} From bdaf250acf92e80f70168ca793b07067cd7bc8db Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 08/21] test(status): cover history ranges and series --- src/lib/status/history.test.ts | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/lib/status/history.test.ts diff --git a/src/lib/status/history.test.ts b/src/lib/status/history.test.ts new file mode 100644 index 0000000..b40b8ec --- /dev/null +++ b/src/lib/status/history.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest" +import { chartSeries, filterHistory, historyRangeOptions, historyYears, readHistory } from "./history" + +const history = readHistory({ + days: [ + { date: "2026-10-05", stations: [{ code: "RCK", name: "RAJIV CHOWK" }] }, + { + date: "2026-10-12", + stations: [ + { code: "RCK", name: "RAJIV CHOWK" }, + { code: "PTCK", name: "PATEL CHOWK" }, + ], + }, + { date: "2025-03-01", stations: [{ code: "NDI", name: "NEW DELHI" }] }, + ], +}) + +const young = readHistory({ + days: [ + { + date: "2026-10-10", + stations: Array.from({ length: 45 }, (_, i) => ({ code: `C${i}`, name: `Station ${i}` })), + }, + { + date: "2026-10-12", + stations: [ + { code: "RCK", name: "RAJIV CHOWK" }, + { code: "PTCK", name: "PATEL CHOWK" }, + ], + }, + ], +}) + +describe("status history", () => { + it("reads count from stations", () => { + expect(history.days.map((day) => ({ date: day.date, count: day.count }))).toEqual([ + { date: "2025-03-01", count: 1 }, + { date: "2026-10-05", count: 1 }, + { date: "2026-10-12", count: 2 }, + ]) + }) + + it("lists years newest first", () => { + expect(historyYears(history)).toEqual([2026, 2025]) + }) + + it("offers only ranges the history can support", () => { + expect(historyRangeOptions(young, "2026-10-12").map((item) => item.value)).toEqual(["all", 2026]) + expect(historyRangeOptions(history, "2026-10-12").map((item) => item.value)).toEqual([ + "all", + "week", + "month", + "year", + 2026, + 2025, + ]) + }) + + it("filters last week and fills missing days with zero", () => { + expect(filterHistory(history, "week", "2026-10-12").map((day) => day.date)).toEqual(["2026-10-12"]) + const series = chartSeries(history, "week", "2026-10-12") + expect(series).toHaveLength(7) + expect(series[0]).toEqual({ date: "2026-10-06", count: 0 }) + expect(series.at(-1)).toEqual({ date: "2026-10-12", count: 2 }) + }) + + it("plots only recorded days for all", () => { + expect(chartSeries(young, "all", "2026-10-12")).toEqual([ + { date: "2026-10-10", count: 45 }, + { date: "2026-10-12", count: 2 }, + ]) + }) + + it("includes a day on the edge of the week window", () => { + expect(filterHistory(history, "week", "2026-10-11").map((day) => day.date)).toEqual(["2026-10-05"]) + }) + + it("filters a chosen year", () => { + expect(filterHistory(history, 2025, "2026-10-12").map((day) => day.date)).toEqual(["2025-03-01"]) + }) +}) From a56ce3ff752ce3bca7173d8b9533436eef5cc3b8 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 09/21] feat(status): load history and service update packs --- src/lib/status/load.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/lib/status/load.ts b/src/lib/status/load.ts index c93b96e..cdae842 100644 --- a/src/lib/status/load.ts +++ b/src/lib/status/load.ts @@ -1,18 +1,28 @@ -import type { StatusPack, StatusStation } from "./pack" +import type { StatusPack, StatusStation, ServiceUpdate } from "./pack" +import { readHistory, type StatusHistory } from "./history" const modules = import.meta.glob("../../../data/en/status.json", { eager: true, import: "default" }) as Record< string, unknown > +const historyModules = import.meta.glob("../../../data/en/status-history.json", { + eager: true, + import: "default", +}) as Record export function loadStatus(): StatusPack { const raw = Object.values(modules)[0] return readPack(raw) } +export function loadStatusHistory(): StatusHistory { + const raw = Object.values(historyModules)[0] + return readHistory(raw) +} + export function readPack(raw: unknown): StatusPack { if (!raw || typeof raw !== "object") return { fetchedAt: "", stations: {} } - const row = raw as { fetchedAt?: unknown; stations?: unknown } + const row = raw as { fetchedAt?: unknown; stations?: unknown; serviceUpdate?: unknown } const fetchedAt = typeof row.fetchedAt === "string" ? row.fetchedAt : "" const stations: Record = {} if (row.stations && typeof row.stations === "object") { @@ -21,7 +31,8 @@ export function readPack(raw: unknown): StatusPack { if (station) stations[code] = station } } - return { fetchedAt, stations } + const serviceUpdate = readServiceUpdate(row.serviceUpdate) + return serviceUpdate ? { fetchedAt, stations, serviceUpdate } : { fetchedAt, stations } } export function statusClock(fetchedAt: string): string { @@ -42,3 +53,15 @@ function readStation(value: unknown): StatusStation | null { lineCodes: Array.isArray(row.lineCodes) ? row.lineCodes.filter((item): item is string => typeof item === "string") : [], } } + +function readServiceUpdate(value: unknown): ServiceUpdate | null { + if (!value || typeof value !== "object") return null + const row = value as Record + if (typeof row.title !== "string" || typeof row.text !== "string" || !row.text.trim()) return null + return { + title: row.title.trim() || "Service update", + text: row.text.trim(), + dated: typeof row.dated === "string" && row.dated.trim() ? row.dated.trim() : null, + fetchedAt: typeof row.fetchedAt === "string" ? row.fetchedAt : "", + } +} From 76017bafde441ad159c7329821c053f4f71c83ac Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 10/21] test(status): cover service update pack loading --- src/lib/status/load.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/status/load.test.ts b/src/lib/status/load.test.ts index 6271c55..b748e95 100644 --- a/src/lib/status/load.test.ts +++ b/src/lib/status/load.test.ts @@ -5,11 +5,18 @@ describe("status pack file", () => { it("reads closed stations and treats a broken file as empty", () => { const pack = readPack({ fetchedAt: "2026-10-10T08:00:00.000", + serviceUpdate: { + title: "SERVICE UPDATE", + text: "Gates closed at Rajiv Chowk.", + dated: "10.10.2026", + fetchedAt: "2026-10-10T08:00:00.000", + }, stations: { RCK: { phrase: "Station Closed", note: "Stay on the train.", lineCodes: ["LN2"] }, }, }) expect(pack.stations.RCK.phrase).toBe("Station Closed") + expect(pack.serviceUpdate?.dated).toBe("10.10.2026") expect(statusClock(pack.fetchedAt)).toBe("2026-10-10 08:00:00") expect(readPack(null)).toEqual({ fetchedAt: "", stations: {} }) }) From 556879a4b83f4e9e715e4982fcb12e8c09ebf36c Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 11/21] feat(status): add history and service update copy --- src/i18n/copy.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/i18n/copy.ts b/src/i18n/copy.ts index 3fb4acb..aae7474 100644 --- a/src/i18n/copy.ts +++ b/src/i18n/copy.ts @@ -202,6 +202,25 @@ export type Copy = { statusOpenLabel: string statusCheckedLabel: string statusLineClosed: (count: number) => string + statusHistoryTitle: string + statusHistoryLead: string + statusHistoryRange: string + statusHistoryAll: string + statusHistoryWeek: string + statusHistoryMonth: string + statusHistoryYear: string + statusHistoryAxisClosed: string + statusHistoryAxisDate: string + statusHistoryHint: string + statusHistoryNone: string + statusHistoryClose: string + statusHistoryDay: (date: string, count: number) => string + statusServiceTitle: string + statusServiceNotice: string + statusServiceLive: string + statusServiceSource: string + statusServiceFetched: (when: string) => string + statusServiceDated: (dated: string) => string gateBefore: (name: string, line: string | null, km: number | null) => string gateAfter: (name: string, line: string | null, km: number | null) => string gateNear: (name: string, km: number | null) => string @@ -417,6 +436,26 @@ export const copy = { statusOpenLabel: "open", statusCheckedLabel: "checked", statusLineClosed: (count) => (count === 1 ? "1 closed" : `${count} closed`), + statusHistoryTitle: "Closures over time", + statusHistoryLead: "Closed gates recorded on each check day.", + statusHistoryRange: "History range", + statusHistoryAll: "All days", + statusHistoryWeek: "Last week", + statusHistoryMonth: "Last month", + statusHistoryYear: "Last year", + statusHistoryAxisClosed: "Closed", + statusHistoryAxisDate: "Date", + statusHistoryHint: "Tap a day to see which stations were closed.", + statusHistoryNone: "No closed stations recorded that day.", + statusHistoryClose: "Close", + statusHistoryDay: (date, count) => + count === 1 ? `${date} · 1 closed` : `${date} · ${count} closed`, + statusServiceTitle: "Service update", + statusServiceNotice: "Official notice", + statusServiceLive: "Live", + statusServiceSource: "Packed from Delhi Metro service updates. Not an official DMRC app.", + statusServiceFetched: (when) => when, + statusServiceDated: (dated) => dated, gateBefore: (name, line, km) => `Before${line ? ` on the ${line}` : ""}: ${name}${kmText(km)}`, gateAfter: (name, line, km) => `After${line ? ` on the ${line}` : ""}: ${name}${kmText(km)}`, gateNear: (name, km) => `Closest open gate: ${name}${kmText(km)}`, From df0d3866b15497c64820c52a2ba3296953041e81 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Mon, 12 Oct 2026 09:34:07 +0530 Subject: [PATCH 12/21] feat(status): add history chart and service update ui --- src/ui/StatusHistory.tsx | 361 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/ui/StatusHistory.tsx diff --git a/src/ui/StatusHistory.tsx b/src/ui/StatusHistory.tsx new file mode 100644 index 0000000..64fc34c --- /dev/null +++ b/src/ui/StatusHistory.tsx @@ -0,0 +1,361 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { createPortal } from "react-dom" +import { Check, ChevronDown, Radio, X } from "lucide-react" +import type { Copy } from "../i18n/copy" +import type { LineChip } from "../lib/transit/types" +import { + chartSeries, + formatHistoryDate, + historyRangeOptions, + type HistoryDay, + type HistoryRange, + type HistoryRangeOption, + type StatusHistory, +} from "../lib/status/history" +import { statusClock } from "../lib/status/load" + +export function StatusHistoryPanel({ + copy, + history, + lineByCode, +}: { + copy: Copy + history: StatusHistory + lineByCode: Map +}) { + const options = useMemo(() => historyRangeOptions(history), [history]) + const [range, setRange] = useState(() => options[0]?.value ?? "all") + const [picked, setPicked] = useState(null) + const series = useMemo(() => chartSeries(history, range), [history, range]) + const max = Math.max(1, ...series.map((row) => row.count)) + const ticks = useMemo(() => plotTicks(max), [max]) + const selected: HistoryDay | null = useMemo(() => { + if (!picked) return null + return history.days.find((day) => day.date === picked) ?? null + }, [history.days, picked]) + + useEffect(() => { + if (!options.some((item) => item.value === range)) { + setRange(options[0]?.value ?? "all") + setPicked(null) + } + }, [options, range]) + + if (history.days.length === 0) return null + + return ( +
+
+
+

{copy.statusHistoryTitle}

+

{copy.statusHistoryLead}

+
+ {options.length > 0 ? ( + { + setPicked(null) + setRange(next) + }} + /> + ) : null} +
+
+
+ {copy.statusHistoryAxisClosed} + +
+
+ {series.map((row) => { + const height = row.count === 0 ? 4 : Math.max(8, Math.round((row.count / max) * 100)) + const active = picked === row.date + const dayLabel = copy.statusHistoryDay(formatHistoryDate(row.date), row.count) + return ( + + ) + })} +
+
+
+ +

{copy.statusHistoryAxisDate}

+
+
+
+

{copy.statusHistoryHint}

+ {selected ? ( + setPicked(null)} /> + ) : null} +
+ ) +} + +function HistoryRangeMenu({ + copy, + options, + value, + onChange, +}: { + copy: Copy + options: HistoryRangeOption[] + value: HistoryRange + onChange: (next: HistoryRange) => void +}) { + const [open, setOpen] = useState(false) + const root = useRef(null) + const listId = "status-history-range-list" + const current = options.find((item) => sameRange(item.value, value)) ?? options[0] + const label = current ? optionLabel(copy, current) : copy.statusHistoryAll + + useEffect(() => { + if (!open) return + function onPointer(event: MouseEvent) { + if (!root.current?.contains(event.target as Node)) setOpen(false) + } + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false) + } + window.addEventListener("mousedown", onPointer) + window.addEventListener("keydown", onKey) + return () => { + window.removeEventListener("mousedown", onPointer) + window.removeEventListener("keydown", onKey) + } + }, [open]) + + return ( +
+ + {copy.statusHistoryRange} + + + {open ? ( +
    + {options.map((item) => { + const selected = sameRange(item.value, value) + return ( +
  • + +
  • + ) + })} +
+ ) : null} +
+ ) +} + +function StatusDayDialog({ + copy, + day, + lineByCode, + onClose, +}: { + copy: Copy + day: HistoryDay + lineByCode: Map + onClose: () => void +}) { + const box = useRef(null) + const title = copy.statusHistoryDay(formatHistoryDate(day.date), day.count) + + useEffect(() => { + const previous = document.body.style.overflow + document.body.style.overflow = "hidden" + box.current?.querySelector("button")?.focus() + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose() + } + window.addEventListener("keydown", onKey) + return () => { + document.body.style.overflow = previous + window.removeEventListener("keydown", onKey) + } + }, [onClose]) + + return createPortal( +
+ +
+ {day.stations.length === 0 ? ( +

{copy.statusHistoryNone}

+ ) : ( +
    + {day.stations.map((stop, index) => { + const lines = lineByCode.get(stop.code) ?? [] + const accent = lines[0]?.color + return ( +
  1. + {index + 1} + + + {stop.name} + +
  2. + ) + })} +
+ )} + + , + document.body, + ) +} + +function LineDots({ lines }: { lines: LineChip[] }) { + if (lines.length === 0) return null + return ( +