Lap function: double-click Select, CMD_LAP, brief summary, phone-side in progress (#46) #115

Merged
robert merged 2 commits from area/lap-function into main 2026-09-05 22:16:59 +02:00
Owner

Lap function (#46)

Implements the button gesture + state machine + wire usage + brief on-watch summary for marking a
lap mid-ride. The wire contract this needed (RIDE_CMD_LAP, LAP_NUMBER/LAP_DISTANCE_M/
LAP_TIME_S/LAP_AVG_SPEED_MMS, keys 30-33) was already fully specified and code-generated by
issue #7 — this PR wires real code to it, it does not add any new message keys.

Status

  • Watch side: done, verified, in this PR now.
  • Companion (phone) side: in progress, being built by a delegated agent on this same branch.
    Follow-up commits will land here before this is ready to merge. Opening now per request rather than
    waiting, since the watch half is independently complete and verified.

Watch side

  • watchapp/src/c/state.h / state.c: ride_state_lap_mark(time_t now) queues RIDE_CMD_LAP on the
    same D43 dropout-survival command queue start/pause/stop already use — only while
    RIDE_STATE_RUNNING and no stop confirmation pending. Touches no RideStateSnapshot field and does
    not set checkpoint_dirty: "without disturbing the ride state" holds literally, not just in spirit.
  • watchapp/src/c/ride_link.h / ride_link.c: ride_link_lap_mark() wraps it with a vibration and an
    immediate send attempt; ride_link_handle_inbox() now also decodes the four inbound lap keys into
    field_store and calls carousel_show_lap_summary() the moment they arrive.
  • watchapp/src/c/carousel.h / carousel.c: double-click Select is the chosen gesture. An audit of
    every button's existing single/long bindings (#60) found every slot already claimed — double-click is
    the one SDK click type nothing in this app used yet, and it composes cleanly with the existing
    subscriptions on Select. carousel_show_lap_summary() shows a QUAD-template overlay (the four
    FIELD_LAP_* ids #7 already reserved) for 4 seconds, or until any button press, then returns to
    whatever page was already showing.
  • watchapp/tests/test_state.c: 7 new host tests (no-op cases, queue-share/queue-full behaviour, and
    that every persisted snapshot field is bit-for-bit unchanged across a lap mark).
  • docs/DECISIONS.md D65: records the gesture audit, the phone-computes/watch-displays split, the
    overlay-not-a-page design, the GPX point-marker-not-a-new-segment call for the companion side, and
    the #4 (PebbleKit transport) gap stated below.

Known scope gap, stated plainly

Issue #4 (the real PebbleKit Android transport) is still open and explicitly gates Phase 2 —
PebbleTransport is currently a placeholder interface with no real inbound/outbound AppMessage path
on the phone side at all. That means "laps sent to the phone" cannot be a genuine Bluetooth round trip
yet. The watch side above is real and independently verified (see below); the phone side is pure,
host-tested logic (lap-split computation reusing DerivedMetrics' average-speed definition, a GPX
point-level lap marker, a Room laps table + migration, a lap list in ride history) with a named seam
for #4 to call once it lands — not a fabricated Bluetooth round trip.

Verified for real

  • Host tests: 55/55 pass for state.c (compiled directly with gcc -std=c11 -Wall -Wextra -Werror, mirroring tests/CMakeLists.txt's flags exactly — cmake itself isn't installed in this
    sandbox).
  • pebble build: emery/gabbro/basalt all link clean. Static RAM cost is 796 bytes on every
    target (117820 -> 117024 bytes free heap on emery/gabbro, 52284 -> 51488 on basalt, measured by
    diffing against a stashed pre-change build) — no new dynamic allocation anywhere in this change.
  • emery emulator, end to end: started a real ride; used pebble emu-button click select --repeat 2 --interval 100 for a genuine double-click and confirmed via pebble logs that exactly one
    CMD=4 (RIDE_CMD_LAP) was queued, not two start/pause commands — proving the gesture doesn't
    collide with #60's existing bindings; injected a synthetic STATE_ACK and the four LAP_* fields
    via pebble send-app-message and screenshotted the real, rendered QUAD summary; confirmed
    auto-dismiss after ~4s back to the prior page; confirmed a button press mid-overlay dismisses it
    without also performing its own action; confirmed the ride state was RUNNING throughout by
    observing a subsequent single Select press correctly send CMD=1 (pause).

Companion-side test/build output will be added in a follow-up comment once those commits land.

## Lap function (#46) Implements the button gesture + state machine + wire usage + brief on-watch summary for marking a lap mid-ride. The wire contract this needed (`RIDE_CMD_LAP`, `LAP_NUMBER`/`LAP_DISTANCE_M`/ `LAP_TIME_S`/`LAP_AVG_SPEED_MMS`, keys 30-33) was already fully specified and code-generated by issue #7 — this PR wires real code to it, it does not add any new message keys. ### Status - **Watch side: done, verified, in this PR now.** - **Companion (phone) side: in progress**, being built by a delegated agent on this same branch. Follow-up commits will land here before this is ready to merge. Opening now per request rather than waiting, since the watch half is independently complete and verified. ### Watch side - `watchapp/src/c/state.h` / `state.c`: `ride_state_lap_mark(time_t now)` queues `RIDE_CMD_LAP` on the same D43 dropout-survival command queue start/pause/stop already use — only while `RIDE_STATE_RUNNING` and no stop confirmation pending. Touches no `RideStateSnapshot` field and does not set `checkpoint_dirty`: "without disturbing the ride state" holds literally, not just in spirit. - `watchapp/src/c/ride_link.h` / `ride_link.c`: `ride_link_lap_mark()` wraps it with a vibration and an immediate send attempt; `ride_link_handle_inbox()` now also decodes the four inbound lap keys into `field_store` and calls `carousel_show_lap_summary()` the moment they arrive. - `watchapp/src/c/carousel.h` / `carousel.c`: **double-click Select** is the chosen gesture. An audit of every button's existing single/long bindings (#60) found every slot already claimed — double-click is the one SDK click type nothing in this app used yet, and it composes cleanly with the existing subscriptions on Select. `carousel_show_lap_summary()` shows a QUAD-template overlay (the four `FIELD_LAP_*` ids #7 already reserved) for 4 seconds, or until any button press, then returns to whatever page was already showing. - `watchapp/tests/test_state.c`: 7 new host tests (no-op cases, queue-share/queue-full behaviour, and that every persisted snapshot field is bit-for-bit unchanged across a lap mark). - `docs/DECISIONS.md` D65: records the gesture audit, the phone-computes/watch-displays split, the overlay-not-a-page design, the GPX point-marker-not-a-new-segment call for the companion side, and the #4 (PebbleKit transport) gap stated below. ### Known scope gap, stated plainly Issue #4 (the real PebbleKit Android transport) is still **open** and explicitly gates Phase 2 — `PebbleTransport` is currently a placeholder interface with no real inbound/outbound AppMessage path on the phone side at all. That means "laps sent to the phone" cannot be a genuine Bluetooth round trip yet. The watch side above is real and independently verified (see below); the phone side is pure, host-tested logic (lap-split computation reusing `DerivedMetrics`' average-speed definition, a GPX point-level lap marker, a Room `laps` table + migration, a lap list in ride history) with a named seam for #4 to call once it lands — not a fabricated Bluetooth round trip. ### Verified for real - **Host tests**: 55/55 pass for `state.c` (compiled directly with `gcc -std=c11 -Wall -Wextra -Werror`, mirroring `tests/CMakeLists.txt`'s flags exactly — `cmake` itself isn't installed in this sandbox). - **`pebble build`**: emery/gabbro/basalt all link clean. Static RAM cost is **796 bytes** on every target (117820 -> 117024 bytes free heap on emery/gabbro, 52284 -> 51488 on basalt, measured by diffing against a stashed pre-change build) — no new dynamic allocation anywhere in this change. - **emery emulator, end to end**: started a real ride; used `pebble emu-button click select --repeat 2 --interval 100` for a genuine double-click and confirmed via `pebble logs` that exactly **one** `CMD=4` (`RIDE_CMD_LAP`) was queued, not two start/pause commands — proving the gesture doesn't collide with #60's existing bindings; injected a synthetic `STATE_ACK` and the four `LAP_*` fields via `pebble send-app-message` and screenshotted the real, rendered QUAD summary; confirmed auto-dismiss after ~4s back to the prior page; confirmed a button press mid-overlay dismisses it without also performing its own action; confirmed the ride state was `RUNNING` throughout by observing a subsequent single Select press correctly send `CMD=1` (pause). Companion-side test/build output will be added in a follow-up comment once those commits land.
Watch side of the lap function: double-click Select, CMD_LAP, brief summary (#46)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
a6ceaee301
Implements the watch half of issue #46 on top of the wire vocabulary and
FieldIds issue #7 already reserved for it (RIDE_CMD_LAP, LAP_NUMBER/
LAP_DISTANCE_M/LAP_TIME_S/LAP_AVG_SPEED_MMS, FIELD_LAP_*, the QUAD-ready
"LAP"/"LAP DIST"/"LAP TIME"/"LAP AVG" labels) — none of that is new here,
only the code that actually calls it.

state.c/state.h: ride_state_lap_mark(time_t now) queues RIDE_CMD_LAP on the
same dropout-survival command queue (D43) start/pause/stop already use, only
while RIDE_STATE_RUNNING and no stop confirmation pending. Touches nothing
else — no RideState transition, no RideStateSnapshot field, no checkpoint_
dirty — satisfying the issue's "without disturbing the ride state" literally,
not just in spirit. 7 new host tests cover the no-op cases, the queue-share/
queue-full behaviour, and that every persisted field is bit-for-bit unchanged
across a lap mark.

ride_link.c/ride_link.h: ride_link_lap_mark() wraps it with a vibration and
an immediate send attempt. ride_link_handle_inbox() now also decodes the
four inbound lap keys into field_store and calls carousel_show_lap_summary()
the moment any of them arrives.

carousel.c/carousel.h: double-click Select is the chosen gesture — an audit
of every button's existing single/long bindings (#60) found nothing else
free; double-click is a distinct SDK click type that composes with the
existing subscriptions with no conflict. carousel_show_lap_summary() shows a
QUAD overlay (FIELD_LAP_NUMBER/DISTANCE/TIME/AVG_SPEED) for 4 seconds or
until any button press, then returns to whichever page was already showing,
with no separate page-index bookkeeping needed.

docs/DECISIONS.md: D65 records the gesture audit, the phone-computes/watch-
displays split, the overlay-not-a-page choice, the GPX point-level-marker-
not-a-new-segment call for the companion side, and the honest #4 (PebbleKit
transport) gap this still has to cross before "sent to the phone" is a real
Bluetooth round trip rather than a synthetic AppMessage in an emulator.

Verified for real, not just compiled:
- Host tests: 55/55 pass (gcc -Wall -Wextra -Werror, mirroring
  tests/CMakeLists.txt's flags; cmake itself is unavailable in this sandbox).
- `pebble build`: emery/gabbro/basalt all link clean. Static RAM cost is 796
  bytes on every target (117820 -> 117024 free heap on emery/gabbro, 52284 ->
  51488 on basalt) — no new dynamic allocation anywhere in this change.
- emery emulator, end to end: real double-click queues exactly one CMD=4
  (not two start/pause commands); a synthetic STATE_ACK + LAP_* AppMessage
  (pebble send-app-message) produces a real rendered QUAD summary; the
  overlay auto-dismisses after ~4s back to the prior page; a button press
  mid-overlay dismisses it and does not also perform its own action; a
  subsequent single Select press correctly sent CMD=1 (pause), proving the
  ride state was RUNNING throughout and never disturbed by any of it.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Companion side of the lap function: split computation, GPX marker, Room laps table (#46)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
d4c8792eb4
Phone-side half of issue #46, on top of Vela's watch-side commit on this branch. Per
docs/DECISIONS.md D65, built as pure/host-tested logic with a named seam for issue #4 (the still-open
real PebbleKit transport) to call once it lands -- no real inbound/outbound AppMessage path exists
anywhere in companion/ yet, matching ProtocolHandshake's existing precedent for this exact situation.

- LapSplitCalculator (companion/core): pure lap-number/distance/time/avg-speed split from two
  cumulative-ride figures, host-tested. No existing AVG_SPEED_MMS producer exists anywhere in this
  codebase to reuse (checked before writing) -- average speed is a new distance/time formula, not a
  reused one; stated plainly in the KDoc rather than silently claiming a reuse that isn't there.
- LapWireEncoding (companion/pebble): LapSplit -> the four PROTOCOL.md 2.3 keys, widened to Map<Int,
  Long> for LAP_DISTANCE_M/LAP_TIME_S's uint32 range. Nothing calls it yet (#4 gate).
- GpxLapMarker + GpxWriter/GpxRecorder: a lap marker as a <pp:lap> element inside the same
  <extensions> container heartRateBpm already uses, under a new, deliberately non-resolving
  pedalpebble.invalid namespace (RFC 2606). Real, checked deviation from D65's literal "closest to
  CMD_TIME" wording: GpxWriter flushes every point immediately, so an already-written point can never
  be retroactively edited -- GpxRecorder.markLap() attaches to the *next* point written instead, honestly
  documented in that method's own KDoc rather than silently claimed as an exact match.
- Room: new `laps` table (LapEntity/LapDao), RideHistoryDatabase MIGRATION_1_2 (version 1 -> 2),
  written exactly like RouteDatabase.MIGRATION_1_2 (plain CREATE TABLE IF NOT EXISTS, no destructive
  fallback). No enforced FK onto ride_history.id, matching CueSheetEntity's existing precedent and for
  the same underlying reason: a lap is recorded while the ride is still in progress, before its
  ride_history row exists.
- LapHistory/LapHistoryStorage (companion/core) + RoomLapHistoryStorage (companion/ride): mirrors
  RideHistory/RideHistoryStorage's split exactly.
- RideHistoryScreen: laps expand in place under a ride's row (no navigation library exists in this
  project yet to build a real detail screen on).
- RideService.recordLap(): the seam itself -- computes the split, queues the GPX marker, upserts the
  Room row. Nothing calls it yet; documented plainly why, same as RouteDatabase's own "Verification
  gap" house style.

Verified for real: ./gradlew :companion:core:test (311 tests, 0 failures), :companion:pebble:
testDebugUnitTest (51 tests, 0 failures, includes LapWireEncodingTest), :companion:ride:assembleDebug,
:companion:pebble:assembleDebug and :companion:assembleDebug (full app) all BUILD SUCCESSFUL, KSP
exported companion/ride/schemas/.../RideHistoryDatabase/2.json matching MIGRATION_1_2's SQL exactly.
Not verified (same sandbox limitation RouteDatabase/RideHistoryDatabase already carry): Room's
generated _Impl classes and MIGRATION_1_2 actually executing against a real SQLite database -- no
device/emulator attached here -- and RideHistoryScreen's lap list not visually checked on a device.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 52a19851b3 into main 2026-09-05 22:16:59 +02:00
Sign in to join this conversation.
No description provided.