Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cursor/rules/50-local-files.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ These paths stay on this machine. They are listed in `.gitignore`. Do not delete
- `detro.archives/` — later collectors. Read `detro.archives/README.md` and `detro.archives/CATALOG.md`. The app does not import this folder.
- `curls/` — saved request samples. Field notes are in `curls/STRUCTURE.md`. `docs/DMRC_API.md` records what those captures showed.
- `prompt.md` — original product brief. Shipped scope is in `docs/PRODUCT.md` and `AGENTS.md`.
- `.env` — local secrets (for example `STATUS_BAKE_TOKEN`). Never commit.

Committed data the app reads: `data/manifest.json`, `data/en/line_list.json`, `data/en/stations/`, `data/en/station_brief/`, `data/en/coordinates.json`, `data/en/journeys/` once the pack has been built, `data/en/platforms.json`, which `python3 src/lib/offline/build_platforms.py` builds from that pack, `data/en/status.json` and `data/en/status-history.json`, which `python3 src/lib/offline/build_status.py` packs from `metro_line` notes, linepage phrases, and the corporate service-update page. `tests/fixtures/dmrc/` stays too. Screen copy in `src/i18n/` is English only. Street tiles for the metro area are saved in `public/map-tiles/` by `python3 src/lib/offline/download_tiles.py`. Do not add zoom levels past 14, and do not download them from the service worker.
2 changes: 1 addition & 1 deletion .cursor/rules/60-deployment.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ When the user says to deploy, follow `.cursor/skills/deploy-detro/SKILL.md`. Ope

The Pages project `detro` already exists and is Direct Upload. Do not create a second project, do not connect Pages to Git, and do not upload with Wrangler from this machine.

Production is Cloudflare Pages only. No Vercel project, Pages Functions, Workers, R2, KV, D1, analytics, or a paid add-on.
Production for the app is Cloudflare Pages only. No Vercel project, Pages Functions, R2, KV, D1, analytics, or a paid add-on. One exception: Worker `detro-status-ping` (`workers/status-ping`) may cron-dispatch the Status bake. Do not add other Workers.

Do not deploy `data/en/routes/`, `data/en/fares/`, `data/en/first-last/`, `data/hi/`, `scripts/`, `detro.archives/`, `curls/`, or `prompt.md`.

Expand Down
7 changes: 2 additions & 5 deletions .github/workflows/status-bake.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
name: Status

# Renamed from status.yml so Actions creates a new workflow id (old path never got schedule).
# Clock is Cloudflare Worker detro-status-ping (cron → this dispatch).
# GitHub Actions schedule never registered on this repo; do not rely on it.
on:
# TEMP verify: every 10 minutes UTC. After a schedule run appears, restore:
# schedule: - cron: "30 2,8,14 * * *" # 08:00 / 14:00 / 20:00 IST
schedule:
- cron: "*/10 * * * *"
workflow_dispatch:

