Skip to content
Open
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
14 changes: 14 additions & 0 deletions deploy/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 49 additions & 0 deletions test/timing-screen.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"<div class=\"driver-row-header\".*?</div>", 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
76 changes: 71 additions & 5 deletions web/timing-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
'<div class="driver-row-header">',
...COLUMNS.map(({ label, cell }) => `<span class="${cell}">${label}</span>`),
'</div>',
].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 {
Expand Down
Loading