diff --git a/.cursor/rules/50-local-files.mdc b/.cursor/rules/50-local-files.mdc index a568caa..632ce0b 100644 --- a/.cursor/rules/50-local-files.mdc +++ b/.cursor/rules/50-local-files.mdc @@ -15,4 +15,4 @@ These paths stay on this machine. They are listed in `.gitignore`. Do not delete - `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`. -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, and `data/en/status.json`, which `python3 src/lib/offline/build_status.py` packs from `metro_line` notes and linepage phrases. `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. +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. 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 )") diff --git a/AGENTS.md b/AGENTS.md index 8c7e400..df3ebc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,11 +25,11 @@ src/lib/status/ Closed-gate pack. Missing codes stay open. src/lib/plan/ Plan state, saved trips, selection. 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. +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. ``` -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 and metro_line notes. 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. +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. ## Constraints 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" + } + ] + } + ] +} 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" + } + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 99a8d43..6b2786d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 06:00, 14:00, and 20:00 Asia/Kolkata by GitHub Actions. A pull request opens only when the closed list 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 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. ## No env file diff --git a/docs/DMRC_API.md b/docs/DMRC_API.md index b01c8d7..9d402d6 100644 --- a/docs/DMRC_API.md +++ b/docs/DMRC_API.md @@ -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` only when the closed list changed. GitHub Actions polls three times a day at 06:00, 14:00, and 20:00 Asia/Kolkata. A failed bake keeps the previous file 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`. 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. ## Integration limits diff --git a/docs/plans/2026-10-10-status-design.md b/docs/plans/2026-10-10-status-design.md index 474e1bd..a6e72bd 100644 --- a/docs/plans/2026-10-10-status-design.md +++ b/docs/plans/2026-10-10-status-design.md @@ -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 06:00, 14:00, and 20:00 Asia/Kolkata (`30 0,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. +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 failed fetch keeps the previous file and clock. Silence is not treated as all-clear. @@ -26,7 +26,7 @@ The app never sends the official site’s `Origin` or `Referer`. The bake collec ## Surfaces -- **Status** — closed and open counts, the pack clock, each closed station once, and one nearest open gate. +- **Status** — closed and open counts, the pack clock, each closed station once, one nearest open gate, a closures-over-time chart from `status-history.json`, and the baked corporate service update when present. - **Plan** — Least and Fewest still use the asked pair. Alternative is the ride via the one nearest open gate. - **Lines, City** — a closed mark on those stations. A blocked change is skipped. Trains still ride through. - **Saved** — a stored pair is recalculated when the saved ride violates access. Fare and timing may then stay missing. 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`. diff --git a/src/App.tsx b/src/App.tsx index 94b619f..687b32e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,7 @@ import { clearTrips, readTripQuery, readTrips, removeTrip, toggleTrip, tripQuery import type { Journey, Lang, Suggestion } from "./lib/transit/types" import coordinatesFile from "../data/en/coordinates.json" import { accessByCode } from "./lib/status/pack" -import { loadStatus, statusClock } from "./lib/status/load" +import { loadStatus, loadStatusHistory, statusClock } from "./lib/status/load" import { locationCopy, readLocation, rememberedLocation, resetLocationMemory, type LocationResult } from "./lib/status/location" import { nearestBoardable, nearestStation, type Coordinate } from "./lib/status/nearest" import { allowSelectedEnds, substituteOpen } from "./lib/status/substitute" @@ -42,6 +42,7 @@ import { ThemeChrome, ThemeCycle } from "./ui/ThemeCycle" const snapshot = loadSnapshot() const statusPack = loadStatus() +const statusHistory = loadStatusHistory() const snapshotDate = snapshot.fetchedAt.slice(0, 10) const interchangeCodes = new Set( snapshot.stations.filter((station) => station.lines.en.length > 1).map((station) => station.code), @@ -826,6 +827,7 @@ export function App() { 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)}`, diff --git a/src/index.css b/src/index.css index cef5664..80fbb36 100644 --- a/src/index.css +++ b/src/index.css @@ -1190,7 +1190,7 @@ h3 { .map-list, .map-pane, .map-detail, -section:not(.ticket):not(.departures):not(.map-station), +section:not(.ticket):not(.departures):not(.map-station):not(.status-service):not(.status-history), .ticket, .itinerary, .result { @@ -1200,7 +1200,7 @@ section:not(.ticket):not(.departures):not(.map-station), } .planner, -section:not(.ticket):not(.departures):not(.map-station), +section:not(.ticket):not(.departures):not(.map-station):not(.status-service):not(.status-history), .map-intro, .map-stage, .map-list, @@ -4251,7 +4251,26 @@ summary { } .map-line-chart { - max-height: min(48vh, 22rem); + max-height: min(40vh, 18rem); + } + + .map-split { + gap: 12px; + padding-bottom: max(12px, env(safe-area-inset-bottom)); + } + + .map-detail:has(.map-detail-stage) { + padding: 14px 14px 16px; + } + + .map-detail-stage { + min-height: 15.5rem; + padding: 1rem 0.85rem 1.15rem; + } + + .map-detail-stage .map-maze { + width: min(100%, 21.5rem); + max-width: 100%; } .result { @@ -5234,22 +5253,554 @@ summary { margin: 0; } -.status-split { - min-height: calc(100dvh - 7.5rem); - justify-content: center; +.status-page.sheet { + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 100%; + max-width: 40rem; + margin-inline: auto; +} + +.status-top.status-split { + min-height: 0; + width: 100%; } -.status-split > .pane { +.status-top > .pane { align-self: stretch; } +.status-below { + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 100%; + margin-top: 1.25rem; + padding-top: 0.35rem; +} + +.status-history { + width: 100%; + margin: 0; + padding: 0.95rem 1rem 1.05rem; + background: var(--card); + border: 0; + border-radius: var(--radius); +} + +.status-history-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.65rem 1rem; +} + +.status-history-head h2, +.status-day-head h3 { + margin: 0.15rem 0 0; + font-size: 1.05rem; + line-height: 1.3; +} + +.status-history-range { + position: relative; + z-index: 2; + display: inline-flex; + flex-direction: column; + align-items: stretch; + min-width: 10.5rem; +} + +.status-history-trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.55rem; + min-height: 44px; + min-width: 10.5rem; + margin: 0; + padding: 0 0.85rem 0 1rem; + border: 0; + border-radius: 999px; + background: var(--shade); + color: var(--ink); + font: inherit; + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; +} + +.status-history-trigger:hover, +.status-history-range.is-open .status-history-trigger { + background: color-mix(in srgb, var(--shade) 72%, var(--ink) 8%); +} + +.status-history-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ink) 18%, transparent); +} + +.status-history-caret { + flex: 0 0 auto; + color: var(--muted); + transition: transform 0.15s ease; +} + +.status-history-range.is-open .status-history-caret { + transform: rotate(180deg); +} + +.status-history-menu { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + left: 0; + z-index: 5; + margin: 0; + padding: 0.35rem; + list-style: none; + display: grid; + gap: 0.15rem; + min-width: 100%; + border: 0; + border-radius: 14px; + background: var(--card); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ink) 10%, transparent), + 0 14px 32px color-mix(in srgb, var(--ink) 16%, transparent); +} + +.status-history-option { + width: 100%; + display: grid; + grid-template-columns: 1.25rem minmax(0, 1fr); + align-items: center; + gap: 0.45rem; + min-height: 40px; + margin: 0; + padding: 0.35rem 0.55rem; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.status-history-option:hover, +.status-history-option:focus-visible { + background: var(--shade); + outline: none; +} + +.status-history-option.is-selected { + background: color-mix(in srgb, var(--bad-fill) 70%, var(--shade)); + color: var(--bad); +} + +.status-history-check { + display: grid; + place-items: center; + width: 1.25rem; + height: 1.25rem; + color: var(--bad); +} + +.status-plot { + position: relative; + margin-top: 0.75rem; + padding: 1.65rem 0 0.15rem; + overflow-x: auto; + background: transparent; +} + +.status-plot-frame { + display: grid; + grid-template-columns: 1.35rem 2.1rem auto; + grid-template-rows: minmax(14rem, auto) auto; + column-gap: 0.35rem; + row-gap: 0; + align-items: stretch; + width: fit-content; + max-width: 100%; + margin-inline: auto; +} + +.status-plot-y-title { + grid-column: 1; + grid-row: 1; + align-self: center; + justify-self: center; + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + writing-mode: vertical-rl; + transform: rotate(180deg); + white-space: nowrap; +} + +.status-plot-y { + grid-column: 2; + grid-row: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + padding: 0.2rem 0; + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.status-plot-pane { + grid-column: 3; + grid-row: 1; + min-height: 14rem; + padding: 0.35rem 0.2rem 0; + border-left: 1px solid color-mix(in srgb, var(--muted) 22%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--muted) 22%, transparent); + background-image: repeating-linear-gradient( + to top, + color-mix(in srgb, var(--muted) 12%, transparent) 0 1px, + transparent 1px 2.4rem + ); + background-position: left bottom; + background-repeat: repeat; +} + +.status-plot-x { + grid-column: 3; + grid-row: 2; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.status-plot-cols, +.status-plot-x-row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 3.5rem; + gap: 2.25rem; + justify-content: center; + padding-inline: 1.25rem 1.4rem; +} + +.status-plot-cols { + position: relative; + align-items: end; + min-height: 13.25rem; +} + +.status-plot:not(.is-few) .status-plot-cols, +.status-plot:not(.is-few) .status-plot-x-row { + grid-auto-columns: minmax(2.75rem, 3.5rem); + gap: 1.25rem; +} + +.status-plot-col { + position: relative; + min-height: 13.25rem; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--ink); + display: flex; + align-items: flex-end; + justify-content: center; + cursor: pointer; +} + +.status-plot-tip { + position: absolute; + left: 50%; + bottom: calc(100% - 0.15rem); + z-index: 2; + transform: translate(-50%, -0.35rem); + padding: 0.28rem 0.5rem; + border-radius: 6px; + background: var(--ink); + color: var(--card); + font-size: 0.72rem; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease, transform 0.12s ease; +} + +.status-plot-col:hover .status-plot-tip, +.status-plot-col:focus-visible .status-plot-tip, +.status-plot-col.is-active .status-plot-tip { + opacity: 1; + transform: translate(-50%, -0.55rem); +} + +.status-plot-track { + width: 0.7rem; + max-width: 0.7rem; + height: 12.5rem; + display: flex; + align-items: flex-end; + justify-content: center; + border: 0; + background: transparent; + overflow: visible; +} + +.status-plot-fill { + width: 100%; + min-height: 3px; + border-radius: 2px 2px 0 0; + background: var(--bad); +} + +.status-plot-col.is-active .status-plot-fill, +.status-plot-col:hover .status-plot-fill, +.status-plot-col:focus-visible .status-plot-fill { + background: color-mix(in srgb, var(--bad) 82%, var(--ink)); + outline: none; +} + +.status-plot-col:focus-visible { + outline: none; +} + +.status-plot-x-row { + margin-top: 0.45rem; + align-items: start; + justify-items: center; +} + +.status-plot-date { + font-size: 0.78rem; + font-weight: 700; + color: var(--muted); + white-space: nowrap; + text-align: center; +} + +.status-plot-date.is-active { + color: var(--ink); +} + +.status-plot-x-title { + margin: 0.4rem 0 0; + text-align: center; + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status-history-hint { + margin: 0.7rem 0 0; +} + +.status-day-layer { + z-index: 40; +} + +.status-day-dialog { + width: min(28rem, 100%); + max-height: min(68dvh, 28rem); + overflow: auto; + padding: 1.15rem 1rem 1rem; + border: 1px solid var(--line); + box-shadow: 0 18px 48px color-mix(in srgb, var(--ink) 28%, transparent); +} + +.status-day-head { + position: static; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin: 0 0 0.95rem; + padding: 0; + background: transparent; +} + +.status-day-close { + flex: 0 0 auto; + width: 44px; + height: 44px; + display: grid; + place-items: center; + margin: 0; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--shade); + color: var(--ink); +} + +.status-day-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0; +} + +.status-day-list li { + display: grid; + grid-template-columns: 1.5rem minmax(0, 1fr); + gap: 0.55rem; + align-items: center; + min-height: 44px; + padding: 0.4rem 0.7rem 0.4rem 0.15rem; + border-radius: 0; + background: transparent; + border: 0; + border-right: 3px solid transparent; + border-bottom: 1px solid var(--line); + font-size: 0.92rem; +} + +.status-day-list li:last-child { + border-bottom: 0; +} + +.status-day-index { + font-size: 0.72rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--muted); +} + +.status-day-main { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem 0.5rem; + min-width: 0; +} + +.status-day-name { + font-weight: 600; +} + +.status-day-dialog .line-dots { + gap: 4px; +} + +.status-day-dialog .line-dot { + width: 10px; + height: 10px; +} + +.status-below > .status-service { + width: min(36rem, 100%); + max-width: 36rem; + margin: 0.85rem auto 0; + padding: 1.2rem 1.2rem 1.15rem; + border: 0; + border-radius: var(--radius); + background: var(--card); + box-shadow: none; + display: flex; + flex-direction: column; + align-items: center; + align-self: center; + text-align: center; + gap: 0.5rem; +} + +.status-service h2 { + margin: 0; + max-width: 28rem; + font-size: 1.15rem; + line-height: 1.25; + color: var(--ink); +} + +.status-service-chips { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.4rem; + margin: 0.15rem 0 0.2rem; +} + +.status-service-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + min-height: 28px; + padding: 0.2rem 0.55rem; + border-radius: 999px; + background: var(--bad-fill); + color: var(--bad); + border: 1px solid var(--bad-line); + font-size: 0.75rem; + font-weight: 700; +} + +.status-service-chip.is-soft { + background: transparent; + color: var(--muted); + border-color: var(--line); +} + +.status-service-chip.is-live { + background: var(--good-fill); + color: var(--good); + border-color: color-mix(in srgb, var(--good) 28%, var(--line)); +} + +.status-service-body { + display: grid; + gap: 0.5rem; + width: 100%; + max-width: 34rem; + margin-top: 0.25rem; + padding: 0.15rem 0 0; + border: 0; + border-radius: 0; + background: transparent; + text-align: left; +} + +.status-service-body p { + margin: 0; + font-size: 0.95rem; + line-height: 1.45; + overflow-wrap: anywhere; + color: var(--ink); +} + +.status-service-source { + margin: 0.25rem 0 0; + max-width: 28rem; + font-size: 0.78rem; + color: var(--muted); +} + @media (min-width: 960px) { - .sheet.split.status-split { - min-height: calc(100dvh - 64px); - align-content: center; - align-items: stretch; - justify-content: center; + .status-page.sheet { + max-width: 76rem; + } + + .status-top.split.status-split { + display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); + gap: calc(var(--space) * 2); + align-items: stretch; + min-height: 0; } .status-stops { @@ -5261,16 +5812,133 @@ summary { padding: 4px 8px; } - .status-split > .pane-stage { - overflow: visible; - } - .status-act, .status-alt, .status-split .locate, .status-split .zoom-row button { min-height: 36px; } + + .status-below { + margin-top: 1.5rem; + padding-top: 0.5rem; + } +} + +@media (max-width: 959px) { + .status-page.sheet { + gap: 12px; + max-width: 100%; + } + + /* Same idea as Lines map-split: stacked cards need a real gap. */ + .status-top.split.status-split { + display: flex; + flex-direction: column; + gap: 12px; + } + + .status-trip, + .status-board { + gap: 10px; + } + + .status-line-btn { + grid-template-columns: 8px minmax(0, 1fr) minmax(2.25rem, 30%) auto; + gap: 8px 12px; + padding: 10px 4px; + } + + .status-line-name { + font-size: 0.9rem; + } + + .status-line-rail { + min-width: 2.25rem; + width: 100%; + } + + .status-line-btn .status-group-count { + font-size: 0.72rem; + padding: 2px 7px; + white-space: nowrap; + } + + .status-below { + margin-top: 0; + padding-top: 0; + gap: 12px; + } + + .status-history { + padding: 12px; + } + + .status-history-head { + flex-direction: column; + align-items: stretch; + gap: 0.7rem; + } + + .status-history-range, + .status-history-trigger { + width: 100%; + min-width: 0; + } + + .status-plot { + padding-top: 1.35rem; + } + + .status-plot-frame { + column-gap: 0.25rem; + } + + .status-plot-cols, + .status-plot-x-row { + gap: 1.75rem; + padding-inline: 0.85rem 1rem; + } + + .status-below > .status-service { + width: min(36rem, 100%); + max-width: 36rem; + margin: 0 auto; + padding: 12px; + align-items: center; + align-self: center; + text-align: center; + } + + .status-service h2 { + font-size: 1.05rem; + } + + .status-service-chips { + flex-wrap: nowrap; + justify-content: center; + gap: 0.35rem; + width: auto; + max-width: 100%; + } + + .status-service-chip { + flex: 0 1 auto; + min-height: 28px; + padding: 0.15rem 0.45rem; + font-size: 0.7rem; + white-space: nowrap; + } + + .status-service-body { + max-width: 34rem; + font-size: 0.92rem; + text-align: left; + } + + .status-service-source { + max-width: 28rem; + } } .board-dot.is-closed { 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 = [] 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"]) + }) +}) 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) +} 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: {} }) }) 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 : "", + } +} 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 = { 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 ( +