concurrency:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ node_modules
dist
.DS_Store
*.local
.env
.dev.vars
.wrangler
worker-configuration.d.ts
*.tsbuildinfo
__pycache__/
*.pyc
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ src/lib/play/ Play games. Questions are built only from saved data.
src/ui/ Screens. They consume transit types.
data/en/ English snapshot that ships: lines, stations, briefs, coordinates, journeys, platforms, status, status history.
public/map-tiles/ Street tiles for the metro area, through zoom 14.
workers/status-ping/ Cloudflare cron that dispatches the Status bake workflow (not part of the app shell).
```

Routes are calculated from station order. A packed journey supplies the fare, platform, and first and last train when that pair was saved. `python3 src/lib/offline/build_platforms.py` reads the packs and writes `data/en/platforms.json`, one boarding platform per station, line, and direction. `python3 src/lib/offline/build_status.py` writes `data/en/status.json` from live linepage phrases, metro_line notes, and the corporate service-update page, and unions each day’s closed stations into `data/en/status-history.json`. Closed gates cannot be boarded or left. Trains still pass. A transfer is allowed only when a note says the interchange facility is available. Plan still shows Least and Fewest for the asked pair, and Alternative via the one nearest open gate.
Expand All @@ -40,7 +41,7 @@ Routes are calculated from station order. A packed journey supplies the fare, pl
- The interface is English only. Do not add Hindi chrome, theme names, a language control, or Hindi strings in `src/i18n/`. `data/hi/` and the download scripts stay on this machine. The app does not load them.
- Do not load fonts from a CDN. Vendored font files are allowed.
- Do not claim live arrivals, live lifts, or official affiliation.
- Do not push or open a pull request until the user has verified the change locally and said to deploy. A nit that cannot change the site stays local until the next product change. Follow `docs/DEPLOYMENT.md`. When the user says to deploy, follow `.cursor/skills/deploy-detro/SKILL.md`. Do not add analytics, ads, or a paid service.
- Do not push or open a pull request until the user has verified the change locally and said to deploy. A nit that cannot change the site stays local until the next product change. Follow `docs/DEPLOYMENT.md`. When the user says to deploy, follow `.cursor/skills/deploy-detro/SKILL.md`. Do not add analytics, ads, or a paid service. The only Cloudflare Worker allowed is `detro-status-ping` for Status bake dispatch.

## Local files

Expand All @@ -50,6 +51,7 @@ These stay on the machine and out of Git. Do not delete them.
- `data/en/routes/`, `data/en/fares/`, `data/en/first-last/` — pair downloads. `python3 src/lib/offline/build_journeys.py` packs them into `data/en/journeys/`.
- `scripts/`, `detro.archives/`, `curls/`, `prompt.md` — collectors and the original brief. Helpers under `.cursor/skills/` may be committed.
- `data/download-*` and the unused English dumps listed in `.gitignore`.
- `.env` — local secrets. Never commit.

The same list is in `.cursor/rules/50-local-files.mdc`.

Expand Down
2 changes: 1 addition & 1 deletion docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The status bake collector is not the app. It may send the official `Origin` and

## Status

Closed gates are a showstopper. Status sits before Play. The pack is polled three times a day at 08:00, 14:00, and 20:00 Asia/Kolkata by GitHub Actions. Each bake also unions that day’s closed stations into `data/en/status-history.json` and refreshes the corporate service-update notice when it changed. A pull request opens when `status.json` or `status-history.json` changed, then merges through `check`. There is no R2, Worker, or Pages Function. Closed means no board and no alight. Trains still pass. Transfer is true only when a note says the interchange facility is available. A “will not be available” interchange note, or a silent closed station, blocks a change. Status names one nearest open station, not a list of gates. Plan keeps Least and Fewest for the asked pair and offers Alternative via that one open substitute. Location is requested only after “Use my location.” Coordinates are not stored.
Closed gates are a showstopper. Status sits before Play. The pack is polled three times a day at 08:00, 14:00, and 20:00 Asia/Kolkata. A Cloudflare Worker (`workers/status-ping`, cron `30 2,8,14 * * *` UTC) `POST`s GitHub `workflow_dispatch` for `.github/workflows/status-bake.yml`, because Actions `schedule` never registered on this repository. The bake itself still runs in Actions: `build_status.py`, then a pull request when `status.json` or `status-history.json` changed, then merge through `check`. Each bake also unions that day’s closed stations into `data/en/status-history.json` and refreshes the corporate service-update notice when it changed. The only Worker allowed is this status ping. There is no R2, KV, D1, Pages Function, or app-facing Worker. Closed means no board and no alight. Trains still pass. Transfer is true only when a note says the interchange facility is available. A “will not be available” interchange note, or a silent closed station, blocks a change. Status names one nearest open station, not a list of gates. Plan keeps Least and Fewest for the asked pair and offers Alternative via that one open substitute. Location is requested only after “Use my location.” Coordinates are not stored.

## No env file

Expand Down
8 changes: 6 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ The workflow needs two GitHub secrets, both free to create: `CLOUDFLARE_API_TOKE

The Status bake needs a third secret, `STATUS_BAKE_TOKEN`: a classic personal access token with the `repo` scope. The bake opens a pull request with that token so CI runs and production can upload. The default `GITHUB_TOKEN` cannot start workflows from its own push. Do not print the token and do not commit it.

### Status bake clock (Worker)

GitHub Actions `schedule` never registered on this repository. The clock is Worker `detro-status-ping` under `workers/status-ping/`: cron `30 2,8,14 * * *` UTC (08:00 / 14:00 / 20:00 Asia/Kolkata) `POST`s `workflow_dispatch` for `.github/workflows/status-bake.yml`. Put the same PAT on the Worker as secret `STATUS_BAKE_TOKEN` (`npx wrangler secret put STATUS_BAKE_TOKEN` from that folder). Deploy the Worker with Wrangler from this machine when the ping changes; do not upload the Pages site with Wrangler from this machine. See `workers/status-ping/README.md`.

