From 823feee498e1752a998ed4237685a0691f5d7e63 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Tue, 13 Oct 2026 12:38:58 +0530 Subject: [PATCH 1/5] feat(status): persist last poll stamp on the ping worker --- workers/status-ping/README.md | 13 +++++- workers/status-ping/src/index.ts | 73 ++++++++++++++++++++++++++---- workers/status-ping/wrangler.jsonc | 8 +++- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/workers/status-ping/README.md b/workers/status-ping/README.md index b3c9368..d70957e 100644 --- a/workers/status-ping/README.md +++ b/workers/status-ping/README.md @@ -15,10 +15,19 @@ npx wrangler deploy # 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): +## Last poll (Status UI trial) + +Successful dispatches store Asia/Kolkata `polledAt` in KV (`STATUS_POLL`). Anyone may read it: + +```bash +curl -sS "https://detro-status-ping.rushforlinux.workers.dev" +# {"polledAt":"2026-10-13T14:00:00.000"} or {"polledAt":null} +``` + +Manual kick (also refreshes `polledAt`): ```bash -curl -X POST "https://detro-status-ping..workers.dev" \ +curl -X POST "https://detro-status-ping.rushforlinux.workers.dev" \ -H "Authorization: Bearer $STATUS_BAKE_TOKEN" ``` diff --git a/workers/status-ping/src/index.ts b/workers/status-ping/src/index.ts index 74d3567..575ed85 100644 --- a/workers/status-ping/src/index.ts +++ b/workers/status-ping/src/index.ts @@ -1,19 +1,66 @@ /** - * Cron-only ping: POST GitHub workflow_dispatch for the Status bake. - * GitHub Actions schedule never registered on this repo; this Worker is the clock. + * Cron ping: POST GitHub workflow_dispatch for the Status bake. + * Remembers the last successful poll time (KV) for the Status UI trial (GET JSON). */ export interface Env { STATUS_BAKE_TOKEN: string; + STATUS_POLL: KVNamespace; GITHUB_OWNER: string; GITHUB_REPO: string; GITHUB_WORKFLOW: string; } +const POLL_KEY = "polledAt"; + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept", +}; + +function kolkataStamp(when = new Date()): string { + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-GB", { + timeZone: "Asia/Kolkata", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }) + .formatToParts(when) + .filter((part) => part.type !== "literal") + .map((part) => [part.type, part.value]), + ) as Record; + return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.000`; +} + +async function rememberPoll(env: Env, polledAt: string): Promise { + await env.STATUS_POLL.put(POLL_KEY, polledAt); +} + +async function readPoll(env: Env): Promise { + const value = await env.STATUS_POLL.get(POLL_KEY); + return value && value.trim() ? value.trim() : null; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...CORS, + }, + }); +} + async function dispatchStatusBake(env: Env): Promise { const token = env.STATUS_BAKE_TOKEN; if (!token) { - return new Response("STATUS_BAKE_TOKEN is missing", { status: 500 }); + return new Response("STATUS_BAKE_TOKEN is missing", { status: 500, headers: CORS }); } const url = `https://api.github.com/repos/${env.GITHUB_OWNER}/${env.GITHUB_REPO}/actions/workflows/${env.GITHUB_WORKFLOW}/dispatches`; @@ -30,13 +77,14 @@ async function dispatchStatusBake(env: Env): Promise { }); if (res.status === 204) { - // 204 must not carry a body (Fetch API). - return new Response(null, { status: 204 }); + await rememberPoll(env, kolkataStamp()); + return new Response(null, { status: 204, headers: CORS }); } const body = await res.text(); return new Response(`GitHub ${res.status}: ${body.slice(0, 500)}`, { status: 502, + headers: CORS, }); } @@ -57,15 +105,24 @@ export default { ); }, - /** Manual kick: POST with Authorization: Bearer . */ async fetch(request: Request, env: Env): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS }); + } + + if (request.method === "GET") { + const polledAt = await readPoll(env); + return jsonResponse({ polledAt }); + } + if (request.method !== "POST") { - return new Response("POST only", { status: 405 }); + return new Response("GET or POST only", { status: 405, headers: CORS }); } + const auth = request.headers.get("Authorization") ?? ""; const expected = `Bearer ${env.STATUS_BAKE_TOKEN}`; if (!env.STATUS_BAKE_TOKEN || auth !== expected) { - return new Response("unauthorized", { status: 401 }); + return new Response("unauthorized", { status: 401, headers: CORS }); } return dispatchStatusBake(env); }, diff --git a/workers/status-ping/wrangler.jsonc b/workers/status-ping/wrangler.jsonc index 3f50da4..1095fbd 100644 --- a/workers/status-ping/wrangler.jsonc +++ b/workers/status-ping/wrangler.jsonc @@ -13,5 +13,11 @@ "GITHUB_OWNER": "CodeDotJS", "GITHUB_REPO": "detro", "GITHUB_WORKFLOW": "status-bake.yml" - } + }, + "kv_namespaces": [ + { + "binding": "STATUS_POLL", + "id": "b2bf8d13bdfd47a09e33c0f67393200e" + } + ] } From 2bc36c745940860944622b6f0574fb022ad5e0f1 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Tue, 13 Oct 2026 12:38:58 +0530 Subject: [PATCH 2/5] feat(status): show last poll time on the status board --- src/i18n/copy.ts | 4 ++++ src/lib/status/lastPoll.test.ts | 14 ++++++++++++++ src/lib/status/lastPoll.ts | 22 +++++++++++++++++++++ src/ui/StatusView.tsx | 34 +++++++++++++++++++++++++++------ 4 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 src/lib/status/lastPoll.test.ts create mode 100644 src/lib/status/lastPoll.ts diff --git a/src/i18n/copy.ts b/src/i18n/copy.ts index f28d5f4..2733d17 100644 --- a/src/i18n/copy.ts +++ b/src/i18n/copy.ts @@ -202,6 +202,8 @@ export type Copy = { statusClosedLabel: string statusOpenLabel: string statusCheckedLabel: string + statusPolledLabel: string + statusPolledFresh: (when: string) => string statusLineClosed: (count: number) => string statusHistoryTitle: string statusHistoryLead: string @@ -437,6 +439,8 @@ export const copy = { statusClosedLabel: "closed", statusOpenLabel: "open", statusCheckedLabel: "checked", + statusPolledLabel: "polled", + statusPolledFresh: (when) => `Last poll ${when}`, statusLineClosed: (count) => (count === 1 ? "1 closed" : `${count} closed`), statusHistoryTitle: "Closures over time", statusHistoryLead: "Closed gates recorded on each check day.", diff --git a/src/lib/status/lastPoll.test.ts b/src/lib/status/lastPoll.test.ts new file mode 100644 index 0000000..1b23089 --- /dev/null +++ b/src/lib/status/lastPoll.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest" +import { readLastPollPayload } from "./lastPoll" + +describe("last poll payload", () => { + it("reads polledAt", () => { + expect(readLastPollPayload({ polledAt: "2026-10-13T14:00:00.000" })).toBe("2026-10-13T14:00:00.000") + }) + + it("rejects empty or missing", () => { + expect(readLastPollPayload({ polledAt: null })).toBeNull() + expect(readLastPollPayload({})).toBeNull() + expect(readLastPollPayload(null)).toBeNull() + }) +}) diff --git a/src/lib/status/lastPoll.ts b/src/lib/status/lastPoll.ts new file mode 100644 index 0000000..b377508 --- /dev/null +++ b/src/lib/status/lastPoll.ts @@ -0,0 +1,22 @@ +/** Trial: last successful Status bake dispatch from the Cloudflare ping Worker. */ +export const STATUS_PING_URL = "https://detro-status-ping.rushforlinux.workers.dev" + +export function readLastPollPayload(raw: unknown): string | null { + if (!raw || typeof raw !== "object") return null + const polledAt = (raw as { polledAt?: unknown }).polledAt + return typeof polledAt === "string" && polledAt.trim() ? polledAt.trim() : null +} + +export async function fetchLastPoll(signal?: AbortSignal): Promise { + try { + const res = await fetch(STATUS_PING_URL, { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }) + if (!res.ok) return null + return readLastPollPayload(await res.json()) + } catch { + return null + } +} diff --git a/src/ui/StatusView.tsx b/src/ui/StatusView.tsx index 674ab51..7a17f2a 100644 --- a/src/ui/StatusView.tsx +++ b/src/ui/StatusView.tsx @@ -1,8 +1,9 @@ -import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react" +import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react" import { ArrowRight, LocateFixed, X } from "lucide-react" import type { Copy } from "../i18n/copy" import { lineIndexFor, searchCatalog, type CatalogStation } from "../lib/dmrc/snapshot" -import { statusClock } from "../lib/status/load" +import { fetchLastPoll } from "../lib/status/lastPoll" +import { statusStamp12, statusTime12 } from "../lib/status/load" import { nearestOpen } from "../lib/status/gates" import { nearestStation, type Coordinate } from "../lib/status/nearest" import type { StatusHistory } from "../lib/status/history" @@ -55,9 +56,13 @@ export function StatusView({ const [looked, setLooked] = useState(() => named(initialFrom, names) ?? named(initialDest, names)) const [openLine, setOpenLine] = useState(null) const [focus, setFocus] = useState(null) + const [polledAt, setPolledAt] = useState(null) const closedCount = Object.keys(pack.stations).length const openCount = Math.max(0, names.size - closedCount) - const clock = pack.fetchedAt ? statusClock(pack.fetchedAt) : "" + const clock = pack.fetchedAt ? statusTime12(pack.fetchedAt) : "" + const clockStamp = pack.fetchedAt ? statusStamp12(pack.fetchedAt) : "" + const polledClock = polledAt ? statusTime12(polledAt) : "" + const polledStamp = polledAt ? statusStamp12(polledAt) : "" const stand = here ? nearestStation(here, coordinates) : null const standName = stand ? (names.get(stand.code) ?? stand.code) : null const fromClosed = Boolean(from && pack.stations[from.code]) @@ -87,6 +92,17 @@ export function StatusView({ const suggestions = useMemo(() => (query.trim() ? searchCatalog(stations, "en", query).slice(0, 8) : []), [query, stations]) const listOpen = searchOpen && query.trim().length > 0 + useEffect(() => { + let alive = true + // Do not abort on Strict Mode remount — a cancelled first request looks like a failure in DevTools. + fetchLastPoll().then((value) => { + if (alive) setPolledAt(value) + }) + return () => { + alive = false + } + }, []) + function lookAt(stop: Stop, lineCode?: string | null) { setLooked(stop) setQuery("") @@ -149,7 +165,7 @@ export function StatusView({

