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>
110 lines
4.5 KiB
Markdown
110 lines
4.5 KiB
Markdown
# 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.
|