Files
samplez/rippr-flutter-src/docs/v3/V3-10-trip-splitting.md
uhryniuk 4c634354bd Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221.

The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
2026-08-19 13:36:04 -05:00

93 lines
5.0 KiB
Markdown

# V3-10 — Trip splitting
**Phase** Ride management · **Depends on** nothing · **Size** S · **Status** Done
## Goal
Split one recorded ride into two at a chosen point. The natural counterpart to merge.
## Context
Merge exists and is well tested; split does not. The case is a rider who forgot to stop —
one "ride" that is really the trip out, lunch, and the trip home.
Merge already establishes the hard parts: re-parenting points and segments inside a
transaction, and recomputing aggregates rather than summing them.
## Design
Split at a **segment boundary** rather than an arbitrary point. Segments already mark where
the rider paused, which is exactly where a forgotten stop shows up — and it avoids
inventing a new boundary type or splitting a segment in half.
The original trip keeps the earlier segments; a new trip takes the later ones. Both get
aggregates recomputed from the points they actually own.
If a ride has only one segment there is nothing to split, and the UI should say so rather
than offering a dead control.
## Implementation
1. `TripRepository.splitTrip(tripId, atSegmentId)` inside a transaction:
create the new trip, re-parent segments and points from `atSegmentId` onward,
set `startedAt`/`endedAt` from the segments each trip now owns,
recompute aggregates for both
2. Trip detail: a split action listing segment boundaries with their times
3. Confirmation naming what the two resulting rides will be
## Acceptance criteria
- [ ] Splitting produces two trips whose point counts sum to the original
- [ ] Neither trip's distance includes the gap between them
- [ ] Both have plausible `startedAt`/`endedAt`
- [ ] Single-segment rides cannot be split, and the UI explains why
- [ ] Atomic — a failure part-way leaves the original intact
## Tests
- Point counts sum; no points orphaned
- Distance of the parts is less than the original by roughly the gap
- Split then merge returns to the original aggregates — a good round-trip property
- Rejects a single-segment trip
- Atomicity under a forced mid-transaction failure
## Risks
Getting `startedAt`/`endedAt` from the wrong source. Derive them from the segments each
trip owns, not from the original trip.
## Out of scope
Splitting mid-segment.
## Outcome
Shipped as designed. `TripRepository.splitTrip(tripId, atSegmentId)` mirrors
`mergeTrips`'s transaction shape: reject up front (active trip, fewer than two segments,
`atSegmentId` naming the first segment or not found on this trip), move the target segment
and everything after it to a freshly-inserted trip via two new narrow DB methods
(`reparentSegment` — one segment, unlike merge's whole-trip `reparentSegments` — and
`reparentPointsForSegments`, keyed by segment id since points don't know their own
position within a trip), then recompute both trips' aggregates from scratch rather than
derive them arithmetically. `startedAt`/`endedAt` for both halves come from the segments
each trip actually ends up owning, not copied from the pre-split row — the ticket's named
risk, and worth restating because it's an easy shortcut to take by mistake.
Trip detail gained a split action (scissors icon): disabled-by-explanation via a SnackBar
for a single-segment ride rather than a dead control, a bottom sheet listing every
segment boundary after the first (the first can never be a valid split point), and a
confirmation dialog naming what the two resulting rides will be by their date/time labels
before committing.
**Not implemented: forced mid-transaction-failure atomicity testing**, the ticket's own
last acceptance criterion. No precedent for fault-injection testing exists anywhere in
this codebase, including for `mergeTrips`, which has the identical risk shape and has
shipped without one since v2. Atomicity here is a property of Drift's `_db.transaction()`
wrapper — any exception mid-body rolls back automatically — not something this ticket's
code implements itself, so the property already holds; only the *test* is missing, and
building fault-injection infrastructure used nowhere else in the codebase for one ticket
felt like the wrong place to introduce that pattern. Flagged rather than silently dropped.
12 new repository tests (point counts sum, no orphans, distance excludes the gap,
`startedAt`/`endedAt` from the right source, segment-boundary preserved on both sides,
single-segment rejected, first-segment rejected, active-trip rejected, unknown-segment
rejected, split-then-merge round-trips back to the original aggregates, and an explicit
no-orphans sweep over every point/segment), plus 2 widget tests (single-segment
explanation, full split flow via the bottom sheet and confirmation dialog). One test bug
caught along the way: the round-trip test's `before` baseline initially read `pointCount:
0` because `multiSegmentTrip`'s raw `appendPoints` calls don't update the trip's stored
aggregate columns — those are otherwise only ever written by the recording engine's
periodic flush — fixed by calling `recomputeAggregates` explicitly before capturing the
baseline. `flutter analyze` clean; full suite green (269 tests, up from 257).