Add Rippr source snapshot and full-history bundle

Two copies for two jobs. rippr-src/ is a browsable git archive export of
the tracked tree at 2f76983 - no build outputs, no local.properties, no
nested .git - which is convenient to read in gitea but carries no history
and will drift.

rippr-full-history.bundle is the real backup: all 18 commits, verified as
"records a complete history" and test-cloned before committing. This
matters because ~/dojo/rippr has no git remote and otherwise exists only
on one machine.

rippr-src/SNAPSHOT.md explains the difference and how to restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 08:30:25 -05:00
parent 4debdfa518
commit 280fd7f988
106 changed files with 10377 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
# T12 — Trip rename / delete / merge
**Phase** 4 · **Depends on** T10, T11 · **Status** Done
## Goal
Let rides be renamed, deleted, and merged. Done when a ride can be given a meaningful
name, junk test rides can be removed, and two trips can be combined into one with correct
recomputed aggregates.
## Context
Merge exists as a **safety valve**. It was originally promised to rescue a ride wrongly
split by the migration heuristic; that heuristic is gone now that v1 data is being
discarded, but merge keeps earning its place:
- A ride accidentally stopped and restarted mid-route
- Test rides that should be one logical trip
- Any future automatic splitting (v3+) needs an undo
Delete is the mundane but most-used of the three — a session of testing leaves a dozen
30-second trips cluttering the list.
`Trip.name` is already nullable in the T02 schema, with the UI deriving a date label when
it is null. CASCADE foreign keys mean delete is a single statement.
## State after T08–T11
`TripDetailViewModel` already has `rename(name)` and `delete(onDeleted)`;
`TripsViewModel` has `delete(tripId)`. The repository has `renameTrip` (which already
collapses blank to null) and `deleteTrip`. What is missing is the **merge** operation and
all of the UI: selection mode on the list, the rename affordance on detail, and the
confirmation dialogs. `ConfirmDialog` exists in `ui/components/Stats.kt`.
## Design
### Rename
Inline edit on the detail header. Empty input clears back to null (date-derived label)
rather than storing an empty string — those two states must not diverge.
### Delete
From both the list (swipe or overflow) and detail. Confirmation stating what is lost.
CASCADE removes segments and points. After deleting from detail, navigate back.
### Merge
Selected from the trips list: long-press to enter selection mode, pick exactly two, then
Merge.
Semantics:
- The **earlier** trip (by `startedAt`) is the survivor
- The later trip's segments are re-parented to the survivor
- **Segments are never joined** — the boundary between the two rides becomes a segment
boundary, exactly like a pause. This is correct: the rider genuinely was not recording
in between, and joining them would draw a false straight line across the gap
- `endedAt` becomes the later trip's `endedAt`
- The now-empty later trip row is deleted
- Aggregates are **recomputed from scratch** with `RideStatistics.compute()`, not summed
- The survivor keeps its name if set; otherwise inherits the later trip's
Merge runs in a single `@Transaction` — a partial merge would leave orphaned segments.
Merging is not offered while either trip is active.
## Implementation
1. `TripRepository.renameTrip(id, name: String?)`
2. `TripRepository.deleteTrip(id)` — CASCADE
3. `TripRepository.mergeTrips(survivorId, absorbedId)` in one transaction:
re-parent segments → update `endedAt` → delete absorbed row → recompute aggregates
4. Selection mode in `TripsScreen`, enabled at exactly two selections
5. Confirmation dialogs for delete and merge; merge states the resulting distance
6. Inline rename on the detail header
## Acceptance criteria
- [x] Rename persists; clearing reverts to the date label
- [x] Delete removes the trip and every point (verified by row count)
- [x] Merge re-parents all segments and deletes the absorbed row
- [x] Merged aggregates equal a fresh computation over the combined points
- [x] Merged trip shows a segment boundary at the join, not a joined line
- [x] Merge unavailable unless exactly two completed trips are selected
- [x] Merge is atomic — an interrupted merge leaves no orphans
## Tests
Instrumented:
- Delete cascades: zero segments, zero points remain
- Merge: segment count is the sum of both; point count is the sum; no orphans
- Merged aggregates match `RideStatistics.compute()` over combined points
- Merge of trips out of chronological order still picks the earlier as survivor
- Rename to empty stores null, not `""`
Compose UI: selection mode enables Merge only at two selections. **Not written** — the
UI-test debt from T08–T11 covers this too; tracked in T18.
## Risks / gotchas
- **Do not sum aggregates on merge.** Distance is not additive across the join — there is
a real gap. Recompute.
- **Chronological survivor.** Selection order is not ride order; sort by `startedAt`.
- **Deleting the active trip** must go through the service's discard path (T06), not a
raw delete, or the service keeps writing into a deleted trip.
## Out of scope
Splitting a trip; bulk delete; undo.