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
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 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`.

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. 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

Expand Down
2 changes: 1 addition & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.",
Expand Down
28 changes: 21 additions & 7 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
14 changes: 14 additions & 0 deletions src/lib/status/lastPoll.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
22 changes: 22 additions & 0 deletions src/lib/status/lastPoll.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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
}
}
6 changes: 5 additions & 1 deletion src/lib/status/load.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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: {} })
})
})
31 changes: 31 additions & 0 deletions src/lib/status/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
Expand Down
34 changes: 28 additions & 6 deletions src/ui/StatusView.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -55,9 +56,13 @@ export function StatusView({
const [looked, setLooked] = useState<Stop | null>(() => named(initialFrom, names) ?? named(initialDest, names))
const [openLine, setOpenLine] = useState<string | null>(null)
const [focus, setFocus] = useState<string | null>(null)
const [polledAt, setPolledAt] = useState<string | null>(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])
Expand Down Expand Up @@ -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("")
Expand Down Expand Up @@ -149,7 +165,7 @@ export function StatusView({
<section className="status-trip">
<p className="status-kicker">{copy.status}</p>
<h1>{copy.statusLead}</h1>
<div className="status-stats">
<div className={polledClock ? "status-stats has-polled" : "status-stats"}>
<p>
<strong>{closedCount}</strong>
<span>{copy.statusClosedLabel}</span>
Expand All @@ -159,11 +175,17 @@ export function StatusView({
<span>{copy.statusOpenLabel}</span>
</p>
{clock ? (
<p title={copy.statusFresh(clock)}>
<strong>{clock.slice(-8, -3) || clock}</strong>
<p title={copy.statusFresh(clockStamp)}>
<strong>{clock}</strong>
<span>{copy.statusCheckedLabel}</span>
</p>
) : null}
{polledClock ? (
<p title={copy.statusPolledFresh(polledStamp)}>
<strong>{polledClock}</strong>
<span>{copy.statusPolledLabel}</span>
</p>
) : null}
</div>
<StatusSearch
copy={copy}
Expand Down
13 changes: 11 additions & 2 deletions workers/status-ping/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<account>.workers.dev" \
curl -X POST "https://detro-status-ping.rushforlinux.workers.dev" \
-H "Authorization: Bearer $STATUS_BAKE_TOKEN"
```

Expand Down
Loading
Loading