{copy.status}

{copy.statusLead}

-
+

{closedCount} {copy.statusClosedLabel} @@ -159,11 +175,17 @@ export function StatusView({ {copy.statusOpenLabel}

{clock ? ( -

- {clock.slice(-8, -3) || clock} +

+ {clock} {copy.statusCheckedLabel}

) : null} + {polledClock ? ( +

+ {polledClock} + {copy.statusPolledLabel} +

+ ) : null}
Date: Tue, 13 Oct 2026 12:38:58 +0530 Subject: [PATCH 3/5] feat(status): format checked and polled times as 12-hour --- src/lib/status/load.test.ts | 6 +++++- src/lib/status/load.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/lib/status/load.test.ts b/src/lib/status/load.test.ts index b748e95..0414edf 100644 --- a/src/lib/status/load.test.ts +++ b/src/lib/status/load.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { readPack, statusClock } from "./load" +import { readPack, statusClock, statusStamp12, statusTime12 } from "./load" describe("status pack file", () => { it("reads closed stations and treats a broken file as empty", () => { @@ -18,6 +18,10 @@ describe("status pack file", () => { 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(statusTime12(pack.fetchedAt)).toBe("8:00 AM") + expect(statusTime12("2026-10-13T14:30:00.000")).toBe("2:30 PM") + expect(statusTime12("2026-10-13T00:05:00.000")).toBe("12:05 AM") + expect(statusStamp12("2026-10-13T14:30:00.000")).toBe("2026-10-13 2:30 PM") expect(readPack(null)).toEqual({ fetchedAt: "", stations: {} }) }) }) diff --git a/src/lib/status/load.ts b/src/lib/status/load.ts index cdae842..0e93f3f 100644 --- a/src/lib/status/load.ts +++ b/src/lib/status/load.ts @@ -40,6 +40,37 @@ export function statusClock(fetchedAt: string): string { return fetchedAt.replace("T", " ").replace(/\.\d+$/, "") } +/** Compact 12-hour clock for Status stats, e.g. `8:00 AM`. */ +export function statusTime12(fetchedAt: string): string { + const parts = readClockParts(fetchedAt) + if (!parts) return "" + return `${parts.hour12}:${parts.minute} ${parts.ampm}` +} + +/** Date plus 12-hour clock for tooltips, e.g. `2026-10-13 8:00 AM`. */ +export function statusStamp12(fetchedAt: string): string { + const parts = readClockParts(fetchedAt) + if (!parts) return statusClock(fetchedAt) + return `${parts.year}-${parts.month}-${parts.day} ${parts.hour12}:${parts.minute} ${parts.ampm}` +} + +function readClockParts(fetchedAt: string) { + if (!fetchedAt) return null + const match = fetchedAt.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::\d{2})?(?:\.\d+)?/) + if (!match) return null + const hour24 = Number(match[4]) + const ampm = hour24 >= 12 ? "PM" : "AM" + const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12 + return { + year: match[1], + month: match[2], + day: match[3], + minute: match[5], + hour12: String(hour12), + ampm, + } +} + function readStation(value: unknown): StatusStation | null { if (!value || typeof value !== "object") return null const row = value as Record From 8acbc5f969f0be4391096c2f2b5ecb140a82b89e Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Tue, 13 Oct 2026 12:38:58 +0530 Subject: [PATCH 4/5] style(status): center status stats in quiet chips --- src/index.css | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/index.css b/src/index.css index acc02e9..cff8d98 100644 --- a/src/index.css +++ b/src/index.css @@ -4771,30 +4771,44 @@ summary { text-align: center; } +/* Trial chip row for Status counts — revert this block if the look does not stick. */ .status-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + gap: 8px; +} + +/* Keep 2×2 when polled is present — four columns in the Status pane wraps “11:23 PM”. */ +.status-stats.has-polled { + grid-template-columns: repeat(2, minmax(0, 1fr)); } .status-stats p { display: flex; flex-direction: column; - gap: 0.15rem; + align-items: center; + justify-content: center; + gap: 0.2rem; margin: 0; - padding: 0; + padding: 0.65rem 0.5rem 0.55rem; + text-align: center; + background: var(--shade); + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 2px); } .status-stats strong { - font-size: 1.55rem; + font-size: 1.45rem; font-variant-numeric: tabular-nums; - line-height: 1.1; + line-height: 1.15; + color: var(--ink); + white-space: nowrap; } .status-stats span { - font-size: 0.75rem; + font-size: 0.7rem; font-weight: 700; - letter-spacing: 0.04em; + letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); } From 33526d9686170f21c8fa98b105d5085c2e307a7b Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Tue, 13 Oct 2026 12:38:58 +0530 Subject: [PATCH 5/5] docs: allow status-poll kv for the last-poll stamp --- .cursor/rules/60-deployment.mdc | 2 +- docs/DECISIONS.md | 2 +- docs/DEPLOYMENT.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.cursor/rules/60-deployment.mdc b/.cursor/rules/60-deployment.mdc index 5f319c8..4d61d30 100644 --- a/.cursor/rules/60-deployment.mdc +++ b/.cursor/rules/60-deployment.mdc @@ -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 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. +Production for the app is Cloudflare Pages only. No Vercel project, Pages Functions, R2, D1, analytics, or a paid add-on. One exception: Worker `detro-status-ping` (`workers/status-ping`) may cron-dispatch the Status bake and may use one KV namespace (`STATUS_POLL`) for the last-poll stamp. Do not add other Workers or KV. Do not deploy `data/en/routes/`, `data/en/fares/`, `data/en/first-last/`, `data/hi/`, `scripts/`, `detro.archives/`, `curls/`, or `prompt.md`. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index dc3caee..95feb0e 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 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. +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. It may use one KV namespace (`STATUS_POLL`) for the last successful poll stamp the Status screen can read. There is no R2, D1, Pages Function, or other 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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2aa3824..c64324d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -171,7 +171,7 @@ 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 -- Extra Workers beyond `detro-status-ping`, and R2, KV, and D1 +- Extra Workers beyond `detro-status-ping`, extra KV beyond `STATUS_POLL`, and R2 or 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.