Sources: [Pages limits](https://developers.cloudflare.com/pages/platform/limits/), [Pages pricing](https://developers.cloudflare.com/pages/functions/pricing/), [Direct Upload with CI](https://developers.cloudflare.com/pages/how-to/use-direct-upload-with-continuous-integration/).

## What must not be turned on
Expand All @@ -167,8 +171,8 @@ These are the switches that turn a free static site into a quota or a bill.
- A second Git connection inside Cloudflare Pages, which would build again and deploy without the test job
- Vercel Analytics, Speed Insights, and Image Optimization, if a Vercel project is ever added
- Cloudflare Web Analytics, Zaraz, and Pages Functions
- Workers, R2, KV, and D1
- A server, a cron job, or a live call to the DMRC backend from the host
- Extra Workers beyond `detro-status-ping`, and R2, KV, and D1
- A live call to the DMRC backend from the Pages host or from any Worker (the status ping only calls GitHub)

The installed app already contains the station lists, the journey pack, the station briefs, and the street tiles. The host only has to serve files.

Expand Down
2 changes: 1 addition & 1 deletion docs/DMRC_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ Live `GET /api/v2/en/metro_line/{code}` and `GET /api/v2/en/station_by_line_line

That day’s line_list rows were all `"Normal Service"`. Linepage station phrases were `"Station Open"` or `"Station Closed"`: 225 open rows, 61 closed rows, 45 unique station codes (an interchange is listed on each line). `stations_updates` is `""` on a quiet line and an array when there are notes. Notes attach by station code. Rajiv Chowk’s note says the interchange facility is available. New Delhi’s note says the interchange will not be available. A closed station with no interchange sentence is silent.

Gate-closed is not skip-station. The pack keeps closed stations only. A missing code is open. `python3 src/lib/offline/build_status.py` writes `data/en/status.json` when the closed list or service-update notice changed, and merges that Asia/Kolkata day’s closed stations into `data/en/status-history.json` (union across bakes). It also fetches `GET /api/v2/en/corporate/service-update/` with the official `Origin` and `Referer` from `curls/service-status`. GitHub Actions polls three times a day at 08:00, 14:00, and 20:00 Asia/Kolkata. A failed bake keeps the previous files and clock.
Gate-closed is not skip-station. The pack keeps closed stations only. A missing code is open. `python3 src/lib/offline/build_status.py` writes `data/en/status.json` when the closed list or service-update notice changed, and merges that Asia/Kolkata day’s closed stations into `data/en/status-history.json` (union across bakes). It also fetches `GET /api/v2/en/corporate/service-update/` with the official `Origin` and `Referer` from `curls/service-status`. A Cloudflare Worker cron dispatches the Status bake workflow three times a day at 08:00, 14:00, and 20:00 Asia/Kolkata. A failed bake keeps the previous files and clock.

## Integration limits

Expand Down
2 changes: 1 addition & 1 deletion docs/plans/2026-10-10-status-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Notes attach by station code across lines. If notes disagree, unavailable wins,

## Refresh

GitHub Actions fetches `line_list`, then `metro_line/{code}` and `station_by_line_linepage/{code}` for each line, three times a day at 08:00, 14:00, and 20:00 Asia/Kolkata (`30 2,8,14 * * *` UTC). `python3 src/lib/offline/build_status.py` writes `data/en/status.json` only when the closed-station map changed. A timestamp-only poll leaves the file alone, so no pull request opens. When there is a change, the bake opens `chore/status-bake` with the `STATUS_BAKE_TOKEN` secret (a classic PAT with `repo` scope), waits for `check`, and merges to `main` so production uploads. The default `GITHUB_TOKEN` cannot start CI from its own push. There is no R2, Worker, or Pages Function. GitHub may start a scheduled run a few minutes late under load.
A Cloudflare Worker (`workers/status-ping`) fires three times a day at 08:00, 14:00, and 20:00 Asia/Kolkata (`30 2,8,14 * * *` UTC) and `POST`s GitHub `workflow_dispatch` for the Status bake. Actions `schedule` never registered on this repo. The bake job fetches `line_list`, then `metro_line/{code}` and `station_by_line_linepage/{code}` for each line. `python3 src/lib/offline/build_status.py` writes `data/en/status.json` only when the closed-station map or the service-update notice content (title / text / dated) changed. Pack and notice fetch clocks alone leave the file alone, so no pull request opens. When there is a change, the bake opens `chore/status-bake` with the `STATUS_BAKE_TOKEN` secret (a classic PAT with `repo` scope), waits for `check`, and merges to `main` so production uploads. The default `GITHUB_TOKEN` cannot start CI from its own push. No R2, KV, D1, or Pages Function. The only Worker is this status ping.

A failed fetch keeps the previous file and clock. Silence is not treated as all-clear.

Expand Down
2 changes: 1 addition & 1 deletion docs/plans/2026-10-12-status-history-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ Date: 2026-10-12. Extends Status with a daily closed-station history chart and a

## 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`.
`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 whose readable content changed (closed map, notice title/text/dated, or history days). Fetch clocks alone do not rewrite `status.json`. GitHub Actions opens a PR when either file differs from `main`.
20 changes: 18 additions & 2 deletions src/lib/offline/build_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ def main() -> int:

out = {"fetchedAt": fetched_at, "stations": pack["stations"]}
if service:
out["serviceUpdate"] = service
previous_service = previous.get("serviceUpdate") if previous else None
# Keep the prior notice object (including its fetch clock) when title/text/dated match.
if normalize_service(previous_service) == normalize_service(service):
out["serviceUpdate"] = previous_service
else:
out["serviceUpdate"] = service
if previous is not None and previous.get("stations") == out["stations"]:
out["fetchedAt"] = previous.get("fetchedAt") or fetched_at

Expand Down Expand Up @@ -81,10 +86,21 @@ def main() -> int:
return 1


def normalize_service(service: object) -> object | None:
"""Compare notice content only. Fetch clocks alone must not open a bake PR."""
if not isinstance(service, dict):
return None if service is None else service
return {
"title": service.get("title"),
"text": service.get("text"),
"dated": service.get("dated"),
}


def normalize_status(pack: dict) -> dict:
return {
"stations": pack.get("stations") or {},
"serviceUpdate": pack.get("serviceUpdate"),
"serviceUpdate": normalize_service(pack.get("serviceUpdate")),
}


Expand Down
70 changes: 70 additions & 0 deletions src/lib/offline/test_build_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Unit checks for status bake equality (no network)."""

from __future__ import annotations

import unittest

from build_status import normalize_service, normalize_status


class NormalizeStatusTest(unittest.TestCase):
def test_service_fetch_clock_alone_is_equal(self) -> None:
previous = {
"fetchedAt": "2026-10-12T23:23:29.000",
"stations": {},
"serviceUpdate": {
"title": "SERVICE UPDATE",
"text": "Gates note",
"dated": "12.10.2026",
"fetchedAt": "2026-10-12T23:23:29.000",
},
}
current = {
"fetchedAt": "2026-10-12T23:23:29.000",
"stations": {},
"serviceUpdate": {
"title": "SERVICE UPDATE",
"text": "Gates note",
"dated": "12.10.2026",
"fetchedAt": "2026-10-13T09:23:44.000",
},
}
self.assertEqual(normalize_status(previous), normalize_status(current))
self.assertEqual(
normalize_service(previous["serviceUpdate"]),
normalize_service(current["serviceUpdate"]),
)

def test_service_text_change_differs(self) -> None:
previous = {
"stations": {},
"serviceUpdate": {
"title": "SERVICE UPDATE",
"text": "Old",
"dated": "12.10.2026",
"fetchedAt": "2026-10-12T23:23:29.000",
},
}
current = {
"stations": {},
"serviceUpdate": {
"title": "SERVICE UPDATE",
"text": "New",
"dated": "12.10.2026",
"fetchedAt": "2026-10-13T09:23:44.000",
},
}
self.assertNotEqual(normalize_status(previous), normalize_status(current))

def test_closed_map_change_differs(self) -> None:
previous = {"stations": {}, "serviceUpdate": None}
current = {
"stations": {"AIIMS": {"phrase": "Station Closed", "lineCodes": ["LN2"]}},
"serviceUpdate": None,
}
self.assertNotEqual(normalize_status(previous), normalize_status(current))


if __name__ == "__main__":
unittest.main()
25 changes: 25 additions & 0 deletions workers/status-ping/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# detro-status-ping

Cloudflare Worker cron that `POST`s GitHub `workflow_dispatch` for `.github/workflows/status-bake.yml` at 08:00 / 14:00 / 20:00 Asia/Kolkata (`30 2,8,14 * * *` UTC).

GitHub Actions `schedule` never registered on this repo. The bake job itself still runs in Actions.

## Setup

```bash
cd workers/status-ping
npm install
npx wrangler login # once
npx wrangler deploy
# Put the same classic PAT used as the repo secret STATUS_BAKE_TOKEN:
# printf '%s' "$STATUS_BAKE_TOKEN" | npx wrangler secret put STATUS_BAKE_TOKEN
```

Manual test after deploy (needs a workers.dev route or a custom route — enable `workers_dev` temporarily if needed):

```bash
curl -X POST "https://detro-status-ping.<account>.workers.dev" \
-H "Authorization: Bearer $STATUS_BAKE_TOKEN"
```

Expect HTTP 204 and a Status run with event `workflow_dispatch` on GitHub.
Loading
Loading