From d057b2557ca52401e7c1b3dbd3cd115533a95d7c Mon Sep 17 00:00:00 2001 From: Jerome Queck Date: Fri, 21 Aug 2026 17:02:38 +0800 Subject: [PATCH 1/2] Reach the live feed on the endpoint Formula 1 still answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formula 1 has closed the legacy SignalR endpoint the upstream recorder used. A negotiate against `livetiming.formula1.com/signalr` now answers 401 with `WWW-Authenticate: Basic realm="Authentication"` and `Bearer`, so the live path received nothing at all — the Ingestor sat in a reconnect loop and the broker carried no records. The replacement, `livetiming.formula1.com/signalrcore`, still negotiates and subscribes without a credential. Upstream chooses between the two clients by whether `F1_TOKEN` is *set*, not by whether its value authenticates: unset omits `--auth` and takes the dead endpoint, set passes `--auth` and takes the live one. So the fix is to set it, and the value is deliberately not a credential — this project holds no F1TV token and is not becoming a system that does (ADR-0002). Whether the free subset actually arrives over an unauthenticated connection is the measurement ADR-0002 leaves outstanding; #42 settles it against a live Session. This change is what makes that measurement possible at all. Verified against the running stack: the recorder now reports `Connection established` and subscribes to all sixteen topics, `server/live.ts` connects to the broker, and a browser's WebSocket receives its seed frame stamped `mode: "live"`. Assisted-by: Claude Opus 5 (reasoning: high) Co-authored-by: Claude --- deploy/compose.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/deploy/compose.yaml b/deploy/compose.yaml index e034cbf..0a1a2c5 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -36,6 +36,20 @@ services: # No username or password: the broker allows anonymous connections, because a credential # this project invented is still a credential to store and keep out of a commit (ADR-0002). OPENF1_MQTT_NO_TLS: "true" + # A switch spelled as a credential, and the value is deliberately not one. + # + # Formula 1 has closed the legacy SignalR endpoint the recorder used to reach: a negotiate + # against `livetiming.formula1.com/signalr` now answers 401 with `WWW-Authenticate: Basic` + # and `Bearer`, so the live path receives nothing at all. The replacement, + # `livetiming.formula1.com/signalrcore`, still negotiates and subscribes anonymously. + # + # Upstream chooses between the two by whether `F1_TOKEN` is *set*, not by whether its value + # authenticates: unset omits `--auth` and takes the dead endpoint, set passes `--auth` and + # takes the live one (`real_time/recording.py`). It is then sent as a bearer, which Formula 1 + # is expected to ignore — this project holds no F1TV token and is not becoming a system that + # does (ADR-0002). Whether the free subset arrives on an unauthenticated connection is the + # measurement ADR-0002 leaves outstanding, and #42 settles it against a live Session. + F1_TOKEN: unauthenticated depends_on: - api From 41632d489798eeb88aebe157ee20790073f5cc9d Mon Sep 17 00:00:00 2001 From: Jerome Queck Date: Fri, 21 Aug 2026 20:48:08 +0800 Subject: [PATCH 2/2] Name the columns the Timing screen was already drawing (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-five columns of figures and not one of them named on screen. A viewer could not tell Gap from Interval, Last from Best, or which of the three `Best` columns belonged to which sector. The design system had specified the header all along — `web/design-system/timing-screen.html` defines `.driver-row-header` with all twenty-five labels, and `driver-row.css` already lays it on the same grid track list as the rows, with header cells wearing the class of the column beneath so a label cannot drift from the figures it names. Only the application never rendered it: neither `web/timing-screen.ts` nor `web/index.html` contained the string `header`. This carries it across, which is ADR-0010's direction of travel. The labels are one list beside the row that fills those columns, rather than twenty-five strings in a second file free to drift from it. The header is the table's first child, as the design system lays it out, which is also what the row striping counts from; it carries no `data-driver`, so a click on it opens nobody by construction rather than by a guard in main.ts. The existing test asserted column *order* ("Gap sits left of Interval, in the order the header names the columns") while no header existed to name them. It now asserts the header is rendered and has a cell for every cell a row has — one short and every label right of the gap names the wrong column while looking perfectly plausible. Assisted-by: Claude Opus 5 (reasoning: high) Co-authored-by: Claude --- test/timing-screen.test.sh | 49 ++++++++++++++++++++++++ web/timing-screen.ts | 76 +++++++++++++++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/test/timing-screen.test.sh b/test/timing-screen.test.sh index ef4591b..a24abf5 100755 --- a/test/timing-screen.test.sh +++ b/test/timing-screen.test.sh @@ -666,4 +666,53 @@ print(counter.columns) assert_equals "a row is twenty-five columns, the whole track list the design system lays out" \ "25" "$(cells_in_a_row <<<"$spark_screen")" +# --- The header names those columns (#70) ----------------------------------------------------- +# +# Twenty-five figures a viewer cannot read is twenty-five figures they cannot use. The header is +# the design system's (docs/adr/0010) and is laid on the same grid as the rows, so the assertion +# that matters is that it has a cell for every cell a row has: one short, and every label right of +# the gap names the wrong column while still looking perfectly plausible. + +cells_in_the_header() { + python3 -c ' +import re, sys +from html.parser import HTMLParser + +class Columns(HTMLParser): + def __init__(self): + super().__init__() + self.depth = 0 + self.columns = 0 + + def handle_starttag(self, tag, attrs): + if self.depth == 1: + self.columns += 1 + self.depth += 1 + + def handle_endtag(self, tag): + self.depth -= 1 + +header = re.search(r"
", sys.stdin.read(), re.S).group(0) +counter = Columns() +counter.feed(header) +print(counter.columns) +' +} + +assert_contains "the screen carries the header that names its columns" \ + 'class="driver-row-header"' "$spark_screen" + +assert_equals "the header has a cell for every cell a row has, so no label names the wrong column" \ + "$(cells_in_a_row <<<"$spark_screen")" "$(cells_in_the_header <<<"$spark_screen")" + +assert_contains "Gap and Interval are named apart, which is the pair the screen most confuses" \ + '>Gap<' "$spark_screen" +assert_contains "the Interval column is named" '>Int<' "$spark_screen" +assert_contains "the speed trap column is named" '>Trap<' "$spark_screen" + +# The header is not a Driver, so a click on it must not open one — the row handler reaches for +# [data-driver] and this is what keeps the header out of its way. +assert_equals "the header carries no data-driver, so clicking it opens nobody" \ + "0" "$(grep -o 'driver-row-header[^>]*data-driver' <<<"$spark_screen" | wc -l | tr -d ' ')" + finish diff --git a/web/timing-screen.ts b/web/timing-screen.ts index 934aa6f..23ae71c 100644 --- a/web/timing-screen.ts +++ b/web/timing-screen.ts @@ -29,13 +29,79 @@ import { tyreBadge } from './tyre.ts'; import { sparkline, type Plot } from './sparkline.ts'; /** - * The markup inside the timing table: one row per Driver, and no row that is not a Driver. `opened` - * is the Driver a viewer has opened (#18), which the rows carry only so the one behind the panel is - * marked — the twenty rows are rendered identically whether a Driver is open or not, which is what - * keeps them updating while one is. + * The twenty-five columns, in the order `driverRow` fills them: the label the design system gives + * each one, and the cell class the figures beneath it wear. The class is what aligns a label to its + * column — header and row are laid on the same grid track list, so a header cell wearing the class + * of the cell below it cannot drift from it (driver-row.css). + * + * Four columns are deliberately unlabelled: the position change and the team bar carry no heading in + * the design, and the Driver's own best beside each sector is headed `Best` three times because that + * is what it is — three separate columns, each naming the sector to its left. + * + * This list is the header's whole definition, and `test/timing-screen.test.sh` holds it to the same + * count as a rendered row, so a column added to `driverRow` without a heading here fails rather than + * silently shifting every label right of it. + */ +const COLUMNS: readonly { readonly label: string; readonly cell: string }[] = [ + { label: 'Pos', cell: 'cell--figure' }, + { label: '', cell: 'cell--figure' }, + { label: '', cell: 'cell' }, + { label: 'No', cell: 'cell--figure' }, + { label: 'Driver', cell: 'cell' }, + { label: 'State', cell: 'cell' }, + { label: 'Gap', cell: 'cell--figure' }, + { label: 'Gap trend', cell: 'cell' }, + { label: 'Int', cell: 'cell--figure' }, + { label: 'Last', cell: 'cell--figure' }, + { label: 'Lap trend', cell: 'cell' }, + { label: 'Best', cell: 'cell--figure' }, + { label: 'S1', cell: 'cell--figure' }, + { label: 'Best', cell: 'cell--figure' }, + { label: 'S2', cell: 'cell--figure' }, + { label: 'Best', cell: 'cell--figure' }, + { label: 'S3', cell: 'cell--figure' }, + { label: 'Best', cell: 'cell--figure' }, + { label: 'Trap', cell: 'cell--figure' }, + { label: 'Tyre', cell: 'cell--centred' }, + { label: 'Age', cell: 'cell--figure' }, + { label: 'St', cell: 'cell--figure' }, + { label: 'Pit', cell: 'cell--figure' }, + { label: 'Deg trend', cell: 'cell' }, + { label: 'Laps', cell: 'cell--figure' }, +]; + +/** + * The header that names the columns (#70). Twenty-five figures a viewer cannot read is twenty-five + * figures they cannot use — Gap from Interval, Last from Best, and which of the three `Best` columns + * belongs to which sector are all unanswerable without it. + * + * It is drawn here rather than written into `index.html` so that the labels sit beside the row they + * name, in one list, instead of twenty-five strings in a second file free to drift from the markup. + */ +export function timingHeader(): string { + return [ + '
', + ...COLUMNS.map(({ label, cell }) => `${label}`), + '
', + ].join(''); +} + +/** + * The markup inside the timing table: the header, then one row per Driver and no row that is not a + * Driver. `opened` is the Driver a viewer has opened (#18), which the rows carry only so the one + * behind the panel is marked — the twenty rows are rendered identically whether a Driver is open or + * not, which is what keeps them updating while one is. + * + * The header is the table's first child, as the design system lays it out, which is also what the + * row striping counts from (`driver-row:nth-child(even)`). A viewer's click reaches a Driver through + * `[data-driver]`, which the header does not carry, so it is inert by construction rather than by a + * guard in main.ts. */ export function timingScreen(state: SessionState, opened?: DriverNumber): string { - return state.drivers.map((driver) => driverRow(driver, driver.number === opened)).join('\n'); + return [ + timingHeader(), + ...state.drivers.map((driver) => driverRow(driver, driver.number === opened)), + ].join('\n'); } function driverRow(driver: Driver, isOpen: boolean): string {