The friends list says a friend is riding when they are sitting still. Pair a trainer, walk away, and you stay marked as riding for as long as the tab is open.
Why
server/internal/hub/hub.go:1439, in the metric ingest:
rm.metrics[rider.ID] = m
rm.lastMetric[rider.ID] = now
Stamped on every metric message from the claim-holding screen, whatever the watts. A paired trainer publishes at roughly 1 Hz whether the cranks are turning or not — 0 W is a sample like any other.
hub.go:332, ridingLocked():
for id, at := range rm.lastMetric {
if now.Sub(at) <= ridingWindow { // 10s
So RidingIDs means "their trainer is connected and alive". The protocol comment says exactly that, honestly:
// Names with live metrics in the last few seconds — the watt dot.
Riding []string `json:"riding,omitempty"`
That is a real and useful signal. It is not riding.
The same word, two definitions
The client already has the stricter rule and uses it inside a room — web/src/lib/status.ts:
export function statusOfRider(rider: { away?: boolean; watts?: number }) {
if (rider.away) return 'away';
return (rider.watts ?? 0) > 0 ? 'riding' : 'online';
}
while every surface outside the room goes through statusOf(), which trusts the server's ridingIds. So one rider reads as riding on the friends page and online in the room they are standing in, from two definitions of one word.
The fix
Stamp a second timestamp, only when the rider is actually producing power, and derive riding from that:
now lastMetric[id] = now every sample, 0 W included
riding = a sample within 10 s
after lastWatts[id] = now only when watts > 0
riding = pedalled within 10 s
The hold matters. Do not test instantaneous watts — a rider coasting into a corner, freewheeling between intervals, or stopping to drink would flicker between riding and online several times a minute. Keeping the existing ridingWindow (10 s) as the hold means coasting holds the mark and sitting down loses it. That window is already a tuned constant; reuse it rather than inventing a second one.
lastMetric stays exactly as it is — it is load-bearing for trainer liveness and for the "watt dot" the in-room surfaces draw. This adds a signal; it does not repurpose one.
Check before assuming the client is right
statusOfRider's watts > 0 is instantaneous and has the same flicker exposure on the in-room surfaces. If it flickers there too, fix both to the same rule in this PR — one definition of riding, in one place, is the whole point of the change. status.ts's own docstring says four surfaces each invented their own dot and #807 unified them; this is the same lesson one level down.
Acceptance criteria
Verify it for real
The verify skill with two riders (?as=) and, ideally, a real trainer per docs/HARDWARE-SESSIONS.md — a simulated trainer producing constant watts cannot demonstrate the bug, because the bug is what happens when watts are zero and samples keep arriving. Pair, sit still, and watch the other rider's friends list.
Deliberately not in scope
Related
#1017 (the friends surfaces this feeds), #807 (one dot, one vocabulary — the same lesson), #706 (away is said, never inferred), #610 (the trainer claim that gates this ingest), #688 (hub size and its concurrency bugs).
The friends list says a friend is riding when they are sitting still. Pair a trainer, walk away, and you stay marked as riding for as long as the tab is open.
Why
server/internal/hub/hub.go:1439, in the metric ingest:Stamped on every metric message from the claim-holding screen, whatever the watts. A paired trainer publishes at roughly 1 Hz whether the cranks are turning or not — 0 W is a sample like any other.
hub.go:332,ridingLocked():So
RidingIDsmeans "their trainer is connected and alive". The protocol comment says exactly that, honestly:That is a real and useful signal. It is not riding.
The same word, two definitions
The client already has the stricter rule and uses it inside a room —
web/src/lib/status.ts:while every surface outside the room goes through
statusOf(), which trusts the server'sridingIds. So one rider reads as riding on the friends page and online in the room they are standing in, from two definitions of one word.The fix
Stamp a second timestamp, only when the rider is actually producing power, and derive riding from that:
The hold matters. Do not test instantaneous watts — a rider coasting into a corner, freewheeling between intervals, or stopping to drink would flicker between riding and online several times a minute. Keeping the existing
ridingWindow(10 s) as the hold means coasting holds the mark and sitting down loses it. That window is already a tuned constant; reuse it rather than inventing a second one.lastMetricstays exactly as it is — it is load-bearing for trainer liveness and for the "watt dot" the in-room surfaces draw. This adds a signal; it does not repurpose one.Check before assuming the client is right
statusOfRider'swatts > 0is instantaneous and has the same flicker exposure on the in-room surfaces. If it flickers there too, fix both to the same rule in this PR — one definition of riding, in one place, is the whole point of the change.status.ts's own docstring says four surfaces each invented their own dot and #807 unified them; this is the same lesson one level down.Acceptance criteria
lastMetricis not repurposed.ridingmeans the same thing on every surface, in and out of a room.make protocolif any struct changes, both sides committed together.go-reviewskill run — this is hub concurrency code underrm.mu, which is where the bugs live (chore(hub): hub.go is 1441 lines, 3.6× the ceiling, and it is where the concurrency bugs live #688).make cigreen.Verify it for real
The
verifyskill with two riders (?as=) and, ideally, a real trainer perdocs/HARDWARE-SESSIONS.md— a simulated trainer producing constant watts cannot demonstrate the bug, because the bug is what happens when watts are zero and samples keep arriving. Pair, sit still, and watch the other rider's friends list.Deliberately not in scope
Related
#1017 (the friends surfaces this feeds), #807 (one dot, one vocabulary — the same lesson), #706 (away is said, never inferred), #610 (the trainer claim that gates this ingest), #688 (hub size and its concurrency bugs).