From 0b6a05b63c1307884ddce07980acb026cc1acc8f Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 28 Jul 2026 01:11:11 +0200 Subject: [PATCH 01/24] Prepare favorites work From da34ba2e67f6bfde505dc21c221fe1c8f50912c9 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 28 Jul 2026 01:48:51 +0200 Subject: [PATCH 02/24] Define Favorites domain --- CONTEXT.md | 13 ++++++--- ...4-media-history-is-a-local-derived-view.md | 2 ++ .../0029-favorites-pin-telemetry-ranges.md | 26 ++++++++++++++++++ ...avorite-media-is-curated-copied-storage.md | 27 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0029-favorites-pin-telemetry-ranges.md create mode 100644 docs/adr/0030-favorite-media-is-curated-copied-storage.md diff --git a/CONTEXT.md b/CONTEXT.md index ae52bff6a..160ad7768 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -96,9 +96,13 @@ _Avoid_: Trim range, active range, ride duration A temporary state of a Ride Recording in which sample persistence halts because the Board has produced no moving Telemetry Sample for a sustained interval, while the Board Session stays live at a reduced poll rate and auto-resumes on the next moving sample. Cuts battery, stored frames, and bucket sample counts together while the board is parked. _Avoid_: Stop recording, auto-stop, sleep, parked mode -**Media History Asset**: -A phone photo or video whose capture time falls inside a selected Ride Recording and which can be placed using a nearby recording-backed GPS fix. The asset remains owned by the OS photo library and is never copied into Ride History. -_Avoid_: Ride photo, recording media, uploaded media +**Favorite**: +A user-created, optionally named durable time range over Ride History, created by trimming a past ride to the span the rider wants to keep. A ride may produce multiple Favorites. A Favorite pins its telemetry range: history deletion skips favorited ranges, and removing a Favorite only unpins — it never deletes telemetry. Owns its Favorite Media. +_Avoid_: Favorite ride, segment, bookmark, saved ride + +**Favorite Media**: +A photo or video the rider explicitly attached to a Favorite, copied from the OS picker into app storage owned by that Favorite and recorded in the native Favorite Media manifest. Placed on the map using a nearby recording-backed GPS fix by capture time. Deleted together with its Favorite. +_Avoid_: Media History Asset, ride photo, gallery match, uploaded media **Map Point**: A user-authored map-visible location that is independent from Ride Recording and Ride History. A Map Point may describe a direction target, trail feature, viewpoint, charging place, or similar location. @@ -311,7 +315,8 @@ _Avoid_: Position update, presence ping, location share, group telemetry - A **Moving Window** belongs to one **Ride Recording** and is derived from which **Telemetry Samples** are excluded from speed metrics; a Ride Recording without one is excluded from **Ride History**. - A **Ride History Marker** belongs to **Ride History** and may explain where a **Ride Recording** lost or regained board data. - An **Idle Pause** belongs to one **Ride Recording**, begins after a sustained absence of moving **Telemetry Samples**, keeps the **Board Session** live at a reduced poll rate, and produces a **Ride History Marker**; its sample gap stays inside the **Moving Window** (and counts toward ride time) when it occurs between two moving spans. -- A **Media History Asset** is a local-only view of an OS photo-library asset matched to one selected **Ride Recording** by capture time and placed from a nearby recording-backed **GPS Fix**. +- A **Favorite** is a durable time range over **Ride History**; its telemetry is pinned against deletion, and a deleted ride leaves its favorited sub-ranges intact. +- **Favorite Media** belongs to one **Favorite**, is copied into app storage, and is placed from a nearby recording-backed **GPS Fix** by capture time. - A **Tune Snapshot** belongs to the currently connected **Board** and is read-only. - A **Tune Profile** belongs to a **Board** and stores semantic field values independently of firmware schema. - A **Tune Profile** also belongs to one **Tune Compatibility**; profiles from other Refloat package versions are retained but not used for the current board state. diff --git a/docs/adr/0014-media-history-is-a-local-derived-view.md b/docs/adr/0014-media-history-is-a-local-derived-view.md index ee47756f1..6411891be 100644 --- a/docs/adr/0014-media-history-is-a-local-derived-view.md +++ b/docs/adr/0014-media-history-is-a-local-derived-view.md @@ -1,5 +1,7 @@ # Media History is a local derived view +> **Superseded by ADR 0030.** Google Play policy blocked broad gallery reads; media moved to an explicit picker flow with copied, Favorite-owned storage. + Media History displays phone photos and videos alongside a selected Ride Recording without making those assets part of durable Ride History. The OS photo library remains durable media truth; native Ride Recording storage remains durable ride truth. Matching is recomputed when Media History is read for a selected ride and is not persisted. ## Matching Contract diff --git a/docs/adr/0029-favorites-pin-telemetry-ranges.md b/docs/adr/0029-favorites-pin-telemetry-ranges.md new file mode 100644 index 000000000..7bb89f8d4 --- /dev/null +++ b/docs/adr/0029-favorites-pin-telemetry-ranges.md @@ -0,0 +1,26 @@ +# Favorites pin telemetry ranges + +A Favorite is a durable, optionally named time range `[startMs, endMs]` over telemetry history, stored in a native table in the telemetry DB on both platforms. It is not a pointer to a ride: history sessions are derived on read (ADR 0004/0005) and have no stable identity, while a time range survives regrouping and allows multiple Favorites per ride, including trimmed sub-ranges selected on the ride timeline. + +## Contract + +- Favorites live in a native table (`@parity` iOS/Android) so telemetry deletion paths can see them. +- A Favorite has a native-minted stable UUID plus `created_at`, ratcheted `updated_at`, and monotonic `sync_seq` from creation. These are native-owned fields; JS cannot supply them. +- `deleteTelemetryRange` and `clearTelemetryHistory` carve out favorited ranges instead of deleting them. Deleting a ride around a Favorite leaves the favorited samples as a telemetry island, which history grouping surfaces as a short standalone ride. +- Rides containing a favorited range are marked in history as not fully deletable. +- Removing a Favorite only unpins: its telemetry stays and becomes deletable like any ride. Its Favorite Media is deleted with it. +- Summary stats (mirroring history session summary fields) are computed once from raw samples at creation time and denormalized onto the row (ADR 0005 style); the route preview is derived on read from pinned samples. + +## Considered Options + +- **Favorite references a session id.** Rejected: session ids are synthesized by grouping and unstable. +- **Cascade delete favorites with their ride.** Rejected: starring means "keep this"; deletion silently destroying favorites betrays that intent. +- **JS-side favorite store passing protected ranges into native deletes.** Rejected: native truth would depend on JS remembering to send it. +- **Delete pinned telemetry when its Favorite is removed.** Rejected: unfavoriting silently destroying telemetry is surprising; unpin-only keeps one rule. + +## Consequences + +- Delete paths need range-hole support; history grouping already tolerates gaps. +- Favorites can enter the existing mutable-row sync pipeline without an identity or schema retrofit. +- Favorited telemetry is exempt from any future retention pruning. +- Orphan favorite islands appear in History after surrounding-ride deletion; this is accepted as honest. diff --git a/docs/adr/0030-favorite-media-is-curated-copied-storage.md b/docs/adr/0030-favorite-media-is-curated-copied-storage.md new file mode 100644 index 000000000..4d85838d6 --- /dev/null +++ b/docs/adr/0030-favorite-media-is-curated-copied-storage.md @@ -0,0 +1,27 @@ +# Favorite Media is curated, copied, favorite-owned storage + +Supersedes ADR 0014 (Media History is a local derived view). The derived-on-read photo-library model was already abandoned in practice — Google Play policy blocked broad gallery reads, so media moved to an explicit picker flow that copies files into app storage (`rideMediaFiles.ts`). This ADR makes the current model official and re-keys it to Favorites. + +## Contract + +- Media attaches only to Favorites. The rider explicitly picks assets; there is no automatic photo-library matching. +- Native owns a `favorite_media` manifest on both platforms. Each immutable row has a local `INTEGER PRIMARY KEY AUTOINCREMENT` upload cursor, a native-minted stable UUID, `favorite_id`, capture time, MIME/media kind, byte count, SHA-256 content hash, and creation time. +- Picked files are imported into a canonical per-Favorite/per-media path in app storage. The manifest is durable metadata truth; filenames do not encode metadata. +- Device-local paths and extensions are local representation and never enter sync DTOs. +- Map placement uses the nearest recording-backed GPS fix to capture time, as before. Asset GPS metadata is ignored. +- Deleting a Favorite raw-deletes its manifest rows as a parent-covered cascade and best-effort deletes its media directory. Reconciliation removes incomplete imports and orphaned files. +- Legacy `rideMedia/` folders are left untouched; no migration (PoC). + +## Considered Options + +- **Keep session-id keying and gate UI to favorites.** Rejected: session ids are derived and unstable; the Favorite is the durable owner. +- **Keep the filesystem as the only record.** Rejected: sync cannot reliably enumerate, identify, validate, or restore media when metadata exists only in local filenames. +- **Derived photo-library matching scoped to favorites.** Rejected: Play policy already forced the picker model; broad gallery reads are not coming back. +- **Migrate existing per-ride media into intersecting favorites.** Rejected as not worth it for a PoC. + +## Consequences + +- The native manifest owns durable Favorite Media metadata; app storage owns the local bytes. Assets survive photo-library changes but cost disk space. +- Manifest metadata may use the ordinary Sync Batch after its parent Favorite. Media bytes require a separate bounded, resumable, idempotent upload contract and are never base64-embedded in the JSON batch. +- Import and deletion cross SQLite and the filesystem, so reconciliation is required to repair interrupted operations. +- Photos on non-favorited rides are no longer possible; favoriting is the gateway to attaching media. From 13482b8d34fc1af03a34e06cf00169f62c4119d8 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 28 Jul 2026 02:04:43 +0200 Subject: [PATCH 03/24] Decouple Favorites from sync --- docs/adr/0029-favorites-pin-telemetry-ranges.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adr/0029-favorites-pin-telemetry-ranges.md b/docs/adr/0029-favorites-pin-telemetry-ranges.md index 7bb89f8d4..cbb54ab21 100644 --- a/docs/adr/0029-favorites-pin-telemetry-ranges.md +++ b/docs/adr/0029-favorites-pin-telemetry-ranges.md @@ -5,7 +5,8 @@ A Favorite is a durable, optionally named time range `[startMs, endMs]` over tel ## Contract - Favorites live in a native table (`@parity` iOS/Android) so telemetry deletion paths can see them. -- A Favorite has a native-minted stable UUID plus `created_at`, ratcheted `updated_at`, and monotonic `sync_seq` from creation. These are native-owned fields; JS cannot supply them. +- A Favorite has a native-minted stable UUID plus native-owned `created_at` and `updated_at`; JS cannot supply them. +- Favorites land independently from backup. The later sync migration adds and backfills `sync_seq`, registers the table with the shared sequence, and switches subsequent writes to the shared Change Timestamp ratchet. - `deleteTelemetryRange` and `clearTelemetryHistory` carve out favorited ranges instead of deleting them. Deleting a ride around a Favorite leaves the favorited samples as a telemetry island, which history grouping surfaces as a short standalone ride. - Rides containing a favorited range are marked in history as not fully deletable. - Removing a Favorite only unpins: its telemetry stays and becomes deletable like any ride. Its Favorite Media is deleted with it. @@ -21,6 +22,6 @@ A Favorite is a durable, optionally named time range `[startMs, endMs]` over tel ## Consequences - Delete paths need range-hole support; history grouping already tolerates gaps. -- Favorites can enter the existing mutable-row sync pipeline without an identity or schema retrofit. +- Sync can adopt Favorites without changing their domain identity; adding the transport-only cursor is a routine additive migration. - Favorited telemetry is exempt from any future retention pruning. - Orphan favorite islands appear in History after surrounding-ride deletion; this is accepted as honest. From 2cc6b17f1c71a5ae2d7d1a807bcd34bf473b2d96 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 28 Jul 2026 02:07:30 +0200 Subject: [PATCH 04/24] Keep Favorites scope local --- docs/adr/0029-favorites-pin-telemetry-ranges.md | 2 -- docs/adr/0030-favorite-media-is-curated-copied-storage.md | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/adr/0029-favorites-pin-telemetry-ranges.md b/docs/adr/0029-favorites-pin-telemetry-ranges.md index cbb54ab21..196fc9b5a 100644 --- a/docs/adr/0029-favorites-pin-telemetry-ranges.md +++ b/docs/adr/0029-favorites-pin-telemetry-ranges.md @@ -6,7 +6,6 @@ A Favorite is a durable, optionally named time range `[startMs, endMs]` over tel - Favorites live in a native table (`@parity` iOS/Android) so telemetry deletion paths can see them. - A Favorite has a native-minted stable UUID plus native-owned `created_at` and `updated_at`; JS cannot supply them. -- Favorites land independently from backup. The later sync migration adds and backfills `sync_seq`, registers the table with the shared sequence, and switches subsequent writes to the shared Change Timestamp ratchet. - `deleteTelemetryRange` and `clearTelemetryHistory` carve out favorited ranges instead of deleting them. Deleting a ride around a Favorite leaves the favorited samples as a telemetry island, which history grouping surfaces as a short standalone ride. - Rides containing a favorited range are marked in history as not fully deletable. - Removing a Favorite only unpins: its telemetry stays and becomes deletable like any ride. Its Favorite Media is deleted with it. @@ -22,6 +21,5 @@ A Favorite is a durable, optionally named time range `[startMs, endMs]` over tel ## Consequences - Delete paths need range-hole support; history grouping already tolerates gaps. -- Sync can adopt Favorites without changing their domain identity; adding the transport-only cursor is a routine additive migration. - Favorited telemetry is exempt from any future retention pruning. - Orphan favorite islands appear in History after surrounding-ride deletion; this is accepted as honest. diff --git a/docs/adr/0030-favorite-media-is-curated-copied-storage.md b/docs/adr/0030-favorite-media-is-curated-copied-storage.md index 4d85838d6..aa7ebedbf 100644 --- a/docs/adr/0030-favorite-media-is-curated-copied-storage.md +++ b/docs/adr/0030-favorite-media-is-curated-copied-storage.md @@ -5,9 +5,8 @@ Supersedes ADR 0014 (Media History is a local derived view). The derived-on-read ## Contract - Media attaches only to Favorites. The rider explicitly picks assets; there is no automatic photo-library matching. -- Native owns a `favorite_media` manifest on both platforms. Each immutable row has a local `INTEGER PRIMARY KEY AUTOINCREMENT` upload cursor, a native-minted stable UUID, `favorite_id`, capture time, MIME/media kind, byte count, SHA-256 content hash, and creation time. +- Native owns a `favorite_media` manifest on both platforms. Each immutable row has a native-minted stable UUID primary key, `favorite_id`, capture time, MIME/media kind, byte count, SHA-256 content hash, and creation time. - Picked files are imported into a canonical per-Favorite/per-media path in app storage. The manifest is durable metadata truth; filenames do not encode metadata. -- Device-local paths and extensions are local representation and never enter sync DTOs. - Map placement uses the nearest recording-backed GPS fix to capture time, as before. Asset GPS metadata is ignored. - Deleting a Favorite raw-deletes its manifest rows as a parent-covered cascade and best-effort deletes its media directory. Reconciliation removes incomplete imports and orphaned files. - Legacy `rideMedia/` folders are left untouched; no migration (PoC). @@ -15,13 +14,12 @@ Supersedes ADR 0014 (Media History is a local derived view). The derived-on-read ## Considered Options - **Keep session-id keying and gate UI to favorites.** Rejected: session ids are derived and unstable; the Favorite is the durable owner. -- **Keep the filesystem as the only record.** Rejected: sync cannot reliably enumerate, identify, validate, or restore media when metadata exists only in local filenames. +- **Keep the filesystem as the only record.** Rejected: metadata encoded only in filenames is fragile to enumerate, validate, and reconcile after interrupted operations. - **Derived photo-library matching scoped to favorites.** Rejected: Play policy already forced the picker model; broad gallery reads are not coming back. - **Migrate existing per-ride media into intersecting favorites.** Rejected as not worth it for a PoC. ## Consequences - The native manifest owns durable Favorite Media metadata; app storage owns the local bytes. Assets survive photo-library changes but cost disk space. -- Manifest metadata may use the ordinary Sync Batch after its parent Favorite. Media bytes require a separate bounded, resumable, idempotent upload contract and are never base64-embedded in the JSON batch. - Import and deletion cross SQLite and the filesystem, so reconciliation is required to repair interrupted operations. - Photos on non-favorited rides are no longer possible; favoriting is the gateway to attaching media. From 5f24d4a00982ac32d435b4a51b2e7aad09ca024a Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 28 Jul 2026 02:24:55 +0200 Subject: [PATCH 05/24] Add Favorites table, bridge API and Favorites tab #287 --- .../modules/vescapecore/VescapeCoreModule.kt | 12 + .../telemetry/FavoriteSummaryBuilder.kt | 95 +++++++ .../vescapecore/telemetry/TelemetryDao.kt | 13 + .../telemetry/TelemetryDatabase.kt | 40 ++- .../telemetry/TelemetryEntities.kt | 68 +++++ .../telemetry/TelemetryRepository.kt | 82 ++++++ .../telemetry/FavoriteSummaryBuilderTest.kt | 227 +++++++++++++++++ .../vescape-core/ios/VescapeCoreModule.swift | 19 ++ .../ios/telemetry/FavoriteStore.swift | 235 ++++++++++++++++++ .../ios/telemetry/FavoriteStoreTests.swift | 215 ++++++++++++++++ .../ios/telemetry/TelemetryDatabase.swift | 9 + .../telemetry/TelemetryMigrationTests.swift | 2 +- .../ios/telemetry/TelemetryRepository.swift | 73 ++++++ modules/vescape-core/src/index.ts | 54 ++++ .../history/components/FavoriteList.tsx | 135 ++++++++++ src/modules/history/lib/favorites.test.ts | 50 ++++ src/modules/history/lib/favorites.ts | 33 +++ .../history/store/favoriteStore.test.ts | 102 ++++++++ src/modules/history/store/favoriteStore.ts | 65 +++++ src/screens/main/MainScreen.tsx | 7 + src/screens/main/history/HistoryControls.tsx | 62 ++++- src/screens/main/mainScreenStore.ts | 12 + src/screens/main/overlays/MainOverlays.tsx | 51 +++- src/screens/main/useMainScreenController.ts | 60 ++++- 24 files changed, 1706 insertions(+), 15 deletions(-) create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt create mode 100644 modules/vescape-core/ios/telemetry/FavoriteStore.swift create mode 100644 modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift create mode 100644 src/modules/history/components/FavoriteList.tsx create mode 100644 src/modules/history/lib/favorites.test.ts create mode 100644 src/modules/history/lib/favorites.ts create mode 100644 src/modules/history/store/favoriteStore.test.ts create mode 100644 src/modules/history/store/favoriteStore.ts diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 39df68476..73d8f08d4 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -526,6 +526,18 @@ class VescapeCoreModule : Module() { AsyncFunction("getProfileStatMonths") { runBlocking { ProfileStatsRepository.get(context.applicationContext).getProfileStatMonths() } } + // Favorites (ADR 0029). JS supplies only the range and an optional name; identity, timestamps + // and the denormalized summary are native. + // @parity /modules/vescape-core/ios/VescapeCoreModule.swift `getFavorites` + AsyncFunction("getFavorites") Coroutine { -> + TelemetryRepository.get(context.applicationContext).getFavorites() + } + AsyncFunction("createFavorite") Coroutine { options: Map -> + TelemetryRepository.get(context.applicationContext).createFavorite(options) + } + AsyncFunction("deleteFavorite") Coroutine { id: String -> + TelemetryRepository.get(context.applicationContext).deleteFavorite(id) + } AsyncFunction("deleteTelemetryBefore") Coroutine { beforeMs: Double -> TelemetryRepository.get(context.applicationContext).deleteBefore(beforeMs.toLong()) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt new file mode 100644 index 000000000..a94d1b8d9 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt @@ -0,0 +1,95 @@ +package expo.modules.vescapecore.telemetry + +/** + * Denormalized ride stats for one Favorite range, mirroring the history session summary fields. + * + * @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `FavoriteSummary` + */ +internal data class FavoriteSummary( + val deviceId: String? = null, + val deviceName: String? = null, + val sampleCount: Int = 0, + val gpsPointCount: Int = 0, + /** Odometer delta across the range, or null when the range carries no odometer readings. */ + val distanceCm: Long? = null, + val movingDurationMs: Long = 0, + val avgSpeedCentiKmh: Int = 0, + val maxSpeedCentiKmh: Int = 0, + val batteryUsedWhMilli: Long = 0, +) + +/** + * Aggregate the buckets built from a Favorite's raw samples into one denormalized summary. Pure so + * both the create path and its tests share one definition. Mirrors how JS collapses minute buckets + * into a history session summary, including the GPS-distance fallback for rides with no odometer. + * + * @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `buildFavoriteSummary` + * @platform-diff Only Android fills `gps_distance_cm`, so the GPS fallback has no iOS counterpart. + */ +internal fun buildFavoriteSummary(buckets: Collection): FavoriteSummary { + if (buckets.isEmpty()) return FavoriteSummary() + + var deviceId: String? = null + var deviceName: String? = null + var sampleCount = 0 + var gpsPointCount = 0 + var sumAbsSpeed = 0L + var sumMovingSpeed = 0L + var movingSampleCount = 0 + var maxSpeedCentiKmh = 0 + var batteryUsedWhMilli = 0L + var odometerDistanceCm: Long? = null + var gpsDistanceCm = 0L + var firstMovingAtMs: Long? = null + var lastMovingAtMs: Long? = null + var firstSampleAtMs = Long.MAX_VALUE + var lastSampleAtMs = Long.MIN_VALUE + + for (bucket in buckets.sortedBy { it.bucketStartMs }) { + sampleCount += bucket.sampleCount + gpsPointCount += bucket.gpsPointCount + sumAbsSpeed += bucket.sumAbsSpeedCentiKmh + sumMovingSpeed += bucket.sumMovingAbsSpeedCentiKmh ?: 0L + movingSampleCount += bucket.movingSpeedSampleCount ?: 0 + maxSpeedCentiKmh = maxOf(maxSpeedCentiKmh, bucket.maxAbsSpeedCentiKmh) + batteryUsedWhMilli += bucket.batteryUsedWhMilli + gpsDistanceCm += bucket.gpsDistanceCm + if (deviceId == null && bucket.deviceId.isNotEmpty()) deviceId = bucket.deviceId + if (deviceName == null) deviceName = bucket.deviceName + val first = bucket.firstOdometerCm + val last = bucket.lastOdometerCm + if (first != null && last != null) { + odometerDistanceCm = (odometerDistanceCm ?: 0L) + maxOf(0L, last - first) + } + bucket.firstMovingAtMs?.let { firstMovingAtMs = minOf(firstMovingAtMs ?: it, it) } + bucket.lastMovingAtMs?.let { lastMovingAtMs = maxOf(lastMovingAtMs ?: it, it) } + firstSampleAtMs = minOf(firstSampleAtMs, bucket.firstSampleAtMs) + lastSampleAtMs = maxOf(lastSampleAtMs, bucket.lastSampleAtMs) + } + + // Moving Window when the range has moving samples, otherwise the wall-clock span it covers — the + // same fallback JS applies to legacy rides with no precomputed window. + val movingStart = firstMovingAtMs + val movingEnd = lastMovingAtMs + val movingDurationMs = when { + movingStart != null && movingEnd != null -> maxOf(0L, movingEnd - movingStart) + firstSampleAtMs <= lastSampleAtMs -> maxOf(0L, lastSampleAtMs - firstSampleAtMs) + else -> 0L + } + + return FavoriteSummary( + deviceId = deviceId, + deviceName = deviceName, + sampleCount = sampleCount, + gpsPointCount = gpsPointCount, + distanceCm = odometerDistanceCm ?: gpsDistanceCm.takeIf { it > 0 }, + movingDurationMs = movingDurationMs, + avgSpeedCentiKmh = when { + movingSampleCount > 0 -> (sumMovingSpeed / movingSampleCount).toInt() + sampleCount > 0 -> (sumAbsSpeed / sampleCount).toInt() + else -> 0 + }, + maxSpeedCentiKmh = maxSpeedCentiKmh, + batteryUsedWhMilli = batteryUsedWhMilli, + ) +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 859c5a3e3..8c46c1d60 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -526,6 +526,19 @@ interface TelemetryDao { @Query("DELETE FROM board_warnings WHERE board_id = :boardId") suspend fun deleteBoardWarnings(boardId: String): Int + + // Favorites — durable pins over Ride History (ADR 0029). Deleting a row only unpins; telemetry + // inside the range is never touched here. + // @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift + + @Query("SELECT * FROM favorites ORDER BY start_ms DESC") + suspend fun getFavorites(): List + + @Insert + suspend fun insertFavorite(favorite: FavoriteEntity) + + @Query("DELETE FROM favorites WHERE id = :id") + suspend fun deleteFavorite(id: String): Int } private fun TelemetryMinuteBucketEntity.merge(next: TelemetryMinuteBucketEntity): TelemetryMinuteBucketEntity { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index 2cd691ef6..0e928e409 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 27 +internal const val TELEMETRY_DATABASE_VERSION = 28 @Database( entities = [ @@ -30,6 +30,7 @@ internal const val TELEMETRY_DATABASE_VERSION = 27 PrivacyZoneEntity::class, MapPointEntity::class, BoardWarningEntity::class, + FavoriteEntity::class, ], version = TELEMETRY_DATABASE_VERSION, exportSchema = false, @@ -472,6 +473,42 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * Favorites (#287). Durable, optionally named time ranges over Ride History (ADR 0029). The row + * carries a native-minted UUID id, native-owned timestamps, and the summary stats computed once + * from the raw samples inside the range. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v28_favorites` + */ + internal val MIGRATION_27_28 = object : Migration(27, 28) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS favorites ( + id TEXT NOT NULL PRIMARY KEY, + device_id TEXT, + device_name TEXT, + name TEXT, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sample_count INTEGER NOT NULL, + gps_point_count INTEGER NOT NULL, + distance_cm INTEGER, + moving_duration_ms INTEGER NOT NULL, + avg_speed_centi_kmh INTEGER NOT NULL, + max_speed_centi_kmh INTEGER NOT NULL, + battery_used_wh_milli INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_favorites_start_ms_end_ms ON favorites(start_ms, end_ms)", + ) + } + } + /** * One-time file rename from the pre-release "telemetry.db" name. Checkpoints the legacy WAL so * the whole database lives in the main file, then renames it in place. Idempotent: once the new @@ -526,6 +563,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, + MIGRATION_27_28, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index bbe809be3..15e4cc3be 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -471,3 +471,71 @@ data class BoardWarningEntity( @ColumnInfo(name = "payload_json") val payloadJson: String, ) + +/** + * One Favorite: a durable, optionally named time range over Ride History (ADR 0029). Identity and + * timestamps are native-minted — JS may only supply the range and the name. + * + * Summary stats are denormalized at creation from raw Telemetry Samples (ADR 0005 style) because + * minute buckets are too coarse for a range that cuts mid-bucket. + * + * @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `Favorite` + * @parity /modules/vescape-core/src/index.ts `Favorite` + */ +@Entity( + tableName = "favorites", + indices = [ + Index(value = ["start_ms", "end_ms"]), + ], +) +data class FavoriteEntity( + @PrimaryKey + val id: String, + @ColumnInfo(name = "device_id") + val deviceId: String?, + @ColumnInfo(name = "device_name") + val deviceName: String?, + val name: String?, + @ColumnInfo(name = "start_ms") + val startMs: Long, + @ColumnInfo(name = "end_ms") + val endMs: Long, + @ColumnInfo(name = "created_at") + val createdAt: Long, + @ColumnInfo(name = "updated_at") + val updatedAt: Long, + @ColumnInfo(name = "sample_count") + val sampleCount: Int, + @ColumnInfo(name = "gps_point_count") + val gpsPointCount: Int, + /** Odometer delta across the range, or null when the range carries no odometer readings. */ + @ColumnInfo(name = "distance_cm") + val distanceCm: Long?, + @ColumnInfo(name = "moving_duration_ms") + val movingDurationMs: Long, + @ColumnInfo(name = "avg_speed_centi_kmh") + val avgSpeedCentiKmh: Int, + @ColumnInfo(name = "max_speed_centi_kmh") + val maxSpeedCentiKmh: Int, + @ColumnInfo(name = "battery_used_wh_milli") + val batteryUsedWhMilli: Long, +) { + /** @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `Favorite.toMap` */ + fun toMap(): Map = mapOf( + "id" to id, + "deviceId" to deviceId, + "deviceName" to deviceName, + "name" to name, + "startMs" to startMs, + "endMs" to endMs, + "createdAtMs" to createdAt, + "updatedAtMs" to updatedAt, + "sampleCount" to sampleCount, + "gpsPointCount" to gpsPointCount, + "distanceM" to distanceCm?.let { it / 100.0 }, + "movingDurationMs" to movingDurationMs, + "avgSpeedKmh" to avgSpeedCentiKmh / 100.0, + "maxSpeedKmh" to maxSpeedCentiKmh / 100.0, + "batteryUsedWh" to batteryUsedWhMilli / 1000.0, + ) +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index 904ce9107..1ee935fcc 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -6,6 +6,7 @@ import android.util.Log import expo.modules.kotlin.jni.NativeArrayBuffer import java.nio.ByteBuffer import java.nio.ByteOrder +import java.util.UUID import org.json.JSONObject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -528,6 +529,87 @@ class TelemetryRepository private constructor(context: Context) { dao.deleteRange(query.fromMs, query.toMs, query.deviceId) } + // Favorites (ADR 0029) + + /** @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `getFavorites` */ + suspend fun getFavorites(): List> = withContext(Dispatchers.IO) { + dao.getFavorites().map { it.toMap() } + } + + /** + * Pin a time range as a Favorite. Identity and timestamps are minted here — the range and the + * optional name are the only things JS gets to supply. Summary stats come from the raw samples + * inside the range, so a range that cuts mid-bucket still gets exact numbers. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `createFavorite` + */ + suspend fun createFavorite(options: Map): Map? = withContext(Dispatchers.IO) { + val startMs = options.requiredLong("startMs") + val endMs = options.requiredLong("endMs") + require(endMs >= startMs) { "endMs must be greater than or equal to startMs" } + val deviceId = options["deviceId"] as? String + val name = (options["name"] as? String)?.trim()?.ifEmpty { null } + flushNow() + + val states = getSampleStates(startMs, endMs, deviceId, Int.MAX_VALUE) + val summary = favoriteSummary(states) + val nowMs = System.currentTimeMillis() + val favorite = FavoriteEntity( + id = UUID.randomUUID().toString(), + deviceId = deviceId ?: summary.deviceId, + deviceName = summary.deviceName, + name = name, + startMs = startMs, + endMs = endMs, + createdAt = nowMs, + updatedAt = nowMs, + sampleCount = summary.sampleCount, + gpsPointCount = summary.gpsPointCount, + distanceCm = summary.distanceCm, + movingDurationMs = summary.movingDurationMs, + avgSpeedCentiKmh = summary.avgSpeedCentiKmh, + maxSpeedCentiKmh = summary.maxSpeedCentiKmh, + batteryUsedWhMilli = summary.batteryUsedWhMilli, + ) + dao.insertFavorite(favorite) + favorite.toMap() + } + + /** + * Unpin a Favorite. Telemetry in its range stays and becomes normally deletable (ADR 0029). + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `deleteFavorite` + */ + suspend fun deleteFavorite(id: String): Boolean = withContext(Dispatchers.IO) { + dao.deleteFavorite(id) > 0 + } + + /** + * Run the raw samples of a Favorite range through the same Metric Sanitizers the recording flush + * applies, then collapse the resulting buckets into one denormalized summary. Exclusion ranges are + * deliberately not persisted: creating a Favorite is a read of Ride History, not a rewrite. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `favoriteSummary` + */ + private fun favoriteSummary(states: List): FavoriteSummary { + if (states.isEmpty()) return FavoriteSummary() + val telemetryPoints = states.map { it.state.toBucketPoint() } + val sanitization = sanitizeTelemetrySamples(telemetryPoints, metricSanitizerConfig) + val sanitizedPoints = telemetryPoints.mapIndexed { index, point -> + point.copy( + excludedFromAvgSpeed = sanitization.samples[index].excludedFromAvgSpeed, + excludedFromMaxSpeed = sanitization.samples[index].excludedFromMaxSpeed, + excludedFromMaxDuty = sanitization.samples[index].excludedFromMaxDuty, + ) + } + return buildFavoriteSummary( + buildTelemetryBuckets( + telemetryPoints = sanitizedPoints, + locationPoints = states.toBucketLocationPoints(), + ), + ) + } + suspend fun rebuildBuckets(onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }): Int = withContext(Dispatchers.IO) { val firstMs = dao.firstFrameAt() ?: return@withContext 0 val lastMs = dao.lastFrameAt() ?: return@withContext 0 diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt new file mode 100644 index 000000000..52dd9aa3e --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt @@ -0,0 +1,227 @@ +package expo.modules.vescapecore.telemetry + +import androidx.sqlite.db.SupportSQLiteDatabase +import java.lang.reflect.Proxy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Favorites are durable pins over Ride History (ADR 0029). The summary is computed once from the raw + * Telemetry Samples inside the range — including ranges that cut a minute bucket in half, which is + * exactly what bucket-derived stats cannot express. + * + * @parity /modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift + */ +class FavoriteSummaryBuilderTest { + @Test + fun summaryAggregatesRawSamplesAcrossBucketBoundaries() { + val summary = buildFavoriteSummary(bucketsFor(ridePoints(startMs = 0, count = 120))) + + assertEquals(120, summary.sampleCount) + assertEquals(2_000, summary.avgSpeedCentiKmh) + assertEquals(2_000, summary.maxSpeedCentiKmh) + assertEquals(119_000L, summary.movingDurationMs) + // 1 m per sample interval. Distance sums per-bucket odometer deltas, so the hop across a bucket + // boundary is not counted — the same arithmetic history session rows already use. + assertEquals(11_800L, summary.distanceCm) + assertEquals("board-1", summary.deviceId) + } + + /** + * The point of computing from raw samples: a Favorite trimmed inside a minute bucket must report + * the trimmed span, not the whole bucket the samples happen to live in. + */ + @Test + fun summaryOfAMidBucketRangeCoversOnlyTheTrimmedSamples() { + val trimmed = ridePoints(startMs = 0, count = 120) + .filter { it.capturedAtMs in 30_000L..89_000L } + + val summary = buildFavoriteSummary(bucketsFor(trimmed)) + + assertEquals(60, summary.sampleCount) + assertEquals(59_000L, summary.movingDurationMs) + assertEquals(5_800L, summary.distanceCm) + } + + /** + * Idle samples below the moving threshold are excluded from average speed by the Metric + * Sanitizers, exactly as they are while recording — a trimmed Favorite must not average them in. + */ + @Test + fun summaryExcludesIdleSamplesFromAverageSpeed() { + val idle = ridePoints(startMs = 0, count = 10, speedCentiKmh = 0) + val moving = ridePoints(startMs = 10_000, count = 10) + + val summary = buildFavoriteSummary(bucketsFor(idle + moving)) + + assertEquals(20, summary.sampleCount) + assertEquals(2_000, summary.avgSpeedCentiKmh) + assertEquals(9_000L, summary.movingDurationMs) + } + + /** + * A range with no samples at all (deleted telemetry, wrong device) yields an empty summary instead + * of failing the create path. + */ + @Test + fun summaryOfAnEmptyRangeIsZeroed() { + val summary = buildFavoriteSummary(emptyList()) + + assertEquals(0, summary.sampleCount) + assertEquals(0L, summary.movingDurationMs) + assertNull(summary.distanceCm) + } + + /** A ride recorded without odometer readings still reports distance, from the GPS-distance sum. */ + @Test + fun summaryFallsBackToGpsDistanceWithoutOdometer() { + val summary = buildFavoriteSummary( + listOf(bucket(bucketStartMs = 0, gpsDistanceCm = 4_200L, firstOdometerCm = null, lastOdometerCm = null)), + ) + + assertEquals(4_200L, summary.distanceCm) + } + + @Test + fun favoriteEntityMapsToRiderUnitsAcrossTheBridge() { + val map = favorite(distanceCm = 250_000L).toMap() + + assertEquals(2_500.0, map["distanceM"]) + assertEquals(15.0, map["avgSpeedKmh"]) + assertEquals(30.0, map["maxSpeedKmh"]) + assertEquals(12.5, map["batteryUsedWh"]) + } + + /** + * A range with no odometer and no GPS has no distance to report — the bridge must send null rather + * than a fabricated zero, so the row renders "-" like a history row without distance. + */ + @Test + fun missingDistanceStaysNullAcrossTheBridge() { + assertNull(favorite(distanceCm = null).toMap()["distanceM"]) + } + + @Test + fun migrationAddsFavoritesTableAndRangeIndex() { + val sql = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + if (method.name == "execSQL") { + sql += args?.firstOrNull() as String + null + } else { + throw UnsupportedOperationException(method.name) + } + } as SupportSQLiteDatabase + + TelemetryDatabase.MIGRATION_27_28.migrate(db) + + assertTrue(sql.any { it.contains("CREATE TABLE IF NOT EXISTS favorites") }) + assertTrue(sql.any { it.contains("id TEXT NOT NULL PRIMARY KEY") }) + assertTrue(sql.any { it.contains("start_ms INTEGER NOT NULL") }) + assertTrue(sql.any { it.contains("end_ms INTEGER NOT NULL") }) + assertTrue(sql.any { it.contains("created_at INTEGER NOT NULL") }) + assertTrue(sql.any { it.contains("updated_at INTEGER NOT NULL") }) + assertTrue( + sql.any { + it == "CREATE INDEX IF NOT EXISTS index_favorites_start_ms_end_ms ON favorites(start_ms, end_ms)" + }, + ) + } + + private fun bucketsFor(points: List): Collection { + val sanitization = sanitizeTelemetrySamples(points, MetricSanitizerConfig()) + val sanitized = points.mapIndexed { index, point -> + point.copy( + excludedFromAvgSpeed = sanitization.samples[index].excludedFromAvgSpeed, + excludedFromMaxSpeed = sanitization.samples[index].excludedFromMaxSpeed, + excludedFromMaxDuty = sanitization.samples[index].excludedFromMaxDuty, + ) + } + return buildTelemetryBuckets(telemetryPoints = sanitized, locationPoints = emptyList()) + } + + /** + * A steady ride: constant speed, odometer advancing 1 m per second, no GPS (so the free-spin + * sanitizer has nothing to compare against and leaves max speed alone). + */ + private fun ridePoints( + startMs: Long, + count: Int, + speedCentiKmh: Int = 2_000, + intervalMs: Long = 1_000, + ): List = (0 until count).map { index -> + val capturedAtMs = startMs + index * intervalMs + BucketTelemetryPoint( + capturedAtMs = capturedAtMs, + deviceId = "board-1", + deviceName = "VESC Board", + speedCentiKmh = speedCentiKmh, + batteryVoltageMv = 50_000, + motorCurrentMa = 10_000, + batteryCurrentMa = 5_000, + dutyPermille = 400, + hasFault = false, + odometerCm = capturedAtMs / 10, + ) + } + + private fun bucket( + bucketStartMs: Long, + gpsDistanceCm: Long = 0L, + firstOdometerCm: Long? = 0L, + lastOdometerCm: Long? = 1_000L, + ) = TelemetryMinuteBucketEntity( + bucketStartMs = bucketStartMs, + deviceId = "board-1", + deviceName = "VESC Board", + sampleCount = 10, + firstSampleAtMs = bucketStartMs, + lastSampleAtMs = bucketStartMs + 9_000, + sumAbsSpeedCentiKmh = 20_000L, + movingSpeedSampleCount = 10, + sumMovingAbsSpeedCentiKmh = 20_000L, + maxAbsSpeedCentiKmh = 2_000, + minBatteryVoltageMv = 50_000, + maxMotorCurrentAbsMa = 10_000, + maxBatteryCurrentAbsMa = 5_000, + batteryUsedWhMilli = 1_000L, + batteryRegenWhMilli = 0L, + maxDutyAbsPermille = 400, + faultCount = 0, + firstOdometerCm = firstOdometerCm, + lastOdometerCm = lastOdometerCm, + gpsPointCount = 0, + preciseGpsPointCount = 0, + gpsDistanceCm = gpsDistanceCm, + maxGpsSpeedCentiMps = null, + maxTempMosfetDeciC = null, + maxTempMotorDeciC = null, + firstLatitudeE7 = null, + firstLongitudeE7 = null, + firstMovingAtMs = bucketStartMs, + lastMovingAtMs = bucketStartMs + 9_000, + ) + + private fun favorite(distanceCm: Long?) = FavoriteEntity( + id = "fav-1", + deviceId = "board-1", + deviceName = "VESC Board", + name = "Dolina single track", + startMs = 1_000, + endMs = 61_000, + createdAt = 1_700_000_000_000, + updatedAt = 1_700_000_000_000, + sampleCount = 3, + gpsPointCount = 1, + distanceCm = distanceCm, + movingDurationMs = 60_000, + avgSpeedCentiKmh = 1_500, + maxSpeedCentiKmh = 3_000, + batteryUsedWhMilli = 12_500, + ) +} diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 9fda1104e..b71f862de 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -600,6 +600,25 @@ public class VescapeCoreModule: Module { promise.resolve(ProfileStatsRepository.shared.getProfileStatMonths()) } + // Favorites (ADR 0029). JS supplies only the range and an optional name; identity, timestamps + // and the denormalized summary are native. + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `getFavorites` + AsyncFunction("getFavorites") { (promise: Promise) in + promise.resolve(TelemetryRepository.shared.getFavorites()) + } + + AsyncFunction("createFavorite") { (options: [String: Any], promise: Promise) in + guard let favorite = TelemetryRepository.shared.createFavorite(options) else { + promise.reject("ERR_CREATE_FAVORITE", "favorite range is invalid or could not be stored") + return + } + promise.resolve(favorite) + } + + AsyncFunction("deleteFavorite") { (id: String, promise: Promise) in + promise.resolve(TelemetryRepository.shared.deleteFavorite(id)) + } + AsyncFunction("deleteTelemetryBefore") { (beforeMs: Double, promise: Promise) in promise.resolve(TelemetryRepository.shared.deleteBefore(Int64(beforeMs))) } diff --git a/modules/vescape-core/ios/telemetry/FavoriteStore.swift b/modules/vescape-core/ios/telemetry/FavoriteStore.swift new file mode 100644 index 000000000..d862989fa --- /dev/null +++ b/modules/vescape-core/ios/telemetry/FavoriteStore.swift @@ -0,0 +1,235 @@ +import Foundation +import GRDB + +/// One Favorite: a durable, optionally named time range over Ride History (ADR 0029). Identity and +/// timestamps are native-minted — JS may only supply the range and the name. +/// +/// Summary stats are denormalized at creation from raw Telemetry Samples (ADR 0005 style) because +/// minute buckets are too coarse for a range that cuts mid-bucket. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `FavoriteEntity` +/// @parity /modules/vescape-core/src/index.ts `Favorite` +struct Favorite { + let id: String + let deviceId: String? + let deviceName: String? + let name: String? + let startMs: Int64 + let endMs: Int64 + let createdAtMs: Int64 + let updatedAtMs: Int64 + let summary: FavoriteSummary + + func toMap() -> [String: Any?] { + [ + "id": id, + "deviceId": deviceId, + "deviceName": deviceName, + "name": name, + "startMs": startMs, + "endMs": endMs, + "createdAtMs": createdAtMs, + "updatedAtMs": updatedAtMs, + "sampleCount": summary.sampleCount, + "gpsPointCount": summary.gpsPointCount, + "distanceM": summary.distanceCm.map { Double($0) / 100.0 }, + "movingDurationMs": summary.movingDurationMs, + "avgSpeedKmh": Double(summary.avgSpeedCentiKmh) / 100.0, + "maxSpeedKmh": Double(summary.maxSpeedCentiKmh) / 100.0, + "batteryUsedWh": Double(summary.batteryUsedWhMilli) / 1000.0, + ] + } +} + +/// Denormalized ride stats for one Favorite range, mirroring the history session summary fields. +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt `FavoriteSummary` +struct FavoriteSummary { + var deviceId: String? + var deviceName: String? + var sampleCount = 0 + var gpsPointCount = 0 + /// Odometer delta across the range, or `nil` when the range carries no odometer readings. + var distanceCm: Int64? + var movingDurationMs: Int64 = 0 + var avgSpeedCentiKmh = 0 + var maxSpeedCentiKmh = 0 + var batteryUsedWhMilli: Int64 = 0 +} + +/// Aggregate the buckets built from a Favorite's raw samples into one denormalized summary. Pure so +/// both the create path and its tests share one definition. Mirrors how JS collapses minute buckets +/// into a history session summary. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt `buildFavoriteSummary` +/// @platform-diff Distance uses the odometer delta only. iOS never fills `gps_distance_cm` (its +/// bucket writer stores 0), so the Android GPS-distance fallback has no iOS counterpart yet. +internal func buildFavoriteSummary(_ buckets: [TelemetryBucket]) -> FavoriteSummary { + var summary = FavoriteSummary() + guard !buckets.isEmpty else { return summary } + + var sumAbsSpeed: Int64 = 0 + var sumMovingSpeed: Int64 = 0 + var movingSampleCount = 0 + var distanceCm: Int64? + var firstMovingAtMs: Int64? + var lastMovingAtMs: Int64? + var firstSampleAtMs = Int64.max + var lastSampleAtMs = Int64.min + + for bucket in buckets.sorted(by: { $0.bucketStartMs < $1.bucketStartMs }) { + summary.sampleCount += bucket.sampleCount + summary.gpsPointCount += bucket.gpsPointCount + sumAbsSpeed += bucket.sumAbsSpeedCentiKmh + sumMovingSpeed += bucket.sumMovingAbsSpeedCentiKmh + movingSampleCount += bucket.movingSpeedSampleCount + summary.maxSpeedCentiKmh = max(summary.maxSpeedCentiKmh, bucket.maxAbsSpeedCentiKmh) + summary.batteryUsedWhMilli += bucket.batteryUsedWhMilli + if summary.deviceId == nil, !bucket.deviceId.isEmpty { summary.deviceId = bucket.deviceId } + if summary.deviceName == nil { summary.deviceName = bucket.deviceName } + if let first = bucket.firstOdometerCm, let last = bucket.lastOdometerCm { + distanceCm = (distanceCm ?? 0) + max(0, last - first) + } + if let moving = bucket.firstMovingAtMs { firstMovingAtMs = min(firstMovingAtMs ?? moving, moving) } + if let moving = bucket.lastMovingAtMs { lastMovingAtMs = max(lastMovingAtMs ?? moving, moving) } + firstSampleAtMs = min(firstSampleAtMs, bucket.firstSampleAtMs) + lastSampleAtMs = max(lastSampleAtMs, bucket.lastSampleAtMs) + } + + summary.distanceCm = distanceCm + // Moving Window when the range has moving samples, otherwise the wall-clock span it covers — + // the same fallback JS applies to legacy rides with no precomputed window. + if let first = firstMovingAtMs, let last = lastMovingAtMs { + summary.movingDurationMs = max(0, last - first) + } else if firstSampleAtMs <= lastSampleAtMs { + summary.movingDurationMs = max(0, lastSampleAtMs - firstSampleAtMs) + } + if movingSampleCount > 0 { + summary.avgSpeedCentiKmh = Int(sumMovingSpeed / Int64(movingSampleCount)) + } else if summary.sampleCount > 0 { + summary.avgSpeedCentiKmh = Int(sumAbsSpeed / Int64(summary.sampleCount)) + } + return summary +} + +/// DB-backed storage for Favorites. Pure CRUD: the range is pinned against telemetry deletion by the +/// delete paths, not here, and removing a row only unpins (ADR 0029). +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +struct FavoriteStore { + /// Resolves the shared GRDB writer at call time so it always sees the current pool (swapped on + /// database restore). `nil` while the pool failed to open. + private let resolveWriter: () -> DatabaseWriter? + + static let shared = FavoriteStore { TelemetryDatabase.pool } + + init(_ resolveWriter: @escaping () -> DatabaseWriter?) { + self.resolveWriter = resolveWriter + } + + /// Test seam: bind to an explicit writer (e.g. an in-memory `DatabaseQueue`). + init(dbWriter: DatabaseWriter) { + self.resolveWriter = { dbWriter } + } + + // MARK: - Schema + + /// Create the Favorites table. Called from the app-data `DatabaseMigrator` and reused by tests so + /// the schema stays single-source. Mirrors Android `FavoriteEntity`. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `FavoriteEntity` + static func createTables(_ db: Database) throws { + try db.execute(sql: """ + CREATE TABLE favorites ( + id TEXT NOT NULL PRIMARY KEY, + device_id TEXT, + device_name TEXT, + name TEXT, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sample_count INTEGER NOT NULL, + gps_point_count INTEGER NOT NULL, + distance_cm INTEGER, + moving_duration_ms INTEGER NOT NULL, + avg_speed_centi_kmh INTEGER NOT NULL, + max_speed_centi_kmh INTEGER NOT NULL, + battery_used_wh_milli INTEGER NOT NULL + ) + """) + try db.execute(sql: "CREATE INDEX index_favorites_start_ms_end_ms ON favorites(start_ms, end_ms)") + } + + // MARK: - Reads + + func list() -> [Favorite] { + guard let writer = resolveWriter() else { return [] } + return (try? writer.read { db in + try Row.fetchAll(db, sql: "SELECT * FROM favorites ORDER BY start_ms DESC").map(Self.favorite) + }) ?? [] + } + + // MARK: - Writes + + /// Insert a Favorite whose identity and timestamps were minted by the caller's native clock. + @discardableResult + func insert(_ favorite: Favorite) -> Bool { + guard let writer = resolveWriter() else { return false } + do { + try writer.write { db in + try db.execute( + sql: """ + INSERT INTO favorites ( + id, device_id, device_name, name, start_ms, end_ms, created_at, updated_at, + sample_count, gps_point_count, distance_cm, moving_duration_ms, + avg_speed_centi_kmh, max_speed_centi_kmh, battery_used_wh_milli + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + favorite.id, favorite.deviceId, favorite.deviceName, favorite.name, + favorite.startMs, favorite.endMs, favorite.createdAtMs, favorite.updatedAtMs, + favorite.summary.sampleCount, favorite.summary.gpsPointCount, favorite.summary.distanceCm, + favorite.summary.movingDurationMs, favorite.summary.avgSpeedCentiKmh, + favorite.summary.maxSpeedCentiKmh, favorite.summary.batteryUsedWhMilli, + ] + ) + } + return true + } catch { + return false + } + } + + /// Unpin one Favorite. Telemetry inside its range is untouched and becomes deletable again. + @discardableResult + func delete(_ id: String) -> Bool { + guard let writer = resolveWriter() else { return false } + return (try? writer.write { db in + try db.execute(sql: "DELETE FROM favorites WHERE id = ?", arguments: [id]) + return db.changesCount > 0 + }) ?? false + } + + private static func favorite(_ row: Row) -> Favorite { + Favorite( + id: row["id"] as String, + deviceId: row["device_id"] as String?, + deviceName: row["device_name"] as String?, + name: row["name"] as String?, + startMs: row["start_ms"] as Int64, + endMs: row["end_ms"] as Int64, + createdAtMs: row["created_at"] as Int64, + updatedAtMs: row["updated_at"] as Int64, + summary: FavoriteSummary( + deviceId: row["device_id"] as String?, + deviceName: row["device_name"] as String?, + sampleCount: row["sample_count"] as Int, + gpsPointCount: row["gps_point_count"] as Int, + distanceCm: row["distance_cm"] as Int64?, + movingDurationMs: row["moving_duration_ms"] as Int64, + avgSpeedCentiKmh: row["avg_speed_centi_kmh"] as Int, + maxSpeedCentiKmh: row["max_speed_centi_kmh"] as Int, + batteryUsedWhMilli: row["battery_used_wh_milli"] as Int64 + ) + ) + } +} diff --git a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift new file mode 100644 index 000000000..2652373e7 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -0,0 +1,215 @@ +import GRDB +import XCTest +@testable import VescapeCore + +/// Favorites are durable pins over Ride History (ADR 0029): identity and timestamps are native, and +/// the summary is computed once from the raw Telemetry Samples inside the range — including ranges +/// that cut a minute bucket in half, which is exactly what bucket-derived stats cannot express. +final class FavoriteStoreTests: XCTestCase { + private var queue: DatabaseQueue! + private var store: FavoriteStore! + + override func setUpWithError() throws { + queue = try DatabaseQueue() + try queue.write { db in try FavoriteStore.createTables(db) } + store = FavoriteStore(dbWriter: queue) + } + + override func tearDownWithError() throws { + store = nil + queue = nil + } + + // MARK: - Store + + func testInsertedFavoriteRoundTripsThroughTheStore() throws { + let favorite = makeFavorite( + id: "fav-1", + name: "Dolina single track", + startMs: 1_000, + endMs: 61_000, + summary: FavoriteSummary( + deviceId: "board-1", + deviceName: "VESC Board", + sampleCount: 12, + gpsPointCount: 4, + distanceCm: 123_400, + movingDurationMs: 55_000, + avgSpeedCentiKmh: 1_850, + maxSpeedCentiKmh: 4_210, + batteryUsedWhMilli: 9_600 + ) + ) + + XCTAssertTrue(store.insert(favorite)) + + let stored = try XCTUnwrap(store.list().first) + XCTAssertEqual(stored.id, "fav-1") + XCTAssertEqual(stored.name, "Dolina single track") + XCTAssertEqual(stored.startMs, 1_000) + XCTAssertEqual(stored.endMs, 61_000) + XCTAssertEqual(stored.deviceId, "board-1") + XCTAssertEqual(stored.summary.distanceCm, 123_400) + XCTAssertEqual(stored.summary.movingDurationMs, 55_000) + XCTAssertEqual(stored.summary.avgSpeedCentiKmh, 1_850) + XCTAssertEqual(stored.summary.maxSpeedCentiKmh, 4_210) + XCTAssertEqual(stored.summary.batteryUsedWhMilli, 9_600) + } + + func testListReturnsNewestRangeFirst() { + store.insert(makeFavorite(id: "older", startMs: 1_000, endMs: 2_000)) + store.insert(makeFavorite(id: "newer", startMs: 9_000, endMs: 10_000)) + + XCTAssertEqual(store.list().map(\.id), ["newer", "older"]) + } + + /// Removing a Favorite unpins it and nothing else: only its own row goes away. + func testDeleteRemovesOnlyTheTargetRow() { + store.insert(makeFavorite(id: "fav-1", startMs: 1_000, endMs: 2_000)) + store.insert(makeFavorite(id: "fav-2", startMs: 3_000, endMs: 4_000)) + + XCTAssertTrue(store.delete("fav-1")) + XCTAssertEqual(store.list().map(\.id), ["fav-2"]) + XCTAssertFalse(store.delete("fav-1")) + } + + func testBridgeMapConvertsStoredIntegersToRiderUnits() { + let map = makeFavorite( + id: "fav-1", + startMs: 1_000, + endMs: 2_000, + summary: FavoriteSummary( + sampleCount: 3, + gpsPointCount: 1, + distanceCm: 250_000, + movingDurationMs: 60_000, + avgSpeedCentiKmh: 1_500, + maxSpeedCentiKmh: 3_000, + batteryUsedWhMilli: 12_500 + ) + ).toMap() + + XCTAssertEqual(map["distanceM"] as? Double, 2_500.0) + XCTAssertEqual(map["avgSpeedKmh"] as? Double, 15.0) + XCTAssertEqual(map["maxSpeedKmh"] as? Double, 30.0) + XCTAssertEqual(map["batteryUsedWh"] as? Double, 12.5) + } + + /// A range with no odometer readings has no distance to report — the bridge must send `nil` + /// rather than a fabricated zero, so the row renders "-" like a history row without distance. + func testMissingDistanceStaysNullAcrossTheBridge() { + let map = makeFavorite(id: "fav-1", startMs: 1_000, endMs: 2_000).toMap() + + XCTAssertNil(map["distanceM"] ?? nil) + } + + // MARK: - Summary from raw samples + + func testSummaryAggregatesRawSamplesAcrossBucketBoundaries() { + let points = ridePoints(startMs: 0, count: 120, speedCentiKmh: 2_000, intervalMs: 1_000) + + let summary = TelemetryRepository.favoriteSummary(points, config: MetricSanitizerConfig()) + + XCTAssertEqual(summary.sampleCount, 120) + XCTAssertEqual(summary.avgSpeedCentiKmh, 2_000) + XCTAssertEqual(summary.maxSpeedCentiKmh, 2_000) + XCTAssertEqual(summary.movingDurationMs, 119_000) + // 1 m per sample interval. Distance sums per-bucket odometer deltas, so the hop across a bucket + // boundary is not counted — the same arithmetic history session rows already use. + XCTAssertEqual(summary.distanceCm, 11_800) + XCTAssertEqual(summary.deviceId, "board-1") + } + + /// The point of computing from raw samples: a Favorite trimmed inside a minute bucket must report + /// the trimmed span, not the whole bucket the samples happen to live in. + func testSummaryOfAMidBucketRangeCoversOnlyTheTrimmedSamples() { + let all = ridePoints(startMs: 0, count: 120, speedCentiKmh: 2_000, intervalMs: 1_000) + let trimmed = all.filter { $0.capturedAtMs >= 30_000 && $0.capturedAtMs <= 89_000 } + + let summary = TelemetryRepository.favoriteSummary(trimmed, config: MetricSanitizerConfig()) + + XCTAssertEqual(summary.sampleCount, 60) + XCTAssertEqual(summary.movingDurationMs, 59_000) + XCTAssertEqual(summary.distanceCm, 5_800) + } + + /// Idle samples below the moving threshold are excluded from average speed by the Metric + /// Sanitizers, exactly as they are while recording — a trimmed Favorite must not average them in. + func testSummaryExcludesIdleSamplesFromAverageSpeed() { + let idle = ridePoints(startMs: 0, count: 10, speedCentiKmh: 0, intervalMs: 1_000) + let moving = ridePoints(startMs: 10_000, count: 10, speedCentiKmh: 2_000, intervalMs: 1_000) + + let summary = TelemetryRepository.favoriteSummary(idle + moving, config: MetricSanitizerConfig()) + + XCTAssertEqual(summary.sampleCount, 20) + XCTAssertEqual(summary.avgSpeedCentiKmh, 2_000) + XCTAssertEqual(summary.movingDurationMs, 9_000) + } + + /// A range with no samples at all (deleted telemetry, wrong device) yields an empty summary + /// instead of crashing the create path. + func testSummaryOfAnEmptyRangeIsZeroed() { + let summary = TelemetryRepository.favoriteSummary([], config: MetricSanitizerConfig()) + + XCTAssertEqual(summary.sampleCount, 0) + XCTAssertEqual(summary.movingDurationMs, 0) + XCTAssertNil(summary.distanceCm) + } + + // MARK: - Fixtures + + private func makeFavorite( + id: String, + name: String? = nil, + startMs: Int64, + endMs: Int64, + summary: FavoriteSummary = FavoriteSummary() + ) -> Favorite { + Favorite( + id: id, + deviceId: summary.deviceId, + deviceName: summary.deviceName, + name: name, + startMs: startMs, + endMs: endMs, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_000_000, + summary: summary + ) + } + + /// A steady ride: constant speed, odometer advancing 1 m per interval, no GPS (so the free-spin + /// sanitizer has nothing to compare against and leaves max speed alone). + private func ridePoints( + startMs: Int64, + count: Int, + speedCentiKmh: Int, + intervalMs: Int64 + ) -> [BucketTelemetryPoint] { + (0.. [[String: Any?]] { + FavoriteStore.shared.list().map { $0.toMap() } + } + + /// Pin a time range as a Favorite. Identity and timestamps are minted here — the range and the + /// optional name are the only things JS gets to supply. Summary stats come from the raw samples + /// inside the range, so a range that cuts mid-bucket still gets exact numbers. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `createFavorite` + func createFavorite(_ options: [String: Any]) -> [String: Any?]? { + flushBlocking() + guard let pool else { return nil } + let startMs = telemetryLong(options["startMs"]) ?? 0 + let endMs = telemetryLong(options["endMs"]) ?? 0 + guard endMs >= startMs else { return nil } + let deviceId = options["deviceId"] as? String + let trimmedName = (options["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let config = queue.sync { metricConfig } + let points = (try? pool.read { db in + try Row.fetchAll( + db, + sql: """ + SELECT * FROM telemetry_frames + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + ORDER BY captured_at_ms ASC + """, + arguments: [startMs, endMs, deviceId, deviceId] + ).compactMap(bucketPoint) + }) ?? [] + let summary = Self.favoriteSummary(points, config: config) + let nowMs = telemetryNowMs() + let favorite = Favorite( + id: UUID().uuidString, + deviceId: deviceId ?? summary.deviceId, + deviceName: summary.deviceName, + name: (trimmedName?.isEmpty ?? true) ? nil : trimmedName, + startMs: startMs, + endMs: endMs, + createdAtMs: nowMs, + updatedAtMs: nowMs, + summary: summary + ) + guard FavoriteStore.shared.insert(favorite) else { return nil } + return favorite.toMap() + } + + /// Unpin a Favorite. Telemetry in its range stays and becomes normally deletable (ADR 0029). + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `deleteFavorite` + func deleteFavorite(_ id: String) -> Bool { + FavoriteStore.shared.delete(id) + } + + /// Run the raw samples of a Favorite range through the same Metric Sanitizers the recording flush + /// applies, then collapse the resulting buckets into one denormalized summary. Exclusion ranges + /// are deliberately not persisted: creating a Favorite is a read of Ride History, not a rewrite. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `favoriteSummary` + internal static func favoriteSummary( + _ points: [BucketTelemetryPoint], + config: MetricSanitizerConfig + ) -> FavoriteSummary { + guard !points.isEmpty else { return FavoriteSummary() } + let sanitization = sanitizeTelemetrySamples(points, config: config) + var sanitized = points + for i in sanitized.indices { + sanitized[i].excludedFromAvgSpeed = sanitization.samples[i].excludedFromAvgSpeed + sanitized[i].excludedFromMaxSpeed = sanitization.samples[i].excludedFromMaxSpeed + sanitized[i].excludedFromMaxDuty = sanitization.samples[i].excludedFromMaxDuty + } + return buildFavoriteSummary(buildTelemetryBuckets(sanitized)) + } + func deleteBefore(_ beforeMs: Int64) -> Int { guard let pool else { return 0 } return (try? pool.write { db in diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 3380e7fb3..0ce34a14a 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -731,6 +731,43 @@ export interface TelemetrySummary { droppedPendingSamples: number } +/** + * One Favorite: a durable, optionally named time range over Ride History (ADR 0029). Identity, + * timestamps and the summary stats are native-owned — JS only ever sends a range and a name. + * + * @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `Favorite` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `FavoriteEntity` + */ +export interface Favorite { + id: string + deviceId: string | null + deviceName: string | null + name: string | null + startMs: number + endMs: number + createdAtMs: number + updatedAtMs: number + sampleCount: number + gpsPointCount: number + /** Null when the favorited range carries no distance source. */ + distanceM: number | null + movingDurationMs: number + avgSpeedKmh: number + maxSpeedKmh: number + batteryUsedWh: number +} + +/** + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `createFavorite` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `createFavorite` + */ +export interface CreateFavoriteOptions { + startMs: number + endMs: number + deviceId?: string + name?: string +} + export interface RefloatConfigField { id: string label: string @@ -1355,6 +1392,9 @@ type VescapeCoreNativeModule = NativeEventEmitter & { limit?: number }): Promise getTelemetrySummary(): Promise + getFavorites(): Promise + createFavorite(options: CreateFavoriteOptions): Promise + deleteFavorite(id: string): Promise getDiagnosticEvents(options: DiagnosticEventOptions): Promise clearDiagnosticEvents(): Promise getBoardWarnings(): Promise @@ -1818,6 +1858,20 @@ export async function getTelemetrySummary(): Promise { return native.getTelemetrySummary() } +export async function getFavorites(): Promise { + return native.getFavorites() +} + +/** Pin a time range as a Favorite. Native mints the id, the timestamps and the summary stats. */ +export async function createFavorite(options: CreateFavoriteOptions): Promise { + return native.createFavorite(options) +} + +/** Unpin a Favorite. Its telemetry stays and becomes normally deletable (ADR 0029). */ +export async function deleteFavorite(id: string): Promise { + return native.deleteFavorite(id) +} + export async function getDiagnosticEvents( options: DiagnosticEventOptions = {}, ): Promise { diff --git a/src/modules/history/components/FavoriteList.tsx b/src/modules/history/components/FavoriteList.tsx new file mode 100644 index 000000000..fa0beaaec --- /dev/null +++ b/src/modules/history/components/FavoriteList.tsx @@ -0,0 +1,135 @@ +import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native' +import { StarIcon, TrashIcon } from 'phosphor-react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +import { IconButton } from '@/components/base/IconButton' +import { Placeholder } from '@/components/base/Placeholder' +import { Text } from '@/components/base/Text' +import { theme } from '@/constants/theme' +import { telemetry } from '@/modules/board/constants/telemetry' +import { formatRideDate, formatRideTime } from '@/modules/history/lib/rideFormat' +import type { Favorite } from '@/modules/history/store/favoriteStore' + +interface FavoriteListProps { + favorites: Favorite[] + loading: boolean + onRemove: (favorite: Favorite) => void +} + +/** Favorites tab: the starred ranges, newest first. Unnamed rows fall back to date, like history. */ +export function FavoriteList({ favorites, loading, onRemove }: FavoriteListProps) { + const insets = useSafeAreaInsets() + + if (loading && favorites.length === 0) { + return ( + + + + ) + } + + if (favorites.length === 0) { + return ( + + + + ) + } + + return ( + + {favorites.map((favorite) => ( + + + + {favorite.name ?? formatRideDate(favorite.startMs, favorite.endMs)} + + + {formatRideTime(favorite.startMs, favorite.endMs)} + {favorite.deviceName ? ` · ${favorite.deviceName}` : ''} + + + {formatDuration(favorite.movingDurationMs)} · {formatDistance(favorite.distanceM)} ·{' '} + {telemetry.speed.formatWithUnit(favorite.maxSpeedKmh)} ·{' '} + {favorite.batteryUsedWh.toFixed(1)} Wh + + + onRemove(favorite)} + /> + + ))} + + ) +} + +function formatDuration(ms: number): string { + const mins = Math.max(1, Math.round(ms / 60_000)) + if (mins < 60) return `${mins}m` + const h = Math.floor(mins / 60) + const rem = mins % 60 + return rem ? `${h}h ${rem}m` : `${h}h` +} + +function formatDistance(distanceM: number | null): string { + if (distanceM == null) return '-' + return `${(distanceM / 1000).toFixed(2)} km` +} + +const styles = StyleSheet.create({ + wrap: { + ...StyleSheet.absoluteFill, + zIndex: 12, + alignItems: 'center', + justifyContent: 'center', + }, + listWrap: { + ...StyleSheet.absoluteFill, + zIndex: 12, + }, + content: { + width: '100%', + paddingHorizontal: 16, + gap: 8, + }, + row: { + borderRadius: 12, + borderWidth: 1, + borderColor: theme.palette.slate.border, + backgroundColor: theme.alpha(theme.palette.slate.surfaceDeep, 0.85), + paddingVertical: 10, + paddingHorizontal: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + rowMain: { + flex: 1, + minWidth: 0, + gap: 2, + }, + rowTitle: { + color: theme.palette.slate.textPrimary, + fontSize: 13, + fontWeight: '700', + }, + rowSubtitle: { + color: theme.palette.slate.textSecondary, + fontSize: 12, + }, + rowMeta: { + color: theme.palette.slate.textMuted, + fontSize: 11, + }, +}) diff --git a/src/modules/history/lib/favorites.test.ts b/src/modules/history/lib/favorites.test.ts new file mode 100644 index 000000000..0b3fe1d3e --- /dev/null +++ b/src/modules/history/lib/favorites.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from 'bun:test' + +import type { Favorite } from 'vescape-core' + +import { favoriteRangeForSession, findSessionFavorite } from '@/modules/history/lib/favorites' + +const session = { + startAtMs: 1_000_000, + endAtMs: 1_600_000, + movingStartAtMs: 1_100_000, + movingEndAtMs: 1_500_000, + deviceId: 'board-1', +} + +function favorite(overrides: Partial): Favorite { + return { + id: 'fav-1', + deviceId: 'board-1', + deviceName: 'VESC Board', + name: null, + startMs: 1_100_000, + endMs: 1_500_000, + createdAtMs: 0, + updatedAtMs: 0, + sampleCount: 0, + gpsPointCount: 0, + distanceM: null, + movingDurationMs: 0, + avgSpeedKmh: 0, + maxSpeedKmh: 0, + batteryUsedWh: 0, + ...overrides, + } +} + +test('star pins the full Moving Window, not the idle-padded ride span', () => { + expect(favoriteRangeForSession(session)).toEqual({ startMs: 1_100_000, endMs: 1_500_000 }) +}) + +test('legacy rides without a Moving Window fall back to their wall-clock span', () => { + expect( + favoriteRangeForSession({ ...session, movingStartAtMs: null, movingEndAtMs: null }), + ).toEqual({ startMs: 1_000_000, endMs: 1_600_000 }) +}) + +test('a ride counts as favorited only when a favorite covers its exact range and board', () => { + expect(findSessionFavorite([favorite({})], session)?.id).toBe('fav-1') + expect(findSessionFavorite([favorite({ endMs: 1_400_000 })], session)).toBeNull() + expect(findSessionFavorite([favorite({ deviceId: 'board-2' })], session)).toBeNull() +}) diff --git a/src/modules/history/lib/favorites.ts b/src/modules/history/lib/favorites.ts new file mode 100644 index 000000000..99e9b556d --- /dev/null +++ b/src/modules/history/lib/favorites.ts @@ -0,0 +1,33 @@ +import type { Favorite } from 'vescape-core' + +import { rideMovingWindow, type HistorySession } from '@/modules/history/lib/sessions' + +/** + * The range a star on an open ride pins: the full Moving Window, so favoriting a whole ride is one + * tap. Rides with no precomputed window (legacy data) fall back to their wall-clock span. + */ +export function favoriteRangeForSession( + session: Pick, +): { startMs: number; endMs: number } { + const window = rideMovingWindow(session) + return window ?? { startMs: session.startAtMs, endMs: session.endAtMs } +} + +/** True when a Favorite already covers this ride's Moving Window, so the star reads as filled. */ +export function findSessionFavorite( + favorites: Favorite[], + session: Pick< + HistorySession, + 'movingStartAtMs' | 'movingEndAtMs' | 'startAtMs' | 'endAtMs' | 'deviceId' + >, +): Favorite | null { + const range = favoriteRangeForSession(session) + return ( + favorites.find( + (favorite) => + favorite.startMs === range.startMs && + favorite.endMs === range.endMs && + (favorite.deviceId ?? null) === session.deviceId, + ) ?? null + ) +} diff --git a/src/modules/history/store/favoriteStore.test.ts b/src/modules/history/store/favoriteStore.test.ts new file mode 100644 index 000000000..960427903 --- /dev/null +++ b/src/modules/history/store/favoriteStore.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, expect, mock, test } from 'bun:test' + +import type { Favorite } from 'vescape-core' + +const actualVescapeCore = await import('@/../modules/vescape-core/src/index') + +function favorite(overrides: Partial & Pick): Favorite { + return { + deviceId: 'board-1', + deviceName: 'VESC Board', + name: null, + endMs: overrides.startMs + 60_000, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_000_000, + sampleCount: 120, + gpsPointCount: 20, + distanceM: 1_180, + movingDurationMs: 59_000, + avgSpeedKmh: 20, + maxSpeedKmh: 32, + batteryUsedWh: 12.5, + ...overrides, + } +} + +const getFavorites = mock(async () => [] as Favorite[]) +const createFavorite = mock(async (): Promise => { + throw new Error('createFavorite not stubbed') +}) +const deleteFavorite = mock(async () => true) + +const vescapeCoreMock = { + ...actualVescapeCore, + getFavorites, + createFavorite, + deleteFavorite, +} + +mock.module('vescape-core', () => vescapeCoreMock) +mock.module('../../modules/vescape-core/src/index', () => vescapeCoreMock) + +beforeEach(async () => { + getFavorites.mockClear() + createFavorite.mockClear() + deleteFavorite.mockClear() + getFavorites.mockImplementation(async () => []) + createFavorite.mockImplementation(async () => { + throw new Error('createFavorite not stubbed') + }) + deleteFavorite.mockImplementation(async () => true) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + useFavoriteStore.setState({ favorites: [], loading: false, error: undefined }) +}) + +test('loads favorites from native', async () => { + const stored = favorite({ id: 'fav-1', startMs: 2_000_000 }) + getFavorites.mockImplementation(async () => [stored]) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + await useFavoriteStore.getState().load() + + expect(useFavoriteStore.getState().favorites).toEqual([stored]) + expect(useFavoriteStore.getState().loading).toBe(false) +}) + +test('keeps the list newest first after adding a favorite', async () => { + const older = favorite({ id: 'older', startMs: 1_000_000 }) + const newer = favorite({ id: 'newer', startMs: 3_000_000 }) + getFavorites.mockImplementation(async () => [older]) + createFavorite.mockImplementation(async () => newer) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + await useFavoriteStore.getState().load() + await useFavoriteStore.getState().add({ startMs: newer.startMs, endMs: newer.endMs }) + + expect(useFavoriteStore.getState().favorites.map((f) => f.id)).toEqual(['newer', 'older']) +}) + +test('surfaces a create failure instead of inserting a phantom row', async () => { + createFavorite.mockImplementation(async () => { + throw new Error('range has no samples') + }) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + const created = await useFavoriteStore.getState().add({ startMs: 1_000, endMs: 2_000 }) + + expect(created).toBeNull() + expect(useFavoriteStore.getState().favorites).toEqual([]) + expect(useFavoriteStore.getState().error).toBe('range has no samples') +}) + +test('removes only the deleted favorite', async () => { + const kept = favorite({ id: 'kept', startMs: 1_000_000 }) + getFavorites.mockImplementation(async () => [favorite({ id: 'gone', startMs: 2_000_000 }), kept]) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + await useFavoriteStore.getState().load() + await useFavoriteStore.getState().remove('gone') + + expect(deleteFavorite).toHaveBeenCalledWith('gone') + expect(useFavoriteStore.getState().favorites).toEqual([kept]) +}) diff --git a/src/modules/history/store/favoriteStore.ts b/src/modules/history/store/favoriteStore.ts new file mode 100644 index 000000000..f49b01500 --- /dev/null +++ b/src/modules/history/store/favoriteStore.ts @@ -0,0 +1,65 @@ +import { create } from 'zustand' +import { + createFavorite, + deleteFavorite, + getFavorites, + type Favorite, + type CreateFavoriteOptions, +} from 'vescape-core' + +interface FavoriteState { + favorites: Favorite[] + loading: boolean + error: string | undefined +} + +interface FavoriteActions { + load: () => Promise + /** Pin a range. Native owns identity, timestamps and stats — JS only sends range + name. */ + add: (options: CreateFavoriteOptions) => Promise + /** Unpin. Telemetry inside the range stays (ADR 0029). */ + remove: (id: string) => Promise +} + +export const useFavoriteStore = create((set, get) => ({ + favorites: [], + loading: false, + error: undefined, + + async load() { + set({ loading: true, error: undefined }) + try { + set({ favorites: await getFavorites() }) + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err) }) + } finally { + set({ loading: false }) + } + }, + + async add(options) { + set({ error: undefined }) + try { + const favorite = await createFavorite(options) + set({ + favorites: [favorite, ...get().favorites].sort((a, b) => b.startMs - a.startMs), + }) + return favorite + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err) }) + return null + } + }, + + async remove(id) { + set({ error: undefined }) + try { + await deleteFavorite(id) + set({ favorites: get().favorites.filter((favorite) => favorite.id !== id) }) + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err) }) + } + }, +})) + +export type { Favorite } diff --git a/src/screens/main/MainScreen.tsx b/src/screens/main/MainScreen.tsx index 9bb0f5828..22d3e1c62 100644 --- a/src/screens/main/MainScreen.tsx +++ b/src/screens/main/MainScreen.tsx @@ -208,6 +208,13 @@ export function MainScreen({ sessions: controller.sessions, historySheetVisible: controller.historySheetVisible, setHistorySheetVisible: controller.setHistorySheetVisible, + historyTab: controller.historyTab, + selectHistoryTab: controller.selectHistoryTab, + favorites: controller.favorites, + favoritesLoading: controller.favoritesLoading, + selectedSessionFavorite: controller.selectedSessionFavorite, + toggleSelectedRideFavorite: controller.toggleSelectedRideFavorite, + removeFavorite: controller.removeFavorite, selectSession: controller.selectSession, loadMoreHistory: controller.loadMoreHistory, selectPreviousRide: controller.selectPreviousRide, diff --git a/src/screens/main/history/HistoryControls.tsx b/src/screens/main/history/HistoryControls.tsx index a39507e6c..c5612e920 100644 --- a/src/screens/main/history/HistoryControls.tsx +++ b/src/screens/main/history/HistoryControls.tsx @@ -1,26 +1,75 @@ import { StyleSheet, View } from 'react-native' -import { ArrowLeftIcon, TrashIcon } from 'phosphor-react-native' +import { + ArrowLeftIcon, + ClockCounterClockwiseIcon, + StarIcon, + TrashIcon, +} from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IconButton } from '@/components/base/IconButton' -import { ScreenTitle } from '@/components/base/ScreenTitle' +import { PillSelector, PillSelectorItem } from '@/components/controls/PillSelector' +import { theme } from '@/constants/theme' +import type { HistoryTab } from '@/screens/main/mainScreenStore' interface HistoryControlsProps { loading: boolean + tab: HistoryTab canRemove: boolean + /** Star is offered only for an open ride; filled once that ride is already favorited. */ + canFavorite: boolean + favorited: boolean + onSelectTab: (tab: HistoryTab) => void onBack: () => void onRemove: () => void + onToggleFavorite: () => void } -export function HistoryControls({ loading, canRemove, onBack, onRemove }: HistoryControlsProps) { +export function HistoryControls({ + loading, + tab, + canRemove, + canFavorite, + favorited, + onSelectTab, + onBack, + onRemove, + onToggleFavorite, +}: HistoryControlsProps) { const insets = useSafeAreaInsets() return ( - - + + + onSelectTab('history')} + /> + onSelectTab('favorites')} + /> + + {canFavorite ? ( + + ) : null} {canRemove ? ( ) : ( @@ -46,8 +95,9 @@ const styles = StyleSheet.create({ row: { flexDirection: 'row', alignItems: 'center', + gap: 8, }, - titleWrap: { + tabsWrap: { flex: 1, alignItems: 'center', }, diff --git a/src/screens/main/mainScreenStore.ts b/src/screens/main/mainScreenStore.ts index e3679246b..6c520ffe9 100644 --- a/src/screens/main/mainScreenStore.ts +++ b/src/screens/main/mainScreenStore.ts @@ -5,8 +5,12 @@ import type { MainViewState } from '@/screens/main/mainViewState' export type MapSelector = 'navigation' | 'style' | null +/** Which list the history screen shows: recorded rides, or the Favorites the rider starred. */ +export type HistoryTab = 'history' | 'favorites' + interface MainScreenState { mode: MainViewState + historyTab: HistoryTab historySheetVisible: boolean mapSelector: MapSelector perspectiveEnabled: boolean @@ -21,6 +25,7 @@ interface MainScreenActions { enterWeather: () => void enterLegalLimits: () => void enterHistory: () => void + setHistoryTab: (tab: HistoryTab) => void setHistorySheetVisible: (visible: boolean) => void setMapSelector: (selector: MapSelector) => void dismissMapSelector: () => void @@ -31,6 +36,7 @@ interface MainScreenActions { const initialState: MainScreenState = { mode: 'telemetry', + historyTab: 'history', historySheetVisible: false, mapSelector: null, perspectiveEnabled: true, @@ -65,6 +71,12 @@ export const useMainScreenStore = create((s set({ mode: 'history', mapSelector: null }) }, + setHistoryTab(tab) { + set((state) => + state.historyTab === tab ? state : { historyTab: tab, historySheetVisible: false }, + ) + }, + setHistorySheetVisible(visible) { set({ historySheetVisible: visible }) }, diff --git a/src/screens/main/overlays/MainOverlays.tsx b/src/screens/main/overlays/MainOverlays.tsx index 5cde4da70..18648c537 100644 --- a/src/screens/main/overlays/MainOverlays.tsx +++ b/src/screens/main/overlays/MainOverlays.tsx @@ -37,12 +37,13 @@ import Animated, { type SharedValue, } from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import type { HistoryMarker, MapPointKind } from 'vescape-core' +import type { Favorite, HistoryMarker, MapPointKind } from 'vescape-core' import { ConfirmModal } from '@/components/modals/ConfirmModal' import { EdgeDrawer } from '@/components/overlays/AnchoredSheet' import { MediaHistoryViewer } from '@/modules/history/components/MediaHistoryViewer' import { FloatingBar } from '@/modules/board/components/FloatingBar' +import { FavoriteList } from '@/modules/history/components/FavoriteList' import { HistorySessionSheet } from '@/modules/history/components/HistorySessionSheet' import { IconButton } from '@/components/base/IconButton' import { MapNavigationSelector } from '@/modules/map/components/MapNavigationSelector' @@ -71,7 +72,7 @@ import { OffscreenMapIndicator, type OffscreenMapIndicatorState, } from '@/screens/main/map/offscreenMapIndicators' -import type { MapSelector } from '@/screens/main/mainScreenStore' +import type { HistoryTab, MapSelector } from '@/screens/main/mainScreenStore' import type { MainViewState } from '@/screens/main/mainViewState' import { HistoryControls } from '@/screens/main/history/HistoryControls' import { HistoryEmptyState } from '@/modules/history/components/HistoryEmptyState' @@ -161,6 +162,13 @@ interface MainHistoryOverlayProps { sessions: HistorySession[] historySheetVisible: boolean setHistorySheetVisible: (visible: boolean) => void + historyTab: HistoryTab + selectHistoryTab: (tab: HistoryTab) => void + favorites: Favorite[] + favoritesLoading: boolean + selectedSessionFavorite: Favorite | null + toggleSelectedRideFavorite: () => Promise + removeFavorite: (id: string) => Promise selectSession: (session: HistorySession | null) => Promise loadMoreHistory: () => Promise selectPreviousRide: () => Promise @@ -1087,7 +1095,30 @@ export function MainOverlays({ /> - {mode === 'history' && history.selectedSession && ( + {mode === 'history' && history.historyTab === 'favorites' && ( + <> + { + void history.removeFavorite(favorite.id) + }} + /> + undefined} + onToggleFavorite={() => undefined} + /> + + )} + + {mode === 'history' && history.historyTab === 'history' && history.selectedSession && ( <> {historyBusy && ( @@ -1123,14 +1154,21 @@ export function MainOverlays({ { + void history.toggleSelectedRideFavorite() + }} /> )} - {mode === 'history' && !history.selectedSession && ( + {mode === 'history' && history.historyTab === 'history' && !history.selectedSession && ( <> {historyBusy ? ( @@ -1141,9 +1179,14 @@ export function MainOverlays({ )} undefined} + onToggleFavorite={() => undefined} /> )} diff --git a/src/screens/main/useMainScreenController.ts b/src/screens/main/useMainScreenController.ts index 89f791315..505781a76 100644 --- a/src/screens/main/useMainScreenController.ts +++ b/src/screens/main/useMainScreenController.ts @@ -6,7 +6,7 @@ import { useShallow } from 'zustand/react/shallow' import { exitApp } from 'vescape-core' import type { MainMapHandle } from '@/screens/main/map/MainMap' -import { useMainScreenStore } from '@/screens/main/mainScreenStore' +import { useMainScreenStore, type HistoryTab } from '@/screens/main/mainScreenStore' import { getLatestSession, getNextRideSession, @@ -14,6 +14,8 @@ import { } from '@/screens/main/mainState' import { useBleStore } from '@/modules/board/store/bleStore' import { useHistoryStore, type HistorySession } from '@/modules/history/store/historyStore' +import { useFavoriteStore } from '@/modules/history/store/favoriteStore' +import { favoriteRangeForSession, findSessionFavorite } from '@/modules/history/lib/favorites' import { useMapStore } from '@/modules/map/store/mapStore' import { useSettingsStore } from '@/modules/settings/store/settingsStore' import { useWeatherStore } from '@/modules/weather/store/weatherStore' @@ -34,6 +36,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) const [openMediaAssetId, setOpenMediaAssetId] = useState(null) const { mode, + historyTab, historySheetVisible, mapSelector, perspectiveEnabled, @@ -43,6 +46,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) enterWeather, enterLegalLimits, enterHistory, + setHistoryTab, setHistorySheetVisible, setMapSelector, dismissMapSelector, @@ -52,6 +56,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) } = useMainScreenStore( useShallow((s) => ({ mode: s.mode, + historyTab: s.historyTab, historySheetVisible: s.historySheetVisible, mapSelector: s.mapSelector, perspectiveEnabled: s.perspectiveEnabled, @@ -61,6 +66,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) enterWeather: s.enterWeather, enterLegalLimits: s.enterLegalLimits, enterHistory: s.enterHistory, + setHistoryTab: s.setHistoryTab, setHistorySheetVisible: s.setHistorySheetVisible, setMapSelector: s.setMapSelector, dismissMapSelector: s.dismissMapSelector, @@ -69,6 +75,16 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) setActiveHistoryMapMetric: s.setActiveHistoryMapMetric, })), ) + const { favorites, favoritesLoading, loadFavorites, addFavorite, removeFavorite } = + useFavoriteStore( + useShallow((s) => ({ + favorites: s.favorites, + favoritesLoading: s.loading, + loadFavorites: s.load, + addFavorite: s.add, + removeFavorite: s.remove, + })), + ) const liveLocations = useBleStore((s) => s.liveLocationHistory) const latestApproximateLocation = useBleStore((s) => s.latestApproximateLocation) const fetchWeather = useWeatherStore((s) => s.fetch) @@ -225,14 +241,44 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) requestAnimationFrame(() => mapRef.current?.recenterLive()) }, [enterTelemetry, mapRef]) + const selectedSessionFavorite = useMemo( + () => (selectedSession ? findSessionFavorite(favorites, selectedSession) : null), + [favorites, selectedSession], + ) + + const selectHistoryTab = useCallback( + (tab: HistoryTab) => { + setHistoryTab(tab) + if (tab === 'favorites') void loadFavorites() + }, + [loadFavorites, setHistoryTab], + ) + + /** Star on an open ride pins its full Moving Window; starring again unpins that same range. */ + const toggleSelectedRideFavorite = useCallback(async () => { + const session = useHistoryStore.getState().selectedSession + if (!session) return + const existing = findSessionFavorite(useFavoriteStore.getState().favorites, session) + if (existing) { + await removeFavorite(existing.id) + return + } + const range = favoriteRangeForSession(session) + await addFavorite({ + ...range, + ...(session.deviceId ? { deviceId: session.deviceId } : {}), + }) + }, [addFavorite, removeFavorite]) + const exitHistory = useCallback(() => { setOpenMediaAssetId(null) + setHistoryTab('history') void selectSession(null) enterTelemetry() requestAnimationFrame(() => mapRef.current?.recenterLive({ resetPadding: true, animationDuration: 0 }), ) - }, [enterTelemetry, mapRef, selectSession]) + }, [enterTelemetry, mapRef, selectSession, setHistoryTab]) const loadOlderHistoryPages = useCallback( async (targetSessionCount = TARGET_INITIAL_HISTORY_SESSIONS) => { @@ -251,6 +297,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) const enterHistoryMode = useCallback(async () => { enterHistory() + void loadFavorites() await loadInitial() await loadOlderHistoryPages() if (useMainScreenStore.getState().mode !== 'history') return @@ -258,7 +305,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) if (latest) { await selectSession(latest) } - }, [enterHistory, loadInitial, loadOlderHistoryPages, selectSession]) + }, [enterHistory, loadFavorites, loadInitial, loadOlderHistoryPages, selectSession]) const selectPreviousRide = useCallback(async () => { setOpenMediaAssetId(null) @@ -430,6 +477,13 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) historyError, historySheetVisible, setHistorySheetVisible, + historyTab, + selectHistoryTab, + favorites, + favoritesLoading, + selectedSessionFavorite, + toggleSelectedRideFavorite, + removeFavorite, selectSession, loadMoreHistory: loadMore, selectPreviousRide, From 091c74d519ab37c81cf321b5e37518dec9e13c72 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 28 Jul 2026 02:37:09 +0200 Subject: [PATCH 06/24] Key favorites by board_id instead of ble device id #287 --- .../telemetry/FavoriteSummaryBuilder.kt | 8 ----- .../telemetry/TelemetryDatabase.kt | 4 +-- .../telemetry/TelemetryEntities.kt | 23 +++++++++----- .../telemetry/TelemetryRepository.kt | 20 +++++++++--- .../telemetry/FavoriteSummaryBuilderTest.kt | 15 ++++++--- .../ios/telemetry/FavoriteStore.swift | 31 ++++++++----------- .../ios/telemetry/FavoriteStoreTests.swift | 15 +++++---- .../ios/telemetry/TelemetryRepository.swift | 31 ++++++++++++++++--- modules/vescape-core/src/index.ts | 9 ++++-- .../history/components/FavoriteList.tsx | 2 +- src/modules/history/lib/favorites.test.ts | 9 +++--- src/modules/history/lib/favorites.ts | 16 +++++----- .../history/store/favoriteStore.test.ts | 4 +-- 13 files changed, 110 insertions(+), 77 deletions(-) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt index a94d1b8d9..c801583c2 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt @@ -6,8 +6,6 @@ package expo.modules.vescapecore.telemetry * @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `FavoriteSummary` */ internal data class FavoriteSummary( - val deviceId: String? = null, - val deviceName: String? = null, val sampleCount: Int = 0, val gpsPointCount: Int = 0, /** Odometer delta across the range, or null when the range carries no odometer readings. */ @@ -29,8 +27,6 @@ internal data class FavoriteSummary( internal fun buildFavoriteSummary(buckets: Collection): FavoriteSummary { if (buckets.isEmpty()) return FavoriteSummary() - var deviceId: String? = null - var deviceName: String? = null var sampleCount = 0 var gpsPointCount = 0 var sumAbsSpeed = 0L @@ -54,8 +50,6 @@ internal fun buildFavoriteSummary(buckets: Collection 0 }, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index 0e928e409..7ac855d1f 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -486,8 +486,7 @@ abstract class TelemetryDatabase : RoomDatabase() { """ CREATE TABLE IF NOT EXISTS favorites ( id TEXT NOT NULL PRIMARY KEY, - device_id TEXT, - device_name TEXT, + board_id TEXT, name TEXT, start_ms INTEGER NOT NULL, end_ms INTEGER NOT NULL, @@ -506,6 +505,7 @@ abstract class TelemetryDatabase : RoomDatabase() { db.execSQL( "CREATE INDEX IF NOT EXISTS index_favorites_start_ms_end_ms ON favorites(start_ms, end_ms)", ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_favorites_board_id ON favorites(board_id)") } } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index 15e4cc3be..ba390a3be 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -486,15 +486,18 @@ data class BoardWarningEntity( tableName = "favorites", indices = [ Index(value = ["start_ms", "end_ms"]), + Index(value = ["board_id"]), ], ) data class FavoriteEntity( @PrimaryKey val id: String, - @ColumnInfo(name = "device_id") - val deviceId: String?, - @ColumnInfo(name = "device_name") - val deviceName: String?, + /** + * Owning Board (`boards.id`), or null when the recorded samples match no saved Board. Never the + * BLE peripheral id: that changes on re-link and differs per install, so it is not an identity. + */ + @ColumnInfo(name = "board_id") + val boardId: String?, val name: String?, @ColumnInfo(name = "start_ms") val startMs: Long, @@ -520,11 +523,15 @@ data class FavoriteEntity( @ColumnInfo(name = "battery_used_wh_milli") val batteryUsedWhMilli: Long, ) { - /** @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `Favorite.toMap` */ - fun toMap(): Map = mapOf( + /** + * Board name is resolved on read from `boards`, not snapshotted, so renames propagate. + * + * @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `Favorite.toMap` + */ + fun toMap(boardName: String?): Map = mapOf( "id" to id, - "deviceId" to deviceId, - "deviceName" to deviceName, + "boardId" to boardId, + "boardName" to boardName, "name" to name, "startMs" to startMs, "endMs" to endMs, diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index 1ee935fcc..cc83f52dd 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -531,9 +531,15 @@ class TelemetryRepository private constructor(context: Context) { // Favorites (ADR 0029) - /** @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `getFavorites` */ + /** + * Board names are resolved here, not stored on the row: a Favorite outlives board renames, and a + * snapshot would drift. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `getFavorites` + */ suspend fun getFavorites(): List> = withContext(Dispatchers.IO) { - dao.getFavorites().map { it.toMap() } + val boardNames = dao.getBoards().associate { it.id to it.name } + dao.getFavorites().map { it.toMap(boardNames[it.boardId]) } } /** @@ -553,11 +559,15 @@ class TelemetryRepository private constructor(context: Context) { val states = getSampleStates(startMs, endMs, deviceId, Int.MAX_VALUE) val summary = favoriteSummary(states) + val boards = dao.getBoards() + // The ble id is a transport key — it changes on re-link and differs per install — so the + // Favorite keeps the durable `boards.id` instead. + // @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `boardId` + val boardId = deviceId?.let { ble -> boards.firstOrNull { it.bleId == ble }?.id } val nowMs = System.currentTimeMillis() val favorite = FavoriteEntity( id = UUID.randomUUID().toString(), - deviceId = deviceId ?: summary.deviceId, - deviceName = summary.deviceName, + boardId = boardId, name = name, startMs = startMs, endMs = endMs, @@ -572,7 +582,7 @@ class TelemetryRepository private constructor(context: Context) { batteryUsedWhMilli = summary.batteryUsedWhMilli, ) dao.insertFavorite(favorite) - favorite.toMap() + favorite.toMap(boards.firstOrNull { it.id == boardId }?.name) } /** diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt index 52dd9aa3e..731a01a32 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt @@ -26,7 +26,6 @@ class FavoriteSummaryBuilderTest { // 1 m per sample interval. Distance sums per-bucket odometer deltas, so the hop across a bucket // boundary is not counted — the same arithmetic history session rows already use. assertEquals(11_800L, summary.distanceCm) - assertEquals("board-1", summary.deviceId) } /** @@ -86,8 +85,11 @@ class FavoriteSummaryBuilderTest { @Test fun favoriteEntityMapsToRiderUnitsAcrossTheBridge() { - val map = favorite(distanceCm = 250_000L).toMap() + val map = favorite(distanceCm = 250_000L).toMap(boardName = "Onewheel") + assertEquals("board-uuid-1", map["boardId"]) + // Board name is resolved on read, never snapshotted, so renames propagate to old favorites. + assertEquals("Onewheel", map["boardName"]) assertEquals(2_500.0, map["distanceM"]) assertEquals(15.0, map["avgSpeedKmh"]) assertEquals(30.0, map["maxSpeedKmh"]) @@ -100,7 +102,7 @@ class FavoriteSummaryBuilderTest { */ @Test fun missingDistanceStaysNullAcrossTheBridge() { - assertNull(favorite(distanceCm = null).toMap()["distanceM"]) + assertNull(favorite(distanceCm = null).toMap(boardName = null)["distanceM"]) } @Test @@ -122,6 +124,7 @@ class FavoriteSummaryBuilderTest { assertTrue(sql.any { it.contains("CREATE TABLE IF NOT EXISTS favorites") }) assertTrue(sql.any { it.contains("id TEXT NOT NULL PRIMARY KEY") }) + assertTrue(sql.any { it.contains("board_id TEXT") }) assertTrue(sql.any { it.contains("start_ms INTEGER NOT NULL") }) assertTrue(sql.any { it.contains("end_ms INTEGER NOT NULL") }) assertTrue(sql.any { it.contains("created_at INTEGER NOT NULL") }) @@ -131,6 +134,9 @@ class FavoriteSummaryBuilderTest { it == "CREATE INDEX IF NOT EXISTS index_favorites_start_ms_end_ms ON favorites(start_ms, end_ms)" }, ) + assertTrue( + sql.any { it == "CREATE INDEX IF NOT EXISTS index_favorites_board_id ON favorites(board_id)" }, + ) } private fun bucketsFor(points: List): Collection { @@ -209,8 +215,7 @@ class FavoriteSummaryBuilderTest { private fun favorite(distanceCm: Long?) = FavoriteEntity( id = "fav-1", - deviceId = "board-1", - deviceName = "VESC Board", + boardId = "board-uuid-1", name = "Dolina single track", startMs = 1_000, endMs = 61_000, diff --git a/modules/vescape-core/ios/telemetry/FavoriteStore.swift b/modules/vescape-core/ios/telemetry/FavoriteStore.swift index d862989fa..24ffd5a10 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStore.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStore.swift @@ -11,8 +11,9 @@ import GRDB /// @parity /modules/vescape-core/src/index.ts `Favorite` struct Favorite { let id: String - let deviceId: String? - let deviceName: String? + /// Owning Board (`boards.id`), or `nil` when the recorded samples match no saved Board. Never the + /// BLE peripheral id: that changes on re-link and differs per install, so it is not an identity. + let boardId: String? let name: String? let startMs: Int64 let endMs: Int64 @@ -20,11 +21,12 @@ struct Favorite { let updatedAtMs: Int64 let summary: FavoriteSummary - func toMap() -> [String: Any?] { + /// Board name is resolved on read from `boards`, not snapshotted, so renames propagate. + func toMap(boardName: String?) -> [String: Any?] { [ "id": id, - "deviceId": deviceId, - "deviceName": deviceName, + "boardId": boardId, + "boardName": boardName, "name": name, "startMs": startMs, "endMs": endMs, @@ -44,8 +46,6 @@ struct Favorite { /// Denormalized ride stats for one Favorite range, mirroring the history session summary fields. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt `FavoriteSummary` struct FavoriteSummary { - var deviceId: String? - var deviceName: String? var sampleCount = 0 var gpsPointCount = 0 /// Odometer delta across the range, or `nil` when the range carries no odometer readings. @@ -84,8 +84,6 @@ internal func buildFavoriteSummary(_ buckets: [TelemetryBucket]) -> FavoriteSumm movingSampleCount += bucket.movingSpeedSampleCount summary.maxSpeedCentiKmh = max(summary.maxSpeedCentiKmh, bucket.maxAbsSpeedCentiKmh) summary.batteryUsedWhMilli += bucket.batteryUsedWhMilli - if summary.deviceId == nil, !bucket.deviceId.isEmpty { summary.deviceId = bucket.deviceId } - if summary.deviceName == nil { summary.deviceName = bucket.deviceName } if let first = bucket.firstOdometerCm, let last = bucket.lastOdometerCm { distanceCm = (distanceCm ?? 0) + max(0, last - first) } @@ -140,8 +138,7 @@ struct FavoriteStore { try db.execute(sql: """ CREATE TABLE favorites ( id TEXT NOT NULL PRIMARY KEY, - device_id TEXT, - device_name TEXT, + board_id TEXT, name TEXT, start_ms INTEGER NOT NULL, end_ms INTEGER NOT NULL, @@ -157,6 +154,7 @@ struct FavoriteStore { ) """) try db.execute(sql: "CREATE INDEX index_favorites_start_ms_end_ms ON favorites(start_ms, end_ms)") + try db.execute(sql: "CREATE INDEX index_favorites_board_id ON favorites(board_id)") } // MARK: - Reads @@ -179,13 +177,13 @@ struct FavoriteStore { try db.execute( sql: """ INSERT INTO favorites ( - id, device_id, device_name, name, start_ms, end_ms, created_at, updated_at, + id, board_id, name, start_ms, end_ms, created_at, updated_at, sample_count, gps_point_count, distance_cm, moving_duration_ms, avg_speed_centi_kmh, max_speed_centi_kmh, battery_used_wh_milli - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ - favorite.id, favorite.deviceId, favorite.deviceName, favorite.name, + favorite.id, favorite.boardId, favorite.name, favorite.startMs, favorite.endMs, favorite.createdAtMs, favorite.updatedAtMs, favorite.summary.sampleCount, favorite.summary.gpsPointCount, favorite.summary.distanceCm, favorite.summary.movingDurationMs, favorite.summary.avgSpeedCentiKmh, @@ -212,16 +210,13 @@ struct FavoriteStore { private static func favorite(_ row: Row) -> Favorite { Favorite( id: row["id"] as String, - deviceId: row["device_id"] as String?, - deviceName: row["device_name"] as String?, + boardId: row["board_id"] as String?, name: row["name"] as String?, startMs: row["start_ms"] as Int64, endMs: row["end_ms"] as Int64, createdAtMs: row["created_at"] as Int64, updatedAtMs: row["updated_at"] as Int64, summary: FavoriteSummary( - deviceId: row["device_id"] as String?, - deviceName: row["device_name"] as String?, sampleCount: row["sample_count"] as Int, gpsPointCount: row["gps_point_count"] as Int, distanceCm: row["distance_cm"] as Int64?, diff --git a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift index 2652373e7..4fb2e2909 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -25,12 +25,11 @@ final class FavoriteStoreTests: XCTestCase { func testInsertedFavoriteRoundTripsThroughTheStore() throws { let favorite = makeFavorite( id: "fav-1", + boardId: "board-uuid-1", name: "Dolina single track", startMs: 1_000, endMs: 61_000, summary: FavoriteSummary( - deviceId: "board-1", - deviceName: "VESC Board", sampleCount: 12, gpsPointCount: 4, distanceCm: 123_400, @@ -48,7 +47,7 @@ final class FavoriteStoreTests: XCTestCase { XCTAssertEqual(stored.name, "Dolina single track") XCTAssertEqual(stored.startMs, 1_000) XCTAssertEqual(stored.endMs, 61_000) - XCTAssertEqual(stored.deviceId, "board-1") + XCTAssertEqual(stored.boardId, "board-uuid-1") XCTAssertEqual(stored.summary.distanceCm, 123_400) XCTAssertEqual(stored.summary.movingDurationMs, 55_000) XCTAssertEqual(stored.summary.avgSpeedCentiKmh, 1_850) @@ -87,8 +86,9 @@ final class FavoriteStoreTests: XCTestCase { maxSpeedCentiKmh: 3_000, batteryUsedWhMilli: 12_500 ) - ).toMap() + ).toMap(boardName: "Onewheel") + XCTAssertEqual(map["boardName"] as? String, "Onewheel") XCTAssertEqual(map["distanceM"] as? Double, 2_500.0) XCTAssertEqual(map["avgSpeedKmh"] as? Double, 15.0) XCTAssertEqual(map["maxSpeedKmh"] as? Double, 30.0) @@ -98,7 +98,7 @@ final class FavoriteStoreTests: XCTestCase { /// A range with no odometer readings has no distance to report — the bridge must send `nil` /// rather than a fabricated zero, so the row renders "-" like a history row without distance. func testMissingDistanceStaysNullAcrossTheBridge() { - let map = makeFavorite(id: "fav-1", startMs: 1_000, endMs: 2_000).toMap() + let map = makeFavorite(id: "fav-1", startMs: 1_000, endMs: 2_000).toMap(boardName: nil) XCTAssertNil(map["distanceM"] ?? nil) } @@ -117,7 +117,6 @@ final class FavoriteStoreTests: XCTestCase { // 1 m per sample interval. Distance sums per-bucket odometer deltas, so the hop across a bucket // boundary is not counted — the same arithmetic history session rows already use. XCTAssertEqual(summary.distanceCm, 11_800) - XCTAssertEqual(summary.deviceId, "board-1") } /// The point of computing from raw samples: a Favorite trimmed inside a minute bucket must report @@ -160,6 +159,7 @@ final class FavoriteStoreTests: XCTestCase { private func makeFavorite( id: String, + boardId: String? = nil, name: String? = nil, startMs: Int64, endMs: Int64, @@ -167,8 +167,7 @@ final class FavoriteStoreTests: XCTestCase { ) -> Favorite { Favorite( id: id, - deviceId: summary.deviceId, - deviceName: summary.deviceName, + boardId: boardId, name: name, startMs: startMs, endMs: endMs, diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 855bbfdd5..1121bfe68 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -233,9 +233,14 @@ internal final class TelemetryRepository { // MARK: - Favorites (ADR 0029) + /// Board names are resolved here, not stored on the row: a Favorite outlives board renames, and + /// a snapshot would drift. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `getFavorites` func getFavorites() -> [[String: Any?]] { - FavoriteStore.shared.list().map { $0.toMap() } + let boardNames = Self.boardNamesById() + return FavoriteStore.shared.list().map { favorite in + favorite.toMap(boardName: favorite.boardId.flatMap { boardNames[$0] }) + } } /// Pin a time range as a Favorite. Identity and timestamps are minted here — the range and the @@ -266,8 +271,7 @@ internal final class TelemetryRepository { let nowMs = telemetryNowMs() let favorite = Favorite( id: UUID().uuidString, - deviceId: deviceId ?? summary.deviceId, - deviceName: summary.deviceName, + boardId: deviceId.flatMap { Self.boardId(forBleId: $0) }, name: (trimmedName?.isEmpty ?? true) ? nil : trimmedName, startMs: startMs, endMs: endMs, @@ -276,7 +280,26 @@ internal final class TelemetryRepository { summary: summary ) guard FavoriteStore.shared.insert(favorite) else { return nil } - return favorite.toMap() + return favorite.toMap(boardName: favorite.boardId.flatMap { Self.boardNamesById()[$0] }) + } + + /// The Board that recorded under this BLE peripheral id, resolved once at creation. The ble id is + /// a transport key — it changes on re-link and differs per install — so the durable `boards.id` is + /// what the Favorite keeps. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `boardId` + private static func boardId(forBleId bleId: String) -> String? { + AppDataRepository.shared.getBoards().first { board in + (board["link"] as? [String: Any?])?["bleId"] as? String == bleId + }?["id"] as? String + } + + private static func boardNamesById() -> [String: String] { + var names: [String: String] = [:] + for board in AppDataRepository.shared.getBoards() { + guard let id = board["id"] as? String, let name = board["name"] as? String else { continue } + names[id] = name + } + return names } /// Unpin a Favorite. Telemetry in its range stays and becomes normally deletable (ADR 0029). diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 0ce34a14a..1175a0ec2 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -740,8 +740,13 @@ export interface TelemetrySummary { */ export interface Favorite { id: string - deviceId: string | null - deviceName: string | null + /** + * Owning Board (`Board.id`), or null when the recorded samples match no saved Board. Never a BLE + * peripheral id: that changes on re-link and differs per install, so it is not an identity. + */ + boardId: string | null + /** Resolved from `boards` on read, not snapshotted — board renames propagate to old Favorites. */ + boardName: string | null name: string | null startMs: number endMs: number diff --git a/src/modules/history/components/FavoriteList.tsx b/src/modules/history/components/FavoriteList.tsx index fa0beaaec..0861c59d7 100644 --- a/src/modules/history/components/FavoriteList.tsx +++ b/src/modules/history/components/FavoriteList.tsx @@ -54,7 +54,7 @@ export function FavoriteList({ favorites, loading, onRemove }: FavoriteListProps {formatRideTime(favorite.startMs, favorite.endMs)} - {favorite.deviceName ? ` · ${favorite.deviceName}` : ''} + {favorite.boardName ? ` · ${favorite.boardName}` : ''} {formatDuration(favorite.movingDurationMs)} · {formatDistance(favorite.distanceM)} ·{' '} diff --git a/src/modules/history/lib/favorites.test.ts b/src/modules/history/lib/favorites.test.ts index 0b3fe1d3e..312cd56c8 100644 --- a/src/modules/history/lib/favorites.test.ts +++ b/src/modules/history/lib/favorites.test.ts @@ -9,14 +9,13 @@ const session = { endAtMs: 1_600_000, movingStartAtMs: 1_100_000, movingEndAtMs: 1_500_000, - deviceId: 'board-1', } function favorite(overrides: Partial): Favorite { return { id: 'fav-1', - deviceId: 'board-1', - deviceName: 'VESC Board', + boardId: 'board-uuid-1', + boardName: 'Onewheel', name: null, startMs: 1_100_000, endMs: 1_500_000, @@ -43,8 +42,8 @@ test('legacy rides without a Moving Window fall back to their wall-clock span', ).toEqual({ startMs: 1_000_000, endMs: 1_600_000 }) }) -test('a ride counts as favorited only when a favorite covers its exact range and board', () => { +test('a ride counts as favorited only when a favorite covers its exact Moving Window', () => { expect(findSessionFavorite([favorite({})], session)?.id).toBe('fav-1') expect(findSessionFavorite([favorite({ endMs: 1_400_000 })], session)).toBeNull() - expect(findSessionFavorite([favorite({ deviceId: 'board-2' })], session)).toBeNull() + expect(findSessionFavorite([favorite({ startMs: 1_050_000 })], session)).toBeNull() }) diff --git a/src/modules/history/lib/favorites.ts b/src/modules/history/lib/favorites.ts index 99e9b556d..c03854283 100644 --- a/src/modules/history/lib/favorites.ts +++ b/src/modules/history/lib/favorites.ts @@ -13,21 +13,19 @@ export function favoriteRangeForSession( return window ?? { startMs: session.startAtMs, endMs: session.endAtMs } } -/** True when a Favorite already covers this ride's Moving Window, so the star reads as filled. */ +/** + * The Favorite already covering this ride's Moving Window, so the star reads as filled. Matched on + * the range alone: only one Board Session records at a time, so a range never spans two boards, and + * a Favorite stores a Board id rather than the ble id a history session carries. + */ export function findSessionFavorite( favorites: Favorite[], - session: Pick< - HistorySession, - 'movingStartAtMs' | 'movingEndAtMs' | 'startAtMs' | 'endAtMs' | 'deviceId' - >, + session: Pick, ): Favorite | null { const range = favoriteRangeForSession(session) return ( favorites.find( - (favorite) => - favorite.startMs === range.startMs && - favorite.endMs === range.endMs && - (favorite.deviceId ?? null) === session.deviceId, + (favorite) => favorite.startMs === range.startMs && favorite.endMs === range.endMs, ) ?? null ) } diff --git a/src/modules/history/store/favoriteStore.test.ts b/src/modules/history/store/favoriteStore.test.ts index 960427903..636b5a74f 100644 --- a/src/modules/history/store/favoriteStore.test.ts +++ b/src/modules/history/store/favoriteStore.test.ts @@ -6,8 +6,8 @@ const actualVescapeCore = await import('@/../modules/vescape-core/src/index') function favorite(overrides: Partial & Pick): Favorite { return { - deviceId: 'board-1', - deviceName: 'VESC Board', + boardId: 'board-uuid-1', + boardName: 'Onewheel', name: null, endMs: overrides.startMs + 60_000, createdAtMs: 1_700_000_000_000, From 8a36f965e0beffdfaade86c2739ddd3544a65559 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 28 Jul 2026 02:45:43 +0200 Subject: [PATCH 07/24] Guard favorite star against double taps and surface failures #287 --- .../history/store/favoriteStore.test.ts | 18 ++++++++++- src/modules/history/store/favoriteStore.ts | 13 ++++++-- src/screens/main/MainScreen.tsx | 2 ++ src/screens/main/overlays/MainOverlays.tsx | 8 +++-- src/screens/main/useMainScreenController.ts | 31 +++++++++++++------ 5 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/modules/history/store/favoriteStore.test.ts b/src/modules/history/store/favoriteStore.test.ts index 636b5a74f..131d9c600 100644 --- a/src/modules/history/store/favoriteStore.test.ts +++ b/src/modules/history/store/favoriteStore.test.ts @@ -49,7 +49,7 @@ beforeEach(async () => { }) deleteFavorite.mockImplementation(async () => true) const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') - useFavoriteStore.setState({ favorites: [], loading: false, error: undefined }) + useFavoriteStore.setState({ favorites: [], loading: false, saving: false, error: undefined }) }) test('loads favorites from native', async () => { @@ -100,3 +100,19 @@ test('removes only the deleted favorite', async () => { expect(deleteFavorite).toHaveBeenCalledWith('gone') expect(useFavoriteStore.getState().favorites).toEqual([kept]) }) + +test('a second star tap while a create is in flight does not add a duplicate', async () => { + const created = favorite({ id: 'fav-1', startMs: 2_000_000 }) + createFavorite.mockImplementation(async () => created) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + const [first, second] = await Promise.all([ + useFavoriteStore.getState().add({ startMs: created.startMs, endMs: created.endMs }), + useFavoriteStore.getState().add({ startMs: created.startMs, endMs: created.endMs }), + ]) + + expect(createFavorite).toHaveBeenCalledTimes(1) + expect([first, second]).toEqual([created, null]) + expect(useFavoriteStore.getState().favorites).toEqual([created]) + expect(useFavoriteStore.getState().saving).toBe(false) +}) diff --git a/src/modules/history/store/favoriteStore.ts b/src/modules/history/store/favoriteStore.ts index f49b01500..f3c023f45 100644 --- a/src/modules/history/store/favoriteStore.ts +++ b/src/modules/history/store/favoriteStore.ts @@ -10,6 +10,8 @@ import { interface FavoriteState { favorites: Favorite[] loading: boolean + /** A create/delete is in flight. Single-flight: the star must not queue a second mutation. */ + saving: boolean error: string | undefined } @@ -24,6 +26,7 @@ interface FavoriteActions { export const useFavoriteStore = create((set, get) => ({ favorites: [], loading: false, + saving: false, error: undefined, async load() { @@ -38,7 +41,8 @@ export const useFavoriteStore = create((set, ge }, async add(options) { - set({ error: undefined }) + if (get().saving) return null + set({ saving: true, error: undefined }) try { const favorite = await createFavorite(options) set({ @@ -48,16 +52,21 @@ export const useFavoriteStore = create((set, ge } catch (err) { set({ error: err instanceof Error ? err.message : String(err) }) return null + } finally { + set({ saving: false }) } }, async remove(id) { - set({ error: undefined }) + if (get().saving) return + set({ saving: true, error: undefined }) try { await deleteFavorite(id) set({ favorites: get().favorites.filter((favorite) => favorite.id !== id) }) } catch (err) { set({ error: err instanceof Error ? err.message : String(err) }) + } finally { + set({ saving: false }) } }, })) diff --git a/src/screens/main/MainScreen.tsx b/src/screens/main/MainScreen.tsx index 22d3e1c62..ac852a0c4 100644 --- a/src/screens/main/MainScreen.tsx +++ b/src/screens/main/MainScreen.tsx @@ -212,6 +212,8 @@ export function MainScreen({ selectHistoryTab: controller.selectHistoryTab, favorites: controller.favorites, favoritesLoading: controller.favoritesLoading, + favoritesSaving: controller.favoritesSaving, + favoritesError: controller.favoritesError, selectedSessionFavorite: controller.selectedSessionFavorite, toggleSelectedRideFavorite: controller.toggleSelectedRideFavorite, removeFavorite: controller.removeFavorite, diff --git a/src/screens/main/overlays/MainOverlays.tsx b/src/screens/main/overlays/MainOverlays.tsx index 18648c537..05f981c67 100644 --- a/src/screens/main/overlays/MainOverlays.tsx +++ b/src/screens/main/overlays/MainOverlays.tsx @@ -166,6 +166,8 @@ interface MainHistoryOverlayProps { selectHistoryTab: (tab: HistoryTab) => void favorites: Favorite[] favoritesLoading: boolean + favoritesSaving: boolean + favoritesError: string | undefined selectedSessionFavorite: Favorite | null toggleSelectedRideFavorite: () => Promise removeFavorite: (id: string) => Promise @@ -1153,7 +1155,7 @@ export function MainOverlays({ /> - {mode === 'history' && history.historyError ? ( + {mode === 'history' && (history.historyError ?? history.favoritesError) ? ( - {history.historyError} + {history.historyError ?? history.favoritesError} ) : null} diff --git a/src/screens/main/useMainScreenController.ts b/src/screens/main/useMainScreenController.ts index 505781a76..3b881f4e7 100644 --- a/src/screens/main/useMainScreenController.ts +++ b/src/screens/main/useMainScreenController.ts @@ -75,16 +75,25 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) setActiveHistoryMapMetric: s.setActiveHistoryMapMetric, })), ) - const { favorites, favoritesLoading, loadFavorites, addFavorite, removeFavorite } = - useFavoriteStore( - useShallow((s) => ({ - favorites: s.favorites, - favoritesLoading: s.loading, - loadFavorites: s.load, - addFavorite: s.add, - removeFavorite: s.remove, - })), - ) + const { + favorites, + favoritesLoading, + favoritesSaving, + favoritesError, + loadFavorites, + addFavorite, + removeFavorite, + } = useFavoriteStore( + useShallow((s) => ({ + favorites: s.favorites, + favoritesLoading: s.loading, + favoritesSaving: s.saving, + favoritesError: s.error, + loadFavorites: s.load, + addFavorite: s.add, + removeFavorite: s.remove, + })), + ) const liveLocations = useBleStore((s) => s.liveLocationHistory) const latestApproximateLocation = useBleStore((s) => s.latestApproximateLocation) const fetchWeather = useWeatherStore((s) => s.fetch) @@ -481,6 +490,8 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) selectHistoryTab, favorites, favoritesLoading, + favoritesSaving, + favoritesError, selectedSessionFavorite, toggleSelectedRideFavorite, removeFavorite, From 8a5abd374a008367da658fbddd9078c0545ce51c Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 28 Jul 2026 14:17:16 +0200 Subject: [PATCH 08/24] Trim range selection for Favorites #288 --- src/app/settings/components/charts.tsx | 49 ++++ src/components/charts/TelemetryLineChart.tsx | 216 +++++++++++++++++- .../history/lib/favoritePreview.test.ts | 117 ++++++++++ src/modules/history/lib/favoritePreview.ts | 125 ++++++++++ src/screens/main/MainScreen.tsx | 8 +- src/screens/main/history/HistoryControls.tsx | 44 ++++ .../main/history/HistoryTelemetryPanel.tsx | 13 +- src/screens/main/history/TrimStatsBar.tsx | 49 ++++ src/screens/main/mainScreenStore.ts | 41 +++- src/screens/main/map/MainMapLayers.tsx | 44 ++++ src/screens/main/overlays/MainOverlays.tsx | 51 ++++- src/screens/main/useMainScreenController.ts | 56 ++++- 12 files changed, 786 insertions(+), 27 deletions(-) create mode 100644 src/modules/history/lib/favoritePreview.test.ts create mode 100644 src/modules/history/lib/favoritePreview.ts create mode 100644 src/screens/main/history/TrimStatsBar.tsx diff --git a/src/app/settings/components/charts.tsx b/src/app/settings/components/charts.tsx index 6c6bf9194..10a348819 100644 --- a/src/app/settings/components/charts.tsx +++ b/src/app/settings/components/charts.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Easing, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated' import { ChartLineUpIcon } from 'phosphor-react-native' +import { Text } from '@/components/base/Text' import { LinearGauge } from '@/components/charts/LinearGauge' import { IconHero } from '@/components/settings/IconHero' import { TelemetryLineChart } from '@/components/charts/TelemetryLineChart' @@ -320,6 +321,47 @@ function RandomLineChartsShowcase() { ) } +function TrimChartShowcase() { + const points = useMemo( + () => generateChartData({ count: 160, base: 18, variance: 5, seed: 21, spikeEvery: 29 }), + [], + ) + const domainStartMs = points[0]?.date.getTime() ?? 0 + const domainEndMs = points.at(-1)?.date.getTime() ?? 0 + const span = domainEndMs - domainStartMs + const seed = useMemo( + () => ({ startMs: domainStartMs + span * 0.2, endMs: domainStartMs + span * 0.8 }), + [domainStartMs, span], + ) + const [range, setRange] = useState(seed) + const currentPoint = points.at(-1) ?? null + const chartRange = computeAutoRange(points, { includeZero: true, minSpan: 10, paddingRatio: 0.1 }) + const selectedSeconds = Math.round((range.endMs - range.startMs) / 1000) + + return ( + + setRange({ startMs, endMs }), + onCommit: (startMs, endMs) => setRange({ startMs, endMs }), + }} + /> + Selected span: {selectedSeconds}s + + ) +} + const CELL_SCENARIOS = { 'Small imbalance': { cells: [4.012, 4.03, 4.028, 4.031, 4.019, 4.03, 4.027, 4.03, 4.025, 4.029], @@ -403,6 +445,7 @@ export default function ChartsPage() { + @@ -413,4 +456,10 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.palette.slate.bg }, content: { padding: 12, gap: 12, paddingBottom: 40 }, chartExample: { marginBottom: 10 }, + trimReadout: { + color: theme.palette.slate.textSecondary, + fontSize: 12, + fontWeight: '700', + textAlign: 'center', + }, }) diff --git a/src/components/charts/TelemetryLineChart.tsx b/src/components/charts/TelemetryLineChart.tsx index cbd8f1cd6..b7472bbed 100644 --- a/src/components/charts/TelemetryLineChart.tsx +++ b/src/components/charts/TelemetryLineChart.tsx @@ -128,6 +128,62 @@ function createScrubGesture({ }) } +/** + * Range-trim pan: one gesture over the whole graph. onBegin grabs whichever handle is nearer the + * touch; onUpdate drags it in free milliseconds, clamped to the domain and unable to cross its peer. + * The handle tracks the finger on the UI thread; JS is poked (throttled) only to drive the preview. + * Module-level so the shared-value writes live outside the component's render closure. + */ +function createTrimGesture({ + enabled, + chartWidth, + domainStartMs, + domainEndMs, + trimStartMs, + trimEndMs, + activeHandle, + notifyTrim, + commitTrim, +}: { + enabled: boolean + chartWidth: number + domainStartMs: number + domainEndMs: number + trimStartMs: SharedValue + trimEndMs: SharedValue + activeHandle: SharedValue<0 | 1 | null> + notifyTrim: (startMs: number, endMs: number) => void + commitTrim: (startMs: number, endMs: number) => void +}) { + const span = domainEndMs - domainStartMs + return Gesture.Pan() + .enabled(enabled) + .onBegin((event) => { + 'worklet' + const xStart = (chartWidth * (trimStartMs.value - domainStartMs)) / span + const xEnd = (chartWidth * (trimEndMs.value - domainStartMs)) / span + activeHandle.value = Math.abs(event.x - xStart) <= Math.abs(event.x - xEnd) ? 0 : 1 + }) + .onUpdate((event) => { + 'worklet' + const clampedX = Math.max(0, Math.min(chartWidth, event.x)) + let ms = domainStartMs + (clampedX / chartWidth) * span + if (activeHandle.value === 0) { + if (ms > trimEndMs.value) ms = trimEndMs.value + trimStartMs.value = ms + } else if (activeHandle.value === 1) { + if (ms < trimStartMs.value) ms = trimStartMs.value + trimEndMs.value = ms + } + runOnJS(notifyTrim)(trimStartMs.value, trimEndMs.value) + }) + .onFinalize(() => { + 'worklet' + activeHandle.value = null + runOnJS(commitTrim)(trimStartMs.value, trimEndMs.value) + }) +} + function pickMarkerIndex(table: MarkerTable, timeMs: number | null): number { 'worklet' const count = table.ts.length @@ -260,6 +316,24 @@ export interface SecondaryChartSeries { formatValue?: (value: number) => string } +/** + * Turns the chart into a range trimmer: two draggable handles over the timeline, the region outside + * them dimmed. `startMs`/`endMs` seed the handles (re-seeded when either changes). Handles are + * clamped to the chart's own time domain and cannot cross — free milliseconds, no snapping, no + * minimum span. `onChange` fires per drag frame (throttled by the chart); `onCommit` fires on + * release. Both report the raw span so consumers can drive a live map/stats preview. + */ +export interface ChartTrimConfig { + startMs: number + endMs: number + onChange: (startMs: number, endMs: number) => void + onCommit: (startMs: number, endMs: number) => void +} + +// Trim handle position is pushed to JS at most this often; the handle itself tracks the finger on +// the UI thread, so this only paces the map/stats preview, mirroring the scrub-seek throttle. +const TRIM_NOTIFY_THROTTLE_MS = 50 + interface TelemetryLineChartProps { label?: string value: string @@ -283,6 +357,8 @@ interface TelemetryLineChartProps { scrubbable?: boolean /** Reserve the right-axis gutter so charts with and without a secondary axis align. */ reserveRightAxis?: boolean + /** When set, the chart is a range trimmer instead of a scrubber. */ + trim?: ChartTrimConfig } interface ChartLineSegmentsProps { @@ -388,6 +464,7 @@ export function TelemetryLineChart({ onScrubTimeChange, scrubbable = false, reserveRightAxis = false, + trim, }: TelemetryLineChartProps) { 'use no memo' const [chartWidth, setChartWidth] = useState(0) @@ -398,6 +475,12 @@ export function TelemetryLineChart({ const onPointSelectedRef = useRef(onPointSelected) const onGestureStartRef = useRef(onGestureStart) const onScrubTimeChangeRef = useRef(onScrubTimeChange) + const trimOnChangeRef = useRef(trim?.onChange) + const trimOnCommitRef = useRef(trim?.onCommit) + const lastTrimNotifyAtRef = useRef(0) + const trimStartMs = useSharedValue(trim?.startMs ?? 0) + const trimEndMs = useSharedValue(trim?.endMs ?? 0) + const activeTrimHandle = useSharedValue<0 | 1 | null>(null) // Live charts keep streaming while the user scrubs; rebuilding paths and the marker // table mid-gesture starves the JS thread. Freeze the series for the drag instead. const liveSeriesRef = useRef({ points, secondary }) @@ -412,9 +495,19 @@ export function TelemetryLineChart({ onPointSelectedRef.current = onPointSelected onGestureStartRef.current = onGestureStart onScrubTimeChangeRef.current = onScrubTimeChange + trimOnChangeRef.current = trim?.onChange + trimOnCommitRef.current = trim?.onCommit liveSeriesRef.current = { points, secondary } }) + // Re-seed the handles whenever a new trim session opens (start/end change identity). + useEffect(() => { + if (!trim) return + setSharedValue(trimStartMs, trim.startMs) + setSharedValue(trimEndMs, trim.endMs) + // eslint-disable-next-line react-hooks/exhaustive-deps -- shared values are stable refs + }, [trim?.startMs, trim?.endMs]) + useEffect(() => { setSharedValue(currentTimeMs, currentPoint?.date.getTime() ?? null) }, [currentPoint, currentTimeMs]) @@ -520,7 +613,10 @@ export function TelemetryLineChart({ }, []) const scrubEnabled = - points.length > 0 && chartWidth > 0 && (scrubbable || !!onPointSelected || !!onScrubTimeChange) + !trim && + points.length > 0 && + chartWidth > 0 && + (scrubbable || !!onPointSelected || !!onScrubTimeChange) const hasScrubCallback = !!onScrubTimeChange const panGesture = useMemo( @@ -546,6 +642,72 @@ export function TelemetryLineChart({ ], ) + // Trim shares the chart's own time domain: first→last plotted sample maps to [0, chartWidth]. + const trimDomainStartMs = displayPoints[0]?.date.getTime() ?? 0 + const trimDomainEndMs = displayPoints.at(-1)?.date.getTime() ?? 0 + const trimEnabled = !!trim && chartWidth > 0 && trimDomainEndMs > trimDomainStartMs + + const notifyTrim = useCallback((start: number, end: number) => { + const now = Date.now() + if (now - lastTrimNotifyAtRef.current < TRIM_NOTIFY_THROTTLE_MS) return + lastTrimNotifyAtRef.current = now + trimOnChangeRef.current?.(start, end) + }, []) + const commitTrim = useCallback((start: number, end: number) => { + lastTrimNotifyAtRef.current = 0 + trimOnCommitRef.current?.(start, end) + }, []) + + const trimGesture = useMemo( + () => + // eslint-disable-next-line react-hooks/refs -- shared values are only touched inside worklets + createTrimGesture({ + enabled: trimEnabled, + chartWidth, + domainStartMs: trimDomainStartMs, + domainEndMs: trimDomainEndMs, + trimStartMs, + trimEndMs, + activeHandle: activeTrimHandle, + notifyTrim, + commitTrim, + }), + [ + activeTrimHandle, + chartWidth, + commitTrim, + notifyTrim, + trimDomainEndMs, + trimDomainStartMs, + trimEnabled, + trimEndMs, + trimStartMs, + ], + ) + + const trimStartXStyle = useAnimatedStyle(() => { + const span = trimDomainEndMs - trimDomainStartMs + const x = span > 0 ? (chartWidth * (trimStartMs.value - trimDomainStartMs)) / span : 0 + return { transform: [{ translateX: Math.max(0, Math.min(chartWidth, x)) }] } + }) + const trimEndXStyle = useAnimatedStyle(() => { + const span = trimDomainEndMs - trimDomainStartMs + const x = span > 0 ? (chartWidth * (trimEndMs.value - trimDomainStartMs)) / span : 0 + return { transform: [{ translateX: Math.max(0, Math.min(chartWidth, x)) }] } + }) + const trimDimLeftStyle = useAnimatedStyle(() => { + const span = trimDomainEndMs - trimDomainStartMs + const x = span > 0 ? (chartWidth * (trimStartMs.value - trimDomainStartMs)) / span : 0 + return { width: Math.max(0, Math.min(chartWidth, x)) } + }) + const trimDimRightStyle = useAnimatedStyle(() => { + const span = trimDomainEndMs - trimDomainStartMs + const x = span > 0 ? (chartWidth * (trimEndMs.value - trimDomainStartMs)) / span : chartWidth + return { width: Math.max(0, chartWidth - Math.max(0, Math.min(chartWidth, x))) } + }) + + const activeGesture = trim ? trimGesture : panGesture + const yMid = (range.y.min + range.y.max) / 2 const secondaryYMid = secondary ? (secondary.range.y.min + secondary.range.y.max) / 2 : 0 @@ -606,7 +768,7 @@ export function TelemetryLineChart({ {formatAxisNumber(range.y.min)} - + {chartWidth > 0 && ( @@ -672,7 +834,7 @@ export function TelemetryLineChart({ /> )} - {chartWidth > 0 && hasMarker && ( + {chartWidth > 0 && hasMarker && !trim && ( {isDragging && ( )} + {trim && chartWidth > 0 && ( + + + + + + + + + + + )} @@ -798,6 +972,42 @@ const styles = StyleSheet.create({ left: 0, pointerEvents: 'none', }, + trimOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, + trimDim: { + position: 'absolute', + top: 0, + bottom: 0, + backgroundColor: theme.alpha(theme.palette.slate.bg, 0.6), + }, + trimDimLeft: { + left: 0, + }, + trimDimRight: { + right: 0, + }, + trimHandle: { + position: 'absolute', + top: 0, + bottom: 0, + width: 2, + marginLeft: -1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.palette.amber.color, + }, + trimHandleKnob: { + width: 12, + height: 20, + borderRadius: 6, + backgroundColor: theme.palette.amber.color, + borderWidth: 1, + borderColor: theme.palette.slate.surfaceDeep, + }, xAxis: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/src/modules/history/lib/favoritePreview.test.ts b/src/modules/history/lib/favoritePreview.test.ts new file mode 100644 index 000000000..2c39d0035 --- /dev/null +++ b/src/modules/history/lib/favoritePreview.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from 'bun:test' +import type { HistoryGpsSample, TelemetrySample } from 'vescape-core' + +import { summarizeFavoriteRange } from '@/modules/history/lib/favoritePreview' + +function sample(overrides: Partial & { capturedAtMs: number }): TelemetrySample { + return { + speedKmh: 0, + batteryVoltage: 50, + batteryCurrent: 0, + dutyCycle: 0, + tempMosfet: null, + tempMotor: null, + ...overrides, + } as TelemetrySample +} + +function gps(capturedAtMs: number, distanceFromPreviousM: number | null): HistoryGpsSample { + return { capturedAtMs, distanceFromPreviousM } as HistoryGpsSample +} + +test('summarizes only samples inside the range', () => { + const samples = [ + sample({ capturedAtMs: 0, speedKmh: 5, dutyCycle: 0.1 }), + sample({ capturedAtMs: 1_000, speedKmh: 20, dutyCycle: 0.5, tempMotor: 40 }), + sample({ capturedAtMs: 2_000, speedKmh: 30, dutyCycle: 0.8, tempMotor: 55, tempMosfet: 60 }), + sample({ capturedAtMs: 3_000, speedKmh: 99, dutyCycle: 0.95 }), + ] + + const stats = summarizeFavoriteRange(samples, [], 1_000, 2_000) + + expect(stats.sampleCount).toBe(2) + expect(stats.maxSpeedKmh).toBe(30) + expect(stats.avgSpeedKmh).toBe(25) + expect(stats.maxDuty).toBe(0.8) + expect(stats.maxTempMotor).toBe(55) + expect(stats.maxTempMosfet).toBe(60) +}) + +test('range bounds are order-independent', () => { + const samples = [ + sample({ capturedAtMs: 1_000, speedKmh: 10 }), + sample({ capturedAtMs: 2_000, speedKmh: 20 }), + ] + expect(summarizeFavoriteRange(samples, [], 2_000, 1_000)).toEqual( + summarizeFavoriteRange(samples, [], 1_000, 2_000), + ) +}) + +test('speed uses magnitude so reverse riding still counts', () => { + const samples = [ + sample({ capturedAtMs: 0, speedKmh: -40 }), + sample({ capturedAtMs: 1_000, speedKmh: -20 }), + ] + const stats = summarizeFavoriteRange(samples, [], 0, 1_000) + expect(stats.maxSpeedKmh).toBe(40) + expect(stats.avgSpeedKmh).toBe(30) +}) + +test('integrates pack energy across time, splitting used and regen', () => { + const samples = [ + // 1 h at +100 W → +100 Wh used. + sample({ capturedAtMs: 0, batteryVoltage: 50, batteryCurrent: 2 }), + sample({ capturedAtMs: 3_600_000, batteryVoltage: 50, batteryCurrent: 2 }), + ] + // Long gap must not integrate: cap at 5 s, so a single interval this long is skipped. + const stats = summarizeFavoriteRange(samples, [], 0, 3_600_000) + expect(stats.batteryUsedWh).toBe(0) + expect(stats.batteryRegenWh).toBe(0) +}) + +test('integrates within the gap cap and separates regen', () => { + const used = summarizeFavoriteRange( + [ + sample({ capturedAtMs: 0, batteryVoltage: 50, batteryCurrent: 10 }), + sample({ capturedAtMs: 1_000, batteryVoltage: 50, batteryCurrent: 10 }), + ], + [], + 0, + 1_000, + ) + // 500 W over 1 s = 500 / 3600 Wh. + expect(used.batteryUsedWh).toBeCloseTo(500 / 3600, 6) + expect(used.batteryRegenWh).toBe(0) + + const regen = summarizeFavoriteRange( + [ + sample({ capturedAtMs: 0, batteryVoltage: 50, batteryCurrent: -10 }), + sample({ capturedAtMs: 1_000, batteryVoltage: 50, batteryCurrent: -10 }), + ], + [], + 0, + 1_000, + ) + expect(regen.batteryRegenWh).toBeCloseTo(500 / 3600, 6) + expect(regen.batteryUsedWh).toBe(0) +}) + +test('distance sums GPS deltas inside the range, null when absent', () => { + const gpsSamples = [gps(500, null), gps(1_000, 10), gps(1_500, 25), gps(2_500, 99)] + const samples = [sample({ capturedAtMs: 1_000 }), sample({ capturedAtMs: 2_000 })] + const stats = summarizeFavoriteRange(samples, gpsSamples, 1_000, 2_000) + // Only GPS at 1_500 falls in (1000, 2000]; the 1000 boundary is exclusive to avoid the + // pre-range delta leaking in. + expect(stats.distanceM).toBe(25) + + const noGps = summarizeFavoriteRange(samples, [], 1_000, 2_000) + expect(noGps.distanceM).toBeNull() +}) + +test('empty range yields zeroed stats', () => { + const samples = [sample({ capturedAtMs: 10_000 })] + const stats = summarizeFavoriteRange(samples, [], 0, 1_000) + expect(stats.sampleCount).toBe(0) + expect(stats.distanceM).toBeNull() + expect(stats.maxSpeedKmh).toBe(0) +}) diff --git a/src/modules/history/lib/favoritePreview.ts b/src/modules/history/lib/favoritePreview.ts new file mode 100644 index 000000000..a6fee2ece --- /dev/null +++ b/src/modules/history/lib/favoritePreview.ts @@ -0,0 +1,125 @@ +import type { HistoryGpsSample, TelemetrySample } from 'vescape-core' + +/** + * Stats shown live while trimming a Favorite. A best-effort JS preview from the already-loaded + * ride samples — the durable Favorite summary is recomputed natively at save (slice 1). Field names + * mirror {@link HistorySession} so a trim preview can be spread over the selected session and fed to + * the existing stats bar unchanged. + */ +export interface FavoriteRangeStats { + sampleCount: number + distanceM: number | null + maxSpeedKmh: number + avgSpeedKmh: number + maxTempMosfet: number | null + maxTempMotor: number | null + maxDuty: number + batteryUsedWh: number + batteryRegenWh: number +} + +// Board samples that straddle a long recording gap should not integrate energy across the gap. +const MAX_ENERGY_INTEGRATION_GAP_MS = 5_000 + +const EMPTY_STATS: FavoriteRangeStats = { + sampleCount: 0, + distanceM: null, + maxSpeedKmh: 0, + avgSpeedKmh: 0, + maxTempMosfet: null, + maxTempMotor: null, + maxDuty: 0, + batteryUsedWh: 0, + batteryRegenWh: 0, +} + +/** + * Summarize a trimmed time range from ride samples. `samples` and `gpsSamples` must be sorted + * ascending by `capturedAtMs` — the caller sorts once per session, not per drag frame. Range bounds + * are order-independent; `[a, b]` and `[b, a]` summarize the same span. + */ +export function summarizeFavoriteRange( + samples: TelemetrySample[], + gpsSamples: HistoryGpsSample[], + startMs: number, + endMs: number, +): FavoriteRangeStats { + const lo = Math.min(startMs, endMs) + const hi = Math.max(startMs, endMs) + + let sampleCount = 0 + let speedSum = 0 + let maxSpeedKmh = 0 + let maxDuty = 0 + let maxTempMosfet: number | null = null + let maxTempMotor: number | null = null + let batteryUsedWh = 0 + let batteryRegenWh = 0 + let previous: TelemetrySample | null = null + + for (const sample of samples) { + if (sample.capturedAtMs < lo) { + previous = sample + continue + } + if (sample.capturedAtMs > hi) break + + sampleCount += 1 + const speed = Math.abs(sample.speedKmh) + speedSum += speed + maxSpeedKmh = Math.max(maxSpeedKmh, speed) + maxDuty = Math.max(maxDuty, sample.dutyCycle) + if (sample.tempMosfet != null) { + maxTempMosfet = + maxTempMosfet == null ? sample.tempMosfet : Math.max(maxTempMosfet, sample.tempMosfet) + } + if (sample.tempMotor != null) { + maxTempMotor = + maxTempMotor == null ? sample.tempMotor : Math.max(maxTempMotor, sample.tempMotor) + } + + if (previous && previous.capturedAtMs >= lo) { + const dtMs = sample.capturedAtMs - previous.capturedAtMs + if (dtMs > 0 && dtMs <= MAX_ENERGY_INTEGRATION_GAP_MS) { + // Trapezoidal energy over the interval: pack power (V·I) integrated across dt. + const powerW = + (previous.batteryVoltage * previous.batteryCurrent + + sample.batteryVoltage * sample.batteryCurrent) / + 2 + const wh = (powerW * dtMs) / 3_600_000 + if (wh >= 0) batteryUsedWh += wh + else batteryRegenWh += -wh + } + } + previous = sample + } + + if (sampleCount === 0) return EMPTY_STATS + + return { + sampleCount, + distanceM: sumGpsDistance(gpsSamples, lo, hi), + maxSpeedKmh, + avgSpeedKmh: speedSum / sampleCount, + maxTempMosfet, + maxTempMotor, + maxDuty, + batteryUsedWh, + batteryRegenWh, + } +} + +/** Distance from GPS deltas inside the range; null when the range carries no GPS distance. */ +function sumGpsDistance(gpsSamples: HistoryGpsSample[], lo: number, hi: number): number | null { + let total = 0 + let counted = false + for (const gps of gpsSamples) { + if (gps.capturedAtMs <= lo) continue + if (gps.capturedAtMs > hi) break + if (gps.distanceFromPreviousM != null) { + total += gps.distanceFromPreviousM + counted = true + } + } + return counted ? total : null +} diff --git a/src/screens/main/MainScreen.tsx b/src/screens/main/MainScreen.tsx index ac852a0c4..02c854e4c 100644 --- a/src/screens/main/MainScreen.tsx +++ b/src/screens/main/MainScreen.tsx @@ -196,6 +196,7 @@ export function MainScreen({ enterHistoryMode: controller.enterHistoryMode, selectedSession: controller.selectedSession, sessionSamples: controller.sessionSamples, + sessionGpsSamples: controller.sessionGpsSamples, sessionMarkers: controller.sessionMarkers, previousRide: controller.previousRide, nextRide: controller.nextRide, @@ -215,7 +216,12 @@ export function MainScreen({ favoritesSaving: controller.favoritesSaving, favoritesError: controller.favoritesError, selectedSessionFavorite: controller.selectedSessionFavorite, - toggleSelectedRideFavorite: controller.toggleSelectedRideFavorite, + trimming: controller.trimming, + trimSeed: controller.trimSeed, + beginTrimFavorite: controller.beginTrimFavorite, + updateTrimRange: controller.updateTrimRange, + cancelTrim: controller.cancelTrim, + saveTrim: controller.saveTrim, removeFavorite: controller.removeFavorite, selectSession: controller.selectSession, loadMoreHistory: controller.loadMoreHistory, diff --git a/src/screens/main/history/HistoryControls.tsx b/src/screens/main/history/HistoryControls.tsx index c5612e920..75cac2cf5 100644 --- a/src/screens/main/history/HistoryControls.tsx +++ b/src/screens/main/history/HistoryControls.tsx @@ -1,13 +1,16 @@ import { StyleSheet, View } from 'react-native' import { ArrowLeftIcon, + CheckIcon, ClockCounterClockwiseIcon, StarIcon, TrashIcon, + XIcon, } from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IconButton } from '@/components/base/IconButton' +import { Text } from '@/components/base/Text' import { PillSelector, PillSelectorItem } from '@/components/controls/PillSelector' import { theme } from '@/constants/theme' import type { HistoryTab } from '@/screens/main/mainScreenStore' @@ -19,10 +22,15 @@ interface HistoryControlsProps { /** Star is offered only for an open ride; filled once that ride is already favorited. */ canFavorite: boolean favorited: boolean + /** Trim mode swaps tabs/star/trash for a cancel/save pair over the range being pinned. */ + trimming: boolean + saving: boolean onSelectTab: (tab: HistoryTab) => void onBack: () => void onRemove: () => void onToggleFavorite: () => void + onCancelTrim: () => void + onSaveTrim: () => void } export function HistoryControls({ @@ -31,12 +39,39 @@ export function HistoryControls({ canRemove, canFavorite, favorited, + trimming, + saving, onSelectTab, onBack, onRemove, onToggleFavorite, + onCancelTrim, + onSaveTrim, }: HistoryControlsProps) { const insets = useSafeAreaInsets() + + if (trimming) { + return ( + + + + + + Trim favorite + + + + + + ) + } + return ( @@ -101,4 +136,13 @@ const styles = StyleSheet.create({ flex: 1, alignItems: 'center', }, + trimTitleWrap: { + flex: 1, + alignItems: 'center', + }, + trimTitle: { + color: theme.palette.slate.textPrimary, + fontSize: 14, + fontWeight: '800', + }, }) diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 6dabf3300..86aee666e 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -21,6 +21,7 @@ import { } from '@/components/charts/chartMath' import { TelemetryLineChart, + type ChartTrimConfig, type SecondaryChartSeries, } from '@/components/charts/TelemetryLineChart' import { PrevNextSelector } from '@/components/controls/PrevNextSelector' @@ -61,6 +62,8 @@ interface HistoryTelemetryPanelProps { onSeek?: (timeMs: number) => void onMetricInteraction?: (metric: HistoryMetricKey) => void onHeightChange?: (height: number) => void + /** When set, the primary chart becomes a Favorite range trimmer and scrubbing is suspended. */ + trim?: ChartTrimConfig } const CHART_MAX_POINTS = 220 @@ -88,6 +91,7 @@ export function HistoryTelemetryPanel({ onSeek, onMetricInteraction, onHeightChange, + trim, }: HistoryTelemetryPanelProps) { const insets = useSafeAreaInsets() const [headTimeMs, setHeadTimeMs] = useState(null) @@ -534,10 +538,11 @@ export function HistoryTelemetryPanel({ formatValue={(v) => telemetry.speed.formatWithUnit(v)} getPointColor={speedPointColor} onGestureStart={() => onMetricInteraction?.('speed')} - onPointSelected={handlePointSelected} + onPointSelected={trim ? undefined : handlePointSelected} scrubTimeMs={scrubTimeMs} - onScrubTimeChange={handleScrubTimeChange} + onScrubTimeChange={trim ? undefined : handleScrubTimeChange} excludedRanges={speedExcludedRanges} + trim={trim} /> {OPTIONAL_CHART_METRICS.filter((m) => activeCharts.has(m.key)).map((metric) => { @@ -556,9 +561,9 @@ export function HistoryTelemetryPanel({ formatValue={cfg.formatValue} getPointColor={cfg.getPointColor} onGestureStart={() => onMetricInteraction?.(metric.key)} - onPointSelected={handlePointSelected} + onPointSelected={trim ? undefined : handlePointSelected} scrubTimeMs={scrubTimeMs} - onScrubTimeChange={handleScrubTimeChange} + onScrubTimeChange={trim ? undefined : handleScrubTimeChange} excludedRanges={ 'excludedRanges' in cfg ? (cfg.excludedRanges as ExcludedRange[] | undefined) diff --git a/src/screens/main/history/TrimStatsBar.tsx b/src/screens/main/history/TrimStatsBar.tsx new file mode 100644 index 000000000..aa7e351a9 --- /dev/null +++ b/src/screens/main/history/TrimStatsBar.tsx @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import type { HistoryGpsSample, TelemetrySample } from 'vescape-core' + +import { summarizeFavoriteRange } from '@/modules/history/lib/favoritePreview' +import type { HistorySession } from '@/modules/history/store/historyStore' +import { HistoryStatsBar } from '@/screens/main/history/HistoryStatsBar' +import { useMainScreenStore } from '@/screens/main/mainScreenStore' + +interface TrimStatsBarProps { + session: HistorySession + samples: TelemetrySample[] + gpsSamples: HistoryGpsSample[] +} + +/** + * Ride stats recomputed live for the range being trimmed. Subscribes to the trim range directly so + * each drag frame re-renders only this bar. Reuses the history stats bar by spreading a preview + * summary over the open session; before any drag it shows the ride's own stats. + */ +export function TrimStatsBar({ session, samples, gpsSamples }: TrimStatsBarProps) { + const trimRange = useMainScreenStore((s) => s.trimRange) + const sortedSamples = useMemo( + () => [...samples].sort((a, b) => a.capturedAtMs - b.capturedAtMs), + [samples], + ) + const sortedGps = useMemo( + () => [...gpsSamples].sort((a, b) => a.capturedAtMs - b.capturedAtMs), + [gpsSamples], + ) + const previewSession = useMemo(() => { + if (!trimRange) return session + const stats = summarizeFavoriteRange( + sortedSamples, + sortedGps, + trimRange.startMs, + trimRange.endMs, + ) + return { + ...session, + startAtMs: trimRange.startMs, + endAtMs: trimRange.endMs, + movingStartAtMs: trimRange.startMs, + movingEndAtMs: trimRange.endMs, + ...stats, + } + }, [session, sortedSamples, sortedGps, trimRange]) + + return +} diff --git a/src/screens/main/mainScreenStore.ts b/src/screens/main/mainScreenStore.ts index 6c520ffe9..88d0a3612 100644 --- a/src/screens/main/mainScreenStore.ts +++ b/src/screens/main/mainScreenStore.ts @@ -8,6 +8,12 @@ export type MapSelector = 'navigation' | 'style' | null /** Which list the history screen shows: recorded rides, or the Favorites the rider starred. */ export type HistoryTab = 'history' | 'favorites' +/** The time span a rider is trimming into a Favorite. Non-null means trim mode is active. */ +export interface TrimRange { + startMs: number + endMs: number +} + interface MainScreenState { mode: MainViewState historyTab: HistoryTab @@ -15,6 +21,7 @@ interface MainScreenState { mapSelector: MapSelector perspectiveEnabled: boolean seekTimeMs: number | null + trimRange: TrimRange | null activeHistoryMapMetric: HistoryMetricKey } @@ -31,6 +38,12 @@ interface MainScreenActions { dismissMapSelector: () => void setPerspectiveEnabled: (enabled: boolean) => void setSeekTimeMs: (timeMs: number | null) => void + /** Enter trim mode seeded with a default range (the ride's full Moving Window). */ + beginTrim: (range: TrimRange) => void + /** Live-update the trimmed span while a handle is dragged. */ + setTrimRange: (range: TrimRange) => void + /** Leave trim mode (save or cancel). */ + endTrim: () => void setActiveHistoryMapMetric: (metric: HistoryMetricKey) => void } @@ -41,6 +54,7 @@ const initialState: MainScreenState = { mapSelector: null, perspectiveEnabled: true, seekTimeMs: null, + trimRange: null, activeHistoryMapMetric: 'speed', } @@ -52,7 +66,13 @@ export const useMainScreenStore = create((s }, enterTelemetry() { - set({ mode: 'telemetry', historySheetVisible: false, mapSelector: null, seekTimeMs: null }) + set({ + mode: 'telemetry', + historySheetVisible: false, + mapSelector: null, + seekTimeMs: null, + trimRange: null, + }) }, enterMap() { @@ -97,6 +117,25 @@ export const useMainScreenStore = create((s set((state) => (state.seekTimeMs === timeMs ? state : { seekTimeMs: timeMs })) }, + beginTrim(range) { + // The scrub head and a trim selection are mutually exclusive interactions on the chart. + set({ trimRange: range, seekTimeMs: null }) + }, + + setTrimRange(range) { + set((state) => + state.trimRange && + state.trimRange.startMs === range.startMs && + state.trimRange.endMs === range.endMs + ? state + : { trimRange: range }, + ) + }, + + endTrim() { + set((state) => (state.trimRange === null ? state : { trimRange: null })) + }, + setActiveHistoryMapMetric(metric) { set((state) => state.activeHistoryMapMetric === metric ? state : { activeHistoryMapMetric: metric }, diff --git a/src/screens/main/map/MainMapLayers.tsx b/src/screens/main/map/MainMapLayers.tsx index 745c2f247..f41713359 100644 --- a/src/screens/main/map/MainMapLayers.tsx +++ b/src/screens/main/map/MainMapLayers.tsx @@ -285,6 +285,44 @@ function SeekPositionPin({ rideGpsSamples }: { rideGpsSamples: HistoryGpsSample[ ) } +// Live sub-range highlight while trimming a Favorite. Subscribes to the trim range directly so a +// drag only re-renders this layer, not the whole map. rideGpsSamples is a stable prop. +function TrimRouteHighlight({ rideGpsSamples }: { rideGpsSamples: HistoryGpsSample[] }) { + const trimRange = useMainScreenStore((s) => s.trimRange) + const shape = useMemo(() => { + if (!trimRange) return null + const lo = Math.min(trimRange.startMs, trimRange.endMs) + const hi = Math.max(trimRange.startMs, trimRange.endMs) + const coordinates: [number, number][] = [] + for (const gps of rideGpsSamples) { + if (gps.capturedAtMs < lo) continue + if (gps.capturedAtMs > hi) break + coordinates.push([gps.longitude, gps.latitude]) + } + if (coordinates.length < 2) return null + return { + type: 'Feature', + geometry: { type: 'LineString', coordinates }, + properties: {}, + } as const + }, [trimRange, rideGpsSamples]) + + if (!shape) return null + return ( + + + + ) +} + export function HistoryMapLayers({ rideRouteShape, rideRoute, @@ -316,6 +354,8 @@ export function HistoryMapLayers({ onOpenMedia: MainMapLayersProps['onOpenMedia'] highContrastRoutes: boolean }) { + // Flips only on trim enter/exit, so the whole-route layers dim without per-drag re-renders. + const trimming = useMainScreenStore((s) => s.trimRange != null) const [highlightProgress, setHighlightProgress] = useState(0) const highlightDurationMs = useMemo( () => getHistoryRouteHighlightDurationMs(rideRoute), @@ -376,6 +416,7 @@ export function HistoryMapLayers({ lineWidth: highContrastRoutes ? 8 : 0, lineCap: 'round', lineJoin: 'round', + lineOpacity: trimming ? 0.3 : 1, }} /> @@ -395,10 +437,12 @@ export function HistoryMapLayers({ lineWidth: highContrastRoutes ? 5 : 4, lineCap: 'round', lineJoin: 'round', + lineOpacity: trimming ? 0.3 : 1, }} /> )} + {rideRoute[0] && ( void selectedSession: HistorySession | null sessionSamples: TelemetrySample[] + sessionGpsSamples: HistoryGpsSample[] sessionMarkers: HistoryMarker[] previousRide: HistorySession | null nextRide: HistorySession | null @@ -169,7 +171,12 @@ interface MainHistoryOverlayProps { favoritesSaving: boolean favoritesError: string | undefined selectedSessionFavorite: Favorite | null - toggleSelectedRideFavorite: () => Promise + trimming: boolean + trimSeed: { startMs: number; endMs: number } | null + beginTrimFavorite: () => void + updateTrimRange: (startMs: number, endMs: number) => void + cancelTrim: () => void + saveTrim: () => Promise removeFavorite: (id: string) => Promise selectSession: (session: HistorySession | null) => Promise loadMoreHistory: () => Promise @@ -1112,10 +1119,14 @@ export function MainOverlays({ canRemove={false} canFavorite={false} favorited={false} + trimming={false} + saving={false} onSelectTab={history.selectHistoryTab} onBack={history.exitHistory} onRemove={() => undefined} onToggleFavorite={() => undefined} + onCancelTrim={() => undefined} + onSaveTrim={() => undefined} /> )} @@ -1134,8 +1145,8 @@ export function MainOverlays({ movingEndAtMs={history.selectedSession.movingEndAtMs} deviceName={history.selectedSession.deviceName} samples={history.sessionSamples} - canPrevious={history.canPreviousRide} - canNext={!!history.nextRide} + canPrevious={!history.trimming && history.canPreviousRide} + canNext={!history.trimming && !!history.nextRide} mediaAssets={history.mediaHistory.assets} mediaUnmatched={history.mediaHistory.unmatched} mediaLoading={history.mediaHistory.loading} @@ -1152,19 +1163,41 @@ export function MainOverlays({ onSeek={history.onSeek} onMetricInteraction={history.setActiveHistoryMapMetric} onHeightChange={setPanelHeight} + trim={ + history.trimming && history.trimSeed + ? { + startMs: history.trimSeed.startMs, + endMs: history.trimSeed.endMs, + onChange: history.updateTrimRange, + onCommit: history.updateTrimRange, + } + : undefined + } /> - + {history.trimming ? ( + + ) : ( + + )} { - void history.toggleSelectedRideFavorite() + onToggleFavorite={history.beginTrimFavorite} + onCancelTrim={history.cancelTrim} + onSaveTrim={() => { + void history.saveTrim() }} /> @@ -1185,10 +1218,14 @@ export function MainOverlays({ canRemove={false} canFavorite={false} favorited={false} + trimming={false} + saving={false} onSelectTab={history.selectHistoryTab} onBack={history.exitHistory} onRemove={() => undefined} onToggleFavorite={() => undefined} + onCancelTrim={() => undefined} + onSaveTrim={() => undefined} /> )} diff --git a/src/screens/main/useMainScreenController.ts b/src/screens/main/useMainScreenController.ts index 3b881f4e7..cd4bc0179 100644 --- a/src/screens/main/useMainScreenController.ts +++ b/src/screens/main/useMainScreenController.ts @@ -34,6 +34,12 @@ const MAX_HISTORY_PREFETCH_PAGES = 8 export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) { const backPressedOnce = useRef(false) const [openMediaAssetId, setOpenMediaAssetId] = useState(null) + // Flips only on trim enter/exit; the live range lives in the store where narrow subscribers + // (map highlight, stats preview) read it without re-rendering the whole screen. + const trimming = useMainScreenStore((s) => s.trimRange != null) + // A stable seed for the chart handles, captured when trim opens so per-drag store writes don't + // fight the handle positions. + const [trimSeed, setTrimSeed] = useState<{ startMs: number; endMs: number } | null>(null) const { mode, historyTab, @@ -184,6 +190,9 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) useEffect(() => { setSeekTimeMs(null) + // Switching rides abandons any in-progress trim. The stale seed is harmless — it is only read + // while trimming, which endTrim() ends here. + useMainScreenStore.getState().endTrim() }, [selectedSession, setSeekTimeMs]) useEffect(() => { @@ -263,25 +272,41 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) [loadFavorites, setHistoryTab], ) - /** Star on an open ride pins its full Moving Window; starring again unpins that same range. */ - const toggleSelectedRideFavorite = useCallback(async () => { + /** Star on an open ride opens trim mode, seeded with the full Moving Window (one-tap whole ride). */ + const beginTrimFavorite = useCallback(() => { const session = useHistoryStore.getState().selectedSession if (!session) return - const existing = findSessionFavorite(useFavoriteStore.getState().favorites, session) - if (existing) { - await removeFavorite(existing.id) - return - } const range = favoriteRangeForSession(session) - await addFavorite({ - ...range, + setTrimSeed(range) + useMainScreenStore.getState().beginTrim(range) + }, [setTrimSeed]) + + /** Per drag-frame update of the trimmed span; drives the live map highlight and stats preview. */ + const updateTrimRange = useCallback((startMs: number, endMs: number) => { + useMainScreenStore.getState().setTrimRange({ startMs, endMs }) + }, []) + + const cancelTrim = useCallback(() => { + useMainScreenStore.getState().endTrim() + }, []) + + /** Save the trimmed range as a Favorite. Native mints identity, timestamps and durable stats. */ + const saveTrim = useCallback(async () => { + const range = useMainScreenStore.getState().trimRange + const session = useHistoryStore.getState().selectedSession + if (!range || !session) return + const favorite = await addFavorite({ + startMs: Math.min(range.startMs, range.endMs), + endMs: Math.max(range.startMs, range.endMs), ...(session.deviceId ? { deviceId: session.deviceId } : {}), }) - }, [addFavorite, removeFavorite]) + if (favorite) useMainScreenStore.getState().endTrim() + }, [addFavorite]) const exitHistory = useCallback(() => { setOpenMediaAssetId(null) setHistoryTab('history') + useMainScreenStore.getState().endTrim() void selectSession(null) enterTelemetry() requestAnimationFrame(() => @@ -402,6 +427,10 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) useCallback(() => { const handler = BackHandler.addEventListener('hardwareBackPress', () => { if (mode === 'history') { + if (useMainScreenStore.getState().trimRange) { + useMainScreenStore.getState().endTrim() + return true + } exitHistory() return true } @@ -493,7 +522,12 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) favoritesSaving, favoritesError, selectedSessionFavorite, - toggleSelectedRideFavorite, + trimming, + trimSeed, + beginTrimFavorite, + updateTrimRange, + cancelTrim, + saveTrim, removeFavorite, selectSession, loadMoreHistory: loadMore, From b0e4792ba818d06d68ea8b63abdc91738bfeda89 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 00:20:59 +0200 Subject: [PATCH 09/24] Pin favorited telemetry during deletion #289 --- .../vescapecore/telemetry/TelemetryDao.kt | 17 ++++ .../telemetry/TelemetryRangeSubtraction.kt | 57 +++++++++++++ .../telemetry/TelemetryRepository.kt | 68 ++++++++++++++-- .../TelemetryRangeSubtractionTest.kt | 63 +++++++++++++++ .../telemetry/TelemetryRangeSubtraction.swift | 61 ++++++++++++++ .../TelemetryRangeSubtractionTests.swift | 77 ++++++++++++++++++ .../ios/telemetry/TelemetryRepository.swift | 80 +++++++++++++++---- .../components/HistorySessionSheet.tsx | 21 ++++- src/modules/history/lib/favorites.test.ts | 15 +++- src/modules/history/lib/favorites.ts | 10 +++ .../history/store/historyStore.test.ts | 1 + src/modules/history/store/historyStore.ts | 17 ++-- src/screens/main/history/HistoryOverlay.tsx | 11 ++- 13 files changed, 466 insertions(+), 32 deletions(-) create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt create mode 100644 modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift create mode 100644 modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 85fe1a9c0..ade8fb314 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -73,6 +73,9 @@ interface TelemetryDao { @Insert suspend fun insertFrames(frames: List): List + @Update + suspend fun updateFrame(frame: TelemetryFrameEntity) + @Insert suspend fun insertMarkers(markers: List) @@ -293,6 +296,20 @@ interface TelemetryDao { return frames } + @Query("DELETE FROM telemetry_frames WHERE captured_at_ms >= :fromMs AND captured_at_ms <= :toMs") + suspend fun deleteFramesRangeAllDevices(fromMs: Long, toMs: Long): Int + + @Query("DELETE FROM telemetry_markers WHERE occurred_at_ms >= :fromMs AND occurred_at_ms <= :toMs") + suspend fun deleteMarkersRangeAllDevices(fromMs: Long, toMs: Long): Int + + @Transaction + suspend fun deleteRangeAllDevices(fromMs: Long, toMs: Long): Int { + val frames = deleteFramesRangeAllDevices(fromMs, toMs) + deleteMarkersRangeAllDevices(fromMs, toMs) + deleteExclusionsRange(fromMs, toMs) + return frames + } + @Query("DELETE FROM telemetry_frames") suspend fun clearFrames() diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt new file mode 100644 index 000000000..a653e2dbc --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt @@ -0,0 +1,57 @@ +package expo.modules.vescapecore.telemetry + +/** + * Inclusive telemetry time range. Deletion uses inclusive SQL bounds, so subtraction returns the + * exact inclusive holes that remain deletable. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift `TelemetryTimeRange` + */ +internal data class TelemetryTimeRange( + val startMs: Long, + val endMs: Long, +) { + init { + require(endMs >= startMs) + } +} + +/** + * Carve protected ranges out of one requested delete range. Protected ranges are clipped, sorted, + * and merged first so overlapping Favorites never produce duplicate or inverted delete ranges. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift `subtractProtectedTelemetryRanges` + */ +internal fun subtractProtectedTelemetryRanges( + deleteRange: TelemetryTimeRange, + protectedRanges: Collection, +): List { + val protected = protectedRanges + .mapNotNull { range -> + val start = maxOf(deleteRange.startMs, range.startMs) + val end = minOf(deleteRange.endMs, range.endMs) + if (start <= end) TelemetryTimeRange(start, end) else null + } + .sortedBy { it.startMs } + .fold(mutableListOf()) { merged, range -> + val previous = merged.lastOrNull() + if ( + previous != null && + (range.startMs <= previous.endMs || previous.endMs != Long.MAX_VALUE && range.startMs == previous.endMs + 1) + ) { + merged[merged.lastIndex] = previous.copy(endMs = maxOf(previous.endMs, range.endMs)) + } else { + merged += range + } + merged + } + + val deletable = mutableListOf() + var cursor = deleteRange.startMs + for (range in protected) { + if (cursor < range.startMs) deletable += TelemetryTimeRange(cursor, range.startMs - 1) + if (range.endMs == Long.MAX_VALUE) return deletable + cursor = range.endMs + 1 + } + if (cursor <= deleteRange.endMs) deletable += TelemetryTimeRange(cursor, deleteRange.endMs) + return deletable +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index cc83f52dd..ad6126643 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -526,7 +526,14 @@ class TelemetryRepository private constructor(context: Context) { suspend fun deleteRange(options: Map): Int = withContext(Dispatchers.IO) { val query = RangeMutationOptions.from(options) flushNow() - dao.deleteRange(query.fromMs, query.toMs, query.deviceId) + val requested = TelemetryTimeRange(query.fromMs, query.toMs) + val protected = favoriteTelemetryRanges() + promoteProtectedRangeStarts(protected, query.deviceId) + val deleted = subtractProtectedTelemetryRanges(requested, protected).sumOf { range -> + dao.deleteRange(range.startMs, range.endMs, query.deviceId) + } + rebuildBuckets() + deleted } // Favorites (ADR 0029) @@ -621,12 +628,12 @@ class TelemetryRepository private constructor(context: Context) { } suspend fun rebuildBuckets(onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }): Int = withContext(Dispatchers.IO) { - val firstMs = dao.firstFrameAt() ?: return@withContext 0 - val lastMs = dao.lastFrameAt() ?: return@withContext 0 - dao.clearBuckets() dao.clearExclusions() + val firstMs = dao.firstFrameAt() ?: return@withContext 0 + val lastMs = dao.lastFrameAt() ?: return@withContext 0 + val chunkMs = 3_600_000L val chunks = ((lastMs - firstMs) / chunkMs + 1).toInt() var rebuiltBuckets = 0 @@ -665,7 +672,19 @@ class TelemetryRepository private constructor(context: Context) { } suspend fun clearAll() = withContext(Dispatchers.IO) { - dao.clearAll() + flushNow() + val protected = favoriteTelemetryRanges() + if (protected.isEmpty()) { + dao.clearAll() + } else { + promoteProtectedRangeStarts(protected, deviceId = null) + val requested = TelemetryTimeRange(Long.MIN_VALUE, Long.MAX_VALUE) + for (range in subtractProtectedTelemetryRanges(requested, protected)) { + dao.deleteRangeAllDevices(range.startMs, range.endMs) + } + dao.clearDiagnosticEvents() + rebuildBuckets() + } synchronized(lock) { pending.clear() pendingMarkers.clear() @@ -677,6 +696,45 @@ class TelemetryRepository private constructor(context: Context) { } } + /** + * Favorites protect time ranges globally. A Board can be re-linked after a Favorite is created, + * so its current BLE id cannot safely identify the historical telemetry device id. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `favoriteTelemetryRanges` + */ + private suspend fun favoriteTelemetryRanges(): List = + dao.getFavorites().map { TelemetryTimeRange(it.startMs, it.endMs) } + + /** + * Android stores delta frames. Promote the first retained frame in each protected island before + * deleting its predecessor, otherwise the Favorite could survive in SQLite but become undecodable. + * + * iOS stores full keyframe rows for every sample and needs no promotion. + */ + private suspend fun promoteProtectedRangeStarts( + protected: Collection, + deviceId: String?, + ) { + for (range in protected) { + val devices = if (deviceId != null) { + listOf(deviceId) + } else { + dao.getFrames(range.startMs, range.endMs, null, Int.MAX_VALUE) + .map { it.deviceId } + .distinct() + } + for (protectedDeviceId in devices) { + val first = getSampleStates( + range.startMs, + range.endMs, + protectedDeviceId, + Int.MAX_VALUE, + ).firstOrNull() ?: continue + dao.updateFrame(first.state.toFrame(previous = null, keyframe = true).copy(id = first.id)) + } + } + } + private fun scheduleFlushLocked() { if (flushScheduled) return flushScheduled = true diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt new file mode 100644 index 000000000..1201b7a5e --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt @@ -0,0 +1,63 @@ +package expo.modules.vescapecore.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TelemetryRangeSubtractionTest { + private val requested = TelemetryTimeRange(100, 200) + + @Test + fun `full overlap leaves nothing deletable`() { + assertEquals( + emptyList(), + subtractProtectedTelemetryRanges(requested, listOf(TelemetryTimeRange(50, 250))), + ) + } + + @Test + fun `partial overlap carves each edge`() { + assertEquals( + listOf(TelemetryTimeRange(151, 200)), + subtractProtectedTelemetryRanges(requested, listOf(TelemetryTimeRange(50, 150))), + ) + assertEquals( + listOf(TelemetryTimeRange(100, 149)), + subtractProtectedTelemetryRanges(requested, listOf(TelemetryTimeRange(150, 250))), + ) + } + + @Test + fun `multiple overlapping favorites merge before subtraction`() { + assertEquals( + listOf(TelemetryTimeRange(100, 119), TelemetryTimeRange(181, 200)), + subtractProtectedTelemetryRanges( + requested, + listOf( + TelemetryTimeRange(120, 160), + TelemetryTimeRange(140, 180), + ), + ), + ) + } + + @Test + fun `one favorite stays protected across separate delete requests`() { + val favorite = listOf(TelemetryTimeRange(120, 180)) + assertEquals( + listOf(TelemetryTimeRange(100, 119)), + subtractProtectedTelemetryRanges(TelemetryTimeRange(100, 150), favorite), + ) + assertEquals( + listOf(TelemetryTimeRange(181, 200)), + subtractProtectedTelemetryRanges(TelemetryTimeRange(151, 200), favorite), + ) + } + + @Test + fun `adjacent disjoint favorite does not affect deletion`() { + assertEquals( + listOf(requested), + subtractProtectedTelemetryRanges(requested, listOf(TelemetryTimeRange(201, 250))), + ) + } +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift new file mode 100644 index 000000000..8918e728a --- /dev/null +++ b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Inclusive telemetry time range. Deletion uses inclusive SQL bounds, so subtraction returns the +/// exact inclusive holes that remain deletable. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt `TelemetryTimeRange` +internal struct TelemetryTimeRange: Equatable { + let startMs: Int64 + let endMs: Int64 + + init(startMs: Int64, endMs: Int64) { + precondition(endMs >= startMs) + self.startMs = startMs + self.endMs = endMs + } +} + +/// Carve protected ranges out of one requested delete range. Protected ranges are clipped, sorted, +/// and merged first so overlapping Favorites never produce duplicate or inverted delete ranges. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt `subtractProtectedTelemetryRanges` +internal func subtractProtectedTelemetryRanges( + deleteRange: TelemetryTimeRange, + protectedRanges: [TelemetryTimeRange] +) -> [TelemetryTimeRange] { + let clipped = protectedRanges.compactMap { range -> TelemetryTimeRange? in + let start = max(deleteRange.startMs, range.startMs) + let end = min(deleteRange.endMs, range.endMs) + return start <= end ? TelemetryTimeRange(startMs: start, endMs: end) : nil + }.sorted { $0.startMs < $1.startMs } + + var protected: [TelemetryTimeRange] = [] + for range in clipped { + if + let previous = protected.last, + range.startMs <= previous.endMs || + (previous.endMs != Int64.max && range.startMs == previous.endMs + 1) + { + protected[protected.count - 1] = TelemetryTimeRange( + startMs: previous.startMs, + endMs: max(previous.endMs, range.endMs) + ) + } else { + protected.append(range) + } + } + + var deletable: [TelemetryTimeRange] = [] + var cursor = deleteRange.startMs + for range in protected { + if cursor < range.startMs { + deletable.append(TelemetryTimeRange(startMs: cursor, endMs: range.startMs - 1)) + } + if range.endMs == Int64.max { return deletable } + cursor = range.endMs + 1 + } + if cursor <= deleteRange.endMs { + deletable.append(TelemetryTimeRange(startMs: cursor, endMs: deleteRange.endMs)) + } + return deletable +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift new file mode 100644 index 000000000..3bebc1b02 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift @@ -0,0 +1,77 @@ +import XCTest +@testable import VescapeCore + +final class TelemetryRangeSubtractionTests: XCTestCase { + private let requested = TelemetryTimeRange(startMs: 100, endMs: 200) + + func testFullOverlapLeavesNothingDeletable() { + XCTAssertEqual( + subtractProtectedTelemetryRanges( + deleteRange: requested, + protectedRanges: [TelemetryTimeRange(startMs: 50, endMs: 250)] + ), + [] + ) + } + + func testPartialOverlapCarvesEachEdge() { + XCTAssertEqual( + subtractProtectedTelemetryRanges( + deleteRange: requested, + protectedRanges: [TelemetryTimeRange(startMs: 50, endMs: 150)] + ), + [TelemetryTimeRange(startMs: 151, endMs: 200)] + ) + XCTAssertEqual( + subtractProtectedTelemetryRanges( + deleteRange: requested, + protectedRanges: [TelemetryTimeRange(startMs: 150, endMs: 250)] + ), + [TelemetryTimeRange(startMs: 100, endMs: 149)] + ) + } + + func testMultipleOverlappingFavoritesMergeBeforeSubtraction() { + XCTAssertEqual( + subtractProtectedTelemetryRanges( + deleteRange: requested, + protectedRanges: [ + TelemetryTimeRange(startMs: 120, endMs: 160), + TelemetryTimeRange(startMs: 140, endMs: 180), + ] + ), + [ + TelemetryTimeRange(startMs: 100, endMs: 119), + TelemetryTimeRange(startMs: 181, endMs: 200), + ] + ) + } + + func testOneFavoriteStaysProtectedAcrossSeparateDeleteRequests() { + let favorite = [TelemetryTimeRange(startMs: 120, endMs: 180)] + XCTAssertEqual( + subtractProtectedTelemetryRanges( + deleteRange: TelemetryTimeRange(startMs: 100, endMs: 150), + protectedRanges: favorite + ), + [TelemetryTimeRange(startMs: 100, endMs: 119)] + ) + XCTAssertEqual( + subtractProtectedTelemetryRanges( + deleteRange: TelemetryTimeRange(startMs: 151, endMs: 200), + protectedRanges: favorite + ), + [TelemetryTimeRange(startMs: 181, endMs: 200)] + ) + } + + func testAdjacentDisjointFavoriteDoesNotAffectDeletion() { + XCTAssertEqual( + subtractProtectedTelemetryRanges( + deleteRange: requested, + protectedRanges: [TelemetryTimeRange(startMs: 201, endMs: 250)] + ), + [requested] + ) + } +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 1121bfe68..64145ad5b 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -345,30 +345,40 @@ internal final class TelemetryRepository { let fromMs = telemetryLong(options["fromMs"]) ?? 0 let toMs = telemetryLong(options["toMs"]) ?? 0 let deviceId = options["deviceId"] as? String - return (try? pool.write { db in - let count = try Int.fetchOne( - db, - sql: "SELECT COUNT(*) FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?)", - arguments: [fromMs, toMs, deviceId, deviceId] - ) ?? 0 - try db.execute(sql: "DELETE FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", arguments: [fromMs, toMs, deviceId, deviceId, deviceId]) - try db.execute(sql: "DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= ? AND first_sample_at_ms <= ? AND device_id = ?", arguments: [fromMs, toMs, deviceId ?? ""]) - try db.execute(sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms >= ? AND start_ms <= ?", arguments: [fromMs, toMs]) - try db.execute(sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", arguments: [fromMs, toMs, deviceId, deviceId, deviceId]) + guard toMs >= fromMs else { return 0 } + let deletable = subtractProtectedTelemetryRanges( + deleteRange: TelemetryTimeRange(startMs: fromMs, endMs: toMs), + protectedRanges: favoriteTelemetryRanges() + ) + let deleted = (try? pool.write { db in + var count = 0 + for range in deletable { + count += try Int.fetchOne( + db, + sql: "SELECT COUNT(*) FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", + arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId] + ) ?? 0 + try db.execute(sql: "DELETE FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId]) + try db.execute(sql: "DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= ? AND first_sample_at_ms <= ? AND device_id = ?", arguments: [range.startMs, range.endMs, deviceId ?? ""]) + try db.execute(sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms >= ? AND start_ms <= ?", arguments: [range.startMs, range.endMs]) + try db.execute(sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ? AND ((? IS NOT NULL AND device_id = ?) OR (? IS NULL AND device_id IS NULL))", arguments: [range.startMs, range.endMs, deviceId, deviceId, deviceId]) + } return count }) ?? 0 + _ = rebuildBuckets() + return deleted } func rebuildBuckets(onProgress: (Int, Int) -> Void = { _, _ in }) -> Int { flushBlocking() guard let pool else { return 0 } return (try? pool.write { db in + try db.execute(sql: "DELETE FROM telemetry_minute_buckets") + try db.execute(sql: "DELETE FROM metric_exclusion_ranges") guard let firstMs = try Int64.fetchOne(db, sql: "SELECT MIN(captured_at_ms) FROM telemetry_frames"), let lastMs = try Int64.fetchOne(db, sql: "SELECT MAX(captured_at_ms) FROM telemetry_frames") else { return 0 } - try db.execute(sql: "DELETE FROM telemetry_minute_buckets") - try db.execute(sql: "DELETE FROM metric_exclusion_ranges") let chunkMs: Int64 = 3_600_000 let chunks = Int((lastMs - firstMs) / chunkMs + 1) @@ -407,12 +417,38 @@ internal final class TelemetryRepository { } func clearAll() { + flushBlocking() guard let pool else { return } - try? pool.write { db in - try db.execute(sql: "DELETE FROM telemetry_frames") - try db.execute(sql: "DELETE FROM telemetry_minute_buckets") - try db.execute(sql: "DELETE FROM telemetry_markers") - try db.execute(sql: "DELETE FROM metric_exclusion_ranges") + let protected = favoriteTelemetryRanges() + if protected.isEmpty { + try? pool.write { db in + try db.execute(sql: "DELETE FROM telemetry_frames") + try db.execute(sql: "DELETE FROM telemetry_minute_buckets") + try db.execute(sql: "DELETE FROM telemetry_markers") + try db.execute(sql: "DELETE FROM metric_exclusion_ranges") + } + } else { + let deletable = subtractProtectedTelemetryRanges( + deleteRange: TelemetryTimeRange(startMs: Int64.min, endMs: Int64.max), + protectedRanges: protected + ) + try? pool.write { db in + for range in deletable { + try db.execute( + sql: "DELETE FROM telemetry_frames WHERE captured_at_ms >= ? AND captured_at_ms <= ?", + arguments: [range.startMs, range.endMs] + ) + try db.execute( + sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ?", + arguments: [range.startMs, range.endMs] + ) + try db.execute( + sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms >= ? AND start_ms <= ?", + arguments: [range.startMs, range.endMs] + ) + } + } + _ = rebuildBuckets() } queue.sync { pendingStates.removeAll() @@ -424,6 +460,16 @@ internal final class TelemetryRepository { } } + /// Favorites protect time ranges globally. A Board can be re-linked after a Favorite is created, + /// so its current BLE id cannot safely identify the historical telemetry device id. + /// + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `favoriteTelemetryRanges` + private func favoriteTelemetryRanges() -> [TelemetryTimeRange] { + FavoriteStore.shared.list().map { + TelemetryTimeRange(startMs: $0.startMs, endMs: $0.endMs) + } + } + private func flushOnQueue() { guard let pool, (!pendingStates.isEmpty || !pendingPersisted.isEmpty || !pendingMarkers.isEmpty) else { return } let markers = pendingMarkers diff --git a/src/modules/history/components/HistorySessionSheet.tsx b/src/modules/history/components/HistorySessionSheet.tsx index 77dd918ce..afe76e54d 100644 --- a/src/modules/history/components/HistorySessionSheet.tsx +++ b/src/modules/history/components/HistorySessionSheet.tsx @@ -10,13 +10,15 @@ import { useWindowDimensions, } from 'react-native' import { Text } from '@/components/base/Text' -import { CaretRightIcon } from 'phosphor-react-native' +import { CaretRightIcon, LockKeyIcon } from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Canvas, Circle, Path, Skia } from '@shopify/react-native-skia' import { interaction, theme } from '@/constants/theme' import { telemetry } from '@/modules/board/constants/telemetry' +import { sessionContainsFavorite } from '@/modules/history/lib/favorites' import { rideDurationMs } from '@/modules/history/lib/sessions' +import type { Favorite } from '@/modules/history/store/favoriteStore' import type { HistorySession, TelemetryMinuteBucket } from '@/modules/history/store/historyStore' interface HistorySessionSheetProps { @@ -24,6 +26,7 @@ interface HistorySessionSheetProps { bottomOffset: number blocks: TelemetryMinuteBucket[] sessions: HistorySession[] + favorites: Favorite[] selectedSessionId: string | null hasMore: boolean loadingMore: boolean @@ -43,6 +46,7 @@ export function HistorySessionSheet({ bottomOffset, blocks, sessions, + favorites, selectedSessionId, hasMore, loadingMore, @@ -109,6 +113,7 @@ export function HistorySessionSheet({ sessions.map((session) => { const selected = session.id === selectedSessionId const routePoints = getSessionRoutePreviewPoints(blocks, session) + const containsFavorite = sessionContainsFavorite(favorites, session) return ( + {containsFavorite && ( + + + + )} ) @@ -323,6 +337,11 @@ const styles = StyleSheet.create({ color: theme.palette.slate.textMuted, fontSize: 11, }, + protectedMarker: { + width: 20, + alignItems: 'center', + justifyContent: 'center', + }, routePreview: { width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, diff --git a/src/modules/history/lib/favorites.test.ts b/src/modules/history/lib/favorites.test.ts index 312cd56c8..76974b3d9 100644 --- a/src/modules/history/lib/favorites.test.ts +++ b/src/modules/history/lib/favorites.test.ts @@ -2,7 +2,11 @@ import { expect, test } from 'bun:test' import type { Favorite } from 'vescape-core' -import { favoriteRangeForSession, findSessionFavorite } from '@/modules/history/lib/favorites' +import { + favoriteRangeForSession, + findSessionFavorite, + sessionContainsFavorite, +} from '@/modules/history/lib/favorites' const session = { startAtMs: 1_000_000, @@ -47,3 +51,12 @@ test('a ride counts as favorited only when a favorite covers its exact Moving Wi expect(findSessionFavorite([favorite({ endMs: 1_400_000 })], session)).toBeNull() expect(findSessionFavorite([favorite({ startMs: 1_050_000 })], session)).toBeNull() }) + +test('a ride contains a favorite when their ranges overlap at all', () => { + expect(sessionContainsFavorite([favorite({ startMs: 900_000, endMs: 1_000_000 })], session)).toBe( + true, + ) + expect( + sessionContainsFavorite([favorite({ startMs: 1_600_001, endMs: 1_700_000 })], session), + ).toBe(false) +}) diff --git a/src/modules/history/lib/favorites.ts b/src/modules/history/lib/favorites.ts index c03854283..1481ddcb3 100644 --- a/src/modules/history/lib/favorites.ts +++ b/src/modules/history/lib/favorites.ts @@ -29,3 +29,13 @@ export function findSessionFavorite( ) ?? null ) } + +/** Any overlap means deleting this history session must leave a protected telemetry island. */ +export function sessionContainsFavorite( + favorites: Favorite[], + session: Pick, +): boolean { + return favorites.some( + (favorite) => favorite.startMs <= session.endAtMs && favorite.endMs >= session.startAtMs, + ) +} diff --git a/src/modules/history/store/historyStore.test.ts b/src/modules/history/store/historyStore.test.ts index 75d12c833..87303f0ce 100644 --- a/src/modules/history/store/historyStore.test.ts +++ b/src/modules/history/store/historyStore.test.ts @@ -126,6 +126,7 @@ test('removes selected session from history and selects next ride', async () => endAtMs: 1_060_000, }) getTelemetryHistory.mockResolvedValueOnce([newest, selected, oldest]) + getTelemetryHistory.mockResolvedValueOnce([newest, oldest]) const { useHistoryStore } = await import('@/modules/history/store/historyStore') diff --git a/src/modules/history/store/historyStore.ts b/src/modules/history/store/historyStore.ts index b4a2f81a4..c68510599 100644 --- a/src/modules/history/store/historyStore.ts +++ b/src/modules/history/store/historyStore.ts @@ -357,6 +357,7 @@ export const useHistoryStore = create((set, get) async removeSelectedSession() { const { selectedSession, sessions } = get() if (!selectedSession) return + const reloadLimit = Math.min(500, Math.max(PAGE_SIZE, get().blocks.length)) liveRefreshVersion++ set({ loadingSession: true, error: undefined }) try { @@ -366,9 +367,8 @@ export const useHistoryStore = create((set, get) deviceId: selectedSession.deviceId, }) const selectedIndex = sessions.findIndex((session) => session.id === selectedSession.id) - const selectedBlockIds = new Set(selectedSession.blockIds) - const blocks = get().blocks.filter((block) => !selectedBlockIds.has(block.id)) - const liveBlocks = get().liveBlocks.filter((block) => !selectedBlockIds.has(block.id)) + const blocks = await getTelemetryHistory({ limit: reloadLimit }) + const liveBlocks = blocks.slice(0, useSettingsStore.getState().liveHistoryLimit) const nextSessions = groupHistorySessions(blocks) const nextSelectedSession = selectedIndex >= 0 @@ -390,6 +390,7 @@ export const useHistoryStore = create((set, get) sessionExclusions: [], markers: [], sessionTruncated: false, + hasMore: blocks.length === reloadLimit, }) if (nextSelectedSession) { await get().selectSession(nextSelectedSession) @@ -402,13 +403,15 @@ export const useHistoryStore = create((set, get) }, async clearHistory() { + const reloadLimit = Math.min(500, Math.max(PAGE_SIZE, get().blocks.length)) set({ loading: true, error: undefined }) try { await clearTelemetryHistory() + const blocks = await getTelemetryHistory({ limit: reloadLimit }) set({ - blocks: [], - sessions: [], - liveBlocks: [], + blocks, + sessions: groupHistorySessions(blocks), + liveBlocks: blocks.slice(0, useSettingsStore.getState().liveHistoryLimit), selectedBlock: null, selectedSession: null, samples: [], @@ -422,7 +425,7 @@ export const useHistoryStore = create((set, get) markers: [], sessionTruncated: false, summary: await getTelemetrySummary(), - hasMore: false, + hasMore: blocks.length === reloadLimit, }) } catch (err) { set({ error: err instanceof Error ? err.message : String(err) }) diff --git a/src/screens/main/history/HistoryOverlay.tsx b/src/screens/main/history/HistoryOverlay.tsx index 9cab1f7a7..bc013954f 100644 --- a/src/screens/main/history/HistoryOverlay.tsx +++ b/src/screens/main/history/HistoryOverlay.tsx @@ -11,6 +11,7 @@ import { HistoryEmptyState } from '@/modules/history/components/HistoryEmptyStat import { HistorySessionSheet } from '@/modules/history/components/HistorySessionSheet' import { MediaHistoryViewer } from '@/modules/history/components/MediaHistoryViewer' import type { MediaAssetInput, MediaHistoryAsset } from '@/modules/history/lib/mediaHistory' +import { sessionContainsFavorite } from '@/modules/history/lib/favorites' import type { HistoryMetricKey } from '@/modules/history/lib/metricColorScale' import type { HistorySession, @@ -98,6 +99,9 @@ export function HistoryOverlay({ history.favoritesSaving const aboveStripBottom = STRIP_CONTENT_HEIGHT + Math.max(insets.bottom * 0.5, 8) + 8 const sheetBottom = Math.max(insets.bottom, 16) + 8 + panelHeight + 8 + const selectedSessionContainsFavorite = + history.selectedSession != null && + sessionContainsFavorite(history.favorites, history.selectedSession) const handleRemoveConfirm = useCallback(() => { setRemoveConfirmVisible(false) @@ -237,6 +241,7 @@ export function HistoryOverlay({ bottomOffset={sheetBottom} blocks={history.blocks} sessions={history.sessions} + favorites={history.favorites} selectedSessionId={history.selectedSession?.id ?? null} hasMore={history.historyHasMore} loadingMore={history.historyLoading} @@ -272,7 +277,11 @@ export function HistoryOverlay({ Date: Thu, 30 Jul 2026 00:29:30 +0200 Subject: [PATCH 10/24] Avoid bucket rebuilds for favorite deletion #289 --- docs/adr/0029-favorites-pin-telemetry-ranges.md | 4 ++-- .../vescapecore/telemetry/TelemetryDao.kt | 10 ++++++++++ .../telemetry/TelemetryRangeSubtraction.kt | 16 ++++++++++++++++ .../vescapecore/telemetry/TelemetryRepository.kt | 12 ++++++------ .../telemetry/TelemetryRangeSubtractionTest.kt | 11 +++++++++++ .../telemetry/TelemetryRangeSubtraction.swift | 14 ++++++++++++++ .../TelemetryRangeSubtractionTests.swift | 10 ++++++++++ .../ios/telemetry/TelemetryRepository.swift | 14 +++++++++----- 8 files changed, 78 insertions(+), 13 deletions(-) diff --git a/docs/adr/0029-favorites-pin-telemetry-ranges.md b/docs/adr/0029-favorites-pin-telemetry-ranges.md index 196fc9b5a..0f57b0012 100644 --- a/docs/adr/0029-favorites-pin-telemetry-ranges.md +++ b/docs/adr/0029-favorites-pin-telemetry-ranges.md @@ -6,7 +6,7 @@ A Favorite is a durable, optionally named time range `[startMs, endMs]` over tel - Favorites live in a native table (`@parity` iOS/Android) so telemetry deletion paths can see them. - A Favorite has a native-minted stable UUID plus native-owned `created_at` and `updated_at`; JS cannot supply them. -- `deleteTelemetryRange` and `clearTelemetryHistory` carve out favorited ranges instead of deleting them. Deleting a ride around a Favorite leaves the favorited samples as a telemetry island, which history grouping surfaces as a short standalone ride. +- `deleteTelemetryRange` and `clearTelemetryHistory` protect every minute bucket touched by a favorited range. Both the precomputed bucket and all its raw samples stay together; only buckets and telemetry wholly outside those bucket-aligned protected ranges are deleted. Deleting a ride around a Favorite leaves the protected buckets as a short standalone ride. - Rides containing a favorited range are marked in history as not fully deletable. - Removing a Favorite only unpins: its telemetry stays and becomes deletable like any ride. Its Favorite Media is deleted with it. - Summary stats (mirroring history session summary fields) are computed once from raw samples at creation time and denormalized onto the row (ADR 0005 style); the route preview is derived on read from pinned samples. @@ -20,6 +20,6 @@ A Favorite is a durable, optionally named time range `[startMs, endMs]` over tel ## Consequences -- Delete paths need range-hole support; history grouping already tolerates gaps. +- Delete paths subtract bucket-aligned protected ranges. This deliberately keeps up to 59 seconds of telemetry beyond each exact Favorite edge so bucket summaries stay truthful without rebuilding the full telemetry database; the Favorite's own range and denormalized summary remain exact. - Favorited telemetry is exempt from any future retention pruning. - Orphan favorite islands appear in History after surrounding-ride deletion; this is accepted as honest. diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index ade8fb314..72feb39e1 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -302,10 +302,20 @@ interface TelemetryDao { @Query("DELETE FROM telemetry_markers WHERE occurred_at_ms >= :fromMs AND occurred_at_ms <= :toMs") suspend fun deleteMarkersRangeAllDevices(fromMs: Long, toMs: Long): Int + @Query( + """ + DELETE FROM telemetry_minute_buckets + WHERE last_sample_at_ms >= :fromMs + AND first_sample_at_ms <= :toMs + """, + ) + suspend fun deleteBucketsRangeAllDevices(fromMs: Long, toMs: Long): Int + @Transaction suspend fun deleteRangeAllDevices(fromMs: Long, toMs: Long): Int { val frames = deleteFramesRangeAllDevices(fromMs, toMs) deleteMarkersRangeAllDevices(fromMs, toMs) + deleteBucketsRangeAllDevices(fromMs, toMs) deleteExclusionsRange(fromMs, toMs) return frames } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt index a653e2dbc..eb00e88f5 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt @@ -15,6 +15,22 @@ internal data class TelemetryTimeRange( } } +/** + * Deletion protection is bucket-granular: retain every raw sample in each minute bucket touched by + * a Favorite so its existing precomputed bucket stays truthful without a history-wide rebuild. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift `expandTelemetryRangeToBuckets` + */ +internal fun expandTelemetryRangeToBuckets( + range: TelemetryTimeRange, + bucketSizeMs: Long = TELEMETRY_BUCKET_SIZE_MS, +): TelemetryTimeRange { + require(bucketSizeMs > 0) + val start = range.startMs - (range.startMs % bucketSizeMs) + val endStart = range.endMs - (range.endMs % bucketSizeMs) + return TelemetryTimeRange(start, endStart + bucketSizeMs - 1) +} + /** * Carve protected ranges out of one requested delete range. Protected ranges are clipped, sorted, * and merged first so overlapping Favorites never produce duplicate or inverted delete ranges. diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index ad6126643..75f13e2a1 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -532,7 +532,6 @@ class TelemetryRepository private constructor(context: Context) { val deleted = subtractProtectedTelemetryRanges(requested, protected).sumOf { range -> dao.deleteRange(range.startMs, range.endMs, query.deviceId) } - rebuildBuckets() deleted } @@ -628,12 +627,12 @@ class TelemetryRepository private constructor(context: Context) { } suspend fun rebuildBuckets(onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }): Int = withContext(Dispatchers.IO) { - dao.clearBuckets() - dao.clearExclusions() - val firstMs = dao.firstFrameAt() ?: return@withContext 0 val lastMs = dao.lastFrameAt() ?: return@withContext 0 + dao.clearBuckets() + dao.clearExclusions() + val chunkMs = 3_600_000L val chunks = ((lastMs - firstMs) / chunkMs + 1).toInt() var rebuiltBuckets = 0 @@ -683,7 +682,6 @@ class TelemetryRepository private constructor(context: Context) { dao.deleteRangeAllDevices(range.startMs, range.endMs) } dao.clearDiagnosticEvents() - rebuildBuckets() } synchronized(lock) { pending.clear() @@ -703,7 +701,9 @@ class TelemetryRepository private constructor(context: Context) { * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `favoriteTelemetryRanges` */ private suspend fun favoriteTelemetryRanges(): List = - dao.getFavorites().map { TelemetryTimeRange(it.startMs, it.endMs) } + dao.getFavorites().map { + expandTelemetryRangeToBuckets(TelemetryTimeRange(it.startMs, it.endMs)) + } /** * Android stores delta frames. Promote the first retained frame in each protected island before diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt index 1201b7a5e..5928ddbea 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt @@ -6,6 +6,17 @@ import org.junit.Test class TelemetryRangeSubtractionTest { private val requested = TelemetryTimeRange(100, 200) + @Test + fun `favorite protection expands to every touched bucket`() { + assertEquals( + TelemetryTimeRange(60_000, 179_999), + expandTelemetryRangeToBuckets( + TelemetryTimeRange(75_000, 120_000), + bucketSizeMs = 60_000, + ), + ) + } + @Test fun `full overlap leaves nothing deletable`() { assertEquals( diff --git a/modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift index 8918e728a..00e339be7 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtraction.swift @@ -15,6 +15,20 @@ internal struct TelemetryTimeRange: Equatable { } } +/// Deletion protection is bucket-granular: retain every raw sample in each minute bucket touched by +/// a Favorite so its existing precomputed bucket stays truthful without a history-wide rebuild. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt `expandTelemetryRangeToBuckets` +internal func expandTelemetryRangeToBuckets( + _ range: TelemetryTimeRange, + bucketSizeMs: Int64 = TELEMETRY_BUCKET_SIZE_MS +) -> TelemetryTimeRange { + precondition(bucketSizeMs > 0) + let start = range.startMs - (range.startMs % bucketSizeMs) + let endStart = range.endMs - (range.endMs % bucketSizeMs) + return TelemetryTimeRange(startMs: start, endMs: endStart + bucketSizeMs - 1) +} + /// Carve protected ranges out of one requested delete range. Protected ranges are clipped, sorted, /// and merged first so overlapping Favorites never produce duplicate or inverted delete ranges. /// diff --git a/modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift index 3bebc1b02..07a3e6555 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift @@ -4,6 +4,16 @@ import XCTest final class TelemetryRangeSubtractionTests: XCTestCase { private let requested = TelemetryTimeRange(startMs: 100, endMs: 200) + func testFavoriteProtectionExpandsToEveryTouchedBucket() { + XCTAssertEqual( + expandTelemetryRangeToBuckets( + TelemetryTimeRange(startMs: 75_000, endMs: 120_000), + bucketSizeMs: 60_000 + ), + TelemetryTimeRange(startMs: 60_000, endMs: 179_999) + ) + } + func testFullOverlapLeavesNothingDeletable() { XCTAssertEqual( subtractProtectedTelemetryRanges( diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 64145ad5b..8085930c7 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -365,7 +365,6 @@ internal final class TelemetryRepository { } return count }) ?? 0 - _ = rebuildBuckets() return deleted } @@ -373,12 +372,12 @@ internal final class TelemetryRepository { flushBlocking() guard let pool else { return 0 } return (try? pool.write { db in - try db.execute(sql: "DELETE FROM telemetry_minute_buckets") - try db.execute(sql: "DELETE FROM metric_exclusion_ranges") guard let firstMs = try Int64.fetchOne(db, sql: "SELECT MIN(captured_at_ms) FROM telemetry_frames"), let lastMs = try Int64.fetchOne(db, sql: "SELECT MAX(captured_at_ms) FROM telemetry_frames") else { return 0 } + try db.execute(sql: "DELETE FROM telemetry_minute_buckets") + try db.execute(sql: "DELETE FROM metric_exclusion_ranges") let chunkMs: Int64 = 3_600_000 let chunks = Int((lastMs - firstMs) / chunkMs + 1) @@ -442,13 +441,16 @@ internal final class TelemetryRepository { sql: "DELETE FROM telemetry_markers WHERE occurred_at_ms >= ? AND occurred_at_ms <= ?", arguments: [range.startMs, range.endMs] ) + try db.execute( + sql: "DELETE FROM telemetry_minute_buckets WHERE last_sample_at_ms >= ? AND first_sample_at_ms <= ?", + arguments: [range.startMs, range.endMs] + ) try db.execute( sql: "DELETE FROM metric_exclusion_ranges WHERE end_ms >= ? AND start_ms <= ?", arguments: [range.startMs, range.endMs] ) } } - _ = rebuildBuckets() } queue.sync { pendingStates.removeAll() @@ -466,7 +468,9 @@ internal final class TelemetryRepository { /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `favoriteTelemetryRanges` private func favoriteTelemetryRanges() -> [TelemetryTimeRange] { FavoriteStore.shared.list().map { - TelemetryTimeRange(startMs: $0.startMs, endMs: $0.endMs) + expandTelemetryRangeToBuckets( + TelemetryTimeRange(startMs: $0.startMs, endMs: $0.endMs) + ) } } From 37e12de85b3aa15f704013a56adbcfa1029199db Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 00:42:01 +0200 Subject: [PATCH 11/24] Add favorite detail and rename #290 --- CONTEXT.md | 2 +- .../modules/vescapecore/VescapeCoreModule.kt | 5 + .../vescapecore/telemetry/TelemetryDao.kt | 7 + .../telemetry/TelemetryRepository.kt | 13 ++ .../vescape-core/ios/VescapeCoreModule.swift | 8 + .../ios/telemetry/FavoriteStore.swift | 17 ++ .../ios/telemetry/FavoriteStoreTests.swift | 38 +++++ .../ios/telemetry/TelemetryRepository.swift | 14 ++ modules/vescape-core/src/index.ts | 9 + src/app/settings/components/modals.tsx | 28 ++++ src/components/modals/TextPromptModal.tsx | 8 +- .../history/components/FavoriteList.tsx | 19 ++- src/modules/history/lib/favorites.test.ts | 82 ++++++++- src/modules/history/lib/favorites.ts | 83 +++++++++- .../history/store/favoriteStore.test.ts | 40 +++++ src/modules/history/store/favoriteStore.ts | 18 ++ src/screens/main/MainScreen.tsx | 5 + src/screens/main/history/HistoryControls.tsx | 47 +++++- .../main/history/HistoryMapLoading.tsx | 30 ++++ src/screens/main/history/HistoryOverlay.tsx | 125 ++++---------- .../main/history/HistoryRideDetail.tsx | 156 ++++++++++++++++++ .../main/history/useHistoryFavorites.ts | 63 ++++++- src/screens/main/mainScreenStore.ts | 20 ++- src/screens/main/useMainScreenController.ts | 6 +- 24 files changed, 729 insertions(+), 114 deletions(-) create mode 100644 src/screens/main/history/HistoryMapLoading.tsx create mode 100644 src/screens/main/history/HistoryRideDetail.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 477de87b5..11cad502d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -97,7 +97,7 @@ A temporary state of a Ride Recording in which sample persistence halts because _Avoid_: Stop recording, auto-stop, sleep, parked mode **Favorite**: -A user-created, optionally named durable time range over Ride History, created by trimming a past ride to the span the rider wants to keep. A ride may produce multiple Favorites. A Favorite pins its telemetry range: history deletion skips favorited ranges, and removing a Favorite only unpins — it never deletes telemetry. Owns its Favorite Media. +A user-created, optionally named durable time range over Ride History, created by trimming a past ride to the span the rider wants to keep. A ride may produce multiple Favorites. Its name can be changed or cleared later; its range and its summary stats cannot — re-trimming is delete and recreate. A Favorite pins its telemetry range: history deletion skips favorited ranges, and removing a Favorite only unpins — it never deletes telemetry. Owns its Favorite Media. _Avoid_: Favorite ride, segment, bookmark, saved ride **Favorite Media**: diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 082223958..85b5ea5a1 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -35,6 +35,7 @@ import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -554,6 +555,10 @@ class VescapeCoreModule : Module() { AsyncFunction("createFavorite") Coroutine { options: Map -> TelemetryRepository.get(context.applicationContext).createFavorite(options) } + AsyncFunction("renameFavorite") Coroutine { id: String, name: String? -> + TelemetryRepository.get(context.applicationContext).renameFavorite(id, name) + ?: throw CodedException("ERR_RENAME_FAVORITE", "favorite does not exist or could not be stored", null) + } AsyncFunction("deleteFavorite") Coroutine { id: String -> TelemetryRepository.get(context.applicationContext).deleteFavorite(id) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 72feb39e1..4441e7ff9 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -545,6 +545,13 @@ interface TelemetryDao { @Insert suspend fun insertFavorite(favorite: FavoriteEntity) + @Query("SELECT * FROM favorites WHERE id = :id") + suspend fun getFavorite(id: String): FavoriteEntity? + + /** Name only: the range and the denormalized summary of a Favorite are immutable (ADR 0029). */ + @Query("UPDATE favorites SET name = :name, updated_at = :updatedAt WHERE id = :id") + suspend fun renameFavorite(id: String, name: String?, updatedAt: Long): Int + @Query("DELETE FROM favorites WHERE id = :id") suspend fun deleteFavorite(id: String): Int } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index 75f13e2a1..d2d64e0fe 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -591,6 +591,19 @@ class TelemetryRepository private constructor(context: Context) { favorite.toMap(boards.firstOrNull { it.id == boardId }?.name) } + /** + * Rename a Favorite, or clear its name with an empty/absent value. The range and the summary are + * immutable — re-trimming is delete + recreate (ADR 0029). `updated_at` is minted here. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `renameFavorite` + */ + suspend fun renameFavorite(id: String, name: String?): Map? = withContext(Dispatchers.IO) { + val trimmed = name?.trim()?.ifEmpty { null } + if (dao.renameFavorite(id, trimmed, System.currentTimeMillis()) == 0) return@withContext null + val favorite = dao.getFavorite(id) ?: return@withContext null + favorite.toMap(dao.getBoards().firstOrNull { it.id == favorite.boardId }?.name) + } + /** * Unpin a Favorite. Telemetry in its range stays and becomes normally deletable (ADR 0029). * diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 530280269..448ee11e3 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -634,6 +634,14 @@ public class VescapeCoreModule: Module { promise.resolve(favorite) } + AsyncFunction("renameFavorite") { (id: String, name: String?, promise: Promise) in + guard let favorite = TelemetryRepository.shared.renameFavorite(id, name: name) else { + promise.reject("ERR_RENAME_FAVORITE", "favorite does not exist or could not be stored") + return + } + promise.resolve(favorite) + } + AsyncFunction("deleteFavorite") { (id: String, promise: Promise) in promise.resolve(TelemetryRepository.shared.deleteFavorite(id)) } diff --git a/modules/vescape-core/ios/telemetry/FavoriteStore.swift b/modules/vescape-core/ios/telemetry/FavoriteStore.swift index 24ffd5a10..8d34af5b6 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStore.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStore.swift @@ -197,6 +197,23 @@ struct FavoriteStore { } } + /// Rename one Favorite, or clear its name with `nil`. The range and the summary are immutable: + /// changing what a Favorite covers is delete + recreate (ADR 0029). Returns the stored row so the + /// caller never has to guess what native now holds. + func rename(_ id: String, name: String?, updatedAtMs: Int64) -> Favorite? { + guard let writer = resolveWriter() else { return nil } + let updated = try? writer.write { db -> Favorite? in + try db.execute( + sql: "UPDATE favorites SET name = ?, updated_at = ? WHERE id = ?", + arguments: [name, updatedAtMs, id] + ) + guard db.changesCount > 0 else { return nil } + return try Row.fetchOne(db, sql: "SELECT * FROM favorites WHERE id = ?", arguments: [id]) + .map(Self.favorite) + } + return updated ?? nil + } + /// Unpin one Favorite. Telemetry inside its range is untouched and becomes deletable again. @discardableResult func delete(_ id: String) -> Bool { diff --git a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift index 4fb2e2909..fc1da9c54 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -62,6 +62,44 @@ final class FavoriteStoreTests: XCTestCase { XCTAssertEqual(store.list().map(\.id), ["newer", "older"]) } + /// Renaming touches the name and `updated_at` only: the pinned range and the summary a Favorite + /// was created with must survive, because re-trimming is delete + recreate. + func testRenameKeepsRangeAndSummaryAndBumpsUpdatedAt() throws { + store.insert( + makeFavorite( + id: "fav-1", + name: "Dolina", + startMs: 1_000, + endMs: 61_000, + summary: FavoriteSummary(sampleCount: 12, movingDurationMs: 55_000) + ) + ) + + let renamed = try XCTUnwrap(store.rename("fav-1", name: "Dolina single track", updatedAtMs: 1_800_000_000_000)) + + XCTAssertEqual(renamed.name, "Dolina single track") + XCTAssertEqual(renamed.startMs, 1_000) + XCTAssertEqual(renamed.endMs, 61_000) + XCTAssertEqual(renamed.summary.sampleCount, 12) + XCTAssertEqual(renamed.summary.movingDurationMs, 55_000) + XCTAssertEqual(renamed.createdAtMs, 1_700_000_000_000) + XCTAssertEqual(renamed.updatedAtMs, 1_800_000_000_000) + } + + /// Naming stays optional after creation too, so clearing a name is a supported rename. + func testRenameToNilClearsTheName() throws { + store.insert(makeFavorite(id: "fav-1", name: "Dolina", startMs: 1_000, endMs: 2_000)) + + let cleared = try XCTUnwrap(store.rename("fav-1", name: nil, updatedAtMs: 1_800_000_000_000)) + + XCTAssertNil(cleared.name) + XCTAssertNil(try XCTUnwrap(store.list().first).name) + } + + func testRenameOfAnUnknownFavoriteReportsNoRow() { + XCTAssertNil(store.rename("missing", name: "Nope", updatedAtMs: 1_800_000_000_000)) + } + /// Removing a Favorite unpins it and nothing else: only its own row goes away. func testDeleteRemovesOnlyTheTargetRow() { store.insert(makeFavorite(id: "fav-1", startMs: 1_000, endMs: 2_000)) diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 8085930c7..55e020e9a 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -302,6 +302,20 @@ internal final class TelemetryRepository { return names } + /// Rename a Favorite, or clear its name with an empty/absent value. The range and the summary are + /// immutable — re-trimming is delete + recreate (ADR 0029). `updated_at` is minted here. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `renameFavorite` + func renameFavorite(_ id: String, name: String?) -> [String: Any?]? { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) + let renamed = FavoriteStore.shared.rename( + id, + name: (trimmed?.isEmpty ?? true) ? nil : trimmed, + updatedAtMs: telemetryNowMs() + ) + guard let renamed else { return nil } + return renamed.toMap(boardName: renamed.boardId.flatMap { Self.boardNamesById()[$0] }) + } + /// Unpin a Favorite. Telemetry in its range stays and becomes normally deletable (ADR 0029). /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `deleteFavorite` func deleteFavorite(_ id: String) -> Bool { diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index fbb84691c..193e83fe1 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -1470,6 +1470,7 @@ type VescapeCoreNativeModule = NativeEventEmitter & { getTelemetrySummary(): Promise getFavorites(): Promise createFavorite(options: CreateFavoriteOptions): Promise + renameFavorite(id: string, name: string | null): Promise deleteFavorite(id: string): Promise getDiagnosticEvents(options: DiagnosticEventOptions): Promise clearDiagnosticEvents(): Promise @@ -1969,6 +1970,14 @@ export async function createFavorite(options: CreateFavoriteOptions): Promise { + return native.renameFavorite(id, name) +} + /** Unpin a Favorite. Its telemetry stays and becomes normally deletable (ADR 0029). */ export async function deleteFavorite(id: string): Promise { return native.deleteFavorite(id) diff --git a/src/app/settings/components/modals.tsx b/src/app/settings/components/modals.tsx index 280fac864..f411e757b 100644 --- a/src/app/settings/components/modals.tsx +++ b/src/app/settings/components/modals.tsx @@ -269,6 +269,33 @@ function TextPromptModalShowcase() { ) } +/** Clearable variant: confirm stays enabled with an empty field, for optional names. */ +function TextPromptModalClearableShowcase() { + const [visible, setVisible] = useState(false) + + return ( + setVisible(true)} />} + > + Tap "Open Modal" below + { + setVisible(false) + console.log(value) + }} + onDismiss={() => setVisible(false)} + /> + + ) +} + interface EdgeDrawerPositionShowcaseProps { edge: 'auto' | 'top' | 'bottom' name: string @@ -427,6 +454,7 @@ export default function ModalsPage() { + void onDismiss: () => void } @@ -19,6 +21,7 @@ function TextPromptModalContent({ placeholder, initialValue, confirmLabel, + allowEmpty, onConfirm, onDismiss, }: TextPromptModalContentProps) { @@ -42,7 +45,7 @@ function TextPromptModalContent({ text.trim() && onConfirm(text.trim())} + onPress={() => (allowEmpty || text.trim()) && onConfirm(text.trim())} > {confirmLabel} @@ -59,6 +62,7 @@ interface TextPromptModalProps { placeholder?: string initialValue: string confirmLabel: string + allowEmpty?: boolean onConfirm: (value: string) => void onDismiss: () => void } @@ -69,6 +73,7 @@ export function TextPromptModal({ placeholder, initialValue, confirmLabel, + allowEmpty, onConfirm, onDismiss, }: TextPromptModalProps) { @@ -80,6 +85,7 @@ export function TextPromptModal({ placeholder={placeholder} initialValue={initialValue} confirmLabel={confirmLabel} + allowEmpty={allowEmpty} onConfirm={onConfirm} onDismiss={onDismiss} /> diff --git a/src/modules/history/components/FavoriteList.tsx b/src/modules/history/components/FavoriteList.tsx index 0861c59d7..5f2612d8d 100644 --- a/src/modules/history/components/FavoriteList.tsx +++ b/src/modules/history/components/FavoriteList.tsx @@ -1,11 +1,11 @@ -import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native' +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native' import { StarIcon, TrashIcon } from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IconButton } from '@/components/base/IconButton' import { Placeholder } from '@/components/base/Placeholder' import { Text } from '@/components/base/Text' -import { theme } from '@/constants/theme' +import { interaction, theme } from '@/constants/theme' import { telemetry } from '@/modules/board/constants/telemetry' import { formatRideDate, formatRideTime } from '@/modules/history/lib/rideFormat' import type { Favorite } from '@/modules/history/store/favoriteStore' @@ -13,11 +13,12 @@ import type { Favorite } from '@/modules/history/store/favoriteStore' interface FavoriteListProps { favorites: Favorite[] loading: boolean + onOpen: (favorite: Favorite) => void onRemove: (favorite: Favorite) => void } /** Favorites tab: the starred ranges, newest first. Unnamed rows fall back to date, like history. */ -export function FavoriteList({ favorites, loading, onRemove }: FavoriteListProps) { +export function FavoriteList({ favorites, loading, onOpen, onRemove }: FavoriteListProps) { const insets = useSafeAreaInsets() if (loading && favorites.length === 0) { @@ -47,7 +48,12 @@ export function FavoriteList({ favorites, loading, onRemove }: FavoriteListProps contentContainerStyle={[styles.content, { paddingBottom: Math.max(insets.bottom, 16) + 16 }]} > {favorites.map((favorite) => ( - + [styles.row, pressed && styles.rowPressed]} + onPress={() => onOpen(favorite)} + > {favorite.name ?? formatRideDate(favorite.startMs, favorite.endMs)} @@ -68,7 +74,7 @@ export function FavoriteList({ favorites, loading, onRemove }: FavoriteListProps testID={`favorite-remove-${favorite.id}`} onPress={() => onRemove(favorite)} /> - + ))} ) @@ -114,6 +120,9 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 10, }, + rowPressed: { + backgroundColor: interaction.pressedBg, + }, rowMain: { flex: 1, minWidth: 0, diff --git a/src/modules/history/lib/favorites.test.ts b/src/modules/history/lib/favorites.test.ts index 76974b3d9..87c51011f 100644 --- a/src/modules/history/lib/favorites.test.ts +++ b/src/modules/history/lib/favorites.test.ts @@ -1,9 +1,10 @@ import { expect, test } from 'bun:test' -import type { Favorite } from 'vescape-core' +import type { Favorite, TelemetryMinuteBucket } from 'vescape-core' import { favoriteRangeForSession, + favoriteToSession, findSessionFavorite, sessionContainsFavorite, } from '@/modules/history/lib/favorites' @@ -52,6 +53,85 @@ test('a ride counts as favorited only when a favorite covers its exact Moving Wi expect(findSessionFavorite([favorite({ startMs: 1_050_000 })], session)).toBeNull() }) +function bucket(overrides: Partial): TelemetryMinuteBucket { + return { + id: 'bucket-1', + startAtMs: 1_100_000, + endAtMs: 1_160_000, + bucketStartMs: 1_100_000, + deviceId: 'ble-1', + deviceName: 'VESC Board', + sampleCount: 60, + gpsPointCount: 10, + preciseGpsPointCount: 8, + maxAbsSpeedKmh: 40, + maxGpsSpeedKmh: null, + avgSpeedKmh: 20, + avgSpeedSampleCount: 60, + minBatteryVoltage: 48, + maxMotorCurrent: 30, + maxBatteryCurrent: 20, + maxDuty: 0.5, + faultCount: 0, + distanceDeltaM: 500, + gpsDistanceM: null, + maxTempMosfet: 40, + maxTempMotor: 35, + batteryUsedWh: 8, + batteryRegenWh: 1, + firstLatitude: 52, + firstLongitude: 21, + firstMovingAtMs: 1_100_000, + lastMovingAtMs: 1_160_000, + boundaryBefore: 'none', + ...overrides, + } +} + +test('a favorite-backed session reports the pinned range and the pinned summary', () => { + const buckets = [ + bucket({ id: 'before', startAtMs: 900_000, endAtMs: 960_000 }), + bucket({ id: 'inside', startAtMs: 1_100_000, endAtMs: 1_160_000 }), + bucket({ id: 'tail', startAtMs: 1_160_001, endAtMs: 1_220_000, firstLatitude: 53 }), + ] + + const detail = favoriteToSession( + favorite({ sampleCount: 90, distanceM: 1_180, maxSpeedKmh: 32, avgSpeedKmh: 20 }), + buckets, + ) + + expect(detail.id).toBe('favorite:fav-1') + expect(detail.startAtMs).toBe(1_100_000) + expect(detail.endAtMs).toBe(1_500_000) + // A pinned range is its own Moving Window, so the chart shows exactly what was trimmed. + expect(detail.movingStartAtMs).toBe(1_100_000) + expect(detail.movingEndAtMs).toBe(1_500_000) + // Stats come from the row, not from the buckets: the row was computed from raw samples. + expect(detail.sampleCount).toBe(90) + expect(detail.distanceM).toBe(1_180) + expect(detail.maxSpeedKmh).toBe(32) + // Only the buckets overlapping the range are read, and geography is derived from them. + expect(detail.blockIds).toEqual(['inside', 'tail']) + expect(detail.minLatitude).toBe(52) + expect(detail.maxLatitude).toBe(53) + expect(detail.deviceId).toBe('ble-1') +}) + +test('a named favorite reads by its name, an unnamed one by its board', () => { + expect(favoriteToSession(favorite({ name: 'Dolina single track' }), []).deviceName).toBe( + 'Dolina single track', + ) + expect(favoriteToSession(favorite({}), []).deviceName).toBe('Onewheel') +}) + +test('a favorite whose buckets are not loaded still yields a detail session', () => { + const detail = favoriteToSession(favorite({ sampleCount: 90 }), []) + + expect(detail.blockIds).toEqual([]) + expect(detail.centerLatitude).toBeNull() + expect(detail.sampleCount).toBe(90) +}) + test('a ride contains a favorite when their ranges overlap at all', () => { expect(sessionContainsFavorite([favorite({ startMs: 900_000, endMs: 1_000_000 })], session)).toBe( true, diff --git a/src/modules/history/lib/favorites.ts b/src/modules/history/lib/favorites.ts index 1481ddcb3..69172cd9e 100644 --- a/src/modules/history/lib/favorites.ts +++ b/src/modules/history/lib/favorites.ts @@ -1,4 +1,4 @@ -import type { Favorite } from 'vescape-core' +import type { Favorite, TelemetryMinuteBucket } from 'vescape-core' import { rideMovingWindow, type HistorySession } from '@/modules/history/lib/sessions' @@ -30,6 +30,87 @@ export function findSessionFavorite( ) } +/** + * A Favorite seen as a ride, so opening one reuses the whole history detail path — range load, + * chart, map route and stats bar — instead of a parallel implementation. + * + * The pinned summary wins over anything derivable from buckets: it was computed from raw samples at + * creation and is exact for a range that cuts a bucket in half. Only what the row cannot carry + * (geography, the buckets to read, the recording device) is derived from the overlapping buckets. + * The name stands in for the device label so a named Favorite reads by its name. + */ +export function favoriteToSession( + favorite: Favorite, + blocks: TelemetryMinuteBucket[], +): HistorySession { + const spanned = blocks + .filter((block) => block.startAtMs <= favorite.endMs && block.endAtMs >= favorite.startMs) + .sort((a, b) => a.startAtMs - b.startAtMs) + const latitudes = spanned.map((block) => block.firstLatitude).filter(isFinitePoint) + const longitudes = spanned.map((block) => block.firstLongitude).filter(isFinitePoint) + return { + id: favoriteSessionId(favorite.id), + deviceId: spanned.find((block) => block.deviceId != null)?.deviceId ?? null, + deviceName: favorite.name ?? favorite.boardName ?? spanned[0]?.deviceName ?? 'Favorite', + startAtMs: favorite.startMs, + endAtMs: favorite.endMs, + // A Favorite is already a trimmed span: it is its own Moving Window, so the chart and the title + // cover exactly what was pinned. + movingStartAtMs: favorite.startMs, + movingEndAtMs: favorite.endMs, + blockIds: spanned.map((block) => block.id), + blockCount: spanned.length, + sampleCount: favorite.sampleCount, + gpsPointCount: favorite.gpsPointCount, + preciseGpsPointCount: sum(spanned.map((block) => block.preciseGpsPointCount)), + distanceM: favorite.distanceM, + maxSpeedKmh: favorite.maxSpeedKmh, + avgSpeedKmh: favorite.avgSpeedKmh, + maxTempMosfet: maxOrNull(spanned.map((block) => block.maxTempMosfet)), + maxTempMotor: maxOrNull(spanned.map((block) => block.maxTempMotor)), + maxDuty: Math.max(0, ...spanned.map((block) => block.maxDuty)), + batteryUsedWh: favorite.batteryUsedWh, + batteryRegenWh: sum(spanned.map((block) => block.batteryRegenWh)), + firstLatitude: latitudes[0] ?? null, + firstLongitude: longitudes[0] ?? null, + centerLatitude: average(latitudes), + centerLongitude: average(longitudes), + minLatitude: minOrNull(latitudes), + maxLatitude: maxOrNull(latitudes), + minLongitude: minOrNull(longitudes), + maxLongitude: maxOrNull(longitudes), + faultCount: sum(spanned.map((block) => block.faultCount)), + boundaryBefore: 'none', + } +} + +/** Namespaced so a favorite-backed session never collides with a grouped history session id. */ +export function favoriteSessionId(favoriteId: string): string { + return `favorite:${favoriteId}` +} + +function isFinitePoint(value: number | null): value is number { + return value != null && Number.isFinite(value) +} + +function sum(values: number[]): number { + return values.reduce((total, value) => total + value, 0) +} + +function average(values: number[]): number | null { + return values.length > 0 ? sum(values) / values.length : null +} + +function minOrNull(values: (number | null)[]): number | null { + const finite = values.filter(isFinitePoint) + return finite.length > 0 ? Math.min(...finite) : null +} + +function maxOrNull(values: (number | null)[]): number | null { + const finite = values.filter(isFinitePoint) + return finite.length > 0 ? Math.max(...finite) : null +} + /** Any overlap means deleting this history session must leave a protected telemetry island. */ export function sessionContainsFavorite( favorites: Favorite[], diff --git a/src/modules/history/store/favoriteStore.test.ts b/src/modules/history/store/favoriteStore.test.ts index 131d9c600..832408c9e 100644 --- a/src/modules/history/store/favoriteStore.test.ts +++ b/src/modules/history/store/favoriteStore.test.ts @@ -27,12 +27,16 @@ const getFavorites = mock(async () => [] as Favorite[]) const createFavorite = mock(async (): Promise => { throw new Error('createFavorite not stubbed') }) +const renameFavorite = mock(async (): Promise => { + throw new Error('renameFavorite not stubbed') +}) const deleteFavorite = mock(async () => true) const vescapeCoreMock = { ...actualVescapeCore, getFavorites, createFavorite, + renameFavorite, deleteFavorite, } @@ -42,11 +46,15 @@ mock.module('../../modules/vescape-core/src/index', () => vescapeCoreMock) beforeEach(async () => { getFavorites.mockClear() createFavorite.mockClear() + renameFavorite.mockClear() deleteFavorite.mockClear() getFavorites.mockImplementation(async () => []) createFavorite.mockImplementation(async () => { throw new Error('createFavorite not stubbed') }) + renameFavorite.mockImplementation(async () => { + throw new Error('renameFavorite not stubbed') + }) deleteFavorite.mockImplementation(async () => true) const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') useFavoriteStore.setState({ favorites: [], loading: false, saving: false, error: undefined }) @@ -116,3 +124,35 @@ test('a second star tap while a create is in flight does not add a duplicate', a expect(useFavoriteStore.getState().favorites).toEqual([created]) expect(useFavoriteStore.getState().saving).toBe(false) }) + +test('a rename mirrors the row native returns, without touching the others', async () => { + const other = favorite({ id: 'other', startMs: 3_000_000 }) + const renamed = favorite({ id: 'fav-1', startMs: 1_000_000, name: 'Dolina single track' }) + getFavorites.mockImplementation(async () => [ + other, + favorite({ id: 'fav-1', startMs: 1_000_000 }), + ]) + renameFavorite.mockImplementation(async () => renamed) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + await useFavoriteStore.getState().load() + await useFavoriteStore.getState().rename('fav-1', 'Dolina single track') + + expect(renameFavorite).toHaveBeenCalledWith('fav-1', 'Dolina single track') + expect(useFavoriteStore.getState().favorites).toEqual([other, renamed]) +}) + +test('a failed rename leaves the stored name alone and surfaces the error', async () => { + const stored = favorite({ id: 'fav-1', startMs: 1_000_000, name: 'Dolina' }) + getFavorites.mockImplementation(async () => [stored]) + renameFavorite.mockImplementation(async () => { + throw new Error('favorite does not exist') + }) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + await useFavoriteStore.getState().load() + await useFavoriteStore.getState().rename('fav-1', null) + + expect(useFavoriteStore.getState().favorites).toEqual([stored]) + expect(useFavoriteStore.getState().error).toBe('favorite does not exist') +}) diff --git a/src/modules/history/store/favoriteStore.ts b/src/modules/history/store/favoriteStore.ts index f3c023f45..f87ed5ffd 100644 --- a/src/modules/history/store/favoriteStore.ts +++ b/src/modules/history/store/favoriteStore.ts @@ -3,6 +3,7 @@ import { createFavorite, deleteFavorite, getFavorites, + renameFavorite, type Favorite, type CreateFavoriteOptions, } from 'vescape-core' @@ -19,6 +20,8 @@ interface FavoriteActions { load: () => Promise /** Pin a range. Native owns identity, timestamps and stats — JS only sends range + name. */ add: (options: CreateFavoriteOptions) => Promise + /** Rename, or clear the name with `null`. Native owns the row; JS mirrors what it returns. */ + rename: (id: string, name: string | null) => Promise /** Unpin. Telemetry inside the range stays (ADR 0029). */ remove: (id: string) => Promise } @@ -57,6 +60,21 @@ export const useFavoriteStore = create((set, ge } }, + async rename(id, name) { + if (get().saving) return + set({ saving: true, error: undefined }) + try { + const renamed = await renameFavorite(id, name) + set({ + favorites: get().favorites.map((favorite) => (favorite.id === id ? renamed : favorite)), + }) + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err) }) + } finally { + set({ saving: false }) + } + }, + async remove(id) { if (get().saving) return set({ saving: true, error: undefined }) diff --git a/src/screens/main/MainScreen.tsx b/src/screens/main/MainScreen.tsx index f60b4c108..8497ae2a0 100644 --- a/src/screens/main/MainScreen.tsx +++ b/src/screens/main/MainScreen.tsx @@ -58,6 +58,11 @@ function buildHistoryOverlayProps(controller: ReturnType void + onDelete: () => void + } saving: boolean onSelectTab: (tab: HistoryTab) => void onBack: () => void @@ -40,6 +50,7 @@ export function HistoryControls({ canFavorite, favorited, trimming, + favorite, saving, onSelectTab, onBack, @@ -55,8 +66,8 @@ export function HistoryControls({ - - + + Trim favorite @@ -72,6 +83,34 @@ export function HistoryControls({ ) } + if (favorite) { + return ( + + + + + + {favorite.title} + + + + + + + ) + } + return ( @@ -136,11 +175,11 @@ const styles = StyleSheet.create({ flex: 1, alignItems: 'center', }, - trimTitleWrap: { + headerTitleWrap: { flex: 1, alignItems: 'center', }, - trimTitle: { + headerTitle: { color: theme.palette.slate.textPrimary, fontSize: 14, fontWeight: '800', diff --git a/src/screens/main/history/HistoryMapLoading.tsx b/src/screens/main/history/HistoryMapLoading.tsx new file mode 100644 index 000000000..204c9e965 --- /dev/null +++ b/src/screens/main/history/HistoryMapLoading.tsx @@ -0,0 +1,30 @@ +import { ActivityIndicator, StyleSheet, View } from 'react-native' + +import { theme } from '@/constants/theme' + +/** Spinner over the map while a ride range is being read from native. */ +export function HistoryMapLoading() { + return ( + + + + ) +} + +const styles = StyleSheet.create({ + wrap: { + position: 'absolute', + top: '50%', + left: '50%', + zIndex: 12, + alignItems: 'center', + justifyContent: 'center', + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: theme.alpha(theme.palette.slate.bg, 0.6), + borderWidth: 1, + borderColor: theme.alpha(theme.palette.slate.light, 0.3), + transform: [{ translateX: -17 }, { translateY: -17 }], + }, +}) diff --git a/src/screens/main/history/HistoryOverlay.tsx b/src/screens/main/history/HistoryOverlay.tsx index bc013954f..d2b224cf2 100644 --- a/src/screens/main/history/HistoryOverlay.tsx +++ b/src/screens/main/history/HistoryOverlay.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react' -import { ActivityIndicator, StyleSheet, View } from 'react-native' +import { StyleSheet, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import type { Favorite, HistoryGpsSample, HistoryMarker } from 'vescape-core' @@ -19,9 +19,8 @@ import type { TelemetrySample, } from '@/modules/history/store/historyStore' import { HistoryControls } from '@/screens/main/history/HistoryControls' -import { HistoryStatsBar } from '@/screens/main/history/HistoryStatsBar' -import { HistoryTelemetryPanel } from '@/screens/main/history/HistoryTelemetryPanel' -import { TrimStatsBar } from '@/screens/main/history/TrimStatsBar' +import { HistoryMapLoading } from '@/screens/main/history/HistoryMapLoading' +import { HistoryRideDetail } from '@/screens/main/history/HistoryRideDetail' import type { HistoryTab } from '@/screens/main/mainScreenStore' import { STRIP_CONTENT_HEIGHT } from '@/screens/main/overlays/BottomTelemetryStrip' @@ -54,6 +53,12 @@ export interface MainHistoryOverlayProps { cancelTrim: () => void saveTrim: () => Promise removeFavorite: (id: string) => Promise + /** The Favorite whose detail is open, or null while the Favorites list is showing. */ + openFavorite: Favorite | null + showFavorite: (favorite: Favorite) => Promise + hideFavorite: () => Promise + renameOpenFavorite: (name: string | null) => Promise + removeOpenFavorite: () => Promise selectSession: (session: HistorySession | null) => Promise loadMoreHistory: () => Promise selectPreviousRide: () => Promise @@ -99,6 +104,11 @@ export function HistoryOverlay({ history.favoritesSaving const aboveStripBottom = STRIP_CONTENT_HEIGHT + Math.max(insets.bottom * 0.5, 8) + 8 const sheetBottom = Math.max(insets.bottom, 16) + 8 + panelHeight + 8 + // Favorite detail is the history detail fed a favorite-backed session: same panel, map and stats, + // only the header actions differ. + const favoriteMode = history.historyTab === 'favorites' && history.openFavorite != null + const detailSession = + history.historyTab === 'history' || favoriteMode ? history.selectedSession : null const selectedSessionContainsFavorite = history.selectedSession != null && sessionContainsFavorite(history.favorites, history.selectedSession) @@ -110,11 +120,14 @@ export function HistoryOverlay({ return ( <> - {visible && history.historyTab === 'favorites' && ( + {visible && history.historyTab === 'favorites' && !history.openFavorite && ( <> { + void history.showFavorite(favorite) + }} onRemove={(favorite) => { void history.removeFavorite(favorite.id) }} @@ -137,87 +150,20 @@ export function HistoryOverlay({ )} - {visible && history.historyTab === 'history' && history.selectedSession && ( - <> - {busy && ( - - - - )} - { - void history.selectPreviousRide() - }} - onNext={() => { - void history.selectNextRide() - }} - onOpenList={() => history.setHistorySheetVisible(true)} - onAddMedia={() => void history.mediaHistory.add()} - onOpenMedia={history.openMedia} - onSeek={history.onSeek} - onMetricInteraction={history.setActiveHistoryMapMetric} - onHeightChange={onPanelHeightChange} - trim={ - history.trimming && history.trimSeed - ? { - startMs: history.trimSeed.startMs, - endMs: history.trimSeed.endMs, - onChange: history.updateTrimRange, - onCommit: history.updateTrimRange, - } - : undefined - } - /> - {history.trimming ? ( - - ) : ( - - )} - setRemoveConfirmVisible(true)} - onToggleFavorite={history.beginTrimFavorite} - onCancelTrim={history.cancelTrim} - onSaveTrim={() => { - void history.saveTrim() - }} - /> - + {visible && detailSession && ( + setRemoveConfirmVisible(true)} + onPanelHeightChange={onPanelHeightChange} + /> )} {visible && history.historyTab === 'history' && !history.selectedSession && ( <> - {busy ? ( - - - - ) : ( - - )} + {busy ? : } void + onPanelHeightChange: (height: number) => void +} + +/** The replayed ride: chart panel, stats and header. Shared by history mode and favorite mode. */ +export function HistoryRideDetail({ + history, + session, + favoriteMode, + busy, + onRemoveSession, + onPanelHeightChange, +}: HistoryRideDetailProps) { + const [renameVisible, setRenameVisible] = useState(false) + const [deleteVisible, setDeleteVisible] = useState(false) + const openFavorite = favoriteMode ? history.openFavorite : null + const trimming = !favoriteMode && history.trimming + + return ( + <> + {busy && } + { + void history.selectPreviousRide() + }} + onNext={() => { + void history.selectNextRide() + }} + onOpenList={() => { + if (favoriteMode) void history.hideFavorite() + else history.setHistorySheetVisible(true) + }} + onAddMedia={() => void history.mediaHistory.add()} + onOpenMedia={history.openMedia} + onSeek={history.onSeek} + onMetricInteraction={history.setActiveHistoryMapMetric} + onHeightChange={onPanelHeightChange} + trim={ + trimming && history.trimSeed + ? { + startMs: history.trimSeed.startMs, + endMs: history.trimSeed.endMs, + onChange: history.updateTrimRange, + onCommit: history.updateTrimRange, + } + : undefined + } + /> + {trimming ? ( + + ) : ( + + )} + setRenameVisible(true), + onDelete: () => setDeleteVisible(true), + } + : undefined + } + onSelectTab={history.selectHistoryTab} + onBack={ + favoriteMode + ? () => { + void history.hideFavorite() + } + : history.exitHistory + } + onRemove={onRemoveSession} + onToggleFavorite={history.beginTrimFavorite} + onCancelTrim={history.cancelTrim} + onSaveTrim={() => { + void history.saveTrim() + }} + /> + + { + setRenameVisible(false) + void history.renameOpenFavorite(value.length > 0 ? value : null) + }} + onDismiss={() => setRenameVisible(false)} + /> + + { + setDeleteVisible(false) + void history.removeOpenFavorite() + }} + onCancel={() => setDeleteVisible(false)} + /> + + ) +} diff --git a/src/screens/main/history/useHistoryFavorites.ts b/src/screens/main/history/useHistoryFavorites.ts index e80a2cdb8..e757d5c0e 100644 --- a/src/screens/main/history/useHistoryFavorites.ts +++ b/src/screens/main/history/useHistoryFavorites.ts @@ -1,8 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' -import { favoriteRangeForSession, findSessionFavorite } from '@/modules/history/lib/favorites' -import { useFavoriteStore } from '@/modules/history/store/favoriteStore' +import { + favoriteRangeForSession, + favoriteToSession, + findSessionFavorite, +} from '@/modules/history/lib/favorites' +import { useFavoriteStore, type Favorite } from '@/modules/history/store/favoriteStore' import { useHistoryStore, type HistorySession } from '@/modules/history/store/historyStore' import { useMainScreenStore, type HistoryTab } from '@/screens/main/mainScreenStore' @@ -10,6 +14,7 @@ import { useMainScreenStore, type HistoryTab } from '@/screens/main/mainScreenSt export function useHistoryFavorites(selectedSession: HistorySession | null) { const trimming = useMainScreenStore((state) => state.trimRange != null) const historyTab = useMainScreenStore((state) => state.historyTab) + const openFavoriteId = useMainScreenStore((state) => state.openFavoriteId) const setHistoryTab = useMainScreenStore((state) => state.setHistoryTab) const [trimSeed, setTrimSeed] = useState<{ startMs: number; endMs: number } | null>(null) const { @@ -19,6 +24,7 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { favoritesError, loadFavorites, addFavorite, + renameFavorite, removeFavorite, } = useFavoriteStore( useShallow((state) => ({ @@ -28,6 +34,7 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { favoritesError: state.error, loadFavorites: state.load, addFavorite: state.add, + renameFavorite: state.rename, removeFavorite: state.remove, })), ) @@ -41,6 +48,11 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { [favorites, selectedSession], ) + const openFavorite = useMemo( + () => favorites.find((favorite) => favorite.id === openFavoriteId) ?? null, + [favorites, openFavoriteId], + ) + const selectHistoryTab = useCallback( (tab: HistoryTab) => { setHistoryTab(tab) @@ -77,8 +89,50 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { if (favorite) useMainScreenStore.getState().endTrim() }, [addFavorite]) + /** + * Favorite detail is the history detail path fed a favorite-backed session, so the chart, the map + * route and the stats all come from the pinned range with no parallel implementation. + */ + const showFavorite = useCallback(async (favorite: Favorite) => { + useMainScreenStore.getState().openFavorite(favorite.id) + await useHistoryStore + .getState() + .selectSession(favoriteToSession(favorite, useHistoryStore.getState().blocks)) + }, []) + + const hideFavorite = useCallback(async () => { + useMainScreenStore.getState().closeFavorite() + await useHistoryStore.getState().selectSession(null) + }, []) + + const renameOpenFavorite = useCallback( + async (name: string | null) => { + const id = useMainScreenStore.getState().openFavoriteId + if (!id) return + await renameFavorite(id, name) + const renamed = useFavoriteStore.getState().favorites.find((item) => item.id === id) + // The name doubles as the session label, so the open detail has to be rebuilt to show it. + if (renamed) { + await useHistoryStore + .getState() + .selectSession(favoriteToSession(renamed, useHistoryStore.getState().blocks)) + } + }, + [renameFavorite], + ) + + /** Unpinning the open Favorite leaves nothing to show: fall back to the Favorites list. */ + const removeOpenFavorite = useCallback(async () => { + const id = useMainScreenStore.getState().openFavoriteId + if (!id) return + await removeFavorite(id) + if (useFavoriteStore.getState().error) return + await hideFavorite() + }, [hideFavorite, removeFavorite]) + const resetHistoryFavorites = useCallback(() => { setHistoryTab('history') + useMainScreenStore.getState().closeFavorite() useMainScreenStore.getState().endTrim() }, [setHistoryTab]) @@ -96,6 +150,11 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { updateTrimRange, cancelTrim, saveTrim, + openFavorite, + showFavorite, + hideFavorite, + renameOpenFavorite, + removeOpenFavorite, removeFavorite, loadFavorites, resetHistoryFavorites, diff --git a/src/screens/main/mainScreenStore.ts b/src/screens/main/mainScreenStore.ts index 88d0a3612..d8042add8 100644 --- a/src/screens/main/mainScreenStore.ts +++ b/src/screens/main/mainScreenStore.ts @@ -17,6 +17,8 @@ export interface TrimRange { interface MainScreenState { mode: MainViewState historyTab: HistoryTab + /** The Favorite whose detail is open, or null while the Favorites list is showing. */ + openFavoriteId: string | null historySheetVisible: boolean mapSelector: MapSelector perspectiveEnabled: boolean @@ -33,6 +35,10 @@ interface MainScreenActions { enterLegalLimits: () => void enterHistory: () => void setHistoryTab: (tab: HistoryTab) => void + /** Open one Favorite's detail. */ + openFavorite: (id: string) => void + /** Back to the Favorites list. */ + closeFavorite: () => void setHistorySheetVisible: (visible: boolean) => void setMapSelector: (selector: MapSelector) => void dismissMapSelector: () => void @@ -50,6 +56,7 @@ interface MainScreenActions { const initialState: MainScreenState = { mode: 'telemetry', historyTab: 'history', + openFavoriteId: null, historySheetVisible: false, mapSelector: null, perspectiveEnabled: true, @@ -72,6 +79,7 @@ export const useMainScreenStore = create((s mapSelector: null, seekTimeMs: null, trimRange: null, + openFavoriteId: null, }) }, @@ -93,10 +101,20 @@ export const useMainScreenStore = create((s setHistoryTab(tab) { set((state) => - state.historyTab === tab ? state : { historyTab: tab, historySheetVisible: false }, + state.historyTab === tab + ? state + : { historyTab: tab, historySheetVisible: false, openFavoriteId: null }, ) }, + openFavorite(id) { + set({ openFavoriteId: id, historySheetVisible: false, seekTimeMs: null }) + }, + + closeFavorite() { + set((state) => (state.openFavoriteId === null ? state : { openFavoriteId: null })) + }, + setHistorySheetVisible(visible) { set({ historySheetVisible: visible }) }, diff --git a/src/screens/main/useMainScreenController.ts b/src/screens/main/useMainScreenController.ts index 8c84dc539..76ff4e587 100644 --- a/src/screens/main/useMainScreenController.ts +++ b/src/screens/main/useMainScreenController.ts @@ -374,6 +374,10 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) useMainScreenStore.getState().endTrim() return true } + if (useMainScreenStore.getState().openFavoriteId) { + void historyFavorites.hideFavorite() + return true + } exitHistory() return true } @@ -401,7 +405,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) return true }) return () => handler.remove() - }, [exitHistory, exitLegalLimitsMode, exitMapFocus, exitWeatherMode, mode]), + }, [exitHistory, exitLegalLimitsMode, exitMapFocus, exitWeatherMode, historyFavorites, mode]), ) return { From 3fae055050550cbc4e8d1ac5a01275d98e5c47fa Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 02:15:35 +0200 Subject: [PATCH 12/24] Store Favorite Media natively #291 --- .../modules/vescapecore/VescapeCoreModule.kt | 6 + .../telemetry/FavoriteMediaStore.kt | 128 +++++++ .../vescapecore/telemetry/TelemetryDao.kt | 27 +- .../telemetry/TelemetryDatabase.kt | 35 +- .../telemetry/TelemetryEntities.kt | 45 +++ .../telemetry/TelemetryRepository.kt | 26 +- .../telemetry/FavoriteMediaTest.kt | 115 +++++++ .../telemetry/FavoriteSummaryBuilderTest.kt | 52 +++ .../vescape-core/ios/VescapeCoreModule.swift | 12 + .../ios/telemetry/FavoriteMediaStore.swift | 312 ++++++++++++++++++ .../telemetry/FavoriteMediaStoreTests.swift | 97 ++++++ .../ios/telemetry/FavoriteStore.swift | 3 + .../ios/telemetry/FavoriteStoreTests.swift | 24 +- .../ios/telemetry/TelemetryDatabase.swift | 7 + .../telemetry/TelemetryMigrationTests.swift | 1 + .../ios/telemetry/TelemetryRepository.swift | 34 +- modules/vescape-core/src/index.ts | 40 +++ .../history/components/HistoryPanelNav.tsx | 28 +- .../components/HistoryRideMediaDrawer.tsx | 2 +- .../components/MediaHistoryGallery.tsx | 8 +- src/modules/history/hooks/useMediaHistory.ts | 83 +++-- src/modules/history/lib/mediaHistory.test.ts | 38 --- src/modules/history/lib/mediaHistory.ts | 30 -- src/modules/history/store/rideMediaFiles.ts | 50 --- .../main/history/HistoryRideDetail.tsx | 1 + .../main/history/HistoryTelemetryPanel.tsx | 27 +- src/screens/main/useMainScreenController.ts | 14 +- 27 files changed, 1052 insertions(+), 193 deletions(-) create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteMediaStore.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteMediaTest.kt create mode 100644 modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift create mode 100644 modules/vescape-core/ios/telemetry/FavoriteMediaStoreTests.swift delete mode 100644 src/modules/history/store/rideMediaFiles.ts diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 85b5ea5a1..1515ede43 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -562,6 +562,12 @@ class VescapeCoreModule : Module() { AsyncFunction("deleteFavorite") Coroutine { id: String -> TelemetryRepository.get(context.applicationContext).deleteFavorite(id) } + AsyncFunction("getFavoriteMedia") Coroutine { favoriteId: String -> + TelemetryRepository.get(context.applicationContext).getFavoriteMedia(favoriteId) + } + AsyncFunction("importFavoriteMedia") Coroutine { options: Map -> + TelemetryRepository.get(context.applicationContext).importFavoriteMedia(options) + } AsyncFunction("deleteTelemetryBefore") Coroutine { beforeMs: Double -> TelemetryRepository.get(context.applicationContext).deleteBefore(beforeMs.toLong()) } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteMediaStore.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteMediaStore.kt new file mode 100644 index 000000000..2ebda9d73 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteMediaStore.kt @@ -0,0 +1,128 @@ +package expo.modules.vescapecore.telemetry + +import android.content.Context +import android.net.Uri +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.security.DigestInputStream +import java.security.MessageDigest +import java.util.UUID + +/** + * Native Favorite Media import and reconciliation (ADR 0030). + * + * @parity /modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift + */ +internal class FavoriteMediaStore( + private val root: File, + private val dao: TelemetryDao, + private val sourceOpener: (String) -> InputStream, +) { + constructor(context: Context, dao: TelemetryDao) : this( + root = File(context.filesDir, "favoriteMedia"), + dao = dao, + sourceOpener = { uri -> + context.contentResolver.openInputStream(Uri.parse(uri)) + ?: Uri.parse(uri).path?.let(::File)?.takeIf(File::isFile)?.let(::FileInputStream) + ?: error("Could not open media source") + }, + ) + + suspend fun list(favoriteId: String): List> { + reconcile(favoriteId) + return dao.getFavoriteMedia(favoriteId).map { media -> + val file = fileFor(media) + media.toMap(file.toURI().toString(), file.name) + } + } + + suspend fun importMedia(options: Map): Map { + val favoriteId = options["favoriteId"] as? String ?: error("favoriteId is required") + check(dao.getFavorite(favoriteId) != null) { "Favorite does not exist" } + val sourceUri = options["uri"] as? String ?: error("uri is required") + val mimeType = options["mimeType"] as? String ?: error("mimeType is required") + val mediaKind = options["mediaKind"] as? String ?: error("mediaKind is required") + require(mimeType.isNotEmpty() && mediaKind in setOf("photo", "video")) { + "Invalid Favorite Media type" + } + val capturedAt = (options["capturedAtMs"] as? Number)?.toLong() + val id = UUID.randomUUID().toString() + val directory = favoriteDirectory(favoriteId).apply { mkdirs() } + val temporary = File(directory, ".$id.import") + val seed = FavoriteMediaEntity( + id = id, + favoriteId = favoriteId, + capturedAt = capturedAt, + mimeType = mimeType, + mediaKind = mediaKind, + byteCount = 0, + contentHash = "", + createdAt = System.currentTimeMillis(), + ) + val destination = fileFor(seed) + val digest = MessageDigest.getInstance("SHA-256") + val byteCount = try { + sourceOpener(sourceUri).use { raw -> + DigestInputStream(raw, digest).use { input -> + temporary.outputStream().use { output -> input.copyTo(output) } + } + } + check(temporary.renameTo(destination)) { "Could not publish imported file" } + destination.length() + } catch (error: Throwable) { + temporary.delete() + throw error + } + val completed = seed.copy( + byteCount = byteCount, + contentHash = digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }, + ) + try { + dao.insertFavoriteMedia(completed) + } catch (error: Throwable) { + destination.delete() + throw error + } + return completed.toMap(destination.toURI().toString(), destination.name) + } + + suspend fun reconcile(favoriteId: String) { + val rows = dao.getFavoriteMedia(favoriteId) + val expected = rows.associateBy { fileFor(it).name } + rows.filter { !fileFor(it).isFile }.forEach { dao.deleteFavoriteMedia(it.id) } + favoriteDirectory(favoriteId).listFiles()?.forEach { file -> + if (file.name.startsWith(".") || file.name !in expected) file.deleteRecursively() + } + } + + fun deleteDirectory(favoriteId: String) { + favoriteDirectory(favoriteId).deleteRecursively() + } + + /** Repair manifest/filesystem disagreement on the normal Favorites read path. */ + suspend fun reconcileAll() { + dao.deleteOrphanFavoriteMedia() + val favoriteIds = dao.getFavorites().mapTo(mutableSetOf()) { it.id } + favoriteIds.forEach { reconcile(it) } + root.listFiles()?.forEach { directory -> + if (directory.name !in favoriteIds) directory.deleteRecursively() + } + } + + private fun favoriteDirectory(favoriteId: String) = File(root, favoriteId) + + private fun fileFor(media: FavoriteMediaEntity) = + File(favoriteDirectory(media.favoriteId), "${media.id}.${extensionForMimeType(media.mimeType)}") + + private fun extensionForMimeType(mimeType: String): String = when (mimeType.lowercase()) { + "image/png" -> "png" + "image/heic", "image/heif" -> "heic" + "image/webp" -> "webp" + "video/quicktime" -> "mov" + "video/webm" -> "webm" + "video/x-m4v" -> "m4v" + "video/mp4" -> "mp4" + else -> if (mimeType.startsWith("video/")) "mp4" else "jpg" + } +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 4441e7ff9..9148c9896 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -553,7 +553,32 @@ interface TelemetryDao { suspend fun renameFavorite(id: String, name: String?, updatedAt: Long): Int @Query("DELETE FROM favorites WHERE id = :id") - suspend fun deleteFavorite(id: String): Int + suspend fun deleteFavoriteRow(id: String): Int + + // Favorite Media — native manifest metadata truth (ADR 0030). + // @parity /modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift + + @Query("SELECT * FROM favorite_media WHERE favorite_id = :favoriteId ORDER BY created_at, id") + suspend fun getFavoriteMedia(favoriteId: String): List + + @Insert + suspend fun insertFavoriteMedia(media: FavoriteMediaEntity) + + @Query("DELETE FROM favorite_media WHERE id = :id") + suspend fun deleteFavoriteMedia(id: String): Int + + @Query("DELETE FROM favorite_media WHERE favorite_id = :favoriteId") + suspend fun deleteFavoriteMediaForFavorite(favoriteId: String): Int + + @Query("DELETE FROM favorite_media WHERE favorite_id NOT IN (SELECT id FROM favorites)") + suspend fun deleteOrphanFavoriteMedia(): Int + + /** Parent-covered raw cascade: media rows and Favorite disappear in one SQLite transaction. */ + @Transaction + suspend fun deleteFavorite(id: String): Int { + deleteFavoriteMediaForFavorite(id) + return deleteFavoriteRow(id) + } } private fun TelemetryMinuteBucketEntity.merge(next: TelemetryMinuteBucketEntity): TelemetryMinuteBucketEntity { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt index c7c3e7621..a13616db8 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt @@ -12,7 +12,7 @@ import java.io.File // @parity /modules/vescape-core/ios/VescapeCoreModule.swift internal const val TELEMETRY_DATABASE_NAME = "vescape.db" internal const val LEGACY_TELEMETRY_DATABASE_NAME = "telemetry.db" -internal const val TELEMETRY_DATABASE_VERSION = 30 +internal const val TELEMETRY_DATABASE_VERSION = 31 @Database( entities = [ @@ -30,6 +30,7 @@ internal const val TELEMETRY_DATABASE_VERSION = 30 PrivacyZoneEntity::class, BoardWarningEntity::class, FavoriteEntity::class, + FavoriteMediaEntity::class, ], version = TELEMETRY_DATABASE_VERSION, exportSchema = false, @@ -539,6 +540,37 @@ abstract class TelemetryDatabase : RoomDatabase() { } } + /** + * Favorite Media (#291). Native manifest metadata truth; bytes live in canonical Favorite-owned + * app storage (ADR 0030). + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryDatabase.swift `v31_favorite_media` + */ + internal val MIGRATION_30_31 = object : Migration(30, 31) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS favorite_media ( + id TEXT NOT NULL PRIMARY KEY, + favorite_id TEXT NOT NULL, + captured_at INTEGER, + mime_type TEXT NOT NULL, + media_kind TEXT NOT NULL, + byte_count INTEGER NOT NULL, + content_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS index_favorite_media_favorite_id_created_at + ON favorite_media(favorite_id, created_at) + """.trimIndent(), + ) + } + } + /** * One-time file rename from the pre-release "telemetry.db" name. Checkpoints the legacy WAL so * the whole database lives in the main file, then renames it in place. Idempotent: once the new @@ -596,6 +628,7 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, + MIGRATION_30_31, ) .fallbackToDestructiveMigration(true) .addCallback(object : Callback() { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index 0d8bef00c..de190b0ec 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -529,3 +529,48 @@ data class FavoriteEntity( "batteryUsedWh" to batteryUsedWhMilli / 1000.0, ) } + +/** + * One immutable Favorite Media manifest row. SQLite owns metadata; the canonical file path is + * derived only from the Favorite and media ids plus the stored MIME type (ADR 0030). + * + * @parity /modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift `FavoriteMedia` + * @parity /modules/vescape-core/src/index.ts `FavoriteMedia` + */ +@Entity( + tableName = "favorite_media", + indices = [ + Index(value = ["favorite_id", "created_at"]), + ], +) +data class FavoriteMediaEntity( + @PrimaryKey + val id: String, + @ColumnInfo(name = "favorite_id") + val favoriteId: String, + @ColumnInfo(name = "captured_at") + val capturedAt: Long?, + @ColumnInfo(name = "mime_type") + val mimeType: String, + @ColumnInfo(name = "media_kind") + val mediaKind: String, + @ColumnInfo(name = "byte_count") + val byteCount: Long, + @ColumnInfo(name = "content_hash") + val contentHash: String, + @ColumnInfo(name = "created_at") + val createdAt: Long, +) { + fun toMap(uri: String, filename: String): Map = mapOf( + "id" to id, + "favoriteId" to favoriteId, + "capturedAtMs" to capturedAt, + "mimeType" to mimeType, + "mediaKind" to mediaKind, + "byteCount" to byteCount, + "contentHash" to contentHash, + "createdAtMs" to createdAt, + "uri" to uri, + "filename" to filename, + ) +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index d2d64e0fe..44e49634f 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -91,6 +91,7 @@ class TelemetryRepository private constructor(context: Context) { private val appContext = context.applicationContext private val db = TelemetryDatabase.get(context) private val dao = db.telemetryDao() + private val favoriteMediaStore = FavoriteMediaStore(appContext, dao) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val lock = Any() private val pending = ArrayDeque() @@ -544,6 +545,7 @@ class TelemetryRepository private constructor(context: Context) { * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `getFavorites` */ suspend fun getFavorites(): List> = withContext(Dispatchers.IO) { + favoriteMediaStore.reconcileAll() val boardNames = dao.getBoards().associate { it.id to it.name } dao.getFavorites().map { it.toMap(boardNames[it.boardId]) } } @@ -610,7 +612,29 @@ class TelemetryRepository private constructor(context: Context) { * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `deleteFavorite` */ suspend fun deleteFavorite(id: String): Boolean = withContext(Dispatchers.IO) { - dao.deleteFavorite(id) > 0 + val deleted = dao.deleteFavorite(id) > 0 + if (deleted) favoriteMediaStore.deleteDirectory(id) + deleted + } + + /** + * Read and reconcile Favorite Media. Missing files remove their manifest rows; temp/orphan files + * are deleted and never published to JS. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `getFavoriteMedia` + */ + suspend fun getFavoriteMedia(favoriteId: String): List> = withContext(Dispatchers.IO) { + favoriteMediaStore.list(favoriteId) + } + + /** + * Copy picker bytes into canonical app storage, hashing as they stream, then publish the + * immutable manifest only after the final file exists. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `importFavoriteMedia` + */ + suspend fun importFavoriteMedia(options: Map): Map = withContext(Dispatchers.IO) { + favoriteMediaStore.importMedia(options) } /** diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteMediaTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteMediaTest.kt new file mode 100644 index 000000000..41598348a --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteMediaTest.kt @@ -0,0 +1,115 @@ +package expo.modules.vescapecore.telemetry + +import java.io.File +import java.lang.reflect.Proxy +import java.net.URI +import java.nio.file.Files +import java.security.MessageDigest +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** @parity /modules/vescape-core/ios/telemetry/FavoriteMediaStoreTests.swift */ +class FavoriteMediaTest { + private lateinit var root: File + private lateinit var rows: MutableList + private lateinit var store: FavoriteMediaStore + + @Before + fun setUp() { + root = Files.createTempDirectory("favorite-media-test").toFile() + rows = mutableListOf() + val dao = Proxy.newProxyInstance( + TelemetryDao::class.java.classLoader, + arrayOf(TelemetryDao::class.java), + ) { _, method, args -> + when (method.name) { + "getFavorite" -> favorite() + "getFavoriteMedia" -> rows.filter { it.favoriteId == args?.first() } + "insertFavoriteMedia" -> { + rows += args?.first() as FavoriteMediaEntity + Unit + } + "deleteFavoriteMedia" -> if (rows.removeIf { it.id == args?.first() }) 1 else 0 + else -> throw UnsupportedOperationException(method.name) + } + } as TelemetryDao + store = FavoriteMediaStore(root, dao) { uri -> File(uri).inputStream() } + } + + @After + fun tearDown() { + root.deleteRecursively() + } + + @Test + fun `import copies bytes then publishes hash and manifest`() = runBlocking { + val source = File(root.parentFile, "picked-${System.nanoTime()}.jpg") + val bytes = "favorite bytes".toByteArray() + source.writeBytes(bytes) + try { + val map = store.importMedia( + mapOf( + "favoriteId" to "favorite-1", + "uri" to source.path, + "capturedAtMs" to 1_234L, + "mimeType" to "image/jpeg", + "mediaKind" to "photo", + ), + ) + + assertEquals(bytes.size.toLong(), map["byteCount"]) + assertEquals( + MessageDigest.getInstance("SHA-256").digest(bytes) + .joinToString("") { "%02x".format(it.toInt() and 0xff) }, + map["contentHash"], + ) + assertTrue(File(URI(map["uri"] as String)).isFile) + assertEquals(listOf(map["id"]), store.list("favorite-1").map { it["id"] }) + } finally { + source.delete() + } + } + + @Test + fun `read reconciliation removes missing rows and orphan files`() = runBlocking { + rows += FavoriteMediaEntity( + id = "missing", + favoriteId = "favorite-1", + capturedAt = 1_000, + mimeType = "image/jpeg", + mediaKind = "photo", + byteCount = 1, + contentHash = "00", + createdAt = 1_000, + ) + val directory = File(root, "favorite-1").apply { mkdirs() } + val orphan = File(directory, "orphan.jpg").apply { writeBytes(byteArrayOf(1)) } + val interrupted = File(directory, ".partial.import").apply { writeBytes(byteArrayOf(2)) } + + assertTrue(store.list("favorite-1").isEmpty()) + assertFalse(orphan.exists()) + assertFalse(interrupted.exists()) + } + + private fun favorite() = FavoriteEntity( + id = "favorite-1", + boardId = null, + name = null, + startMs = 1_000, + endMs = 2_000, + createdAt = 1_000, + updatedAt = 1_000, + sampleCount = 0, + gpsPointCount = 0, + distanceCm = null, + movingDurationMs = 0, + avgSpeedCentiKmh = 0, + maxSpeedCentiKmh = 0, + batteryUsedWhMilli = 0, + ) +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt index d25596a57..396ed944e 100644 --- a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt @@ -139,6 +139,58 @@ class FavoriteSummaryBuilderTest { ) } + @Test + fun favoriteMediaMigrationAddsImmutableManifestMetadata() { + val sql = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + if (method.name == "execSQL") { + sql += args?.firstOrNull() as String + null + } else { + throw UnsupportedOperationException(method.name) + } + } as SupportSQLiteDatabase + + TelemetryDatabase.MIGRATION_30_31.migrate(db) + + assertTrue(sql.any { it.contains("CREATE TABLE IF NOT EXISTS favorite_media") }) + for (column in listOf( + "id TEXT NOT NULL PRIMARY KEY", + "favorite_id TEXT NOT NULL", + "captured_at INTEGER", + "mime_type TEXT NOT NULL", + "media_kind TEXT NOT NULL", + "byte_count INTEGER NOT NULL", + "content_hash TEXT NOT NULL", + "created_at INTEGER NOT NULL", + )) { + assertTrue("$column missing", sql.any { it.contains(column) }) + } + assertTrue(sql.any { it.contains("ON favorite_media(favorite_id, created_at)") }) + } + + @Test + fun favoriteMediaEntityMapsManifestAndCanonicalUriAcrossBridge() { + val map = FavoriteMediaEntity( + id = "media-1", + favoriteId = "favorite-1", + capturedAt = 1_000, + mimeType = "image/jpeg", + mediaKind = "photo", + byteCount = 12, + contentHash = "abc", + createdAt = 2_000, + ).toMap("file:///favoriteMedia/favorite-1/media-1.jpg", "media-1.jpg") + + assertEquals("favorite-1", map["favoriteId"]) + assertEquals(12L, map["byteCount"]) + assertEquals("abc", map["contentHash"]) + assertEquals("file:///favoriteMedia/favorite-1/media-1.jpg", map["uri"]) + } + private fun bucketsFor(points: List): Collection { val sanitization = sanitizeTelemetrySamples(points, MetricSanitizerConfig()) val sanitized = points.mapIndexed { index, point -> diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 448ee11e3..68f4b860d 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -646,6 +646,18 @@ public class VescapeCoreModule: Module { promise.resolve(TelemetryRepository.shared.deleteFavorite(id)) } + AsyncFunction("getFavoriteMedia") { (favoriteId: String, promise: Promise) in + promise.resolve(TelemetryRepository.shared.getFavoriteMedia(favoriteId)) + } + + AsyncFunction("importFavoriteMedia") { (options: [String: Any], promise: Promise) in + do { + promise.resolve(try TelemetryRepository.shared.importFavoriteMedia(options)) + } catch { + promise.reject("ERR_IMPORT_FAVORITE_MEDIA", "favorite media could not be imported", error) + } + } + AsyncFunction("deleteTelemetryBefore") { (beforeMs: Double, promise: Promise) in promise.resolve(TelemetryRepository.shared.deleteBefore(Int64(beforeMs))) } diff --git a/modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift b/modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift new file mode 100644 index 000000000..7ecf526e8 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift @@ -0,0 +1,312 @@ +import CryptoKit +import Foundation +import GRDB + +/// One immutable Favorite Media manifest row. SQLite owns metadata; the canonical file path is +/// derived only from the Favorite and media ids plus the stored MIME type. +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `FavoriteMediaEntity` +/// @parity /modules/vescape-core/src/index.ts `FavoriteMedia` +struct FavoriteMedia { + let id: String + let favoriteId: String + let capturedAtMs: Int64? + let mimeType: String + let mediaKind: String + let byteCount: Int64 + let contentHash: String + let createdAtMs: Int64 + + func toMap(fileURL: URL) -> [String: Any?] { + [ + "id": id, + "favoriteId": favoriteId, + "capturedAtMs": capturedAtMs, + "mimeType": mimeType, + "mediaKind": mediaKind, + "byteCount": byteCount, + "contentHash": contentHash, + "createdAtMs": createdAtMs, + "uri": fileURL.absoluteString, + "filename": fileURL.lastPathComponent, + ] + } +} + +enum FavoriteMediaStoreError: Error { + case favoriteNotFound + case invalidSource + case copyFailed + case manifestWriteFailed +} + +/// Native Favorite Media import and reconciliation (ADR 0030). +/// +/// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteMediaStore.kt +struct FavoriteMediaStore { + private let resolveWriter: () -> DatabaseWriter? + private let rootURL: URL + + static let shared = FavoriteMediaStore( + resolveWriter: { TelemetryDatabase.pool }, + rootURL: defaultRootURL + ) + + init(resolveWriter: @escaping () -> DatabaseWriter?, rootURL: URL) { + self.resolveWriter = resolveWriter + self.rootURL = rootURL + } + + init(dbWriter: DatabaseWriter, rootURL: URL) { + self.resolveWriter = { dbWriter } + self.rootURL = rootURL + } + + static var defaultRootURL: URL { + let support = (try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + )) ?? FileManager.default.temporaryDirectory + return support.appendingPathComponent("favoriteMedia", isDirectory: true) + } + + static func createTables(_ db: Database) throws { + try db.execute(sql: """ + CREATE TABLE favorite_media ( + id TEXT NOT NULL PRIMARY KEY, + favorite_id TEXT NOT NULL, + captured_at INTEGER, + mime_type TEXT NOT NULL, + media_kind TEXT NOT NULL, + byte_count INTEGER NOT NULL, + content_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """) + try db.execute( + sql: "CREATE INDEX index_favorite_media_favorite_id_created_at ON favorite_media(favorite_id, created_at)" + ) + } + + func list(favoriteId: String) -> [FavoriteMedia] { + reconcile(favoriteId: favoriteId) + guard let writer = resolveWriter() else { return [] } + return (try? writer.read { db in + try Row.fetchAll( + db, + sql: "SELECT * FROM favorite_media WHERE favorite_id = ? ORDER BY created_at, id", + arguments: [favoriteId] + ).map(Self.media) + }) ?? [] + } + + func importMedia( + favoriteId: String, + sourceURI: String, + capturedAtMs: Int64?, + mimeType: String, + mediaKind: String + ) throws -> FavoriteMedia { + guard let writer = resolveWriter() else { throw FavoriteMediaStoreError.manifestWriteFailed } + let favoriteExists = (try? writer.read { db in + try Bool.fetchOne( + db, + sql: "SELECT EXISTS(SELECT 1 FROM favorites WHERE id = ?)", + arguments: [favoriteId] + ) + }) ?? false + guard favoriteExists else { throw FavoriteMediaStoreError.favoriteNotFound } + guard let source = URL(string: sourceURI), source.isFileURL else { + throw FavoriteMediaStoreError.invalidSource + } + guard ["photo", "video"].contains(mediaKind), !mimeType.isEmpty else { + throw FavoriteMediaStoreError.invalidSource + } + + let id = UUID().uuidString.lowercased() + let createdAtMs = Int64(Date().timeIntervalSince1970 * 1_000) + let media = FavoriteMedia( + id: id, + favoriteId: favoriteId, + capturedAtMs: capturedAtMs, + mimeType: mimeType, + mediaKind: mediaKind, + byteCount: 0, + contentHash: "", + createdAtMs: createdAtMs + ) + let directory = favoriteDirectory(favoriteId) + let temporary = directory.appendingPathComponent(".\(id).import") + let destination = fileURL(for: media) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let copied: (count: Int64, hash: String) + do { + copied = try copyAndHash(from: source, to: temporary) + try FileManager.default.moveItem(at: temporary, to: destination) + } catch { + try? FileManager.default.removeItem(at: temporary) + throw FavoriteMediaStoreError.copyFailed + } + + let completed = FavoriteMedia( + id: media.id, + favoriteId: media.favoriteId, + capturedAtMs: media.capturedAtMs, + mimeType: media.mimeType, + mediaKind: media.mediaKind, + byteCount: copied.count, + contentHash: copied.hash, + createdAtMs: media.createdAtMs + ) + do { + try writer.write { db in + try db.execute( + sql: """ + INSERT INTO favorite_media ( + id, favorite_id, captured_at, mime_type, media_kind, byte_count, content_hash, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + completed.id, completed.favoriteId, completed.capturedAtMs, completed.mimeType, + completed.mediaKind, completed.byteCount, completed.contentHash, completed.createdAtMs, + ] + ) + } + } catch { + try? FileManager.default.removeItem(at: destination) + throw FavoriteMediaStoreError.manifestWriteFailed + } + return completed + } + + func fileURL(for media: FavoriteMedia) -> URL { + favoriteDirectory(media.favoriteId) + .appendingPathComponent(media.id) + .appendingPathExtension(Self.extensionForMimeType(media.mimeType)) + } + + func deleteDirectory(favoriteId: String) { + try? FileManager.default.removeItem(at: favoriteDirectory(favoriteId)) + } + + /// Repair cross-store disagreement on the normal Favorites read path: remove manifest rows whose + /// parent disappeared, reconcile every live Favorite, and delete directories with no parent. + func reconcileAll() { + guard let writer = resolveWriter() else { return } + let favoriteIds = (try? writer.write { db -> [String] in + try db.execute( + sql: "DELETE FROM favorite_media WHERE favorite_id NOT IN (SELECT id FROM favorites)" + ) + return try String.fetchAll(db, sql: "SELECT id FROM favorites") + }) ?? [] + let live = Set(favoriteIds) + for favoriteId in favoriteIds { reconcile(favoriteId: favoriteId) } + guard let directories = try? FileManager.default.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey] + ) else { return } + for directory in directories where !live.contains(directory.lastPathComponent) { + try? FileManager.default.removeItem(at: directory) + } + } + + func reconcile(favoriteId: String) { + guard let writer = resolveWriter() else { return } + let directory = favoriteDirectory(favoriteId) + let rows = (try? writer.read { db in + try Row.fetchAll( + db, + sql: "SELECT * FROM favorite_media WHERE favorite_id = ?", + arguments: [favoriteId] + ).map(Self.media) + }) ?? [] + let fm = FileManager.default + var expected = Set() + var missing: [String] = [] + for row in rows { + let file = fileURL(for: row) + expected.insert(file.lastPathComponent) + if !fm.fileExists(atPath: file.path) { missing.append(row.id) } + } + if !missing.isEmpty { + try? writer.write { db in + for id in missing { + try db.execute(sql: "DELETE FROM favorite_media WHERE id = ?", arguments: [id]) + } + } + } + guard let entries = try? fm.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) else { return } + for entry in entries where entry.lastPathComponent.hasPrefix(".") || !expected.contains(entry.lastPathComponent) { + try? fm.removeItem(at: entry) + } + } + + private func favoriteDirectory(_ favoriteId: String) -> URL { + rootURL.appendingPathComponent(favoriteId, isDirectory: true) + } + + private static func media(_ row: Row) -> FavoriteMedia { + FavoriteMedia( + id: row["id"], + favoriteId: row["favorite_id"], + capturedAtMs: row["captured_at"], + mimeType: row["mime_type"], + mediaKind: row["media_kind"], + byteCount: row["byte_count"], + contentHash: row["content_hash"], + createdAtMs: row["created_at"] + ) + } + + private static func extensionForMimeType(_ mimeType: String) -> String { + switch mimeType.lowercased() { + case "image/png": return "png" + case "image/heic", "image/heif": return "heic" + case "image/webp": return "webp" + case "video/quicktime": return "mov" + case "video/webm": return "webm" + case "video/x-m4v": return "m4v" + case "video/mp4": return "mp4" + default: return mimeType.hasPrefix("video/") ? "mp4" : "jpg" + } + } + + private func copyAndHash(from source: URL, to destination: URL) throws -> (Int64, String) { + guard let input = InputStream(url: source), let output = OutputStream(url: destination, append: false) + else { throw FavoriteMediaStoreError.invalidSource } + input.open() + output.open() + defer { + input.close() + output.close() + } + var digest = SHA256() + var count: Int64 = 0 + var buffer = [UInt8](repeating: 0, count: 64 * 1_024) + while true { + let read = input.read(&buffer, maxLength: buffer.count) + if read < 0 { throw input.streamError ?? FavoriteMediaStoreError.copyFailed } + if read == 0 { break } + var offset = 0 + while offset < read { + let written = buffer.withUnsafeBytes { rawBuffer in + output.write( + rawBuffer.bindMemory(to: UInt8.self).baseAddress!.advanced(by: offset), + maxLength: read - offset + ) + } + if written <= 0 { throw output.streamError ?? FavoriteMediaStoreError.copyFailed } + offset += written + } + digest.update(data: Data(buffer[0.. Favorite { + Favorite( + id: "favorite-1", + boardId: nil, + name: nil, + startMs: 1_000, + endMs: 2_000, + createdAtMs: 1_000, + updatedAtMs: 1_000, + summary: FavoriteSummary() + ) + } +} diff --git a/modules/vescape-core/ios/telemetry/FavoriteStore.swift b/modules/vescape-core/ios/telemetry/FavoriteStore.swift index 8d34af5b6..3eff60038 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStore.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStore.swift @@ -215,10 +215,13 @@ struct FavoriteStore { } /// Unpin one Favorite. Telemetry inside its range is untouched and becomes deletable again. + /// Favorite Media rows are parent-covered and raw-deleted in the same transaction (ADR 0030); + /// filesystem cleanup is best-effort in the repository after this commit succeeds. @discardableResult func delete(_ id: String) -> Bool { guard let writer = resolveWriter() else { return false } return (try? writer.write { db in + try db.execute(sql: "DELETE FROM favorite_media WHERE favorite_id = ?", arguments: [id]) try db.execute(sql: "DELETE FROM favorites WHERE id = ?", arguments: [id]) return db.changesCount > 0 }) ?? false diff --git a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift index fc1da9c54..c0770d50c 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -11,7 +11,10 @@ final class FavoriteStoreTests: XCTestCase { override func setUpWithError() throws { queue = try DatabaseQueue() - try queue.write { db in try FavoriteStore.createTables(db) } + try queue.write { db in + try FavoriteStore.createTables(db) + try FavoriteMediaStore.createTables(db) + } store = FavoriteStore(dbWriter: queue) } @@ -110,6 +113,25 @@ final class FavoriteStoreTests: XCTestCase { XCTAssertFalse(store.delete("fav-1")) } + func testDeleteRawCascadesFavoriteMediaManifestRows() throws { + store.insert(makeFavorite(id: "fav-1", startMs: 1_000, endMs: 2_000)) + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO favorite_media ( + id, favorite_id, captured_at, mime_type, media_kind, byte_count, content_hash, created_at + ) VALUES ('media-1', 'fav-1', 1000, 'image/jpeg', 'photo', 1, '00', 1000) + """ + ) + } + + XCTAssertTrue(store.delete("fav-1")) + let mediaCount = try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM favorite_media") + } + XCTAssertEqual(mediaCount, 0) + } + func testBridgeMapConvertsStoredIntegersToRiderUnits() { let map = makeFavorite( id: "fav-1", diff --git a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift index 94e792d06..f8cfb4ef1 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryDatabase.swift @@ -435,6 +435,13 @@ enum TelemetryDatabase { try FavoriteStore.createTables(db) } + // Favorite Media (#291). Native manifest metadata truth; bytes live in canonical Favorite-owned + // app storage (ADR 0030). + // @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDatabase.kt `MIGRATION_30_31` + migrator.registerMigration("v31_favorite_media") { db in + try FavoriteMediaStore.createTables(db) + } + return migrator } } diff --git a/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift b/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift index e576b2a0c..a60285919 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryMigrationTests.swift @@ -82,6 +82,7 @@ final class TelemetryMigrationTests: XCTestCase { "boards", "board_settings", "alerts", "app_settings", "telemetry_frames", "telemetry_minute_buckets", "telemetry_markers", "metric_exclusion_ranges", "diagnostic_events", "tune_profiles", "tune_history_entries", "board_warnings", "favorites", + "favorite_media", ] for table in tables { XCTAssertTrue(try queue.read { db in try db.tableExists(table) }, "\(table) is missing") diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 55e020e9a..39ef447a0 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -237,6 +237,7 @@ internal final class TelemetryRepository { /// a snapshot would drift. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `getFavorites` func getFavorites() -> [[String: Any?]] { + FavoriteMediaStore.shared.reconcileAll() let boardNames = Self.boardNamesById() return FavoriteStore.shared.list().map { favorite in favorite.toMap(boardName: favorite.boardId.flatMap { boardNames[$0] }) @@ -319,7 +320,38 @@ internal final class TelemetryRepository { /// Unpin a Favorite. Telemetry in its range stays and becomes normally deletable (ADR 0029). /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `deleteFavorite` func deleteFavorite(_ id: String) -> Bool { - FavoriteStore.shared.delete(id) + let deleted = FavoriteStore.shared.delete(id) + if deleted { FavoriteMediaStore.shared.deleteDirectory(favoriteId: id) } + return deleted + } + + /// Read and reconcile Favorite Media. Missing files remove their manifest rows; temp/orphan files + /// are deleted and never published to JS. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `getFavoriteMedia` + func getFavoriteMedia(_ favoriteId: String) -> [[String: Any?]] { + FavoriteMediaStore.shared.list(favoriteId: favoriteId).map { + $0.toMap(fileURL: FavoriteMediaStore.shared.fileURL(for: $0)) + } + } + + /// Copy picker bytes into canonical app storage, hashing as they stream, then publish the + /// immutable manifest only after the final file exists. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `importFavoriteMedia` + func importFavoriteMedia(_ options: [String: Any]) throws -> [String: Any?] { + guard + let favoriteId = options["favoriteId"] as? String, + let sourceURI = options["uri"] as? String, + let mimeType = options["mimeType"] as? String, + let mediaKind = options["mediaKind"] as? String + else { throw FavoriteMediaStoreError.invalidSource } + let media = try FavoriteMediaStore.shared.importMedia( + favoriteId: favoriteId, + sourceURI: sourceURI, + capturedAtMs: telemetryLong(options["capturedAtMs"]), + mimeType: mimeType, + mediaKind: mediaKind + ) + return media.toMap(fileURL: FavoriteMediaStore.shared.fileURL(for: media)) } /// Run the raw samples of a Favorite range through the same Metric Sanitizers the recording flush diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 193e83fe1..9e8086f3b 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -819,6 +819,32 @@ export interface CreateFavoriteOptions { name?: string } +/** + * One immutable Favorite Media manifest row. Native owns metadata and canonical storage. + * @parity /modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift `FavoriteMedia` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `FavoriteMediaEntity` + */ +export interface FavoriteMedia { + id: string + favoriteId: string + capturedAtMs: number | null + mimeType: string + mediaKind: 'photo' | 'video' + byteCount: number + contentHash: string + createdAtMs: number + uri: string + filename: string +} + +export interface ImportFavoriteMediaOptions { + favoriteId: string + uri: string + capturedAtMs?: number + mimeType: string + mediaKind: 'photo' | 'video' +} + export interface RefloatConfigField { id: string label: string @@ -1472,6 +1498,8 @@ type VescapeCoreNativeModule = NativeEventEmitter & { createFavorite(options: CreateFavoriteOptions): Promise renameFavorite(id: string, name: string | null): Promise deleteFavorite(id: string): Promise + getFavoriteMedia(favoriteId: string): Promise + importFavoriteMedia(options: ImportFavoriteMediaOptions): Promise getDiagnosticEvents(options: DiagnosticEventOptions): Promise clearDiagnosticEvents(): Promise getBoardWarnings(): Promise @@ -1983,6 +2011,18 @@ export async function deleteFavorite(id: string): Promise { return native.deleteFavorite(id) } +/** List native-manifested Favorite Media after filesystem reconciliation. */ +export async function getFavoriteMedia(favoriteId: string): Promise { + return native.getFavoriteMedia(favoriteId) +} + +/** Import picker bytes into canonical Favorite-owned app storage. */ +export async function importFavoriteMedia( + options: ImportFavoriteMediaOptions, +): Promise { + return native.importFavoriteMedia(options) +} + export async function getDiagnosticEvents( options: DiagnosticEventOptions = {}, ): Promise { diff --git a/src/modules/history/components/HistoryPanelNav.tsx b/src/modules/history/components/HistoryPanelNav.tsx index 90756dc08..86c59c71c 100644 --- a/src/modules/history/components/HistoryPanelNav.tsx +++ b/src/modules/history/components/HistoryPanelNav.tsx @@ -14,6 +14,7 @@ interface HistoryPanelNavProps { deviceName: string canPrevious: boolean canNext: boolean + showMedia: boolean mediaCount: number mediaLoading: boolean mediaButtonRef: RefObject @@ -30,6 +31,7 @@ export function HistoryPanelNav({ deviceName, canPrevious, canNext, + showMedia, mediaCount, mediaLoading, mediaButtonRef, @@ -42,17 +44,21 @@ export function HistoryPanelNav({ return ( - 0 ? styles.mediaEnabled : undefined} - /> - {mediaCount > 0 ? ( - - {mediaCount > 99 ? '99+' : mediaCount} - + {showMedia ? ( + <> + 0 ? styles.mediaEnabled : undefined} + /> + {mediaCount > 0 ? ( + + {mediaCount > 99 ? '99+' : mediaCount} + + ) : null} + ) : null} - Add photos and videos captured during this ride to see them on the map. + Add photos and videos captured during this Favorite to see them on the map. ) : ( @@ -85,9 +85,9 @@ export function MediaHistoryGallery({ )} {unmatched.length > 0 ? ( <> - Outside this ride + Outside this Favorite - No creation time inside the ride — shown here but not on the map. + No capture time inside the Favorite. Shown here, but not on the map. diff --git a/src/modules/history/hooks/useMediaHistory.ts b/src/modules/history/hooks/useMediaHistory.ts index b3e98d47c..f3f28ddf7 100644 --- a/src/modules/history/hooks/useMediaHistory.ts +++ b/src/modules/history/hooks/useMediaHistory.ts @@ -1,5 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import * as ImagePicker from 'expo-image-picker' +import { + getFavoriteMedia, + importFavoriteMedia, + type FavoriteMedia, + type ImportFavoriteMediaOptions, +} from 'vescape-core' import { matchMediaHistoryAssets, @@ -11,13 +17,9 @@ import type { HistoryMarker, HistorySession, } from '@/modules/history/store/historyStore' -import { listRideMediaAssets, saveRideMediaAssets } from '@/modules/history/store/rideMediaFiles' - // Google Play's Photo and Video Permissions policy forbids READ_MEDIA_IMAGES/READ_MEDIA_VIDEO -// for this feature, so ride media comes from the permissionless system photo picker: the user -// picks assets, we copy them into the ride's media folder, and place the ones with a -// recoverable creation time on the ride. -async function pickRideAssets(): Promise { +// for this feature, so Favorite Media comes from the permissionless system photo picker. +async function pickFavoriteMedia(favoriteId: string): Promise { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images', 'videos'], allowsMultipleSelection: true, @@ -25,24 +27,38 @@ async function pickRideAssets(): Promise { quality: 1, }) if (result.canceled) return [] - return result.assets.map((asset) => ({ - id: asset.assetId ?? asset.uri, - uri: asset.uri, - filename: asset.fileName ?? '', - mediaType: asset.type === 'video' ? 'video' : 'photo', - creationTime: - resolvePickedAssetCreationTime({ - exif: asset.exif, - filename: asset.fileName ?? '', - }) ?? Number.NaN, - })) + return result.assets.map((asset) => { + const capturedAtMs = resolvePickedAssetCreationTime({ + exif: asset.exif, + filename: asset.fileName ?? '', + }) + return { + favoriteId, + uri: asset.uri, + ...(capturedAtMs == null ? {} : { capturedAtMs }), + mimeType: asset.mimeType ?? (asset.type === 'video' ? 'video/mp4' : 'image/jpeg'), + mediaKind: asset.type === 'video' ? ('video' as const) : ('photo' as const), + } + }) } -export function useMediaHistory({ +function toMediaAsset(media: FavoriteMedia): MediaAssetInput { + return { + id: media.id, + uri: media.uri, + filename: media.filename, + mediaType: media.mediaKind, + creationTime: media.capturedAtMs ?? Number.NaN, + } +} + +export function useFavoriteMedia({ + favoriteId, selectedSession, gpsSamples, markers, }: { + favoriteId: string | null selectedSession: HistorySession | null gpsSamples: HistoryGpsSample[] markers: HistoryMarker[] @@ -56,37 +72,40 @@ export function useMediaHistory({ queueMicrotask(() => { if (cancelled) return setError(null) - if (!selectedSession) { + if (!selectedSession || !favoriteId) { setStored([]) return } - try { - setStored(listRideMediaAssets(selectedSession.id)) - } catch (cause: unknown) { - setStored([]) - setError(cause instanceof Error ? cause.message : 'Could not read ride media') - } + void getFavoriteMedia(favoriteId) + .then((media) => { + if (!cancelled) setStored(media.map(toMediaAsset)) + }) + .catch((cause: unknown) => { + if (cancelled) return + setStored([]) + setError(cause instanceof Error ? cause.message : 'Could not read Favorite Media') + }) }) return () => { cancelled = true } - }, [selectedSession]) + }, [favoriteId, selectedSession]) const add = useCallback(async () => { - if (!selectedSession) return + if (!selectedSession || !favoriteId) return setLoading(true) setError(null) try { - const picked = await pickRideAssets() + const picked = await pickFavoriteMedia(favoriteId) if (picked.length === 0) return - await saveRideMediaAssets(selectedSession.id, picked) - setStored(listRideMediaAssets(selectedSession.id)) + for (const media of picked) await importFavoriteMedia(media) + setStored((await getFavoriteMedia(favoriteId)).map(toMediaAsset)) } catch (cause: unknown) { - setError(cause instanceof Error ? cause.message : 'Could not save picked media') + setError(cause instanceof Error ? cause.message : 'Could not save Favorite Media') } finally { setLoading(false) } - }, [selectedSession]) + }, [favoriteId, selectedSession]) const { assets, unmatched } = useMemo(() => { if (!selectedSession || stored.length === 0) { diff --git a/src/modules/history/lib/mediaHistory.test.ts b/src/modules/history/lib/mediaHistory.test.ts index 24e5a8e28..f96a37660 100644 --- a/src/modules/history/lib/mediaHistory.test.ts +++ b/src/modules/history/lib/mediaHistory.test.ts @@ -5,8 +5,6 @@ import { makeSample } from '@/test-utils/factories' import { clusterMediaHistoryAssets, findVideoTelemetrySample, - decodeRideMediaFilename, - encodeRideMediaFilename, matchMediaHistoryAssets, resolvePickedAssetCreationTime, type MediaAssetInput, @@ -160,39 +158,3 @@ describe('resolvePickedAssetCreationTime', () => { ).toBeNull() }) }) - -describe('ride media filename codec', () => { - test('round-trips creation time and media type', () => { - const name = encodeRideMediaFilename({ - id: 'asset-1', - uri: 'file:///cache/ImagePicker/abc.JPEG', - filename: '', - mediaType: 'photo', - creationTime: 1_717_249_805_000, - }) - expect(name.endsWith('.jpeg')).toBe(true) - expect(decodeRideMediaFilename(name)).toEqual({ - creationTime: 1_717_249_805_000, - mediaType: 'photo', - }) - }) - - test('encodes unknown creation time as x and decodes it as NaN', () => { - const name = encodeRideMediaFilename({ - id: 'asset-2', - uri: 'file:///cache/no-extension', - filename: '', - mediaType: 'video', - creationTime: Number.NaN, - }) - expect(name.endsWith('.mp4')).toBe(true) - const decoded = decodeRideMediaFilename(name) - expect(decoded?.mediaType).toBe('video') - expect(Number.isNaN(decoded?.creationTime)).toBe(true) - }) - - test('rejects foreign filenames', () => { - expect(decodeRideMediaFilename('IMG_1234.jpg')).toBeNull() - expect(decodeRideMediaFilename('.nomedia')).toBeNull() - }) -}) diff --git a/src/modules/history/lib/mediaHistory.ts b/src/modules/history/lib/mediaHistory.ts index fe3c9a37e..71557eeba 100644 --- a/src/modules/history/lib/mediaHistory.ts +++ b/src/modules/history/lib/mediaHistory.ts @@ -68,36 +68,6 @@ export function resolvePickedAssetCreationTime({ return toEpochMs(match.slice(1).map(Number), filename.startsWith('PXL_')) } -// Ride media persists as plain files under rideMedia// with all metadata encoded -// in the filename — there is no database record. `x` marks an unrecoverable creation time. -const RIDE_MEDIA_FILENAME_RE = /^(\d+|x)_(photo|video)_[0-9a-z]+\.\w+$/ - -function shortHash(value: string): string { - let hash = 5381 - for (let index = 0; index < value.length; index += 1) { - hash = ((hash << 5) + hash + value.charCodeAt(index)) >>> 0 - } - return hash.toString(36) -} - -export function encodeRideMediaFilename(asset: MediaAssetInput): string { - const time = Number.isFinite(asset.creationTime) ? String(asset.creationTime) : 'x' - const extension = - /\.(\w+)$/.exec(asset.uri)?.[1]?.toLowerCase() ?? (asset.mediaType === 'video' ? 'mp4' : 'jpg') - return `${time}_${asset.mediaType}_${shortHash(asset.id)}.${extension}` -} - -export function decodeRideMediaFilename( - filename: string, -): { creationTime: number; mediaType: 'photo' | 'video' } | null { - const match = RIDE_MEDIA_FILENAME_RE.exec(filename) - if (!match) return null - return { - creationTime: match[1] === 'x' ? Number.NaN : Number(match[1]), - mediaType: match[2] as 'photo' | 'video', - } -} - function hasBreakBetween(markers: readonly HistoryMarker[], fromMs: number, toMs: number) { return markers.some( (marker) => diff --git a/src/modules/history/store/rideMediaFiles.ts b/src/modules/history/store/rideMediaFiles.ts deleted file mode 100644 index 48dfdac67..000000000 --- a/src/modules/history/store/rideMediaFiles.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Directory, File, Paths } from 'expo-file-system' - -import { - decodeRideMediaFilename, - encodeRideMediaFilename, - type MediaAssetInput, -} from '@/modules/history/lib/mediaHistory' - -// Ride media storage: the filesystem is the only record. Each ride owns a folder of copied -// picker files whose names encode all asset metadata (see encodeRideMediaFilename). -function rideMediaDirectory(sessionId: string): Directory { - return new Directory(Paths.document, 'rideMedia', sessionId) -} - -export function listRideMediaAssets(sessionId: string): MediaAssetInput[] { - const directory = rideMediaDirectory(sessionId) - if (!directory.exists) return [] - return directory.list().flatMap((entry) => { - if (!(entry instanceof File)) return [] - const decoded = decodeRideMediaFilename(entry.name) - if (!decoded) return [] - return [ - { - id: entry.uri, - uri: entry.uri, - filename: entry.name, - mediaType: decoded.mediaType, - creationTime: decoded.creationTime, - }, - ] - }) -} - -export async function saveRideMediaAssets( - sessionId: string, - assets: readonly MediaAssetInput[], -): Promise { - const directory = rideMediaDirectory(sessionId) - directory.create({ intermediates: true, idempotent: true }) - for (const asset of assets) { - const target = new File(directory, encodeRideMediaFilename(asset)) - if (target.exists) continue - await new File(asset.uri).copy(target) - } -} - -export function deleteRideMediaAssets(sessionId: string): void { - const directory = rideMediaDirectory(sessionId) - if (directory.exists) directory.delete() -} diff --git a/src/screens/main/history/HistoryRideDetail.tsx b/src/screens/main/history/HistoryRideDetail.tsx index abfb6a014..1ba4e4763 100644 --- a/src/screens/main/history/HistoryRideDetail.tsx +++ b/src/screens/main/history/HistoryRideDetail.tsx @@ -51,6 +51,7 @@ export function HistoryRideDetail({ samples={history.sessionSamples} canPrevious={!favoriteMode && !trimming && history.canPreviousRide} canNext={!favoriteMode && !trimming && !!history.nextRide} + showMedia={favoriteMode} mediaAssets={history.mediaHistory.assets} mediaUnmatched={history.mediaHistory.unmatched} mediaLoading={history.mediaHistory.loading} diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index bf55680fb..3fd97b7fe 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -38,6 +38,7 @@ interface HistoryTelemetryPanelProps { samples: TelemetrySample[] canPrevious: boolean canNext: boolean + showMedia: boolean mediaAssets: MediaHistoryAsset[] mediaUnmatched: MediaAssetInput[] mediaLoading: boolean @@ -65,6 +66,7 @@ export function HistoryTelemetryPanel({ samples, canPrevious, canNext, + showMedia, mediaAssets, mediaUnmatched, mediaLoading, @@ -155,6 +157,7 @@ export function HistoryTelemetryPanel({ deviceName={deviceName} canPrevious={canPrevious} canNext={canNext} + showMedia={showMedia} mediaCount={mediaAssets.length + mediaUnmatched.length} mediaLoading={mediaLoading} mediaButtonRef={mediaButtonRef} @@ -214,17 +217,19 @@ export function HistoryTelemetryPanel({ )} - setMediaDrawerVisible(false)} - onAdd={onAddMedia} - onOpenMedia={onOpenMedia} - /> + {showMedia ? ( + setMediaDrawerVisible(false)} + onAdd={onAddMedia} + onOpenMedia={onOpenMedia} + /> + ) : null} { - const session = useHistoryStore.getState().selectedSession - if (session) { - try { - deleteRideMediaAssets(session.id) - } catch { - // Ride removal must not fail on media cleanup; orphaned folders are harmless. - } - } void removeSelectedSession() }, [removeSelectedSession]) From 68f544bca3b2d0bba2a969584688c344d6ccd290 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 02:52:53 +0200 Subject: [PATCH 13/24] Fix favorite back --- e2e/flows/history.yaml | 35 +++++++++++++++++-- .../main/history/useHistoryFavorites.ts | 8 +++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/e2e/flows/history.yaml b/e2e/flows/history.yaml index f48465949..8c293fd17 100644 --- a/e2e/flows/history.yaml +++ b/e2e/flows/history.yaml @@ -15,7 +15,7 @@ appId: ${APP_ID} # Newest ride stats (selected automatically). - assertVisible: Distance - assertVisible: Top Speed -- assertVisible: '1.5 km' +- assertVisible: '1.5' - assertVisible: '25 km/h' # Expand the stats panel to verify secondary stats are computed too. @@ -42,7 +42,7 @@ appId: ${APP_ID} - tapOn: id: history-previous-ride - extendedWaitUntil: - visible: '800 m' + visible: '800' timeout: 5000 - assertVisible: '20 km/h' @@ -54,7 +54,7 @@ appId: ${APP_ID} - tapOn: id: history-next-ride - extendedWaitUntil: - visible: '1.5 km' + visible: '1.5' timeout: 5000 - assertVisible: '25 km/h' @@ -69,3 +69,32 @@ appId: ${APP_ID} # Close the sheet by tapping the backdrop. - tapOn: id: history-session-sheet-backdrop + +# A Favorite reuses ride detail without destroying the selected Ride History session. +- tapOn: + id: history-favorite-ride +- assertVisible: Trim favorite +- tapOn: + id: trim-save +- tapOn: + id: history-tab-favorites +- extendedWaitUntil: + visible: + id: favorites-list + timeout: 5000 +- tapOn: + id: 'favorite-row-.*' +- extendedWaitUntil: + visible: + id: favorite-detail-back + timeout: 5000 +- tapOn: + id: favorite-detail-back +- extendedWaitUntil: + visible: + id: favorites-list + timeout: 5000 +- tapOn: + id: history-tab-history +- assertNotVisible: No rides yet +- assertVisible: '1.5' diff --git a/src/screens/main/history/useHistoryFavorites.ts b/src/screens/main/history/useHistoryFavorites.ts index e757d5c0e..6c6c6a961 100644 --- a/src/screens/main/history/useHistoryFavorites.ts +++ b/src/screens/main/history/useHistoryFavorites.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { @@ -17,6 +17,7 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { const openFavoriteId = useMainScreenStore((state) => state.openFavoriteId) const setHistoryTab = useMainScreenStore((state) => state.setHistoryTab) const [trimSeed, setTrimSeed] = useState<{ startMs: number; endMs: number } | null>(null) + const historySessionBeforeFavorite = useRef(null) const { favorites, favoritesLoading, @@ -94,6 +95,7 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { * route and the stats all come from the pinned range with no parallel implementation. */ const showFavorite = useCallback(async (favorite: Favorite) => { + historySessionBeforeFavorite.current = useHistoryStore.getState().selectedSession useMainScreenStore.getState().openFavorite(favorite.id) await useHistoryStore .getState() @@ -101,8 +103,10 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { }, []) const hideFavorite = useCallback(async () => { + const historySession = historySessionBeforeFavorite.current + historySessionBeforeFavorite.current = null useMainScreenStore.getState().closeFavorite() - await useHistoryStore.getState().selectSession(null) + await useHistoryStore.getState().selectSession(historySession) }, []) const renameOpenFavorite = useCallback( From ccd94c168cfa2136cfcbd3806d0da102e34f042a Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 10:36:28 +0200 Subject: [PATCH 14/24] Fix favorites navigation --- e2e/flows/history.yaml | 14 +- .../history/components/FavoriteList.tsx | 144 ------------------ .../history/components/HistoryPanelNav.tsx | 13 +- src/screens/main/MainScreen.tsx | 10 +- src/screens/main/history/HistoryControls.tsx | 112 ++++++++------ src/screens/main/history/HistoryOverlay.tsx | 88 +++++------ .../main/history/HistoryRideDetail.tsx | 39 ++--- .../main/history/HistoryTelemetryPanel.tsx | 6 + .../main/history/useHistoryFavorites.ts | 111 ++++++++++---- src/screens/main/overlays/MainOverlays.tsx | 6 +- src/screens/main/useMainScreenController.ts | 8 +- 11 files changed, 241 insertions(+), 310 deletions(-) delete mode 100644 src/modules/history/components/FavoriteList.tsx diff --git a/e2e/flows/history.yaml b/e2e/flows/history.yaml index 8c293fd17..c3437c5df 100644 --- a/e2e/flows/history.yaml +++ b/e2e/flows/history.yaml @@ -70,7 +70,7 @@ appId: ${APP_ID} - tapOn: id: history-session-sheet-backdrop -# A Favorite reuses ride detail without destroying the selected Ride History session. +# Favorites is a filtered History tab: newest Favorite opens immediately and keeps the same nav. - tapOn: id: history-favorite-ride - assertVisible: Trim favorite @@ -80,20 +80,16 @@ appId: ${APP_ID} id: history-tab-favorites - extendedWaitUntil: visible: - id: favorites-list + id: favorite-rename timeout: 5000 - tapOn: - id: 'favorite-row-.*' + id: history-ride-list-button - extendedWaitUntil: visible: - id: favorite-detail-back + id: history-session-sheet timeout: 5000 - tapOn: - id: favorite-detail-back -- extendedWaitUntil: - visible: - id: favorites-list - timeout: 5000 + id: history-session-sheet-backdrop - tapOn: id: history-tab-history - assertNotVisible: No rides yet diff --git a/src/modules/history/components/FavoriteList.tsx b/src/modules/history/components/FavoriteList.tsx deleted file mode 100644 index 5f2612d8d..000000000 --- a/src/modules/history/components/FavoriteList.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native' -import { StarIcon, TrashIcon } from 'phosphor-react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' - -import { IconButton } from '@/components/base/IconButton' -import { Placeholder } from '@/components/base/Placeholder' -import { Text } from '@/components/base/Text' -import { interaction, theme } from '@/constants/theme' -import { telemetry } from '@/modules/board/constants/telemetry' -import { formatRideDate, formatRideTime } from '@/modules/history/lib/rideFormat' -import type { Favorite } from '@/modules/history/store/favoriteStore' - -interface FavoriteListProps { - favorites: Favorite[] - loading: boolean - onOpen: (favorite: Favorite) => void - onRemove: (favorite: Favorite) => void -} - -/** Favorites tab: the starred ranges, newest first. Unnamed rows fall back to date, like history. */ -export function FavoriteList({ favorites, loading, onOpen, onRemove }: FavoriteListProps) { - const insets = useSafeAreaInsets() - - if (loading && favorites.length === 0) { - return ( - - - - ) - } - - if (favorites.length === 0) { - return ( - - - - ) - } - - return ( - - {favorites.map((favorite) => ( - [styles.row, pressed && styles.rowPressed]} - onPress={() => onOpen(favorite)} - > - - - {favorite.name ?? formatRideDate(favorite.startMs, favorite.endMs)} - - - {formatRideTime(favorite.startMs, favorite.endMs)} - {favorite.boardName ? ` · ${favorite.boardName}` : ''} - - - {formatDuration(favorite.movingDurationMs)} · {formatDistance(favorite.distanceM)} ·{' '} - {telemetry.speed.formatWithUnit(favorite.maxSpeedKmh)} ·{' '} - {favorite.batteryUsedWh.toFixed(1)} Wh - - - onRemove(favorite)} - /> - - ))} - - ) -} - -function formatDuration(ms: number): string { - const mins = Math.max(1, Math.round(ms / 60_000)) - if (mins < 60) return `${mins}m` - const h = Math.floor(mins / 60) - const rem = mins % 60 - return rem ? `${h}h ${rem}m` : `${h}h` -} - -function formatDistance(distanceM: number | null): string { - if (distanceM == null) return '-' - return `${(distanceM / 1000).toFixed(2)} km` -} - -const styles = StyleSheet.create({ - wrap: { - ...StyleSheet.absoluteFill, - zIndex: 12, - alignItems: 'center', - justifyContent: 'center', - }, - listWrap: { - ...StyleSheet.absoluteFill, - zIndex: 12, - }, - content: { - width: '100%', - paddingHorizontal: 16, - gap: 8, - }, - row: { - borderRadius: 12, - borderWidth: 1, - borderColor: theme.palette.slate.border, - backgroundColor: theme.alpha(theme.palette.slate.surfaceDeep, 0.85), - paddingVertical: 10, - paddingHorizontal: 12, - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - rowPressed: { - backgroundColor: interaction.pressedBg, - }, - rowMain: { - flex: 1, - minWidth: 0, - gap: 2, - }, - rowTitle: { - color: theme.palette.slate.textPrimary, - fontSize: 13, - fontWeight: '700', - }, - rowSubtitle: { - color: theme.palette.slate.textSecondary, - fontSize: 12, - }, - rowMeta: { - color: theme.palette.slate.textMuted, - fontSize: 11, - }, -}) diff --git a/src/modules/history/components/HistoryPanelNav.tsx b/src/modules/history/components/HistoryPanelNav.tsx index 86c59c71c..60673c17d 100644 --- a/src/modules/history/components/HistoryPanelNav.tsx +++ b/src/modules/history/components/HistoryPanelNav.tsx @@ -12,6 +12,8 @@ interface HistoryPanelNavProps { titleStartMs: number titleEndMs: number deviceName: string + title?: string + subtitle?: string canPrevious: boolean canNext: boolean showMedia: boolean @@ -29,6 +31,8 @@ export function HistoryPanelNav({ titleStartMs, titleEndMs, deviceName, + title, + subtitle, canPrevious, canNext, showMedia, @@ -41,6 +45,9 @@ export function HistoryPanelNav({ onOpenMediaDrawer, onOpenShareInfo, }: HistoryPanelNavProps) { + const primaryLabel = title ?? formatRideTime(titleStartMs, titleEndMs) + const secondaryLabel = subtitle ?? formatRideMeta(titleStartMs, titleEndMs, deviceName) + return ( @@ -62,7 +69,7 @@ export function HistoryPanelNav({ ) : null} - {formatRideTime(titleStartMs, titleEndMs)} + {primaryLabel} - {formatRideMeta(titleStartMs, titleEndMs, deviceName)} + {secondaryLabel} diff --git a/src/screens/main/MainScreen.tsx b/src/screens/main/MainScreen.tsx index 8497ae2a0..86f5f5256 100644 --- a/src/screens/main/MainScreen.tsx +++ b/src/screens/main/MainScreen.tsx @@ -57,13 +57,15 @@ function buildHistoryOverlayProps(controller: ReturnType void onDelete: () => void } @@ -83,44 +81,25 @@ export function HistoryControls({ ) } - if (favorite) { - return ( - - - - - - {favorite.title} - - - - - - - ) - } - return ( - - + + onSelectTab('history')} @@ -129,26 +108,47 @@ export function HistoryControls({ id="favorites" label="Favorites" icon={StarIcon} + activeLabelOnly + activeWidth={126} + inactiveWidth={46} color={theme.palette.amber} testID="history-tab-favorites" onPress={() => onSelectTab('favorites')} /> - {canFavorite ? ( - - ) : null} - {canRemove ? ( - - ) : ( - - )} + + {favorite ? ( + <> + + + + ) : canFavorite ? ( + + ) : null} + {!favorite && canRemove ? ( + + ) : !favorite ? ( + + ) : null} + ) @@ -169,11 +169,27 @@ const styles = StyleSheet.create({ row: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', gap: 8, }, tabsWrap: { - flex: 1, + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + }, + actions: { + marginLeft: 'auto', + flexDirection: 'row', alignItems: 'center', + gap: 8, + zIndex: 1, + }, + tabs: { + alignSelf: 'center', + }, + tabsContent: { + justifyContent: 'center', }, headerTitleWrap: { flex: 1, diff --git a/src/screens/main/history/HistoryOverlay.tsx b/src/screens/main/history/HistoryOverlay.tsx index d2b224cf2..66bacce33 100644 --- a/src/screens/main/history/HistoryOverlay.tsx +++ b/src/screens/main/history/HistoryOverlay.tsx @@ -1,17 +1,18 @@ import { useCallback, useState } from 'react' import { StyleSheet, View } from 'react-native' +import { StarIcon } from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import type { Favorite, HistoryGpsSample, HistoryMarker } from 'vescape-core' +import { Placeholder } from '@/components/base/Placeholder' import { Text } from '@/components/base/Text' import { ConfirmModal } from '@/components/modals/ConfirmModal' import { theme } from '@/constants/theme' -import { FavoriteList } from '@/modules/history/components/FavoriteList' import { HistoryEmptyState } from '@/modules/history/components/HistoryEmptyState' import { HistorySessionSheet } from '@/modules/history/components/HistorySessionSheet' import { MediaHistoryViewer } from '@/modules/history/components/MediaHistoryViewer' import type { MediaAssetInput, MediaHistoryAsset } from '@/modules/history/lib/mediaHistory' -import { sessionContainsFavorite } from '@/modules/history/lib/favorites' +import { favoriteSessionId, sessionContainsFavorite } from '@/modules/history/lib/favorites' import type { HistoryMetricKey } from '@/modules/history/lib/metricColorScale' import type { HistorySession, @@ -52,14 +53,16 @@ export interface MainHistoryOverlayProps { updateTrimRange: (startMs: number, endMs: number) => void cancelTrim: () => void saveTrim: () => Promise - removeFavorite: (id: string) => Promise - /** The Favorite whose detail is open, or null while the Favorites list is showing. */ + favoriteSessions: HistorySession[] + canPreviousFavorite: boolean + canNextFavorite: boolean + selectPreviousFavorite: () => Promise + selectNextFavorite: () => Promise + /** The selected Favorite while the Favorites tab is active. */ openFavorite: Favorite | null - showFavorite: (favorite: Favorite) => Promise - hideFavorite: () => Promise + selectFavorite: (favorite: Favorite) => Promise renameOpenFavorite: (name: string | null) => Promise removeOpenFavorite: () => Promise - selectSession: (session: HistorySession | null) => Promise loadMoreHistory: () => Promise selectPreviousRide: () => Promise selectNextRide: () => Promise @@ -104,11 +107,9 @@ export function HistoryOverlay({ history.favoritesSaving const aboveStripBottom = STRIP_CONTENT_HEIGHT + Math.max(insets.bottom * 0.5, 8) + 8 const sheetBottom = Math.max(insets.bottom, 16) + 8 + panelHeight + 8 - // Favorite detail is the history detail fed a favorite-backed session: same panel, map and stats, - // only the header actions differ. - const favoriteMode = history.historyTab === 'favorites' && history.openFavorite != null + const favoriteMode = history.historyTab === 'favorites' const detailSession = - history.historyTab === 'history' || favoriteMode ? history.selectedSession : null + history.historyTab === 'history' || history.openFavorite ? history.selectedSession : null const selectedSessionContainsFavorite = history.selectedSession != null && sessionContainsFavorite(history.favorites, history.selectedSession) @@ -120,36 +121,6 @@ export function HistoryOverlay({ return ( <> - {visible && history.historyTab === 'favorites' && !history.openFavorite && ( - <> - { - void history.showFavorite(favorite) - }} - onRemove={(favorite) => { - void history.removeFavorite(favorite.id) - }} - /> - undefined} - onToggleFavorite={() => undefined} - onCancelTrim={() => undefined} - onSaveTrim={() => undefined} - /> - - )} - {visible && detailSession && ( )} - {visible && history.historyTab === 'history' && !history.selectedSession && ( + {visible && !detailSession && ( <> - {busy ? : } + {busy ? ( + + ) : favoriteMode ? ( + + + + ) : ( + + )} history.setHistorySheetVisible(false)} onSelectSession={(session) => { history.setHistorySheetVisible(false) - history.selectRide(session) + if (favoriteMode) { + const favorite = history.favorites.find( + (item) => session.id === favoriteSessionId(item.id), + ) + if (favorite) void history.selectFavorite(favorite) + } else { + history.selectRide(session) + } }} onLoadMore={() => { void history.loadMoreHistory() @@ -239,6 +229,12 @@ export function HistoryOverlay({ } const styles = StyleSheet.create({ + emptyState: { + ...StyleSheet.absoluteFill, + zIndex: 12, + alignItems: 'center', + justifyContent: 'center', + }, historyError: { position: 'absolute', left: 12, diff --git a/src/screens/main/history/HistoryRideDetail.tsx b/src/screens/main/history/HistoryRideDetail.tsx index 1ba4e4763..0f1f9324f 100644 --- a/src/screens/main/history/HistoryRideDetail.tsx +++ b/src/screens/main/history/HistoryRideDetail.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { ConfirmModal } from '@/components/modals/ConfirmModal' import { TextPromptModal } from '@/components/modals/TextPromptModal' -import { formatRideDate } from '@/modules/history/lib/rideFormat' +import { formatRideDate, formatRideTime } from '@/modules/history/lib/rideFormat' import type { HistorySession } from '@/modules/history/store/historyStore' import { HistoryControls } from '@/screens/main/history/HistoryControls' import { HistoryMapLoading } from '@/screens/main/history/HistoryMapLoading' @@ -48,24 +48,35 @@ export function HistoryRideDetail({ movingStartAtMs={session.movingStartAtMs} movingEndAtMs={session.movingEndAtMs} deviceName={session.deviceName} + navigationTitle={ + openFavorite + ? (openFavorite.name ?? formatRideDate(openFavorite.startMs, openFavorite.endMs)) + : undefined + } + navigationSubtitle={ + openFavorite + ? [formatRideTime(openFavorite.startMs, openFavorite.endMs), openFavorite.boardName] + .filter(Boolean) + .join(' · ') + : undefined + } samples={history.sessionSamples} - canPrevious={!favoriteMode && !trimming && history.canPreviousRide} - canNext={!favoriteMode && !trimming && !!history.nextRide} + canPrevious={ + !trimming && (favoriteMode ? history.canPreviousFavorite : history.canPreviousRide) + } + canNext={!trimming && (favoriteMode ? history.canNextFavorite : history.nextRide != null)} showMedia={favoriteMode} mediaAssets={history.mediaHistory.assets} mediaUnmatched={history.mediaHistory.unmatched} mediaLoading={history.mediaHistory.loading} mediaError={history.mediaHistory.error} onPrevious={() => { - void history.selectPreviousRide() + void (favoriteMode ? history.selectPreviousFavorite() : history.selectPreviousRide()) }} onNext={() => { - void history.selectNextRide() - }} - onOpenList={() => { - if (favoriteMode) void history.hideFavorite() - else history.setHistorySheetVisible(true) + void (favoriteMode ? history.selectNextFavorite() : history.selectNextRide()) }} + onOpenList={() => history.setHistorySheetVisible(true)} onAddMedia={() => void history.mediaHistory.add()} onOpenMedia={history.openMedia} onSeek={history.onSeek} @@ -102,21 +113,13 @@ export function HistoryRideDetail({ favorite={ openFavorite ? { - title: - openFavorite.name ?? formatRideDate(openFavorite.startMs, openFavorite.endMs), onRename: () => setRenameVisible(true), onDelete: () => setDeleteVisible(true), } : undefined } onSelectTab={history.selectHistoryTab} - onBack={ - favoriteMode - ? () => { - void history.hideFavorite() - } - : history.exitHistory - } + onBack={history.exitHistory} onRemove={onRemoveSession} onToggleFavorite={history.beginTrimFavorite} onCancelTrim={history.cancelTrim} diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 3fd97b7fe..3375406cd 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -35,6 +35,8 @@ interface HistoryTelemetryPanelProps { movingStartAtMs: number | null movingEndAtMs: number | null deviceName: string + navigationTitle?: string + navigationSubtitle?: string samples: TelemetrySample[] canPrevious: boolean canNext: boolean @@ -63,6 +65,8 @@ export function HistoryTelemetryPanel({ movingStartAtMs, movingEndAtMs, deviceName, + navigationTitle, + navigationSubtitle, samples, canPrevious, canNext, @@ -155,6 +159,8 @@ export function HistoryTelemetryPanel({ titleStartMs={titleStartMs} titleEndMs={titleEndMs} deviceName={deviceName} + title={navigationTitle} + subtitle={navigationSubtitle} canPrevious={canPrevious} canNext={canNext} showMedia={showMedia} diff --git a/src/screens/main/history/useHistoryFavorites.ts b/src/screens/main/history/useHistoryFavorites.ts index 6c6c6a961..70ad06aba 100644 --- a/src/screens/main/history/useHistoryFavorites.ts +++ b/src/screens/main/history/useHistoryFavorites.ts @@ -3,15 +3,28 @@ import { useShallow } from 'zustand/react/shallow' import { favoriteRangeForSession, + favoriteSessionId, favoriteToSession, findSessionFavorite, } from '@/modules/history/lib/favorites' import { useFavoriteStore, type Favorite } from '@/modules/history/store/favoriteStore' -import { useHistoryStore, type HistorySession } from '@/modules/history/store/historyStore' +import { + useHistoryStore, + type HistorySession, + type TelemetryMinuteBucket, +} from '@/modules/history/store/historyStore' import { useMainScreenStore, type HistoryTab } from '@/screens/main/mainScreenStore' +import { + getLatestSession, + getNextRideSession, + getPreviousRideSession, +} from '@/screens/main/mainState' /** Favorites-tab and trim workflow kept outside the already-busy main screen coordinator. */ -export function useHistoryFavorites(selectedSession: HistorySession | null) { +export function useHistoryFavorites( + selectedSession: HistorySession | null, + blocks: TelemetryMinuteBucket[], +) { const trimming = useMainScreenStore((state) => state.trimRange != null) const historyTab = useMainScreenStore((state) => state.historyTab) const openFavoriteId = useMainScreenStore((state) => state.openFavoriteId) @@ -49,17 +62,49 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { [favorites, selectedSession], ) + const favoriteSessions = useMemo( + () => favorites.map((favorite) => favoriteToSession(favorite, blocks)), + [blocks, favorites], + ) + const openFavorite = useMemo( () => favorites.find((favorite) => favorite.id === openFavoriteId) ?? null, [favorites, openFavoriteId], ) + const selectFavorite = useCallback(async (favorite: Favorite) => { + useMainScreenStore.getState().openFavorite(favorite.id) + await useHistoryStore + .getState() + .selectSession(favoriteToSession(favorite, useHistoryStore.getState().blocks)) + }, []) + const selectHistoryTab = useCallback( (tab: HistoryTab) => { + if (tab === useMainScreenStore.getState().historyTab) return + + if (tab === 'history') { + setHistoryTab(tab) + const session = + historySessionBeforeFavorite.current ?? + getLatestSession(useHistoryStore.getState().sessions) + historySessionBeforeFavorite.current = null + void useHistoryStore.getState().selectSession(session) + return + } + + historySessionBeforeFavorite.current = useHistoryStore.getState().selectedSession setHistoryTab(tab) - if (tab === 'favorites') void loadFavorites() + const cachedLatest = useFavoriteStore.getState().favorites[0] + if (cachedLatest) void selectFavorite(cachedLatest) + void loadFavorites().then(() => { + if (useMainScreenStore.getState().historyTab !== 'favorites') return + const latest = useFavoriteStore.getState().favorites[0] + if (latest) void selectFavorite(latest) + else void useHistoryStore.getState().selectSession(null) + }) }, - [loadFavorites, setHistoryTab], + [loadFavorites, selectFavorite, setHistoryTab], ) const beginTrimFavorite = useCallback(() => { @@ -90,24 +135,26 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { if (favorite) useMainScreenStore.getState().endTrim() }, [addFavorite]) - /** - * Favorite detail is the history detail path fed a favorite-backed session, so the chart, the map - * route and the stats all come from the pinned range with no parallel implementation. - */ - const showFavorite = useCallback(async (favorite: Favorite) => { - historySessionBeforeFavorite.current = useHistoryStore.getState().selectedSession - useMainScreenStore.getState().openFavorite(favorite.id) - await useHistoryStore + const selectPreviousFavorite = useCallback(async () => { + const previous = getPreviousRideSession( + favoriteSessions, + useHistoryStore.getState().selectedSession, + ) + if (!previous) return + const favorite = useFavoriteStore .getState() - .selectSession(favoriteToSession(favorite, useHistoryStore.getState().blocks)) - }, []) - - const hideFavorite = useCallback(async () => { - const historySession = historySessionBeforeFavorite.current - historySessionBeforeFavorite.current = null - useMainScreenStore.getState().closeFavorite() - await useHistoryStore.getState().selectSession(historySession) - }, []) + .favorites.find((item) => previous.id === favoriteSessionId(item.id)) + if (favorite) await selectFavorite(favorite) + }, [favoriteSessions, selectFavorite]) + + const selectNextFavorite = useCallback(async () => { + const next = getNextRideSession(favoriteSessions, useHistoryStore.getState().selectedSession) + if (!next) return + const favorite = useFavoriteStore + .getState() + .favorites.find((item) => next.id === favoriteSessionId(item.id)) + if (favorite) await selectFavorite(favorite) + }, [favoriteSessions, selectFavorite]) const renameOpenFavorite = useCallback( async (name: string | null) => { @@ -125,16 +172,23 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { [renameFavorite], ) - /** Unpinning the open Favorite leaves nothing to show: fall back to the Favorites list. */ const removeOpenFavorite = useCallback(async () => { const id = useMainScreenStore.getState().openFavoriteId if (!id) return + const removedIndex = useFavoriteStore.getState().favorites.findIndex((item) => item.id === id) await removeFavorite(id) if (useFavoriteStore.getState().error) return - await hideFavorite() - }, [hideFavorite, removeFavorite]) + const remaining = useFavoriteStore.getState().favorites + const replacement = remaining[Math.min(Math.max(removedIndex, 0), remaining.length - 1)] + if (replacement) await selectFavorite(replacement) + else { + useMainScreenStore.getState().closeFavorite() + await useHistoryStore.getState().selectSession(null) + } + }, [removeFavorite, selectFavorite]) const resetHistoryFavorites = useCallback(() => { + historySessionBeforeFavorite.current = null setHistoryTab('history') useMainScreenStore.getState().closeFavorite() useMainScreenStore.getState().endTrim() @@ -147,6 +201,7 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { favoritesLoading, favoritesSaving, favoritesError, + favoriteSessions, selectedSessionFavorite, trimming, trimSeed, @@ -155,11 +210,13 @@ export function useHistoryFavorites(selectedSession: HistorySession | null) { cancelTrim, saveTrim, openFavorite, - showFavorite, - hideFavorite, + selectFavorite, + canPreviousFavorite: getPreviousRideSession(favoriteSessions, selectedSession) != null, + canNextFavorite: getNextRideSession(favoriteSessions, selectedSession) != null, + selectPreviousFavorite, + selectNextFavorite, renameOpenFavorite, removeOpenFavorite, - removeFavorite, loadFavorites, resetHistoryFavorites, } diff --git a/src/screens/main/overlays/MainOverlays.tsx b/src/screens/main/overlays/MainOverlays.tsx index 7d90562ae..efdf05619 100644 --- a/src/screens/main/overlays/MainOverlays.tsx +++ b/src/screens/main/overlays/MainOverlays.tsx @@ -112,11 +112,7 @@ export function MainOverlays({ <> diff --git a/src/screens/main/useMainScreenController.ts b/src/screens/main/useMainScreenController.ts index a576149f6..b6ef0e8f5 100644 --- a/src/screens/main/useMainScreenController.ts +++ b/src/screens/main/useMainScreenController.ts @@ -118,7 +118,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) removeSelectedSession: s.removeSelectedSession, })), ) - const historyFavorites = useHistoryFavorites(selectedSession) + const historyFavorites = useHistoryFavorites(selectedSession, blocks) const { mapPoints, selectedMapPointId, @@ -366,10 +366,6 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) useMainScreenStore.getState().endTrim() return true } - if (useMainScreenStore.getState().openFavoriteId) { - void historyFavorites.hideFavorite() - return true - } exitHistory() return true } @@ -397,7 +393,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) return true }) return () => handler.remove() - }, [exitHistory, exitLegalLimitsMode, exitMapFocus, exitWeatherMode, historyFavorites, mode]), + }, [exitHistory, exitLegalLimitsMode, exitMapFocus, exitWeatherMode, mode]), ) return { From 8a5ddd2ebafd90d93a3901a3718d64ced7c0fd61 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 10:52:37 +0200 Subject: [PATCH 15/24] Polish favorites controls --- e2e/flows/history.yaml | 8 ++- src/components/forms/Input.tsx | 16 ++++-- .../history/components/HistoryPanelNav.tsx | 33 ++++++++++-- .../components/HistorySessionSheet.tsx | 21 +------- src/screens/main/history/HistoryControls.tsx | 43 +++++++-------- src/screens/main/history/HistoryOverlay.tsx | 8 ++- .../main/history/HistoryRideDetail.tsx | 21 +++++--- .../main/history/HistoryTelemetryPanel.tsx | 53 +++++++++++-------- .../main/history/useHistoryFavorites.ts | 26 +++++---- 9 files changed, 134 insertions(+), 95 deletions(-) diff --git a/e2e/flows/history.yaml b/e2e/flows/history.yaml index c3437c5df..6eaecfb2d 100644 --- a/e2e/flows/history.yaml +++ b/e2e/flows/history.yaml @@ -73,7 +73,13 @@ appId: ${APP_ID} # Favorites is a filtered History tab: newest Favorite opens immediately and keeps the same nav. - tapOn: id: history-favorite-ride -- assertVisible: Trim favorite +- assertVisible: + id: trim-favorite-name +- assertNotVisible: + id: history-ride-list-button +- tapOn: + id: trim-favorite-name +- inputText: Evening ride - tapOn: id: trim-save - tapOn: diff --git a/src/components/forms/Input.tsx b/src/components/forms/Input.tsx index 85f25684a..e05eeb191 100644 --- a/src/components/forms/Input.tsx +++ b/src/components/forms/Input.tsx @@ -15,10 +15,20 @@ export const inputBase = { fontFamily: theme.font('600'), } -interface InputProps extends TextInputProps {} +type InputProps = TextInputProps -export const Input = forwardRef(function Input({ style, ...props }, ref) { - return +export const Input = forwardRef(function Input( + { style, placeholderTextColor = theme.palette.slate.textMuted, ...props }, + ref, +) { + return ( + + ) }) const styles = StyleSheet.create({ diff --git a/src/modules/history/components/HistoryPanelNav.tsx b/src/modules/history/components/HistoryPanelNav.tsx index 60673c17d..6ed1ed500 100644 --- a/src/modules/history/components/HistoryPanelNav.tsx +++ b/src/modules/history/components/HistoryPanelNav.tsx @@ -1,4 +1,4 @@ -import { CaretDownIcon, CloudArrowUpIcon, ImagesSquareIcon } from 'phosphor-react-native' +import { CaretDownIcon, CloudArrowUpIcon, ImagesSquareIcon, StarIcon } from 'phosphor-react-native' import type { RefObject } from 'react' import { Pressable, StyleSheet, View } from 'react-native' @@ -16,7 +16,9 @@ interface HistoryPanelNavProps { subtitle?: string canPrevious: boolean canNext: boolean - showMedia: boolean + favoriteMode: boolean + favorited: boolean + actionDisabled: boolean mediaCount: number mediaLoading: boolean mediaButtonRef: RefObject @@ -24,6 +26,7 @@ interface HistoryPanelNavProps { onNext: () => void onOpenList: () => void onOpenMediaDrawer: () => void + onToggleFavorite: () => void onOpenShareInfo: () => void } @@ -35,7 +38,9 @@ export function HistoryPanelNav({ subtitle, canPrevious, canNext, - showMedia, + favoriteMode, + favorited, + actionDisabled, mediaCount, mediaLoading, mediaButtonRef, @@ -43,6 +48,7 @@ export function HistoryPanelNav({ onNext, onOpenList, onOpenMediaDrawer, + onToggleFavorite, onOpenShareInfo, }: HistoryPanelNavProps) { const primaryLabel = title ?? formatRideTime(titleStartMs, titleEndMs) @@ -51,7 +57,7 @@ export function HistoryPanelNav({ return ( - {showMedia ? ( + {favoriteMode ? ( <> - + {favoriteMode ? ( + + ) : ( + + )} ) diff --git a/src/modules/history/components/HistorySessionSheet.tsx b/src/modules/history/components/HistorySessionSheet.tsx index afe76e54d..77dd918ce 100644 --- a/src/modules/history/components/HistorySessionSheet.tsx +++ b/src/modules/history/components/HistorySessionSheet.tsx @@ -10,15 +10,13 @@ import { useWindowDimensions, } from 'react-native' import { Text } from '@/components/base/Text' -import { CaretRightIcon, LockKeyIcon } from 'phosphor-react-native' +import { CaretRightIcon } from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Canvas, Circle, Path, Skia } from '@shopify/react-native-skia' import { interaction, theme } from '@/constants/theme' import { telemetry } from '@/modules/board/constants/telemetry' -import { sessionContainsFavorite } from '@/modules/history/lib/favorites' import { rideDurationMs } from '@/modules/history/lib/sessions' -import type { Favorite } from '@/modules/history/store/favoriteStore' import type { HistorySession, TelemetryMinuteBucket } from '@/modules/history/store/historyStore' interface HistorySessionSheetProps { @@ -26,7 +24,6 @@ interface HistorySessionSheetProps { bottomOffset: number blocks: TelemetryMinuteBucket[] sessions: HistorySession[] - favorites: Favorite[] selectedSessionId: string | null hasMore: boolean loadingMore: boolean @@ -46,7 +43,6 @@ export function HistorySessionSheet({ bottomOffset, blocks, sessions, - favorites, selectedSessionId, hasMore, loadingMore, @@ -113,7 +109,6 @@ export function HistorySessionSheet({ sessions.map((session) => { const selected = session.id === selectedSessionId const routePoints = getSessionRoutePreviewPoints(blocks, session) - const containsFavorite = sessionContainsFavorite(favorites, session) return ( - {containsFavorite && ( - - - - )} ) @@ -337,11 +323,6 @@ const styles = StyleSheet.create({ color: theme.palette.slate.textMuted, fontSize: 11, }, - protectedMarker: { - width: 20, - alignItems: 'center', - justifyContent: 'center', - }, routePreview: { width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, diff --git a/src/screens/main/history/HistoryControls.tsx b/src/screens/main/history/HistoryControls.tsx index 89a4be64b..d65feae1e 100644 --- a/src/screens/main/history/HistoryControls.tsx +++ b/src/screens/main/history/HistoryControls.tsx @@ -11,8 +11,8 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IconButton } from '@/components/base/IconButton' -import { Text } from '@/components/base/Text' import { PillSelector, PillSelectorItem } from '@/components/controls/PillSelector' +import { Input } from '@/components/forms/Input' import { theme } from '@/constants/theme' import type { HistoryTab } from '@/screens/main/mainScreenStore' @@ -20,9 +20,6 @@ interface HistoryControlsProps { loading: boolean tab: HistoryTab canRemove: boolean - /** Star is offered only for an open ride; filled once that ride is already favorited. */ - canFavorite: boolean - favorited: boolean /** Trim mode swaps tabs/star/trash for a cancel/save pair over the range being pinned. */ trimming: boolean /** @@ -33,10 +30,11 @@ interface HistoryControlsProps { onDelete: () => void } saving: boolean + trimName: string + onTrimNameChange: (name: string) => void onSelectTab: (tab: HistoryTab) => void onBack: () => void onRemove: () => void - onToggleFavorite: () => void onCancelTrim: () => void onSaveTrim: () => void } @@ -45,15 +43,14 @@ export function HistoryControls({ loading, tab, canRemove, - canFavorite, - favorited, trimming, favorite, saving, + trimName, + onTrimNameChange, onSelectTab, onBack, onRemove, - onToggleFavorite, onCancelTrim, onSaveTrim, }: HistoryControlsProps) { @@ -65,9 +62,16 @@ export function HistoryControls({ - - Trim favorite - + - ) : canFavorite ? ( - ) : null} {!favorite && canRemove ? ( @@ -195,9 +191,10 @@ const styles = StyleSheet.create({ flex: 1, alignItems: 'center', }, - headerTitle: { - color: theme.palette.slate.textPrimary, - fontSize: 14, - fontWeight: '800', + nameInput: { + width: '100%', + height: 38, + paddingVertical: 0, + textAlign: 'center', }, }) diff --git a/src/screens/main/history/HistoryOverlay.tsx b/src/screens/main/history/HistoryOverlay.tsx index 66bacce33..f48c5ae4c 100644 --- a/src/screens/main/history/HistoryOverlay.tsx +++ b/src/screens/main/history/HistoryOverlay.tsx @@ -52,7 +52,7 @@ export interface MainHistoryOverlayProps { beginTrimFavorite: () => void updateTrimRange: (startMs: number, endMs: number) => void cancelTrim: () => void - saveTrim: () => Promise + saveTrim: (name: string) => Promise favoriteSessions: HistorySession[] canPreviousFavorite: boolean canNextFavorite: boolean @@ -151,14 +151,13 @@ export function HistoryOverlay({ loading={busy} tab={history.historyTab} canRemove={false} - canFavorite={false} - favorited={false} trimming={false} saving={false} + trimName="" + onTrimNameChange={() => undefined} onSelectTab={history.selectHistoryTab} onBack={history.exitHistory} onRemove={() => undefined} - onToggleFavorite={() => undefined} onCancelTrim={() => undefined} onSaveTrim={() => undefined} /> @@ -170,7 +169,6 @@ export function HistoryOverlay({ bottomOffset={sheetBottom} blocks={history.blocks} sessions={favoriteMode ? history.favoriteSessions : history.sessions} - favorites={history.favorites} selectedSessionId={history.selectedSession?.id ?? null} hasMore={!favoriteMode && history.historyHasMore} loadingMore={history.historyLoading} diff --git a/src/screens/main/history/HistoryRideDetail.tsx b/src/screens/main/history/HistoryRideDetail.tsx index 0f1f9324f..57dfdd238 100644 --- a/src/screens/main/history/HistoryRideDetail.tsx +++ b/src/screens/main/history/HistoryRideDetail.tsx @@ -36,6 +36,7 @@ export function HistoryRideDetail({ }: HistoryRideDetailProps) { const [renameVisible, setRenameVisible] = useState(false) const [deleteVisible, setDeleteVisible] = useState(false) + const [trimName, setTrimName] = useState('') const openFavorite = favoriteMode ? history.openFavorite : null const trimming = !favoriteMode && history.trimming @@ -65,7 +66,9 @@ export function HistoryRideDetail({ !trimming && (favoriteMode ? history.canPreviousFavorite : history.canPreviousRide) } canNext={!trimming && (favoriteMode ? history.canNextFavorite : history.nextRide != null)} - showMedia={favoriteMode} + favoriteMode={favoriteMode} + favorited={history.selectedSessionFavorite != null} + actionDisabled={busy || history.favoritesSaving} mediaAssets={history.mediaHistory.assets} mediaUnmatched={history.mediaHistory.unmatched} mediaLoading={history.mediaHistory.loading} @@ -79,6 +82,10 @@ export function HistoryRideDetail({ onOpenList={() => history.setHistorySheetVisible(true)} onAddMedia={() => void history.mediaHistory.add()} onOpenMedia={history.openMedia} + onToggleFavorite={() => { + setTrimName('') + history.beginTrimFavorite() + }} onSeek={history.onSeek} onMetricInteraction={history.setActiveHistoryMapMetric} onHeightChange={onPanelHeightChange} @@ -106,10 +113,10 @@ export function HistoryRideDetail({ loading={busy} tab={history.historyTab} canRemove={!favoriteMode} - canFavorite={!favoriteMode} - favorited={history.selectedSessionFavorite != null} trimming={trimming} saving={history.favoritesSaving} + trimName={trimName} + onTrimNameChange={setTrimName} favorite={ openFavorite ? { @@ -121,10 +128,12 @@ export function HistoryRideDetail({ onSelectTab={history.selectHistoryTab} onBack={history.exitHistory} onRemove={onRemoveSession} - onToggleFavorite={history.beginTrimFavorite} - onCancelTrim={history.cancelTrim} + onCancelTrim={() => { + setTrimName('') + history.cancelTrim() + }} onSaveTrim={() => { - void history.saveTrim() + void history.saveTrim(trimName) }} /> diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 3375406cd..1a9af0cf7 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -40,7 +40,9 @@ interface HistoryTelemetryPanelProps { samples: TelemetrySample[] canPrevious: boolean canNext: boolean - showMedia: boolean + favoriteMode: boolean + favorited: boolean + actionDisabled: boolean mediaAssets: MediaHistoryAsset[] mediaUnmatched: MediaAssetInput[] mediaLoading: boolean @@ -50,6 +52,7 @@ interface HistoryTelemetryPanelProps { onOpenList: () => void onAddMedia: () => void onOpenMedia: (asset: MediaAssetInput) => void + onToggleFavorite: () => void onSeek?: (timeMs: number) => void onMetricInteraction?: (metric: HistoryMetricKey) => void onHeightChange?: (height: number) => void @@ -70,7 +73,9 @@ export function HistoryTelemetryPanel({ samples, canPrevious, canNext, - showMedia, + favoriteMode, + favorited, + actionDisabled, mediaAssets, mediaUnmatched, mediaLoading, @@ -80,6 +85,7 @@ export function HistoryTelemetryPanel({ onOpenList, onAddMedia, onOpenMedia, + onToggleFavorite, onSeek, onMetricInteraction, onHeightChange, @@ -155,24 +161,29 @@ export function HistoryTelemetryPanel({ style={[styles.panel, { bottom: bottomInset }]} onLayout={(e) => onHeightChange?.(e.nativeEvent.layout.height)} > - setMediaDrawerVisible(true)} - onOpenShareInfo={() => setShareInfoVisible(true)} - /> + {!trim ? ( + setMediaDrawerVisible(true)} + onToggleFavorite={onToggleFavorite} + onOpenShareInfo={() => setShareInfoVisible(true)} + /> + ) : null} {hasChartData && headPoint && optionalChartConfig && headSample != null && ( <> )} - {showMedia ? ( + {favoriteMode ? ( { - const range = useMainScreenStore.getState().trimRange - const session = useHistoryStore.getState().selectedSession - if (!range || !session) return - const favorite = await addFavorite({ - startMs: Math.min(range.startMs, range.endMs), - endMs: Math.max(range.startMs, range.endMs), - ...(session.deviceId ? { deviceId: session.deviceId } : {}), - }) - if (favorite) useMainScreenStore.getState().endTrim() - }, [addFavorite]) + const saveTrim = useCallback( + async (name: string) => { + const range = useMainScreenStore.getState().trimRange + const session = useHistoryStore.getState().selectedSession + if (!range || !session) return + const favorite = await addFavorite({ + startMs: Math.min(range.startMs, range.endMs), + endMs: Math.max(range.startMs, range.endMs), + ...(session.deviceId ? { deviceId: session.deviceId } : {}), + ...(name.trim() ? { name: name.trim() } : {}), + }) + if (favorite) useMainScreenStore.getState().endTrim() + }, + [addFavorite], + ) const selectPreviousFavorite = useCallback(async () => { const previous = getPreviousRideSession( From 094317b9b2da2d062f79017471e8665930986eee Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 11:14:44 +0200 Subject: [PATCH 16/24] Improve favorite trim --- e2e/flows/history.yaml | 4 +- src/app/settings/components/charts.tsx | 4 +- src/components/charts/TelemetryChartTrim.tsx | 234 +++++++++++++----- .../charts/telemetryChartTrimMath.test.ts | 49 ++++ .../charts/telemetryChartTrimMath.ts | 36 +++ src/modules/history/lib/favorites.test.ts | 10 +- src/modules/history/lib/favorites.ts | 14 +- .../main/history/useHistoryFavorites.ts | 13 +- 8 files changed, 283 insertions(+), 81 deletions(-) create mode 100644 src/components/charts/telemetryChartTrimMath.test.ts create mode 100644 src/components/charts/telemetryChartTrimMath.ts diff --git a/e2e/flows/history.yaml b/e2e/flows/history.yaml index 6eaecfb2d..436e5c02e 100644 --- a/e2e/flows/history.yaml +++ b/e2e/flows/history.yaml @@ -82,12 +82,12 @@ appId: ${APP_ID} - inputText: Evening ride - tapOn: id: trim-save -- tapOn: - id: history-tab-favorites +# Saving opens the new Favorite immediately. - extendedWaitUntil: visible: id: favorite-rename timeout: 5000 +- assertVisible: Evening ride - tapOn: id: history-ride-list-button - extendedWaitUntil: diff --git a/src/app/settings/components/charts.tsx b/src/app/settings/components/charts.tsx index 7386ff622..dd197be24 100644 --- a/src/app/settings/components/charts.tsx +++ b/src/app/settings/components/charts.tsx @@ -330,7 +330,7 @@ function TrimChartShowcase() { const domainEndMs = points.at(-1)?.date.getTime() ?? 0 const span = domainEndMs - domainStartMs const seed = useMemo( - () => ({ startMs: domainStartMs + span * 0.2, endMs: domainStartMs + span * 0.8 }), + () => ({ startMs: domainStartMs + span * 0.15, endMs: domainStartMs + span * 0.85 }), [domainStartMs, span], ) const [range, setRange] = useState(seed) @@ -341,7 +341,7 @@ function TrimChartShowcase() { return ( (shared: SharedValue, value: T) { shared.value = value @@ -31,6 +41,8 @@ function createTrimGesture({ trimStartMs, trimEndMs, activeHandle, + dragOriginMs, + beginDrag, notifyTrim, commitTrim, }: { @@ -40,7 +52,9 @@ function createTrimGesture({ domainEndMs: number trimStartMs: SharedValue trimEndMs: SharedValue - activeHandle: SharedValue<0 | 1 | null> + activeHandle: SharedValue + dragOriginMs: SharedValue + beginDrag: () => void notifyTrim: (startMs: number, endMs: number) => void commitTrim: (startMs: number, endMs: number) => void }) { @@ -51,18 +65,33 @@ function createTrimGesture({ 'worklet' const xStart = (chartWidth * (trimStartMs.value - domainStartMs)) / span const xEnd = (chartWidth * (trimEndMs.value - domainStartMs)) / span - activeHandle.value = Math.abs(event.x - xStart) <= Math.abs(event.x - xEnd) ? 0 : 1 + const handle = pickTrimHandle(event.x, xStart, xEnd) + activeHandle.value = handle + dragOriginMs.value = handle === 0 ? trimStartMs.value : trimEndMs.value + runOnJS(beginDrag)() }) .onUpdate((event) => { 'worklet' - const clampedX = Math.max(0, Math.min(chartWidth, event.x)) - let ms = domainStartMs + (clampedX / chartWidth) * span if (activeHandle.value === 0) { - if (ms > trimEndMs.value) ms = trimEndMs.value - trimStartMs.value = ms + trimStartMs.value = moveTrimHandle({ + handle: 0, + originMs: dragOriginMs.value, + translationX: event.translationX, + chartWidth, + domainStartMs, + domainEndMs, + oppositeMs: trimEndMs.value, + }) } else if (activeHandle.value === 1) { - if (ms < trimStartMs.value) ms = trimStartMs.value - trimEndMs.value = ms + trimEndMs.value = moveTrimHandle({ + handle: 1, + originMs: dragOriginMs.value, + translationX: event.translationX, + chartWidth, + domainStartMs, + domainEndMs, + oppositeMs: trimStartMs.value, + }) } runOnJS(notifyTrim)(trimStartMs.value, trimEndMs.value) }) @@ -91,7 +120,12 @@ export function useChartTrim({ const lastNotifyAtRef = useRef(0) const startMs = useSharedValue(trim?.startMs ?? 0) const endMs = useSharedValue(trim?.endMs ?? 0) - const activeHandle = useSharedValue<0 | 1 | null>(null) + const activeHandle = useSharedValue(null) + const dragOriginMs = useSharedValue(0) + const trimWasActiveRef = useRef(false) + const draggingRef = useRef(false) + const trimStartMs = trim?.startMs + const trimEndMs = trim?.endMs useEffect(() => { onChangeRef.current = trim?.onChange @@ -99,11 +133,29 @@ export function useChartTrim({ }) useEffect(() => { - if (!trim) return - setSharedValue(startMs, trim.startMs) - setSharedValue(endMs, trim.endMs) - }, [endMs, startMs, trim]) + if (trimStartMs == null || trimEndMs == null) { + trimWasActiveRef.current = false + cancelAnimation(startMs) + cancelAnimation(endMs) + return + } + if (!trimWasActiveRef.current && domainEndMs > domainStartMs) { + trimWasActiveRef.current = true + setSharedValue(startMs, domainStartMs) + setSharedValue(endMs, domainEndMs) + startMs.value = withTiming(trimStartMs, { duration: TRIM_HINT_ANIMATION_MS }) + endMs.value = withTiming(trimEndMs, { duration: TRIM_HINT_ANIMATION_MS }) + return + } + // Throttled preview updates arrive behind the UI-thread gesture. Never let one rewind a handle. + if (draggingRef.current) return + setSharedValue(startMs, trimStartMs) + setSharedValue(endMs, trimEndMs) + }, [domainEndMs, domainStartMs, endMs, startMs, trimEndMs, trimStartMs]) + const beginDrag = useCallback(() => { + draggingRef.current = true + }, []) const notifyTrim = useCallback((start: number, end: number) => { const now = Date.now() if (now - lastNotifyAtRef.current < TRIM_NOTIFY_THROTTLE_MS) return @@ -111,6 +163,7 @@ export function useChartTrim({ onChangeRef.current?.(start, end) }, []) const commitTrim = useCallback((start: number, end: number) => { + draggingRef.current = false lastNotifyAtRef.current = 0 onCommitRef.current?.(start, end) }, []) @@ -126,39 +179,62 @@ export function useChartTrim({ trimStartMs: startMs, trimEndMs: endMs, activeHandle, + dragOriginMs, + beginDrag, notifyTrim, commitTrim, }), [ activeHandle, + beginDrag, chartWidth, commitTrim, domainEndMs, domainStartMs, + dragOriginMs, enabled, endMs, notifyTrim, startMs, ], ) - const positionFor = (value: number) => { - 'worklet' - const span = domainEndMs - domainStartMs - const x = span > 0 ? (chartWidth * (value - domainStartMs)) / span : 0 - return Math.max(0, Math.min(chartWidth, x)) - } - const startXStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: positionFor(startMs.value) }], - })) - const endXStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: positionFor(endMs.value) }], - })) - const dimLeftStyle = useAnimatedStyle(() => ({ width: positionFor(startMs.value) })) - const dimRightStyle = useAnimatedStyle(() => ({ - width: chartWidth - positionFor(endMs.value), - })) + const positionFor = useCallback( + (value: number) => { + 'worklet' + const span = domainEndMs - domainStartMs + const x = span > 0 ? (chartWidth * (value - domainStartMs)) / span : 0 + return Math.max(0, Math.min(chartWidth, x)) + }, + [chartWidth, domainEndMs, domainStartMs], + ) + const startX = useDerivedValue(() => positionFor(startMs.value)) + const endX = useDerivedValue(() => positionFor(endMs.value)) + const midpointX = useDerivedValue(() => startX.value + (endX.value - startX.value) / 2) + const leftSelectionWidth = useDerivedValue(() => midpointX.value - startX.value) + const rightSelectionWidth = useDerivedValue(() => endX.value - midpointX.value) + const rightDimWidth = useDerivedValue(() => chartWidth - endX.value) + const startHandleX = useDerivedValue(() => startX.value - HANDLE_WIDTH / 2) + const endHandleX = useDerivedValue(() => endX.value - HANDLE_WIDTH / 2) + const leftGradientStart = useDerivedValue(() => vec(startX.value, 0)) + const leftGradientEnd = useDerivedValue(() => vec(midpointX.value, 0)) + const rightGradientStart = useDerivedValue(() => vec(midpointX.value, 0)) + const rightGradientEnd = useDerivedValue(() => vec(endX.value, 0)) - return { gesture, startXStyle, endXStyle, dimLeftStyle, dimRightStyle } + return { + gesture, + startX, + endX, + midpointX, + leftSelectionWidth, + rightSelectionWidth, + rightDimWidth, + startHandleX, + endHandleX, + leftGradientStart, + leftGradientEnd, + rightGradientStart, + rightGradientEnd, + } } interface TelemetryChartTrimOverlayProps { @@ -174,14 +250,62 @@ export function TelemetryChartTrimOverlay({ }: TelemetryChartTrimOverlayProps) { return ( - - - - - - - - + + + + + + + + + + + + ) } @@ -193,34 +317,8 @@ const styles = StyleSheet.create({ left: 0, right: 0, }, - dim: { - position: 'absolute', - top: 0, - bottom: 0, - backgroundColor: theme.alpha(theme.palette.slate.bg, 0.6), - }, - dimLeft: { - left: 0, - }, - dimRight: { - right: 0, - }, - handle: { + canvas: { position: 'absolute', - top: 0, - bottom: 0, - width: 2, - marginLeft: -1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.palette.amber.color, - }, - handleKnob: { - width: 12, - height: 20, - borderRadius: 6, - backgroundColor: theme.palette.amber.color, - borderWidth: 1, - borderColor: theme.palette.slate.surfaceDeep, + inset: 0, }, }) diff --git a/src/components/charts/telemetryChartTrimMath.test.ts b/src/components/charts/telemetryChartTrimMath.test.ts new file mode 100644 index 000000000..51ac0d5b0 --- /dev/null +++ b/src/components/charts/telemetryChartTrimMath.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from 'bun:test' + +import { moveTrimHandle, pickTrimHandle } from '@/components/charts/telemetryChartTrimMath' + +test('selected range is split into equal left and right drag targets', () => { + expect(pickTrimHandle(39, 20, 60)).toBe(0) + expect(pickTrimHandle(40, 20, 60)).toBe(0) + expect(pickTrimHandle(41, 20, 60)).toBe(1) +}) + +test('trim movement is relative to the grabbed handle instead of snapping to the touch', () => { + expect( + moveTrimHandle({ + handle: 0, + originMs: 200, + translationX: 10, + chartWidth: 100, + domainStartMs: 0, + domainEndMs: 1_000, + oppositeMs: 800, + }), + ).toBe(300) +}) + +test('trim handles stop at the chart edges and each other', () => { + const shared = { + chartWidth: 100, + domainStartMs: 0, + domainEndMs: 1_000, + } + expect( + moveTrimHandle({ + ...shared, + handle: 0, + originMs: 200, + translationX: -100, + oppositeMs: 800, + }), + ).toBe(0) + expect( + moveTrimHandle({ + ...shared, + handle: 1, + originMs: 800, + translationX: -100, + oppositeMs: 200, + }), + ).toBe(200) +}) diff --git a/src/components/charts/telemetryChartTrimMath.ts b/src/components/charts/telemetryChartTrimMath.ts new file mode 100644 index 000000000..2439bad39 --- /dev/null +++ b/src/components/charts/telemetryChartTrimMath.ts @@ -0,0 +1,36 @@ +export type TrimHandle = 0 | 1 + +/** The selected range is two generous drag targets split at its midpoint. */ +export function pickTrimHandle(touchX: number, startX: number, endX: number): TrimHandle { + 'worklet' + return touchX <= startX + (endX - startX) / 2 ? 0 : 1 +} + +/** + * Move one trim edge by gesture translation rather than absolute touch position, so grabbing + * anywhere in that edge's half of the selection never snaps the handle under the finger. + */ +export function moveTrimHandle({ + handle, + originMs, + translationX, + chartWidth, + domainStartMs, + domainEndMs, + oppositeMs, +}: { + handle: TrimHandle + originMs: number + translationX: number + chartWidth: number + domainStartMs: number + domainEndMs: number + oppositeMs: number +}): number { + 'worklet' + const span = domainEndMs - domainStartMs + const translatedMs = originMs + (translationX / chartWidth) * span + return handle === 0 + ? Math.max(domainStartMs, Math.min(oppositeMs, translatedMs)) + : Math.min(domainEndMs, Math.max(oppositeMs, translatedMs)) +} diff --git a/src/modules/history/lib/favorites.test.ts b/src/modules/history/lib/favorites.test.ts index 87c51011f..78f072d60 100644 --- a/src/modules/history/lib/favorites.test.ts +++ b/src/modules/history/lib/favorites.test.ts @@ -6,6 +6,7 @@ import { favoriteRangeForSession, favoriteToSession, findSessionFavorite, + initialFavoriteTrimRangeForSession, sessionContainsFavorite, } from '@/modules/history/lib/favorites' @@ -37,7 +38,7 @@ function favorite(overrides: Partial): Favorite { } } -test('star pins the full Moving Window, not the idle-padded ride span', () => { +test('canonical ride range uses the Moving Window, not the idle-padded span', () => { expect(favoriteRangeForSession(session)).toEqual({ startMs: 1_100_000, endMs: 1_500_000 }) }) @@ -47,6 +48,13 @@ test('legacy rides without a Moving Window fall back to their wall-clock span', ).toEqual({ startMs: 1_000_000, endMs: 1_600_000 }) }) +test('new trim handles start 15% inside each ride edge', () => { + expect(initialFavoriteTrimRangeForSession(session)).toEqual({ + startMs: 1_160_000, + endMs: 1_440_000, + }) +}) + test('a ride counts as favorited only when a favorite covers its exact Moving Window', () => { expect(findSessionFavorite([favorite({})], session)?.id).toBe('fav-1') expect(findSessionFavorite([favorite({ endMs: 1_400_000 })], session)).toBeNull() diff --git a/src/modules/history/lib/favorites.ts b/src/modules/history/lib/favorites.ts index 69172cd9e..31307278f 100644 --- a/src/modules/history/lib/favorites.ts +++ b/src/modules/history/lib/favorites.ts @@ -2,10 +2,7 @@ import type { Favorite, TelemetryMinuteBucket } from 'vescape-core' import { rideMovingWindow, type HistorySession } from '@/modules/history/lib/sessions' -/** - * The range a star on an open ride pins: the full Moving Window, so favoriting a whole ride is one - * tap. Rides with no precomputed window (legacy data) fall back to their wall-clock span. - */ +/** The canonical full-ride range. Legacy rides fall back from Moving Window to wall-clock span. */ export function favoriteRangeForSession( session: Pick, ): { startMs: number; endMs: number } { @@ -13,6 +10,15 @@ export function favoriteRangeForSession( return window ?? { startMs: session.startAtMs, endMs: session.endAtMs } } +/** Seed trim handles visibly inside the ride so their draggable direction is obvious. */ +export function initialFavoriteTrimRangeForSession( + session: Pick, +): { startMs: number; endMs: number } { + const range = favoriteRangeForSession(session) + const inset = (range.endMs - range.startMs) * 0.15 + return { startMs: range.startMs + inset, endMs: range.endMs - inset } +} + /** * The Favorite already covering this ride's Moving Window, so the star reads as filled. Matched on * the range alone: only one Board Session records at a time, so a range never spans two boards, and diff --git a/src/screens/main/history/useHistoryFavorites.ts b/src/screens/main/history/useHistoryFavorites.ts index 518712541..b21ca40e7 100644 --- a/src/screens/main/history/useHistoryFavorites.ts +++ b/src/screens/main/history/useHistoryFavorites.ts @@ -2,10 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { - favoriteRangeForSession, favoriteSessionId, favoriteToSession, findSessionFavorite, + initialFavoriteTrimRangeForSession, } from '@/modules/history/lib/favorites' import { useFavoriteStore, type Favorite } from '@/modules/history/store/favoriteStore' import { @@ -110,7 +110,7 @@ export function useHistoryFavorites( const beginTrimFavorite = useCallback(() => { const session = useHistoryStore.getState().selectedSession if (!session) return - const range = favoriteRangeForSession(session) + const range = initialFavoriteTrimRangeForSession(session) setTrimSeed(range) useMainScreenStore.getState().beginTrim(range) }, []) @@ -134,9 +134,14 @@ export function useHistoryFavorites( ...(session.deviceId ? { deviceId: session.deviceId } : {}), ...(name.trim() ? { name: name.trim() } : {}), }) - if (favorite) useMainScreenStore.getState().endTrim() + if (!favorite) return + + historySessionBeforeFavorite.current = session + useMainScreenStore.getState().endTrim() + setHistoryTab('favorites') + await selectFavorite(favorite) }, - [addFavorite], + [addFavorite, selectFavorite, setHistoryTab], ) const selectPreviousFavorite = useCallback(async () => { From 6668bb985bfac9dd32ea76986d70be497b081167 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 12:03:59 +0200 Subject: [PATCH 17/24] Improve favorites --- .../0029-favorites-pin-telemetry-ranges.md | 3 +- e2e/flows/history.yaml | 17 +++- .../modules/vescapecore/VescapeCoreModule.kt | 6 +- .../vescapecore/telemetry/TelemetryDao.kt | 6 +- .../telemetry/TelemetryEntities.kt | 4 +- .../telemetry/TelemetryRepository.kt | 39 ++++++-- .../vescape-core/ios/VescapeCoreModule.swift | 6 +- .../ios/telemetry/FavoriteStore.swift | 32 +++++-- .../ios/telemetry/FavoriteStoreTests.swift | 77 ++++++++++++---- .../ios/telemetry/TelemetryRepository.swift | 46 +++++++--- modules/vescape-core/src/index.ts | 25 +++-- .../history/components/HistoryPanelNav.tsx | 25 +---- .../history/components/HistoryRideLabel.tsx | 70 ++++++++++++++ .../components/HistorySessionSheet.tsx | 72 +++++++-------- src/modules/history/lib/favorites.test.ts | 4 +- src/modules/history/lib/favorites.ts | 4 +- src/modules/history/lib/rideFormat.test.ts | 27 ++++++ src/modules/history/lib/rideFormat.ts | 30 ++++++ .../history/store/favoriteStore.test.ts | 43 ++++++--- src/modules/history/store/favoriteStore.ts | 21 +++-- src/screens/main/MainScreen.tsx | 2 +- src/screens/main/history/HistoryControls.tsx | 6 +- src/screens/main/history/HistoryOverlay.tsx | 5 +- .../main/history/HistoryRideDetail.tsx | 33 ++----- .../main/history/useHistoryFavorites.ts | 91 ++++++++++++++----- src/screens/main/useMainScreenController.ts | 5 +- 26 files changed, 486 insertions(+), 213 deletions(-) create mode 100644 src/modules/history/components/HistoryRideLabel.tsx create mode 100644 src/modules/history/lib/rideFormat.test.ts diff --git a/docs/adr/0029-favorites-pin-telemetry-ranges.md b/docs/adr/0029-favorites-pin-telemetry-ranges.md index 0f57b0012..d934577c0 100644 --- a/docs/adr/0029-favorites-pin-telemetry-ranges.md +++ b/docs/adr/0029-favorites-pin-telemetry-ranges.md @@ -6,10 +6,11 @@ A Favorite is a durable, optionally named time range `[startMs, endMs]` over tel - Favorites live in a native table (`@parity` iOS/Android) so telemetry deletion paths can see them. - A Favorite has a native-minted stable UUID plus native-owned `created_at` and `updated_at`; JS cannot supply them. +- Re-trimming or renaming updates the existing Favorite row in place. Its UUID, `created_at`, Board ownership, and Favorite Media remain stable; native mints a new `updated_at`. - `deleteTelemetryRange` and `clearTelemetryHistory` protect every minute bucket touched by a favorited range. Both the precomputed bucket and all its raw samples stay together; only buckets and telemetry wholly outside those bucket-aligned protected ranges are deleted. Deleting a ride around a Favorite leaves the protected buckets as a short standalone ride. - Rides containing a favorited range are marked in history as not fully deletable. - Removing a Favorite only unpins: its telemetry stays and becomes deletable like any ride. Its Favorite Media is deleted with it. -- Summary stats (mirroring history session summary fields) are computed once from raw samples at creation time and denormalized onto the row (ADR 0005 style); the route preview is derived on read from pinned samples. +- Summary stats (mirroring history session summary fields) are computed from raw samples whenever the range is created or updated and denormalized onto the row (ADR 0005 style); the route preview is derived on read from pinned samples. ## Considered Options diff --git a/e2e/flows/history.yaml b/e2e/flows/history.yaml index 436e5c02e..11f8ad162 100644 --- a/e2e/flows/history.yaml +++ b/e2e/flows/history.yaml @@ -85,9 +85,24 @@ appId: ${APP_ID} # Saving opens the new Favorite immediately. - extendedWaitUntil: visible: - id: favorite-rename + id: favorite-edit timeout: 5000 - assertVisible: Evening ride +- tapOn: + id: favorite-edit +- assertVisible: + id: trim-favorite-name +- tapOn: + id: trim-favorite-name +- eraseText +- inputText: Edited ride +- tapOn: + id: trim-save +- extendedWaitUntil: + visible: + id: favorite-edit + timeout: 5000 +- assertVisible: Edited ride - tapOn: id: history-ride-list-button - extendedWaitUntil: diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 1515ede43..2112e6fef 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -555,9 +555,9 @@ class VescapeCoreModule : Module() { AsyncFunction("createFavorite") Coroutine { options: Map -> TelemetryRepository.get(context.applicationContext).createFavorite(options) } - AsyncFunction("renameFavorite") Coroutine { id: String, name: String? -> - TelemetryRepository.get(context.applicationContext).renameFavorite(id, name) - ?: throw CodedException("ERR_RENAME_FAVORITE", "favorite does not exist or could not be stored", null) + AsyncFunction("updateFavorite") Coroutine { id: String, options: Map -> + TelemetryRepository.get(context.applicationContext).updateFavorite(id, options) + ?: throw CodedException("ERR_UPDATE_FAVORITE", "favorite does not exist or could not be stored", null) } AsyncFunction("deleteFavorite") Coroutine { id: String -> TelemetryRepository.get(context.applicationContext).deleteFavorite(id) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 9148c9896..4e1fb8160 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -548,9 +548,9 @@ interface TelemetryDao { @Query("SELECT * FROM favorites WHERE id = :id") suspend fun getFavorite(id: String): FavoriteEntity? - /** Name only: the range and the denormalized summary of a Favorite are immutable (ADR 0029). */ - @Query("UPDATE favorites SET name = :name, updated_at = :updatedAt WHERE id = :id") - suspend fun renameFavorite(id: String, name: String?, updatedAt: Long): Int + /** Re-trim/rename one row in place so its identity and Favorite Media remain stable. */ + @Update + suspend fun updateFavorite(favorite: FavoriteEntity): Int @Query("DELETE FROM favorites WHERE id = :id") suspend fun deleteFavoriteRow(id: String): Int diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt index de190b0ec..b0eddc4c0 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt @@ -459,8 +459,8 @@ data class BoardWarningEntity( * One Favorite: a durable, optionally named time range over Ride History (ADR 0029). Identity and * timestamps are native-minted — JS may only supply the range and the name. * - * Summary stats are denormalized at creation from raw Telemetry Samples (ADR 0005 style) because - * minute buckets are too coarse for a range that cuts mid-bucket. + * Summary stats are denormalized at creation/update from raw Telemetry Samples (ADR 0005 style) + * because minute buckets are too coarse for a range that cuts mid-bucket. * * @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `Favorite` * @parity /modules/vescape-core/src/index.ts `Favorite` diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index 44e49634f..e88300bcb 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -594,16 +594,39 @@ class TelemetryRepository private constructor(context: Context) { } /** - * Rename a Favorite, or clear its name with an empty/absent value. The range and the summary are - * immutable — re-trimming is delete + recreate (ADR 0029). `updated_at` is minted here. + * Re-trim/rename a Favorite in place. Identity, creation time and Favorite Media stay attached; + * summary stats are rebuilt from raw samples for the new exact range. * - * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `renameFavorite` + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `updateFavorite` */ - suspend fun renameFavorite(id: String, name: String?): Map? = withContext(Dispatchers.IO) { - val trimmed = name?.trim()?.ifEmpty { null } - if (dao.renameFavorite(id, trimmed, System.currentTimeMillis()) == 0) return@withContext null - val favorite = dao.getFavorite(id) ?: return@withContext null - favorite.toMap(dao.getBoards().firstOrNull { it.id == favorite.boardId }?.name) + suspend fun updateFavorite( + id: String, + options: Map, + ): Map? = withContext(Dispatchers.IO) { + val existing = dao.getFavorite(id) ?: return@withContext null + val startMs = options.requiredLong("startMs") + val endMs = options.requiredLong("endMs") + require(endMs >= startMs) { "endMs must be greater than or equal to startMs" } + val deviceId = options["deviceId"] as? String + val name = (options["name"] as? String)?.trim()?.ifEmpty { null } + flushNow() + + val summary = favoriteSummary(getSampleStates(startMs, endMs, deviceId, Int.MAX_VALUE)) + val updated = existing.copy( + name = name, + startMs = startMs, + endMs = endMs, + updatedAt = System.currentTimeMillis(), + sampleCount = summary.sampleCount, + gpsPointCount = summary.gpsPointCount, + distanceCm = summary.distanceCm, + movingDurationMs = summary.movingDurationMs, + avgSpeedCentiKmh = summary.avgSpeedCentiKmh, + maxSpeedCentiKmh = summary.maxSpeedCentiKmh, + batteryUsedWhMilli = summary.batteryUsedWhMilli, + ) + if (dao.updateFavorite(updated) == 0) return@withContext null + updated.toMap(dao.getBoards().firstOrNull { it.id == updated.boardId }?.name) } /** diff --git a/modules/vescape-core/ios/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 68f4b860d..fc59a65d3 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -634,9 +634,9 @@ public class VescapeCoreModule: Module { promise.resolve(favorite) } - AsyncFunction("renameFavorite") { (id: String, name: String?, promise: Promise) in - guard let favorite = TelemetryRepository.shared.renameFavorite(id, name: name) else { - promise.reject("ERR_RENAME_FAVORITE", "favorite does not exist or could not be stored") + AsyncFunction("updateFavorite") { (id: String, options: [String: Any], promise: Promise) in + guard let favorite = TelemetryRepository.shared.updateFavorite(id, options: options) else { + promise.reject("ERR_UPDATE_FAVORITE", "favorite does not exist or could not be stored") return } promise.resolve(favorite) diff --git a/modules/vescape-core/ios/telemetry/FavoriteStore.swift b/modules/vescape-core/ios/telemetry/FavoriteStore.swift index 3eff60038..c8aabfbe8 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStore.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStore.swift @@ -4,8 +4,8 @@ import GRDB /// One Favorite: a durable, optionally named time range over Ride History (ADR 0029). Identity and /// timestamps are native-minted — JS may only supply the range and the name. /// -/// Summary stats are denormalized at creation from raw Telemetry Samples (ADR 0005 style) because -/// minute buckets are too coarse for a range that cuts mid-bucket. +/// Summary stats are denormalized at creation/update from raw Telemetry Samples (ADR 0005 style) +/// because minute buckets are too coarse for a range that cuts mid-bucket. /// /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryEntities.kt `FavoriteEntity` /// @parity /modules/vescape-core/src/index.ts `Favorite` @@ -197,18 +197,32 @@ struct FavoriteStore { } } - /// Rename one Favorite, or clear its name with `nil`. The range and the summary are immutable: - /// changing what a Favorite covers is delete + recreate (ADR 0029). Returns the stored row so the - /// caller never has to guess what native now holds. - func rename(_ id: String, name: String?, updatedAtMs: Int64) -> Favorite? { + /// Re-trim/rename one row in place so identity, creation time and Favorite Media remain stable. + func update(_ favorite: Favorite) -> Favorite? { guard let writer = resolveWriter() else { return nil } let updated = try? writer.write { db -> Favorite? in try db.execute( - sql: "UPDATE favorites SET name = ?, updated_at = ? WHERE id = ?", - arguments: [name, updatedAtMs, id] + sql: """ + UPDATE favorites SET + name = ?, start_ms = ?, end_ms = ?, updated_at = ?, + sample_count = ?, gps_point_count = ?, distance_cm = ?, moving_duration_ms = ?, + avg_speed_centi_kmh = ?, max_speed_centi_kmh = ?, battery_used_wh_milli = ? + WHERE id = ? + """, + arguments: [ + favorite.name, favorite.startMs, favorite.endMs, favorite.updatedAtMs, + favorite.summary.sampleCount, favorite.summary.gpsPointCount, + favorite.summary.distanceCm, favorite.summary.movingDurationMs, + favorite.summary.avgSpeedCentiKmh, favorite.summary.maxSpeedCentiKmh, + favorite.summary.batteryUsedWhMilli, favorite.id, + ] ) guard db.changesCount > 0 else { return nil } - return try Row.fetchOne(db, sql: "SELECT * FROM favorites WHERE id = ?", arguments: [id]) + return try Row.fetchOne( + db, + sql: "SELECT * FROM favorites WHERE id = ?", + arguments: [favorite.id] + ) .map(Self.favorite) } return updated ?? nil diff --git a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift index c0770d50c..95c342179 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -65,9 +65,7 @@ final class FavoriteStoreTests: XCTestCase { XCTAssertEqual(store.list().map(\.id), ["newer", "older"]) } - /// Renaming touches the name and `updated_at` only: the pinned range and the summary a Favorite - /// was created with must survive, because re-trimming is delete + recreate. - func testRenameKeepsRangeAndSummaryAndBumpsUpdatedAt() throws { + func testUpdateKeepsIdentityAndCreationTimeWhileReplacingRangeNameAndSummary() throws { store.insert( makeFavorite( id: "fav-1", @@ -77,30 +75,74 @@ final class FavoriteStoreTests: XCTestCase { summary: FavoriteSummary(sampleCount: 12, movingDurationMs: 55_000) ) ) + try queue.write { db in + try db.execute( + sql: """ + INSERT INTO favorite_media ( + id, favorite_id, captured_at, mime_type, media_kind, byte_count, content_hash, created_at + ) VALUES ('media-1', 'fav-1', 1000, 'image/jpeg', 'photo', 1, '00', 1000) + """ + ) + } - let renamed = try XCTUnwrap(store.rename("fav-1", name: "Dolina single track", updatedAtMs: 1_800_000_000_000)) + let updated = try XCTUnwrap( + store.update( + makeFavorite( + id: "fav-1", + name: "Dolina single track", + startMs: 10_000, + endMs: 50_000, + updatedAtMs: 1_800_000_000_000, + summary: FavoriteSummary(sampleCount: 8, movingDurationMs: 35_000) + ) + ) + ) - XCTAssertEqual(renamed.name, "Dolina single track") - XCTAssertEqual(renamed.startMs, 1_000) - XCTAssertEqual(renamed.endMs, 61_000) - XCTAssertEqual(renamed.summary.sampleCount, 12) - XCTAssertEqual(renamed.summary.movingDurationMs, 55_000) - XCTAssertEqual(renamed.createdAtMs, 1_700_000_000_000) - XCTAssertEqual(renamed.updatedAtMs, 1_800_000_000_000) + XCTAssertEqual(updated.id, "fav-1") + XCTAssertEqual(updated.name, "Dolina single track") + XCTAssertEqual(updated.startMs, 10_000) + XCTAssertEqual(updated.endMs, 50_000) + XCTAssertEqual(updated.summary.sampleCount, 8) + XCTAssertEqual(updated.summary.movingDurationMs, 35_000) + XCTAssertEqual(updated.createdAtMs, 1_700_000_000_000) + XCTAssertEqual(updated.updatedAtMs, 1_800_000_000_000) + let mediaCount = try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM favorite_media WHERE favorite_id = 'fav-1'") + } + XCTAssertEqual(mediaCount, 1) } - /// Naming stays optional after creation too, so clearing a name is a supported rename. - func testRenameToNilClearsTheName() throws { + func testUpdateToNilClearsTheName() throws { store.insert(makeFavorite(id: "fav-1", name: "Dolina", startMs: 1_000, endMs: 2_000)) - let cleared = try XCTUnwrap(store.rename("fav-1", name: nil, updatedAtMs: 1_800_000_000_000)) + let cleared = try XCTUnwrap( + store.update( + makeFavorite( + id: "fav-1", + name: nil, + startMs: 1_000, + endMs: 2_000, + updatedAtMs: 1_800_000_000_000 + ) + ) + ) XCTAssertNil(cleared.name) XCTAssertNil(try XCTUnwrap(store.list().first).name) } - func testRenameOfAnUnknownFavoriteReportsNoRow() { - XCTAssertNil(store.rename("missing", name: "Nope", updatedAtMs: 1_800_000_000_000)) + func testUpdateOfAnUnknownFavoriteReportsNoRow() { + XCTAssertNil( + store.update( + makeFavorite( + id: "missing", + name: "Nope", + startMs: 1_000, + endMs: 2_000, + updatedAtMs: 1_800_000_000_000 + ) + ) + ) } /// Removing a Favorite unpins it and nothing else: only its own row goes away. @@ -223,6 +265,7 @@ final class FavoriteStoreTests: XCTestCase { name: String? = nil, startMs: Int64, endMs: Int64, + updatedAtMs: Int64 = 1_700_000_000_000, summary: FavoriteSummary = FavoriteSummary() ) -> Favorite { Favorite( @@ -232,7 +275,7 @@ final class FavoriteStoreTests: XCTestCase { startMs: startMs, endMs: endMs, createdAtMs: 1_700_000_000_000, - updatedAtMs: 1_700_000_000_000, + updatedAtMs: updatedAtMs, summary: summary ) } diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 39ef447a0..cb8bf9dc7 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -303,18 +303,42 @@ internal final class TelemetryRepository { return names } - /// Rename a Favorite, or clear its name with an empty/absent value. The range and the summary are - /// immutable — re-trimming is delete + recreate (ADR 0029). `updated_at` is minted here. - /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `renameFavorite` - func renameFavorite(_ id: String, name: String?) -> [String: Any?]? { - let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) - let renamed = FavoriteStore.shared.rename( - id, - name: (trimmed?.isEmpty ?? true) ? nil : trimmed, - updatedAtMs: telemetryNowMs() + /// Re-trim/rename a Favorite in place. Identity, creation time and Favorite Media stay attached; + /// summary stats are rebuilt from raw samples for the new exact range. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `updateFavorite` + func updateFavorite(_ id: String, options: [String: Any]) -> [String: Any?]? { + flushBlocking() + guard let existing = FavoriteStore.shared.list().first(where: { $0.id == id }), let pool + else { return nil } + let startMs = telemetryLong(options["startMs"]) ?? 0 + let endMs = telemetryLong(options["endMs"]) ?? 0 + guard endMs >= startMs else { return nil } + let deviceId = options["deviceId"] as? String + let trimmedName = (options["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let config = queue.sync { metricConfig } + let points = (try? pool.read { db in + try Row.fetchAll( + db, + sql: """ + SELECT * FROM telemetry_frames + WHERE captured_at_ms >= ? AND captured_at_ms <= ? AND (? IS NULL OR device_id = ?) + ORDER BY captured_at_ms ASC + """, + arguments: [startMs, endMs, deviceId, deviceId] + ).compactMap(bucketPoint) + }) ?? [] + let updated = Favorite( + id: existing.id, + boardId: existing.boardId, + name: (trimmedName?.isEmpty ?? true) ? nil : trimmedName, + startMs: startMs, + endMs: endMs, + createdAtMs: existing.createdAtMs, + updatedAtMs: telemetryNowMs(), + summary: Self.favoriteSummary(points, config: config) ) - guard let renamed else { return nil } - return renamed.toMap(boardName: renamed.boardId.flatMap { Self.boardNamesById()[$0] }) + guard let stored = FavoriteStore.shared.update(updated) else { return nil } + return stored.toMap(boardName: stored.boardId.flatMap { Self.boardNamesById()[$0] }) } /// Unpin a Favorite. Telemetry in its range stays and becomes normally deletable (ADR 0029). diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 9e8086f3b..a8eab7ebb 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -819,6 +819,17 @@ export interface CreateFavoriteOptions { name?: string } +/** + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `updateFavorite` + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `updateFavorite` + */ +export interface UpdateFavoriteOptions { + startMs: number + endMs: number + deviceId?: string + name: string | null +} + /** * One immutable Favorite Media manifest row. Native owns metadata and canonical storage. * @parity /modules/vescape-core/ios/telemetry/FavoriteMediaStore.swift `FavoriteMedia` @@ -1496,7 +1507,7 @@ type VescapeCoreNativeModule = NativeEventEmitter & { getTelemetrySummary(): Promise getFavorites(): Promise createFavorite(options: CreateFavoriteOptions): Promise - renameFavorite(id: string, name: string | null): Promise + updateFavorite(id: string, options: UpdateFavoriteOptions): Promise deleteFavorite(id: string): Promise getFavoriteMedia(favoriteId: string): Promise importFavoriteMedia(options: ImportFavoriteMediaOptions): Promise @@ -1998,12 +2009,12 @@ export async function createFavorite(options: CreateFavoriteOptions): Promise { - return native.renameFavorite(id, name) +/** Update a Favorite in place, preserving identity and media while native recomputes its summary. */ +export async function updateFavorite( + id: string, + options: UpdateFavoriteOptions, +): Promise { + return native.updateFavorite(id, options) } /** Unpin a Favorite. Its telemetry stays and becomes normally deletable (ADR 0029). */ diff --git a/src/modules/history/components/HistoryPanelNav.tsx b/src/modules/history/components/HistoryPanelNav.tsx index 6ed1ed500..e59707395 100644 --- a/src/modules/history/components/HistoryPanelNav.tsx +++ b/src/modules/history/components/HistoryPanelNav.tsx @@ -6,6 +6,7 @@ import { IconButton } from '@/components/base/IconButton' import { Text } from '@/components/base/Text' import { PrevNextSelector } from '@/components/controls/PrevNextSelector' import { interaction, theme } from '@/constants/theme' +import { HistoryRideLabel } from '@/modules/history/components/HistoryRideLabel' import { formatRideMeta, formatRideTime } from '@/modules/history/lib/rideFormat' interface HistoryPanelNavProps { @@ -90,14 +91,7 @@ export function HistoryPanelNav({ android_ripple={interaction.ripple} onPress={onOpenList} > - - - {primaryLabel} - - - {secondaryLabel} - - + } @@ -180,19 +174,4 @@ const styles = StyleSheet.create({ justifyContent: 'center', gap: 8, }, - titleContent: { - flex: 1, - minWidth: 0, - gap: 1, - }, - titleTime: { - color: theme.palette.slate.textPrimary, - fontSize: 12, - fontWeight: '800', - }, - titleMeta: { - color: theme.palette.slate.textMuted, - fontSize: 9, - fontWeight: '600', - }, }) diff --git a/src/modules/history/components/HistoryRideLabel.tsx b/src/modules/history/components/HistoryRideLabel.tsx new file mode 100644 index 000000000..0575a7a32 --- /dev/null +++ b/src/modules/history/components/HistoryRideLabel.tsx @@ -0,0 +1,70 @@ +import { StyleSheet, View } from 'react-native' + +import { Text } from '@/components/base/Text' +import { theme } from '@/constants/theme' + +interface HistoryRideLabelProps { + title: string + subtitle: string + details?: string + compact?: boolean +} + +/** Shared ride identity hierarchy for the history selector and its session list. */ +export function HistoryRideLabel({ + title, + subtitle, + details, + compact = false, +}: HistoryRideLabelProps) { + return ( + + + {title} + + + {subtitle} + + {details ? ( + + {details} + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + content: { + flex: 1, + minWidth: 0, + gap: 2, + }, + contentCompact: { + gap: 1, + }, + title: { + color: theme.palette.slate.textPrimary, + fontSize: 15, + fontWeight: '700', + }, + titleCompact: { + fontSize: 12, + fontWeight: '800', + }, + subtitle: { + color: theme.palette.slate.textSecondary, + fontSize: 12, + fontWeight: '500', + }, + subtitleCompact: { + color: theme.palette.slate.textMuted, + fontSize: 9, + fontWeight: '600', + }, + details: { + color: theme.palette.slate.textMuted, + fontSize: 11, + fontWeight: '500', + }, +}) diff --git a/src/modules/history/components/HistorySessionSheet.tsx b/src/modules/history/components/HistorySessionSheet.tsx index 77dd918ce..5809a8bf4 100644 --- a/src/modules/history/components/HistorySessionSheet.tsx +++ b/src/modules/history/components/HistorySessionSheet.tsx @@ -13,10 +13,17 @@ import { Text } from '@/components/base/Text' import { CaretRightIcon } from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Canvas, Circle, Path, Skia } from '@shopify/react-native-skia' +import type { Favorite } from 'vescape-core' import { interaction, theme } from '@/constants/theme' -import { telemetry } from '@/modules/board/constants/telemetry' -import { rideDurationMs } from '@/modules/history/lib/sessions' +import { HistoryRideLabel } from '@/modules/history/components/HistoryRideLabel' +import { favoriteSessionId } from '@/modules/history/lib/favorites' +import { + formatFavoriteName, + formatRideListDateTime, + formatRideListDetails, +} from '@/modules/history/lib/rideFormat' +import { rideMovingWindow } from '@/modules/history/lib/sessions' import type { HistorySession, TelemetryMinuteBucket } from '@/modules/history/store/historyStore' interface HistorySessionSheetProps { @@ -24,6 +31,7 @@ interface HistorySessionSheetProps { bottomOffset: number blocks: TelemetryMinuteBucket[] sessions: HistorySession[] + favorites: Favorite[] selectedSessionId: string | null hasMore: boolean loadingMore: boolean @@ -43,6 +51,7 @@ export function HistorySessionSheet({ bottomOffset, blocks, sessions, + favorites, selectedSessionId, hasMore, loadingMore, @@ -58,6 +67,10 @@ export function HistorySessionSheet({ () => sessions.findIndex((session) => session.id === selectedSessionId), [sessions, selectedSessionId], ) + const favoritesBySessionId = useMemo( + () => new Map(favorites.map((favorite) => [favoriteSessionId(favorite.id), favorite])), + [favorites], + ) useEffect(() => { if (!visible || selectedIndex < 0 || viewportHeight <= 0) return @@ -109,6 +122,17 @@ export function HistorySessionSheet({ sessions.map((session) => { const selected = session.id === selectedSessionId const routePoints = getSessionRoutePreviewPoints(blocks, session) + const favorite = favoritesBySessionId.get(session.id) + const rideWindow = rideMovingWindow(session) ?? { + startMs: session.startAtMs, + endMs: session.endAtMs, + } + const dateTime = formatRideListDateTime(rideWindow.startMs, rideWindow.endMs) + const details = formatRideListDetails( + rideWindow.endMs - rideWindow.startMs, + session.distanceM, + favorite?.boardName ?? session.deviceName, + ) return ( - - {new Date(session.startAtMs).toLocaleString()} - - - {session.deviceName} - - - {formatDuration(rideDurationMs(session))} ·{' '} - {formatDistance(session.distanceM)} ·{' '} - {telemetry.speed.formatWithUnit(session.maxSpeedKmh)} · GPS{' '} - {session.gpsPointCount} - + @@ -241,19 +258,6 @@ function formatPreviewPoint(points: RoutePoint[], index: number): { x: number; y return { x, y } } -function formatDuration(ms: number): string { - const mins = Math.max(1, Math.round(ms / 60_000)) - if (mins < 60) return `${mins}m` - const h = Math.floor(mins / 60) - const rem = mins % 60 - return rem ? `${h}h ${rem}m` : `${h}h` -} - -function formatDistance(distanceM: number | null): string { - if (distanceM == null) return '-' - return `${(distanceM / 1000).toFixed(2)} km` -} - const styles = StyleSheet.create({ backdrop: { ...StyleSheet.absoluteFill, @@ -308,20 +312,6 @@ const styles = StyleSheet.create({ rowMain: { flex: 1, minWidth: 0, - gap: 2, - }, - rowDate: { - color: theme.palette.slate.textPrimary, - fontSize: 13, - fontWeight: '700', - }, - rowName: { - color: theme.palette.slate.textSecondary, - fontSize: 12, - }, - rowMeta: { - color: theme.palette.slate.textMuted, - fontSize: 11, }, routePreview: { width: PREVIEW_WIDTH, diff --git a/src/modules/history/lib/favorites.test.ts b/src/modules/history/lib/favorites.test.ts index 78f072d60..1d495f709 100644 --- a/src/modules/history/lib/favorites.test.ts +++ b/src/modules/history/lib/favorites.test.ts @@ -125,9 +125,9 @@ test('a favorite-backed session reports the pinned range and the pinned summary' expect(detail.deviceId).toBe('ble-1') }) -test('a named favorite reads by its name, an unnamed one by its board', () => { +test('a favorite-backed session keeps board identity separate from its name', () => { expect(favoriteToSession(favorite({ name: 'Dolina single track' }), []).deviceName).toBe( - 'Dolina single track', + 'Onewheel', ) expect(favoriteToSession(favorite({}), []).deviceName).toBe('Onewheel') }) diff --git a/src/modules/history/lib/favorites.ts b/src/modules/history/lib/favorites.ts index 31307278f..84ba45a30 100644 --- a/src/modules/history/lib/favorites.ts +++ b/src/modules/history/lib/favorites.ts @@ -43,7 +43,7 @@ export function findSessionFavorite( * The pinned summary wins over anything derivable from buckets: it was computed from raw samples at * creation and is exact for a range that cuts a bucket in half. Only what the row cannot carry * (geography, the buckets to read, the recording device) is derived from the overlapping buckets. - * The name stands in for the device label so a named Favorite reads by its name. + * Favorite identity stays separate from the recording device so each can be presented consistently. */ export function favoriteToSession( favorite: Favorite, @@ -57,7 +57,7 @@ export function favoriteToSession( return { id: favoriteSessionId(favorite.id), deviceId: spanned.find((block) => block.deviceId != null)?.deviceId ?? null, - deviceName: favorite.name ?? favorite.boardName ?? spanned[0]?.deviceName ?? 'Favorite', + deviceName: favorite.boardName ?? spanned[0]?.deviceName ?? '', startAtMs: favorite.startMs, endAtMs: favorite.endMs, // A Favorite is already a trimmed span: it is its own Moving Window, so the chart and the title diff --git a/src/modules/history/lib/rideFormat.test.ts b/src/modules/history/lib/rideFormat.test.ts new file mode 100644 index 000000000..d6172a8a4 --- /dev/null +++ b/src/modules/history/lib/rideFormat.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test' + +import { + formatFavoriteName, + formatRideListDateTime, + formatRideListDetails, +} from '@/modules/history/lib/rideFormat' + +test('list date combines the ride time range with the readable calendar date', () => { + const start = new Date(2026, 6, 10, 23, 30).getTime() + const end = new Date(2026, 6, 10, 23, 34).getTime() + + expect(formatRideListDateTime(start, end)).toBe('23:30 – 23:34 · 10 Jul 2026') +}) + +test('list details use contextual duration units and keep the board last', () => { + expect(formatRideListDetails(50 * 60_000, 1_820, 'Thor3')).toBe('50 min · 1.82 km · Thor3') + expect(formatRideListDetails(83 * 60_000, 12_840, 'Very Long Board Name')).toBe( + '1h 23m · 12.84 km · Very Long Board Name', + ) +}) + +test('unnamed favorites have an explicit identity', () => { + expect(formatFavoriteName(null)).toBe('Unnamed favorite') + expect(formatFavoriteName(' ')).toBe('Unnamed favorite') + expect(formatFavoriteName(' Forest run ')).toBe('Forest run') +}) diff --git a/src/modules/history/lib/rideFormat.ts b/src/modules/history/lib/rideFormat.ts index 15a3eecbe..9893917dd 100644 --- a/src/modules/history/lib/rideFormat.ts +++ b/src/modules/history/lib/rideFormat.ts @@ -29,3 +29,33 @@ export function formatRideMeta(startAtMs: number, endAtMs: number, deviceName: s ? `${formatRideDate(startAtMs, endAtMs)} · ${deviceName}` : formatRideDate(startAtMs, endAtMs) } + +export function formatRideListDateTime(startAtMs: number, endAtMs: number): string { + return `${formatRideTime(startAtMs, endAtMs)} · ${formatRideDate(startAtMs, endAtMs)}` +} + +export function formatRideListDetails( + durationMs: number, + distanceM: number | null, + deviceName: string | null, +): string { + return [ + formatRideListDuration(durationMs), + distanceM == null ? null : `${(distanceM / 1000).toFixed(2)} km`, + deviceName?.trim() || null, + ] + .filter((part): part is string => part != null) + .join(' · ') +} + +export function formatFavoriteName(name: string | null): string { + return name?.trim() || 'Unnamed favorite' +} + +function formatRideListDuration(durationMs: number): string { + const totalMinutes = Math.max(1, Math.round(durationMs / 60_000)) + if (totalMinutes < 60) return `${totalMinutes} min` + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m` +} diff --git a/src/modules/history/store/favoriteStore.test.ts b/src/modules/history/store/favoriteStore.test.ts index 832408c9e..8757208c7 100644 --- a/src/modules/history/store/favoriteStore.test.ts +++ b/src/modules/history/store/favoriteStore.test.ts @@ -27,8 +27,8 @@ const getFavorites = mock(async () => [] as Favorite[]) const createFavorite = mock(async (): Promise => { throw new Error('createFavorite not stubbed') }) -const renameFavorite = mock(async (): Promise => { - throw new Error('renameFavorite not stubbed') +const updateFavorite = mock(async (): Promise => { + throw new Error('updateFavorite not stubbed') }) const deleteFavorite = mock(async () => true) @@ -36,7 +36,7 @@ const vescapeCoreMock = { ...actualVescapeCore, getFavorites, createFavorite, - renameFavorite, + updateFavorite, deleteFavorite, } @@ -46,14 +46,14 @@ mock.module('../../modules/vescape-core/src/index', () => vescapeCoreMock) beforeEach(async () => { getFavorites.mockClear() createFavorite.mockClear() - renameFavorite.mockClear() + updateFavorite.mockClear() deleteFavorite.mockClear() getFavorites.mockImplementation(async () => []) createFavorite.mockImplementation(async () => { throw new Error('createFavorite not stubbed') }) - renameFavorite.mockImplementation(async () => { - throw new Error('renameFavorite not stubbed') + updateFavorite.mockImplementation(async () => { + throw new Error('updateFavorite not stubbed') }) deleteFavorite.mockImplementation(async () => true) const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') @@ -125,34 +125,47 @@ test('a second star tap while a create is in flight does not add a duplicate', a expect(useFavoriteStore.getState().saving).toBe(false) }) -test('a rename mirrors the row native returns, without touching the others', async () => { +test('an update mirrors and re-sorts the row native returns without touching the others', async () => { const other = favorite({ id: 'other', startMs: 3_000_000 }) - const renamed = favorite({ id: 'fav-1', startMs: 1_000_000, name: 'Dolina single track' }) + const updated = favorite({ id: 'fav-1', startMs: 4_000_000, name: 'Dolina single track' }) getFavorites.mockImplementation(async () => [ other, favorite({ id: 'fav-1', startMs: 1_000_000 }), ]) - renameFavorite.mockImplementation(async () => renamed) + updateFavorite.mockImplementation(async () => updated) const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') await useFavoriteStore.getState().load() - await useFavoriteStore.getState().rename('fav-1', 'Dolina single track') + await useFavoriteStore.getState().update('fav-1', { + startMs: 4_000_000, + endMs: 4_060_000, + name: 'Dolina single track', + }) - expect(renameFavorite).toHaveBeenCalledWith('fav-1', 'Dolina single track') - expect(useFavoriteStore.getState().favorites).toEqual([other, renamed]) + expect(updateFavorite).toHaveBeenCalledWith('fav-1', { + startMs: 4_000_000, + endMs: 4_060_000, + name: 'Dolina single track', + }) + expect(useFavoriteStore.getState().favorites).toEqual([updated, other]) }) -test('a failed rename leaves the stored name alone and surfaces the error', async () => { +test('a failed update leaves the stored Favorite alone and surfaces the error', async () => { const stored = favorite({ id: 'fav-1', startMs: 1_000_000, name: 'Dolina' }) getFavorites.mockImplementation(async () => [stored]) - renameFavorite.mockImplementation(async () => { + updateFavorite.mockImplementation(async () => { throw new Error('favorite does not exist') }) const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') await useFavoriteStore.getState().load() - await useFavoriteStore.getState().rename('fav-1', null) + const updated = await useFavoriteStore.getState().update('fav-1', { + startMs: 1_000_000, + endMs: 1_060_000, + name: null, + }) + expect(updated).toBeNull() expect(useFavoriteStore.getState().favorites).toEqual([stored]) expect(useFavoriteStore.getState().error).toBe('favorite does not exist') }) diff --git a/src/modules/history/store/favoriteStore.ts b/src/modules/history/store/favoriteStore.ts index f87ed5ffd..a1ac02f00 100644 --- a/src/modules/history/store/favoriteStore.ts +++ b/src/modules/history/store/favoriteStore.ts @@ -3,15 +3,16 @@ import { createFavorite, deleteFavorite, getFavorites, - renameFavorite, + updateFavorite, type Favorite, type CreateFavoriteOptions, + type UpdateFavoriteOptions, } from 'vescape-core' interface FavoriteState { favorites: Favorite[] loading: boolean - /** A create/delete is in flight. Single-flight: the star must not queue a second mutation. */ + /** One create/update/delete at a time; controls must not queue a second mutation. */ saving: boolean error: string | undefined } @@ -20,8 +21,8 @@ interface FavoriteActions { load: () => Promise /** Pin a range. Native owns identity, timestamps and stats — JS only sends range + name. */ add: (options: CreateFavoriteOptions) => Promise - /** Rename, or clear the name with `null`. Native owns the row; JS mirrors what it returns. */ - rename: (id: string, name: string | null) => Promise + /** Re-trim/rename in place. Native preserves identity/media and recomputes the summary. */ + update: (id: string, options: UpdateFavoriteOptions) => Promise /** Unpin. Telemetry inside the range stays (ADR 0029). */ remove: (id: string) => Promise } @@ -60,16 +61,20 @@ export const useFavoriteStore = create((set, ge } }, - async rename(id, name) { - if (get().saving) return + async update(id, options) { + if (get().saving) return null set({ saving: true, error: undefined }) try { - const renamed = await renameFavorite(id, name) + const updated = await updateFavorite(id, options) set({ - favorites: get().favorites.map((favorite) => (favorite.id === id ? renamed : favorite)), + favorites: get() + .favorites.map((favorite) => (favorite.id === id ? updated : favorite)) + .sort((a, b) => b.startMs - a.startMs), }) + return updated } catch (err) { set({ error: err instanceof Error ? err.message : String(err) }) + return null } finally { set({ saving: false }) } diff --git a/src/screens/main/MainScreen.tsx b/src/screens/main/MainScreen.tsx index 86f5f5256..05369e188 100644 --- a/src/screens/main/MainScreen.tsx +++ b/src/screens/main/MainScreen.tsx @@ -54,6 +54,7 @@ function buildHistoryOverlayProps(controller: ReturnType void + onEdit: () => void onDelete: () => void } saving: boolean @@ -126,9 +126,9 @@ export function HistoryControls({ <> void + beginEditFavorite: () => Promise updateTrimRange: (startMs: number, endMs: number) => void - cancelTrim: () => void + cancelTrim: () => Promise saveTrim: (name: string) => Promise favoriteSessions: HistorySession[] canPreviousFavorite: boolean @@ -61,7 +62,6 @@ export interface MainHistoryOverlayProps { /** The selected Favorite while the Favorites tab is active. */ openFavorite: Favorite | null selectFavorite: (favorite: Favorite) => Promise - renameOpenFavorite: (name: string | null) => Promise removeOpenFavorite: () => Promise loadMoreHistory: () => Promise selectPreviousRide: () => Promise @@ -169,6 +169,7 @@ export function HistoryOverlay({ bottomOffset={sheetBottom} blocks={history.blocks} sessions={favoriteMode ? history.favoriteSessions : history.sessions} + favorites={favoriteMode ? history.favorites : []} selectedSessionId={history.selectedSession?.id ?? null} hasMore={!favoriteMode && history.historyHasMore} loadingMore={history.historyLoading} diff --git a/src/screens/main/history/HistoryRideDetail.tsx b/src/screens/main/history/HistoryRideDetail.tsx index 57dfdd238..547f7cf57 100644 --- a/src/screens/main/history/HistoryRideDetail.tsx +++ b/src/screens/main/history/HistoryRideDetail.tsx @@ -1,8 +1,7 @@ import { useState } from 'react' import { ConfirmModal } from '@/components/modals/ConfirmModal' -import { TextPromptModal } from '@/components/modals/TextPromptModal' -import { formatRideDate, formatRideTime } from '@/modules/history/lib/rideFormat' +import { formatFavoriteName, formatRideTime } from '@/modules/history/lib/rideFormat' import type { HistorySession } from '@/modules/history/store/historyStore' import { HistoryControls } from '@/screens/main/history/HistoryControls' import { HistoryMapLoading } from '@/screens/main/history/HistoryMapLoading' @@ -34,11 +33,10 @@ export function HistoryRideDetail({ onRemoveSession, onPanelHeightChange, }: HistoryRideDetailProps) { - const [renameVisible, setRenameVisible] = useState(false) const [deleteVisible, setDeleteVisible] = useState(false) const [trimName, setTrimName] = useState('') const openFavorite = favoriteMode ? history.openFavorite : null - const trimming = !favoriteMode && history.trimming + const trimming = history.trimming return ( <> @@ -49,11 +47,7 @@ export function HistoryRideDetail({ movingStartAtMs={session.movingStartAtMs} movingEndAtMs={session.movingEndAtMs} deviceName={session.deviceName} - navigationTitle={ - openFavorite - ? (openFavorite.name ?? formatRideDate(openFavorite.startMs, openFavorite.endMs)) - : undefined - } + navigationTitle={openFavorite ? formatFavoriteName(openFavorite.name) : undefined} navigationSubtitle={ openFavorite ? [formatRideTime(openFavorite.startMs, openFavorite.endMs), openFavorite.boardName] @@ -120,7 +114,10 @@ export function HistoryRideDetail({ favorite={ openFavorite ? { - onRename: () => setRenameVisible(true), + onEdit: () => { + setTrimName(openFavorite.name ?? '') + void history.beginEditFavorite() + }, onDelete: () => setDeleteVisible(true), } : undefined @@ -130,27 +127,13 @@ export function HistoryRideDetail({ onRemove={onRemoveSession} onCancelTrim={() => { setTrimName('') - history.cancelTrim() + void history.cancelTrim() }} onSaveTrim={() => { void history.saveTrim(trimName) }} /> - { - setRenameVisible(false) - void history.renameOpenFavorite(value.length > 0 ? value : null) - }} - onDismiss={() => setRenameVisible(false)} - /> - ({ @@ -48,12 +48,18 @@ export function useHistoryFavorites( favoritesError: state.error, loadFavorites: state.load, addFavorite: state.add, - renameFavorite: state.rename, + updateFavorite: state.update, removeFavorite: state.remove, })), ) + const editingFavoriteIdRef = useRef(null) + const keepTrimOnNextSelectionRef = useRef(false) useEffect(() => { + if (keepTrimOnNextSelectionRef.current) { + keepTrimOnNextSelectionRef.current = false + return + } useMainScreenStore.getState().endTrim() }, [selectedSession]) @@ -111,6 +117,31 @@ export function useHistoryFavorites( const session = useHistoryStore.getState().selectedSession if (!session) return const range = initialFavoriteTrimRangeForSession(session) + editingFavoriteIdRef.current = null + setTrimSeed(range) + useMainScreenStore.getState().beginTrim(range) + }, []) + + const beginEditFavorite = useCallback(async () => { + const id = useMainScreenStore.getState().openFavoriteId + const favorite = useFavoriteStore.getState().favorites.find((item) => item.id === id) + if (!favorite) return + + editingFavoriteIdRef.current = favorite.id + const containingSession = useHistoryStore + .getState() + .sessions.find( + (session) => session.startAtMs <= favorite.startMs && session.endAtMs >= favorite.endMs, + ) + if ( + containingSession && + containingSession.id !== useHistoryStore.getState().selectedSession?.id + ) { + keepTrimOnNextSelectionRef.current = true + await useHistoryStore.getState().selectSession(containingSession) + } + + const range = { startMs: favorite.startMs, endMs: favorite.endMs } setTrimSeed(range) useMainScreenStore.getState().beginTrim(range) }, []) @@ -119,18 +150,42 @@ export function useHistoryFavorites( useMainScreenStore.getState().setTrimRange({ startMs, endMs }) }, []) - const cancelTrim = useCallback(() => { + const cancelTrim = useCallback(async () => { + const editingId = editingFavoriteIdRef.current + editingFavoriteIdRef.current = null useMainScreenStore.getState().endTrim() - }, []) + setTrimSeed(null) + if (!editingId) return + const favorite = useFavoriteStore.getState().favorites.find((item) => item.id === editingId) + if (favorite) await selectFavorite(favorite) + }, [selectFavorite]) const saveTrim = useCallback( async (name: string) => { const range = useMainScreenStore.getState().trimRange const session = useHistoryStore.getState().selectedSession if (!range || !session) return + const startMs = Math.min(range.startMs, range.endMs) + const endMs = Math.max(range.startMs, range.endMs) + const editingId = editingFavoriteIdRef.current + if (editingId) { + const updated = await updateFavorite(editingId, { + startMs, + endMs, + ...(session.deviceId ? { deviceId: session.deviceId } : {}), + name: name.trim() || null, + }) + if (!updated) return + editingFavoriteIdRef.current = null + useMainScreenStore.getState().endTrim() + setTrimSeed(null) + await selectFavorite(updated) + return + } + const favorite = await addFavorite({ - startMs: Math.min(range.startMs, range.endMs), - endMs: Math.max(range.startMs, range.endMs), + startMs, + endMs, ...(session.deviceId ? { deviceId: session.deviceId } : {}), ...(name.trim() ? { name: name.trim() } : {}), }) @@ -138,10 +193,11 @@ export function useHistoryFavorites( historySessionBeforeFavorite.current = session useMainScreenStore.getState().endTrim() + setTrimSeed(null) setHistoryTab('favorites') await selectFavorite(favorite) }, - [addFavorite, selectFavorite, setHistoryTab], + [addFavorite, selectFavorite, setHistoryTab, updateFavorite], ) const selectPreviousFavorite = useCallback(async () => { @@ -165,22 +221,6 @@ export function useHistoryFavorites( if (favorite) await selectFavorite(favorite) }, [favoriteSessions, selectFavorite]) - const renameOpenFavorite = useCallback( - async (name: string | null) => { - const id = useMainScreenStore.getState().openFavoriteId - if (!id) return - await renameFavorite(id, name) - const renamed = useFavoriteStore.getState().favorites.find((item) => item.id === id) - // The name doubles as the session label, so the open detail has to be rebuilt to show it. - if (renamed) { - await useHistoryStore - .getState() - .selectSession(favoriteToSession(renamed, useHistoryStore.getState().blocks)) - } - }, - [renameFavorite], - ) - const removeOpenFavorite = useCallback(async () => { const id = useMainScreenStore.getState().openFavoriteId if (!id) return @@ -198,6 +238,9 @@ export function useHistoryFavorites( const resetHistoryFavorites = useCallback(() => { historySessionBeforeFavorite.current = null + editingFavoriteIdRef.current = null + keepTrimOnNextSelectionRef.current = false + setTrimSeed(null) setHistoryTab('history') useMainScreenStore.getState().closeFavorite() useMainScreenStore.getState().endTrim() @@ -215,6 +258,7 @@ export function useHistoryFavorites( trimming, trimSeed, beginTrimFavorite, + beginEditFavorite, updateTrimRange, cancelTrim, saveTrim, @@ -224,7 +268,6 @@ export function useHistoryFavorites( canNextFavorite: getNextRideSession(favoriteSessions, selectedSession) != null, selectPreviousFavorite, selectNextFavorite, - renameOpenFavorite, removeOpenFavorite, loadFavorites, resetHistoryFavorites, diff --git a/src/screens/main/useMainScreenController.ts b/src/screens/main/useMainScreenController.ts index b6ef0e8f5..2d0ec5813 100644 --- a/src/screens/main/useMainScreenController.ts +++ b/src/screens/main/useMainScreenController.ts @@ -119,6 +119,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) })), ) const historyFavorites = useHistoryFavorites(selectedSession, blocks) + const cancelHistoryTrim = historyFavorites.cancelTrim const { mapPoints, selectedMapPointId, @@ -363,7 +364,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) const handler = BackHandler.addEventListener('hardwareBackPress', () => { if (mode === 'history') { if (useMainScreenStore.getState().trimRange) { - useMainScreenStore.getState().endTrim() + void cancelHistoryTrim() return true } exitHistory() @@ -393,7 +394,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) return true }) return () => handler.remove() - }, [exitHistory, exitLegalLimitsMode, exitMapFocus, exitWeatherMode, mode]), + }, [cancelHistoryTrim, exitHistory, exitLegalLimitsMode, exitMapFocus, exitWeatherMode, mode]), ) return { From 4dba8ea1edc9f35966cc187216cae8e22f27c32b Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 12:13:08 +0200 Subject: [PATCH 18/24] Suggest ride names --- e2e/flows/history.yaml | 1 + .../components/HistorySessionSheet.tsx | 6 +++- src/modules/history/lib/rideFormat.test.ts | 29 ++++++++++++++++--- src/modules/history/lib/rideFormat.ts | 18 ++++++++++-- src/screens/main/history/HistoryControls.tsx | 4 ++- .../main/history/HistoryRideDetail.tsx | 17 +++++++++-- 6 files changed, 65 insertions(+), 10 deletions(-) diff --git a/e2e/flows/history.yaml b/e2e/flows/history.yaml index 11f8ad162..bb4e69093 100644 --- a/e2e/flows/history.yaml +++ b/e2e/flows/history.yaml @@ -79,6 +79,7 @@ appId: ${APP_ID} id: history-ride-list-button - tapOn: id: trim-favorite-name +- eraseText - inputText: Evening ride - tapOn: id: trim-save diff --git a/src/modules/history/components/HistorySessionSheet.tsx b/src/modules/history/components/HistorySessionSheet.tsx index 5809a8bf4..1f27f52a9 100644 --- a/src/modules/history/components/HistorySessionSheet.tsx +++ b/src/modules/history/components/HistorySessionSheet.tsx @@ -147,7 +147,11 @@ export function HistorySessionSheet({ diff --git a/src/modules/history/lib/rideFormat.test.ts b/src/modules/history/lib/rideFormat.test.ts index d6172a8a4..08571ba84 100644 --- a/src/modules/history/lib/rideFormat.test.ts +++ b/src/modules/history/lib/rideFormat.test.ts @@ -4,6 +4,7 @@ import { formatFavoriteName, formatRideListDateTime, formatRideListDetails, + suggestFavoriteName, } from '@/modules/history/lib/rideFormat' test('list date combines the ride time range with the readable calendar date', () => { @@ -20,8 +21,28 @@ test('list details use contextual duration units and keep the board last', () => ) }) -test('unnamed favorites have an explicit identity', () => { - expect(formatFavoriteName(null)).toBe('Unnamed favorite') - expect(formatFavoriteName(' ')).toBe('Unnamed favorite') - expect(formatFavoriteName(' Forest run ')).toBe('Forest run') +test('short Favorite suggestions use rider-friendly parts of day', () => { + const at = (day: number, hour: number) => new Date(2026, 6, day, hour).getTime() + + expect(suggestFavoriteName(at(10, 2), at(10, 3))).toBe('Night ride') + expect(suggestFavoriteName(at(10, 7), at(10, 9))).toBe('Morning ride') + expect(suggestFavoriteName(at(10, 13), at(10, 15))).toBe('Afternoon ride') + expect(suggestFavoriteName(at(10, 20), at(11, 2))).toBe('Evening ride') +}) + +test('long Favorite suggestions describe the whole range', () => { + const start = new Date(2026, 6, 10, 9).getTime() + + expect(suggestFavoriteName(start, start + 12 * 3_600_000)).toBe('Day ride') + expect(suggestFavoriteName(start, start + 24 * 3_600_000)).toBe('All-day ride') + expect(suggestFavoriteName(start, start + 40 * 3_600_000)).toBe('Multi-day ride') +}) + +test('a stored Favorite name wins over its generated suggestion', () => { + const start = new Date(2026, 6, 10, 20).getTime() + const end = start + 60 * 60_000 + + expect(formatFavoriteName(null, start, end)).toBe('Evening ride') + expect(formatFavoriteName(' ', start, end)).toBe('Evening ride') + expect(formatFavoriteName(' Forest run ', start, end)).toBe('Forest run') }) diff --git a/src/modules/history/lib/rideFormat.ts b/src/modules/history/lib/rideFormat.ts index 9893917dd..6a49d2b61 100644 --- a/src/modules/history/lib/rideFormat.ts +++ b/src/modules/history/lib/rideFormat.ts @@ -48,8 +48,22 @@ export function formatRideListDetails( .join(' · ') } -export function formatFavoriteName(name: string | null): string { - return name?.trim() || 'Unnamed favorite' +export function formatFavoriteName(name: string | null, startMs: number, endMs: number): string { + return name?.trim() || suggestFavoriteName(startMs, endMs) +} + +export function suggestFavoriteName(startMs: number, endMs: number): string { + const start = Math.min(startMs, endMs) + const durationHours = Math.abs(endMs - startMs) / 3_600_000 + if (durationHours >= 36) return 'Multi-day ride' + if (durationHours >= 18) return 'All-day ride' + if (durationHours >= 12) return 'Day ride' + + const hour = new Date(start).getHours() + if (hour >= 4 && hour < 12) return 'Morning ride' + if (hour >= 12 && hour < 17) return 'Afternoon ride' + if (hour >= 17 && hour < 22) return 'Evening ride' + return 'Night ride' } function formatRideListDuration(durationMs: number): string { diff --git a/src/screens/main/history/HistoryControls.tsx b/src/screens/main/history/HistoryControls.tsx index bda45a7f5..86740091a 100644 --- a/src/screens/main/history/HistoryControls.tsx +++ b/src/screens/main/history/HistoryControls.tsx @@ -31,6 +31,7 @@ interface HistoryControlsProps { } saving: boolean trimName: string + trimNamePlaceholder?: string onTrimNameChange: (name: string) => void onSelectTab: (tab: HistoryTab) => void onBack: () => void @@ -47,6 +48,7 @@ export function HistoryControls({ favorite, saving, trimName, + trimNamePlaceholder = 'Favorite name', onTrimNameChange, onSelectTab, onBack, @@ -66,7 +68,7 @@ export function HistoryControls({ testID="trim-favorite-name" value={trimName} onChangeText={onTrimNameChange} - placeholder="Favorite name" + placeholder={trimNamePlaceholder} editable={!saving} returnKeyType="done" onSubmitEditing={onSaveTrim} diff --git a/src/screens/main/history/HistoryRideDetail.tsx b/src/screens/main/history/HistoryRideDetail.tsx index 547f7cf57..a6a915720 100644 --- a/src/screens/main/history/HistoryRideDetail.tsx +++ b/src/screens/main/history/HistoryRideDetail.tsx @@ -1,7 +1,11 @@ import { useState } from 'react' import { ConfirmModal } from '@/components/modals/ConfirmModal' -import { formatFavoriteName, formatRideTime } from '@/modules/history/lib/rideFormat' +import { + formatFavoriteName, + formatRideTime, + suggestFavoriteName, +} from '@/modules/history/lib/rideFormat' import type { HistorySession } from '@/modules/history/store/historyStore' import { HistoryControls } from '@/screens/main/history/HistoryControls' import { HistoryMapLoading } from '@/screens/main/history/HistoryMapLoading' @@ -47,7 +51,11 @@ export function HistoryRideDetail({ movingStartAtMs={session.movingStartAtMs} movingEndAtMs={session.movingEndAtMs} deviceName={session.deviceName} - navigationTitle={openFavorite ? formatFavoriteName(openFavorite.name) : undefined} + navigationTitle={ + openFavorite + ? formatFavoriteName(openFavorite.name, openFavorite.startMs, openFavorite.endMs) + : undefined + } navigationSubtitle={ openFavorite ? [formatRideTime(openFavorite.startMs, openFavorite.endMs), openFavorite.boardName] @@ -110,6 +118,11 @@ export function HistoryRideDetail({ trimming={trimming} saving={history.favoritesSaving} trimName={trimName} + trimNamePlaceholder={ + history.trimSeed + ? suggestFavoriteName(history.trimSeed.startMs, history.trimSeed.endMs) + : 'Favorite name' + } onTrimNameChange={setTrimName} favorite={ openFavorite From 4998f00a5468d431343950bb1a83e709f833ba73 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 12:16:35 +0200 Subject: [PATCH 19/24] Fix history chart time --- src/components/charts/TelemetryLineChart.tsx | 24 ++++++------------ src/components/charts/chartMath.test.ts | 20 +++++++++++++++ src/components/charts/chartMath.ts | 25 +++++++++++++++++++ .../main/history/HistoryTelemetryPanel.tsx | 2 ++ 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/components/charts/TelemetryLineChart.tsx b/src/components/charts/TelemetryLineChart.tsx index 031507cfa..9c5fb208b 100644 --- a/src/components/charts/TelemetryLineChart.tsx +++ b/src/components/charts/TelemetryLineChart.tsx @@ -26,10 +26,12 @@ import { import { theme } from '@/constants/theme' import { getChartPosition, + getChartTimeLabels, getXPosition, splitChartPointSegments, splitChartLineSegments, type ExcludedRange, + type ChartTimeMode, type TelemetryChartPoint, } from '@/components/charts/chartMath' import { @@ -160,14 +162,6 @@ function formatTime(date: Date): string { return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}` } -function formatRelativeTime(date: Date, now: Date): string { - const diffMs = now.getTime() - date.getTime() - const diffSec = Math.round(diffMs / 1000) - if (diffSec < 60) return `-${diffSec}s` - const diffMin = Math.round(diffSec / 60) - return `-${diffMin}m` -} - function formatAxisNumber(value: number): string { const abs = Math.abs(value) if (abs >= 100 || Number.isInteger(value)) return Math.round(value).toString() @@ -281,6 +275,8 @@ interface TelemetryLineChartProps { formatValue?: (value: number) => string getPointColor?: (value: number) => string windowMs?: number + /** Live charts count back from now; history charts show local wall-clock endpoints. */ + timeMode?: ChartTimeMode excludedRanges?: ExcludedRange[] /** Optional second line plotted on a right-side axis with its own range. */ secondary?: SecondaryChartSeries @@ -393,6 +389,7 @@ export function TelemetryLineChart({ formatValue, getPointColor, windowMs, + timeMode = 'relative', excludedRanges, secondary, scrubTimeMs, @@ -576,15 +573,8 @@ export function TelemetryLineChart({ const secondaryYMid = secondary ? (secondary.range.y.min + secondary.range.y.max) / 2 : 0 const timeLabels = useMemo(() => { - const points = displayPoints - if (points.length < 2) return null - const now = points[points.length - 1].date - const start = windowMs ? new Date(now.getTime() - windowMs) : points[0].date - return { - start: formatRelativeTime(start, now), - end: 'now', - } - }, [displayPoints, windowMs]) + return getChartTimeLabels(displayPoints, windowMs, timeMode) + }, [displayPoints, timeMode, windowMs]) const activeColor = resolveActiveChartColor(currentPoint, color, getPointColor) const valueColorStyle = getPointColor && currentPoint ? { color: activeColor } : undefined diff --git a/src/components/charts/chartMath.test.ts b/src/components/charts/chartMath.test.ts index ef59a801b..77aa3b049 100644 --- a/src/components/charts/chartMath.test.ts +++ b/src/components/charts/chartMath.test.ts @@ -4,6 +4,7 @@ import { computeAutoRange, findNearestChartPointAtX, getChartPosition, + getChartTimeLabels, splitChartLineSegments, type TelemetryChartPoint, toExcludedRanges, @@ -41,6 +42,25 @@ test('findNearestChartPointAtX picks nearest and clamps x', () => { expect(findNearestChartPointAtX(points, 1_000, 100)).toEqual(points[2]) }) +test('history chart time labels use local clock time', () => { + const historyPoints = [ + { date: new Date(2026, 6, 10, 17, 15), value: 10 }, + { date: new Date(2026, 6, 10, 17, 19), value: 20 }, + ] + + expect(getChartTimeLabels(historyPoints, undefined, 'clock')).toEqual({ + start: '17:15', + end: '17:19', + }) +}) + +test('live chart time labels remain relative to now', () => { + expect(getChartTimeLabels(points, undefined, 'relative')).toEqual({ + start: '-2s', + end: 'now', + }) +}) + test('computeAutoRange supports zero include and min span', () => { const positive = [ { date: new Date(base), value: 12 }, diff --git a/src/components/charts/chartMath.ts b/src/components/charts/chartMath.ts index 9470d58bf..01570174f 100644 --- a/src/components/charts/chartMath.ts +++ b/src/components/charts/chartMath.ts @@ -13,6 +13,31 @@ export interface ExcludedRange { reason: string } +export type ChartTimeMode = 'relative' | 'clock' + +export function getChartTimeLabels( + points: TelemetryChartPoint[], + windowMs: number | undefined, + mode: ChartTimeMode, +): { start: string; end: string } | null { + if (points.length < 2) return null + const now = points[points.length - 1].date + const start = windowMs ? new Date(now.getTime() - windowMs) : points[0].date + if (mode === 'clock') { + return { start: formatClockTime(start), end: formatClockTime(now) } + } + const diffMs = now.getTime() - start.getTime() + const diffSec = Math.round(diffMs / 1000) + const startLabel = diffSec < 60 ? `-${diffSec}s` : `-${Math.round(diffSec / 60)}m` + return { start: startLabel, end: 'now' } +} + +function formatClockTime(date: Date): string { + const hours = date.getHours().toString().padStart(2, '0') + const minutes = date.getMinutes().toString().padStart(2, '0') + return `${hours}:${minutes}` +} + const DEFAULT_GAP_MULTIPLIER = 3 export interface AutoRangeOptions { diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 1a9af0cf7..44484a283 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -195,6 +195,7 @@ export function HistoryTelemetryPanel({ currentPoint={headPoint} height={48} containerStyle={styles.chart} + timeMode="clock" formatValue={SPEED_CHART_DEF.formatValue} getPointColor={pointColors.speed} onGestureStart={() => onMetricInteraction?.('speed')} @@ -218,6 +219,7 @@ export function HistoryTelemetryPanel({ currentPoint={{ date: new Date(headSample.capturedAtMs), value: cfg.headValue }} height={40} containerStyle={styles.chart} + timeMode="clock" formatValue={cfg.formatValue} getPointColor={cfg.getPointColor} onGestureStart={() => onMetricInteraction?.(metric.key)} From 5771ac6599faf3dcc56d60666f761ff0db46c085 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 13:03:48 +0200 Subject: [PATCH 20/24] Fix history drawer --- e2e/flows/edge-drawer-focus.yaml | 15 ++ src/app/settings/components/modals.tsx | 72 +++++- src/components/overlays/AnchoredSheet.tsx | 47 +++- .../history/components/HistoryPanelNav.tsx | 4 + .../components/HistorySessionSheet.tsx | 239 ++++++------------ src/screens/main/history/HistoryOverlay.tsx | 8 +- .../main/history/HistoryRideDetail.tsx | 6 +- .../main/history/HistoryTelemetryPanel.tsx | 5 +- 8 files changed, 230 insertions(+), 166 deletions(-) create mode 100644 e2e/flows/edge-drawer-focus.yaml diff --git a/e2e/flows/edge-drawer-focus.yaml b/e2e/flows/edge-drawer-focus.yaml new file mode 100644 index 000000000..f00bb5008 --- /dev/null +++ b/e2e/flows/edge-drawer-focus.yaml @@ -0,0 +1,15 @@ +appId: ${APP_ID} +--- +- runFlow: _launch.yaml +- openLink: 'vescape://settings/components/modals' +- scrollUntilVisible: + element: + id: edge-drawer-focus-open + direction: DOWN +- tapOn: + id: edge-drawer-focus-open +- extendedWaitUntil: + visible: Focused list expanded + timeout: 5000 +- assertVisible: + id: edge-drawer-focused-row diff --git a/src/app/settings/components/modals.tsx b/src/app/settings/components/modals.tsx index f411e757b..b15f01632 100644 --- a/src/app/settings/components/modals.tsx +++ b/src/app/settings/components/modals.tsx @@ -1,7 +1,7 @@ import { ScrollView, StyleSheet, View } from 'react-native' import { Text } from '@/components/base/Text' import { SafeAreaView } from 'react-native-safe-area-context' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { InfoIcon, SquaresFourIcon, UsersThreeIcon } from 'phosphor-react-native' import { Button } from '@/components/base/Button' @@ -407,6 +407,73 @@ function EdgeDrawerLongContentShowcase() { ) } +function EdgeDrawerInitialFocusShowcase() { + const triggerRef = useTriggerRef() + const focusedRowRef = useRef(null) + const [visible, setVisible] = useState(false) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + if (!visible) return + const timer = setTimeout(() => setExpanded(true), 300) + return () => clearTimeout(timer) + }, [visible]) + + return ( + + { + setExpanded(false) + setVisible(true) + }} + /> + + } + > + + A long bottom drawer opens with row 1 inside the visible area. + + setVisible(false)} + > + + {Array.from({ length: expanded ? 24 : 12 }, (_, index) => { + const focused = index === 0 + return ( + + + {focused ? 'Selected row 1' : `Row ${index + 1}`} + + + ) + })} + + + + ) +} + function FloatingSheetShowcase() { const triggerRef = useTriggerRef() const [visible, setVisible] = useState(false) @@ -471,6 +538,7 @@ export default function ModalsPage() { description="Always opens from the bottom. The complete drawer follows a downward drag." /> + @@ -490,6 +558,8 @@ const styles = StyleSheet.create({ padding: 16, }, tileText: { color: theme.palette.slate.textSecondary, fontSize: 14 }, + focusList: { gap: 8 }, + focusedTile: { borderColor: theme.palette.sky.color }, article: { gap: 28, paddingHorizontal: 10, paddingBottom: 24 }, articleLead: { color: theme.palette.slate.textPrimary, diff --git a/src/components/overlays/AnchoredSheet.tsx b/src/components/overlays/AnchoredSheet.tsx index 40ecbb47e..2073520ba 100644 --- a/src/components/overlays/AnchoredSheet.tsx +++ b/src/components/overlays/AnchoredSheet.tsx @@ -272,6 +272,12 @@ interface EdgeDrawerProps { iconColor?: string /** Scroll newly expanded content into view when the drawer grows. */ autoScrollOnContentExpand?: boolean + /** Bring one child into the initially visible drawer area after opening. */ + initialFocusRef?: React.RefObject + /** Called after scrolling settles near the end of the drawer content. */ + onReachContentEnd?: () => void + contentEndThreshold?: number + backdropTestID?: string children: React.ReactNode } @@ -291,6 +297,10 @@ export function EdgeDrawer({ icon: IconComponent, iconColor = theme.palette.slate.textSecondary, autoScrollOnContentExpand = false, + initialFocusRef, + onReachContentEnd, + contentEndThreshold = 80, + backdropTestID, children, }: EdgeDrawerProps) { const insets = useSafeAreaInsets() @@ -407,12 +417,31 @@ export function EdgeDrawer({ scrollOffset.value = initialOffset requestAnimationFrame(() => { scrollRef.current?.scrollTo({ y: initialOffset, animated: false }) + if (!initialFocusRef?.current) return + requestAnimationFrame(() => { + const nativeScrollRef = scrollRef.current?.getNativeScrollRef() + if (!initialFocusRef.current || !nativeScrollRef) return + initialFocusRef.current.measureLayout( + nativeScrollRef, + (_x, focusY, _width, focusHeight) => { + const visibleCenter = height * (opensFromTop ? 0.375 : 0.625) + const minimumOffset = opensFromTop ? 0 : initialOffset + const focusedOffset = Math.max( + minimumOffset, + Math.min(range, focusY + focusHeight / 2 - visibleCenter), + ) + scrollOffsetRef.current = focusedOffset + scrollOffset.value = focusedOffset + scrollRef.current?.scrollTo({ y: focusedOffset, animated: false }) + }, + ) + }) }) return } const bottomDrawerWasFullyOpen = !opensFromTop && scrollOffsetRef.current >= previousRange - 1 - if (bottomDrawerWasFullyOpen && range > previousRange) { + if (!initialFocusRef && bottomDrawerWasFullyOpen && range > previousRange) { requestAnimationFrame(() => { scrollRef.current?.scrollTo({ y: range, animated: true }) }) @@ -431,6 +460,7 @@ export function EdgeDrawer({ animatedGradientFullStrengthRange, autoScrollOnContentExpand, height, + initialFocusRef, opensFromTop, scrollOffset, ], @@ -469,9 +499,20 @@ export function EdgeDrawer({ return } + const distanceFromEnd = + event.nativeEvent.contentSize.height - (offset + event.nativeEvent.layoutMeasurement.height) + if (distanceFromEnd <= contentEndThreshold) onReachContentEnd?.() if (shouldAutoCloseAtOffset(offset)) scrollFullyOut() }, - [dismissRange, finishClose, opensFromTop, scrollFullyOut, shouldAutoCloseAtOffset], + [ + contentEndThreshold, + dismissRange, + finishClose, + onReachContentEnd, + opensFromTop, + scrollFullyOut, + shouldAutoCloseAtOffset, + ], ) const handleScrollEndDrag = useCallback( @@ -540,7 +581,7 @@ export function EdgeDrawer({ /> - + + listButtonRef: RefObject onPrevious: () => void onNext: () => void onOpenList: () => void @@ -45,6 +46,7 @@ export function HistoryPanelNav({ mediaCount, mediaLoading, mediaButtonRef, + listButtonRef, onPrevious, onNext, onOpenList, @@ -86,6 +88,8 @@ export function HistoryPanelNav({ style={styles.navSelector} selectControl={ [styles.titleButton, pressed && styles.titleButtonPressed]} android_ripple={interaction.ripple} diff --git a/src/modules/history/components/HistorySessionSheet.tsx b/src/modules/history/components/HistorySessionSheet.tsx index 1f27f52a9..56bfe339b 100644 --- a/src/modules/history/components/HistorySessionSheet.tsx +++ b/src/modules/history/components/HistorySessionSheet.tsx @@ -1,20 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { - ActivityIndicator, - NativeScrollEvent, - NativeSyntheticEvent, - Pressable, - ScrollView, - StyleSheet, - View, - useWindowDimensions, -} from 'react-native' +import { useMemo, useRef, type RefObject } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native' import { Text } from '@/components/base/Text' -import { CaretRightIcon } from 'phosphor-react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { CaretRightIcon, ClockCounterClockwiseIcon, StarIcon } from 'phosphor-react-native' import { Canvas, Circle, Path, Skia } from '@shopify/react-native-skia' import type { Favorite } from 'vescape-core' +import { EdgeDrawer } from '@/components/overlays/AnchoredSheet' import { interaction, theme } from '@/constants/theme' import { HistoryRideLabel } from '@/modules/history/components/HistoryRideLabel' import { favoriteSessionId } from '@/modules/history/lib/favorites' @@ -28,7 +19,8 @@ import type { HistorySession, TelemetryMinuteBucket } from '@/modules/history/st interface HistorySessionSheetProps { visible: boolean - bottomOffset: number + triggerRef: RefObject + favoriteMode: boolean blocks: TelemetryMinuteBucket[] sessions: HistorySession[] favorites: Favorite[] @@ -40,15 +32,10 @@ interface HistorySessionSheetProps { onLoadMore: () => void } -const CONTENT_PADDING_VERTICAL = 12 -const ROUTE_ROW_HEIGHT = 72 -const ROUTE_ROW_PITCH = ROUTE_ROW_HEIGHT + 8 -const MAX_PANEL_HEIGHT = 480 -const TOP_CLEARANCE = 72 - export function HistorySessionSheet({ visible, - bottomOffset, + triggerRef, + favoriteMode, blocks, sessions, favorites, @@ -59,124 +46,87 @@ export function HistorySessionSheet({ onSelectSession, onLoadMore, }: HistorySessionSheetProps) { - const insets = useSafeAreaInsets() - const { height: windowHeight } = useWindowDimensions() - const scrollRef = useRef(null) - const [viewportHeight, setViewportHeight] = useState(0) - const selectedIndex = useMemo( - () => sessions.findIndex((session) => session.id === selectedSessionId), - [sessions, selectedSessionId], - ) + const selectedRowRef = useRef(null) const favoritesBySessionId = useMemo( () => new Map(favorites.map((favorite) => [favoriteSessionId(favorite.id), favorite])), [favorites], ) - useEffect(() => { - if (!visible || selectedIndex < 0 || viewportHeight <= 0) return - const frame = requestAnimationFrame(() => { - const rowCenterY = - CONTENT_PADDING_VERTICAL + selectedIndex * ROUTE_ROW_PITCH + ROUTE_ROW_HEIGHT / 2 - scrollRef.current?.scrollTo({ - y: Math.max(0, rowCenterY - viewportHeight / 2), - animated: false, - }) - }) - return () => cancelAnimationFrame(frame) - }, [selectedIndex, viewportHeight, visible]) - - if (!visible) return null - - const availableHeight = windowHeight - bottomOffset - Math.max(insets.top, 8) - TOP_CLEARANCE - const panelMaxHeight = Math.max(0, Math.min(MAX_PANEL_HEIGHT, availableHeight)) - - const handleScroll = (event: NativeSyntheticEvent) => { - if (!hasMore || loadingMore) return - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent - const distanceFromEnd = contentSize.height - (contentOffset.y + layoutMeasurement.height) - if (distanceFromEnd < 80) onLoadMore() - } - return ( - <> - - - setViewportHeight(event.nativeEvent.layout.height)} - onScroll={handleScroll} - > - {sessions.length === 0 ? ( - No sessions - ) : ( - sessions.map((session) => { - const selected = session.id === selectedSessionId - const routePoints = getSessionRoutePreviewPoints(blocks, session) - const favorite = favoritesBySessionId.get(session.id) - const rideWindow = rideMovingWindow(session) ?? { - startMs: session.startAtMs, - endMs: session.endAtMs, - } - const dateTime = formatRideListDateTime(rideWindow.startMs, rideWindow.endMs) - const details = formatRideListDetails( - rideWindow.endMs - rideWindow.startMs, - session.distanceM, - favorite?.boardName ?? session.deviceName, - ) - return ( - [ - styles.row, - selected && styles.rowSelected, - pressed && styles.rowPressed, - ]} - onPress={() => onSelectSession(session)} - > - - - - - - - ) - }) - )} - {hasMore && ( - [styles.loadingRow, pressed && styles.loadingPressed]} - disabled={loadingMore} - onPress={onLoadMore} - > - {loadingMore ? ( - - ) : ( - Load older rides - )} - - )} - + + + {sessions.length === 0 ? ( + No sessions + ) : ( + sessions.map((session) => { + const selected = session.id === selectedSessionId + const routePoints = getSessionRoutePreviewPoints(blocks, session) + const favorite = favoritesBySessionId.get(session.id) + const rideWindow = rideMovingWindow(session) ?? { + startMs: session.startAtMs, + endMs: session.endAtMs, + } + const dateTime = formatRideListDateTime(rideWindow.startMs, rideWindow.endMs) + const details = formatRideListDetails( + rideWindow.endMs - rideWindow.startMs, + session.distanceM, + favorite?.boardName ?? session.deviceName, + ) + return ( + [ + styles.row, + selected && styles.rowSelected, + pressed && styles.rowPressed, + ]} + onPress={() => onSelectSession(session)} + > + + + + + + + ) + }) + )} + {hasMore && ( + [styles.loadingRow, pressed && styles.loadingPressed]} + disabled={loadingMore} + onPress={onLoadMore} + > + {loadingMore ? ( + + ) : ( + Load older rides + )} + + )} - + ) } @@ -263,32 +213,7 @@ function formatPreviewPoint(points: RoutePoint[], index: number): { x: number; y } const styles = StyleSheet.create({ - backdrop: { - ...StyleSheet.absoluteFill, - zIndex: 24, - }, - panel: { - position: 'absolute', - left: 16, - right: 16, - zIndex: 25, - backgroundColor: theme.palette.slate.surfaceDeep, - borderRadius: 16, - borderWidth: 1, - borderColor: theme.palette.slate.border, - overflow: 'hidden', - shadowColor: theme.palette.mono.black, - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.45, - shadowRadius: 20, - elevation: 16, - }, - scroll: { - maxHeight: '100%', - }, content: { - paddingHorizontal: 12, - paddingVertical: 12, gap: 8, }, emptyText: { diff --git a/src/screens/main/history/HistoryOverlay.tsx b/src/screens/main/history/HistoryOverlay.tsx index 05c4eb418..a105519af 100644 --- a/src/screens/main/history/HistoryOverlay.tsx +++ b/src/screens/main/history/HistoryOverlay.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { StyleSheet, View } from 'react-native' import { StarIcon } from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -100,13 +100,13 @@ export function HistoryOverlay({ }: HistoryOverlayProps) { const insets = useSafeAreaInsets() const [removeConfirmVisible, setRemoveConfirmVisible] = useState(false) + const listButtonRef = useRef(null) const busy = history.loadingSession || history.historyLoading || history.favoritesLoading || history.favoritesSaving const aboveStripBottom = STRIP_CONTENT_HEIGHT + Math.max(insets.bottom * 0.5, 8) + 8 - const sheetBottom = Math.max(insets.bottom, 16) + 8 + panelHeight + 8 const favoriteMode = history.historyTab === 'favorites' const detailSession = history.historyTab === 'history' || history.openFavorite ? history.selectedSession : null @@ -129,6 +129,7 @@ export function HistoryOverlay({ busy={busy} onRemoveSession={() => setRemoveConfirmVisible(true)} onPanelHeightChange={onPanelHeightChange} + listButtonRef={listButtonRef} /> )} @@ -166,7 +167,8 @@ export function HistoryOverlay({ void onPanelHeightChange: (height: number) => void + listButtonRef: RefObject } /** The replayed ride: chart panel, stats and header. Shared by history mode and favorite mode. */ @@ -36,6 +38,7 @@ export function HistoryRideDetail({ busy, onRemoveSession, onPanelHeightChange, + listButtonRef, }: HistoryRideDetailProps) { const [deleteVisible, setDeleteVisible] = useState(false) const [trimName, setTrimName] = useState('') @@ -75,6 +78,7 @@ export function HistoryRideDetail({ mediaUnmatched={history.mediaHistory.unmatched} mediaLoading={history.mediaHistory.loading} mediaError={history.mediaHistory.error} + listButtonRef={listButtonRef} onPrevious={() => { void (favoriteMode ? history.selectPreviousFavorite() : history.selectPreviousRide()) }} diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 44484a283..2e73c962b 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useRef, useState, type RefObject } from 'react' import { StyleSheet, View } from 'react-native' import { useSharedValue } from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -47,6 +47,7 @@ interface HistoryTelemetryPanelProps { mediaUnmatched: MediaAssetInput[] mediaLoading: boolean mediaError: string | null + listButtonRef: RefObject onPrevious: () => void onNext: () => void onOpenList: () => void @@ -80,6 +81,7 @@ export function HistoryTelemetryPanel({ mediaUnmatched, mediaLoading, mediaError, + listButtonRef, onPrevious, onNext, onOpenList, @@ -176,6 +178,7 @@ export function HistoryTelemetryPanel({ mediaCount={mediaAssets.length + mediaUnmatched.length} mediaLoading={mediaLoading} mediaButtonRef={mediaButtonRef} + listButtonRef={listButtonRef} onPrevious={onPrevious} onNext={onNext} onOpenList={onOpenList} From 0b12793924ddae699182ced747aa120ca1d6a992 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 13:18:00 +0200 Subject: [PATCH 21/24] Fix review feedback --- .../telemetry/FavoriteSummaryBuilder.kt | 2 + .../vescapecore/telemetry/TelemetryDao.kt | 27 ++++++++++ .../telemetry/TelemetryRepository.kt | 13 +++-- .../ios/telemetry/FavoriteStoreTests.swift | 9 ++++ .../ios/telemetry/TelemetryRepository.swift | 25 ++++++--- src/modules/history/hooks/useMediaHistory.ts | 6 ++- src/modules/history/lib/favoritePreview.ts | 5 ++ .../history/store/favoriteStore.test.ts | 52 +++++++++++++++++++ src/modules/history/store/favoriteStore.ts | 22 ++++++-- .../history/store/historyStore.test.ts | 26 ++++++++++ src/modules/history/store/historyStore.ts | 1 + src/screens/main/history/HistoryControls.tsx | 21 ++++++-- src/screens/main/mainScreenStore.test.ts | 12 +++++ src/screens/main/mainScreenStore.ts | 9 +++- 14 files changed, 210 insertions(+), 20 deletions(-) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt index c801583c2..f5089ac22 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt @@ -22,6 +22,8 @@ internal data class FavoriteSummary( * into a history session summary, including the GPS-distance fallback for rides with no odometer. * * @parity /modules/vescape-core/ios/telemetry/FavoriteStore.swift `buildFavoriteSummary` + * @parity /src/modules/history/lib/favoritePreview.ts `summarizeFavoriteRange` + * @platform-diff JS is a live preview over loaded samples; this is the durable sanitized summary. * @platform-diff Only Android fills `gps_distance_cm`, so the GPS fallback has no iOS counterpart. */ internal fun buildFavoriteSummary(buckets: Collection): FavoriteSummary { diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt index 4e1fb8160..c25095186 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryDao.kt @@ -217,6 +217,33 @@ interface TelemetryDao { ) suspend fun getFrames(fromMs: Long, toMs: Long, deviceId: String?, limit: Int): List + @Query( + """ + SELECT DISTINCT device_id FROM telemetry_frames + WHERE captured_at_ms >= :fromMs + AND captured_at_ms <= :toMs + AND device_id IS NOT NULL + ORDER BY device_id ASC + """, + ) + suspend fun getDeviceIdsInRange(fromMs: Long, toMs: Long): List + + @Query( + """ + SELECT * FROM telemetry_frames + WHERE captured_at_ms >= :fromMs + AND captured_at_ms <= :toMs + AND device_id = :deviceId + ORDER BY captured_at_ms ASC + LIMIT 1 + """, + ) + suspend fun getFirstFrameInRange( + fromMs: Long, + toMs: Long, + deviceId: String, + ): TelemetryFrameEntity? + @Query("SELECT COUNT(*) FROM telemetry_frames") suspend fun countFrames(): Long diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index e88300bcb..3e8d51de2 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -779,17 +779,20 @@ class TelemetryRepository private constructor(context: Context) { val devices = if (deviceId != null) { listOf(deviceId) } else { - dao.getFrames(range.startMs, range.endMs, null, Int.MAX_VALUE) - .map { it.deviceId } - .distinct() + dao.getDeviceIdsInRange(range.startMs, range.endMs) } for (protectedDeviceId in devices) { - val first = getSampleStates( + val firstFrame = dao.getFirstFrameInRange( range.startMs, range.endMs, protectedDeviceId, + ) ?: continue + val first = getSampleStates( + range.startMs, + firstFrame.capturedAtMs, + protectedDeviceId, Int.MAX_VALUE, - ).firstOrNull() ?: continue + ).firstOrNull { it.id == firstFrame.id } ?: continue dao.updateFrame(first.state.toFrame(previous = null, keyframe = true).copy(id = first.id)) } } diff --git a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift index 95c342179..c06a1249f 100644 --- a/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -205,6 +205,15 @@ final class FavoriteStoreTests: XCTestCase { XCTAssertNil(map["distanceM"] ?? nil) } + func testFavoriteRangeRequiresValidBridgeBounds() { + XCTAssertNil(TelemetryRepository.favoriteRange([:])) + XCTAssertNil(TelemetryRepository.favoriteRange(["startMs": 2_000, "endMs": 1_000])) + XCTAssertEqual( + TelemetryRepository.favoriteRange(["startMs": 1_000, "endMs": 2_000]), + TelemetryTimeRange(startMs: 1_000, endMs: 2_000) + ) + } + // MARK: - Summary from raw samples func testSummaryAggregatesRawSamplesAcrossBucketBoundaries() { diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index cb8bf9dc7..982990687 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -251,9 +251,9 @@ internal final class TelemetryRepository { func createFavorite(_ options: [String: Any]) -> [String: Any?]? { flushBlocking() guard let pool else { return nil } - let startMs = telemetryLong(options["startMs"]) ?? 0 - let endMs = telemetryLong(options["endMs"]) ?? 0 - guard endMs >= startMs else { return nil } + guard let range = Self.favoriteRange(options) else { return nil } + let startMs = range.startMs + let endMs = range.endMs let deviceId = options["deviceId"] as? String let trimmedName = (options["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let config = queue.sync { metricConfig } @@ -303,6 +303,17 @@ internal final class TelemetryRepository { return names } + /// Favorite ranges are required bridge input. Missing or inverted bounds must fail exactly like + /// Android's `requiredLong` path instead of silently pinning epoch zero. + internal static func favoriteRange(_ options: [String: Any]) -> TelemetryTimeRange? { + guard + let startMs = telemetryLong(options["startMs"]), + let endMs = telemetryLong(options["endMs"]), + endMs >= startMs + else { return nil } + return TelemetryTimeRange(startMs: startMs, endMs: endMs) + } + /// Re-trim/rename a Favorite in place. Identity, creation time and Favorite Media stay attached; /// summary stats are rebuilt from raw samples for the new exact range. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `updateFavorite` @@ -310,9 +321,9 @@ internal final class TelemetryRepository { flushBlocking() guard let existing = FavoriteStore.shared.list().first(where: { $0.id == id }), let pool else { return nil } - let startMs = telemetryLong(options["startMs"]) ?? 0 - let endMs = telemetryLong(options["endMs"]) ?? 0 - guard endMs >= startMs else { return nil } + guard let range = Self.favoriteRange(options) else { return nil } + let startMs = range.startMs + let endMs = range.endMs let deviceId = options["deviceId"] as? String let trimmedName = (options["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let config = queue.sync { metricConfig } @@ -382,6 +393,8 @@ internal final class TelemetryRepository { /// applies, then collapse the resulting buckets into one denormalized summary. Exclusion ranges /// are deliberately not persisted: creating a Favorite is a read of Ride History, not a rewrite. /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `favoriteSummary` + /// @parity /src/modules/history/lib/favoritePreview.ts `summarizeFavoriteRange` + /// @platform-diff JS is a live preview over loaded samples; this is the durable sanitized summary. internal static func favoriteSummary( _ points: [BucketTelemetryPoint], config: MetricSanitizerConfig diff --git a/src/modules/history/hooks/useMediaHistory.ts b/src/modules/history/hooks/useMediaHistory.ts index f3f28ddf7..24ebfb73d 100644 --- a/src/modules/history/hooks/useMediaHistory.ts +++ b/src/modules/history/hooks/useMediaHistory.ts @@ -98,8 +98,12 @@ export function useFavoriteMedia({ try { const picked = await pickFavoriteMedia(favoriteId) if (picked.length === 0) return - for (const media of picked) await importFavoriteMedia(media) + const imports = await Promise.allSettled(picked.map((media) => importFavoriteMedia(media))) setStored((await getFavoriteMedia(favoriteId)).map(toMediaAsset)) + const failedCount = imports.filter((result) => result.status === 'rejected').length + if (failedCount > 0) { + setError(`Could not save ${failedCount} of ${picked.length} Favorite Media items`) + } } catch (cause: unknown) { setError(cause instanceof Error ? cause.message : 'Could not save Favorite Media') } finally { diff --git a/src/modules/history/lib/favoritePreview.ts b/src/modules/history/lib/favoritePreview.ts index a6fee2ece..bbc2670c1 100644 --- a/src/modules/history/lib/favoritePreview.ts +++ b/src/modules/history/lib/favoritePreview.ts @@ -37,6 +37,11 @@ const EMPTY_STATS: FavoriteRangeStats = { * Summarize a trimmed time range from ride samples. `samples` and `gpsSamples` must be sorted * ascending by `capturedAtMs` — the caller sorts once per session, not per drag frame. Range bounds * are order-independent; `[a, b]` and `[b, a]` summarize the same span. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt `buildFavoriteSummary` + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `favoriteSummary` + * @platform-diff This is a best-effort UI preview over already-loaded samples; native recomputes + * durable stats with Metric Sanitizers and persisted bucket semantics when the Favorite is saved. */ export function summarizeFavoriteRange( samples: TelemetrySample[], diff --git a/src/modules/history/store/favoriteStore.test.ts b/src/modules/history/store/favoriteStore.test.ts index 8757208c7..1e5c8515f 100644 --- a/src/modules/history/store/favoriteStore.test.ts +++ b/src/modules/history/store/favoriteStore.test.ts @@ -84,6 +84,58 @@ test('keeps the list newest first after adding a favorite', async () => { expect(useFavoriteStore.getState().favorites.map((f) => f.id)).toEqual(['newer', 'older']) }) +test('does not let a stale load overwrite a favorite added while it was in flight', async () => { + const created = favorite({ id: 'newer', startMs: 3_000_000 }) + let resolveLoad: (favorites: Favorite[]) => void = () => {} + getFavorites.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoad = resolve + }), + ) + createFavorite.mockImplementation(async () => created) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + const load = useFavoriteStore.getState().load() + await Promise.resolve() + await useFavoriteStore.getState().add({ startMs: created.startMs, endMs: created.endMs }) + resolveLoad([]) + await load + + expect(useFavoriteStore.getState().favorites).toEqual([created]) + expect(useFavoriteStore.getState().loading).toBe(false) +}) + +test('does not apply a load snapshot taken during an in-flight favorite mutation', async () => { + const created = favorite({ id: 'newer', startMs: 3_000_000 }) + let resolveCreate: (favorite: Favorite) => void = () => {} + let resolveLoad: (favorites: Favorite[]) => void = () => {} + createFavorite.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreate = resolve + }), + ) + getFavorites.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoad = resolve + }), + ) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + const add = useFavoriteStore.getState().add({ startMs: created.startMs, endMs: created.endMs }) + await Promise.resolve() + const load = useFavoriteStore.getState().load() + resolveCreate(created) + await add + resolveLoad([]) + await load + + expect(useFavoriteStore.getState().favorites).toEqual([created]) + expect(useFavoriteStore.getState().loading).toBe(false) +}) + test('surfaces a create failure instead of inserting a phantom row', async () => { createFavorite.mockImplementation(async () => { throw new Error('range has no samples') diff --git a/src/modules/history/store/favoriteStore.ts b/src/modules/history/store/favoriteStore.ts index a1ac02f00..4b3b62c6d 100644 --- a/src/modules/history/store/favoriteStore.ts +++ b/src/modules/history/store/favoriteStore.ts @@ -27,6 +27,9 @@ interface FavoriteActions { remove: (id: string) => Promise } +let favoriteLoadVersion = 0 +let favoriteMutationVersion = 0 + export const useFavoriteStore = create((set, get) => ({ favorites: [], loading: false, @@ -34,18 +37,26 @@ export const useFavoriteStore = create((set, ge error: undefined, async load() { + const loadVersion = ++favoriteLoadVersion + const mutationVersion = favoriteMutationVersion set({ loading: true, error: undefined }) try { - set({ favorites: await getFavorites() }) + const favorites = await getFavorites() + if (loadVersion === favoriteLoadVersion && mutationVersion === favoriteMutationVersion) { + set({ favorites }) + } } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + if (loadVersion === favoriteLoadVersion && mutationVersion === favoriteMutationVersion) { + set({ error: err instanceof Error ? err.message : String(err) }) + } } finally { - set({ loading: false }) + if (loadVersion === favoriteLoadVersion) set({ loading: false }) } }, async add(options) { if (get().saving) return null + favoriteMutationVersion++ set({ saving: true, error: undefined }) try { const favorite = await createFavorite(options) @@ -57,12 +68,14 @@ export const useFavoriteStore = create((set, ge set({ error: err instanceof Error ? err.message : String(err) }) return null } finally { + favoriteMutationVersion++ set({ saving: false }) } }, async update(id, options) { if (get().saving) return null + favoriteMutationVersion++ set({ saving: true, error: undefined }) try { const updated = await updateFavorite(id, options) @@ -76,12 +89,14 @@ export const useFavoriteStore = create((set, ge set({ error: err instanceof Error ? err.message : String(err) }) return null } finally { + favoriteMutationVersion++ set({ saving: false }) } }, async remove(id) { if (get().saving) return + favoriteMutationVersion++ set({ saving: true, error: undefined }) try { await deleteFavorite(id) @@ -89,6 +104,7 @@ export const useFavoriteStore = create((set, ge } catch (err) { set({ error: err instanceof Error ? err.message : String(err) }) } finally { + favoriteMutationVersion++ set({ saving: false }) } }, diff --git a/src/modules/history/store/historyStore.test.ts b/src/modules/history/store/historyStore.test.ts index 87303f0ce..77e32ec9a 100644 --- a/src/modules/history/store/historyStore.test.ts +++ b/src/modules/history/store/historyStore.test.ts @@ -461,3 +461,29 @@ test('keeps selected session addressable when older page expands it', async () = expect(useHistoryStore.getState().selectedSession?.startAtMs).toBe(1_960_000) expect(useHistoryStore.getState().selectedSession?.endAtMs).toBe(2_060_000) }) + +test('clearHistory invalidates an in-flight live refresh', async () => { + const stale = block({ + id: 'stale', + startAtMs: 1_000_000, + endAtMs: 1_060_000, + }) + let resolveRefresh: (blocks: TelemetryMinuteBucket[]) => void = () => {} + getTelemetryHistory.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + getTelemetryHistory.mockResolvedValueOnce([]) + const { useHistoryStore } = await import('@/modules/history/store/historyStore') + + const refresh = useHistoryStore.getState().refreshLive() + await Promise.resolve() + await useHistoryStore.getState().clearHistory() + resolveRefresh([stale]) + await refresh + + expect(useHistoryStore.getState().blocks).toEqual([]) + expect(useHistoryStore.getState().liveBlocks).toEqual([]) +}) diff --git a/src/modules/history/store/historyStore.ts b/src/modules/history/store/historyStore.ts index c68510599..ccb82a6c7 100644 --- a/src/modules/history/store/historyStore.ts +++ b/src/modules/history/store/historyStore.ts @@ -404,6 +404,7 @@ export const useHistoryStore = create((set, get) async clearHistory() { const reloadLimit = Math.min(500, Math.max(PAGE_SIZE, get().blocks.length)) + liveRefreshVersion++ set({ loading: true, error: undefined }) try { await clearTelemetryHistory() diff --git a/src/screens/main/history/HistoryControls.tsx b/src/screens/main/history/HistoryControls.tsx index 86740091a..eab0b96c9 100644 --- a/src/screens/main/history/HistoryControls.tsx +++ b/src/screens/main/history/HistoryControls.tsx @@ -62,7 +62,13 @@ export function HistoryControls({ return ( - + @@ -90,7 +97,7 @@ export function HistoryControls({ return ( - + ) : null} {!favorite && canRemove ? ( - + ) : !favorite ? ( ) : null} diff --git a/src/screens/main/mainScreenStore.test.ts b/src/screens/main/mainScreenStore.test.ts index fdb253208..78689eda1 100644 --- a/src/screens/main/mainScreenStore.test.ts +++ b/src/screens/main/mainScreenStore.test.ts @@ -82,4 +82,16 @@ describe('mainScreenStore', () => { expect(state.historySheetVisible).toBe(false) expect(state.seekTimeMs).toBe(null) }) + + test('ends Favorite trimming when the ride context changes', () => { + const store = useMainScreenStore.getState() + + store.beginTrim({ startMs: 1_000, endMs: 2_000 }) + store.setHistoryTab('favorites') + expect(useMainScreenStore.getState().trimRange).toBe(null) + + useMainScreenStore.getState().beginTrim({ startMs: 3_000, endMs: 4_000 }) + useMainScreenStore.getState().openFavorite('favorite-1') + expect(useMainScreenStore.getState().trimRange).toBe(null) + }) }) diff --git a/src/screens/main/mainScreenStore.ts b/src/screens/main/mainScreenStore.ts index d8042add8..2e853a6b3 100644 --- a/src/screens/main/mainScreenStore.ts +++ b/src/screens/main/mainScreenStore.ts @@ -103,12 +103,17 @@ export const useMainScreenStore = create((s set((state) => state.historyTab === tab ? state - : { historyTab: tab, historySheetVisible: false, openFavoriteId: null }, + : { + historyTab: tab, + historySheetVisible: false, + openFavoriteId: null, + trimRange: null, + }, ) }, openFavorite(id) { - set({ openFavoriteId: id, historySheetVisible: false, seekTimeMs: null }) + set({ openFavoriteId: id, historySheetVisible: false, seekTimeMs: null, trimRange: null }) }, closeFavorite() { From 9c91dba43205c2376aa24aa312ca18a160bce70b Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 13:47:34 +0200 Subject: [PATCH 22/24] Polish favorites --- CONTEXT.md | 2 +- modules/vescape-core/android/build.gradle | 4 + .../vescapecore/telemetry/FavoriteDaoTest.kt | 73 +++++++++++++++++++ .../modules/vescapecore/VescapeCoreModule.kt | 1 + .../telemetry/TelemetryRepository.kt | 24 ++++-- .../telemetry/FavoriteRangeTest.kt | 17 +++++ .../ios/telemetry/TelemetryRepository.swift | 5 +- .../history/components/HistoryEmptyState.tsx | 18 +++-- .../history/components/HistoryPanelNav.tsx | 9 ++- src/screens/main/history/HistoryOverlay.tsx | 22 +----- 10 files changed, 137 insertions(+), 38 deletions(-) create mode 100644 modules/vescape-core/android/src/androidTest/java/expo/modules/vescapecore/telemetry/FavoriteDaoTest.kt create mode 100644 modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteRangeTest.kt diff --git a/CONTEXT.md b/CONTEXT.md index 11cad502d..5e624e1f3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -97,7 +97,7 @@ A temporary state of a Ride Recording in which sample persistence halts because _Avoid_: Stop recording, auto-stop, sleep, parked mode **Favorite**: -A user-created, optionally named durable time range over Ride History, created by trimming a past ride to the span the rider wants to keep. A ride may produce multiple Favorites. Its name can be changed or cleared later; its range and its summary stats cannot — re-trimming is delete and recreate. A Favorite pins its telemetry range: history deletion skips favorited ranges, and removing a Favorite only unpins — it never deletes telemetry. Owns its Favorite Media. +A user-created, optionally named durable time range over Ride History, created by trimming a past ride to the span the rider wants to keep. A ride may produce multiple Favorites. Its name can be changed or cleared later, and re-trimming updates its range and recomputed summary while preserving its identity and Favorite Media. A Favorite pins its telemetry range: history deletion skips favorited ranges, and removing a Favorite only unpins — it never deletes telemetry. Owns its Favorite Media. _Avoid_: Favorite ride, segment, bookmark, saved ride **Favorite Media**: diff --git a/modules/vescape-core/android/build.gradle b/modules/vescape-core/android/build.gradle index 066c045bf..5154a0e27 100644 --- a/modules/vescape-core/android/build.gradle +++ b/modules/vescape-core/android/build.gradle @@ -24,6 +24,7 @@ android { defaultConfig { minSdkVersion safeExtGet("minSdkVersion", 26) targetSdkVersion safeExtGet("targetSdkVersion", 35) + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -59,4 +60,7 @@ dependencies { testImplementation "junit:junit:4.13.2" testImplementation "org.json:json:20231013" testImplementation "androidx.room:room-testing:2.8.4" + androidTestImplementation "androidx.test:core:1.6.1" + androidTestImplementation "androidx.test.ext:junit:1.2.1" + androidTestImplementation "androidx.test:runner:1.6.2" } diff --git a/modules/vescape-core/android/src/androidTest/java/expo/modules/vescapecore/telemetry/FavoriteDaoTest.kt b/modules/vescape-core/android/src/androidTest/java/expo/modules/vescapecore/telemetry/FavoriteDaoTest.kt new file mode 100644 index 000000000..bed239e7f --- /dev/null +++ b/modules/vescape-core/android/src/androidTest/java/expo/modules/vescapecore/telemetry/FavoriteDaoTest.kt @@ -0,0 +1,73 @@ +package expo.modules.vescapecore.telemetry + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FavoriteDaoTest { + private lateinit var database: TelemetryDatabase + private lateinit var dao: TelemetryDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, TelemetryDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = database.telemetryDao() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun favoriteCrudRoundTrip() = runBlocking { + val older = favorite(id = "older", name = null, startMs = 1_000, updatedAt = 1_000) + val newer = favorite(id = "newer", name = "Evening ride", startMs = 3_000, updatedAt = 3_000) + + dao.insertFavorite(older) + dao.insertFavorite(newer) + assertEquals(listOf(newer, older), dao.getFavorites()) + + val renamed = newer.copy(name = "Night ride", endMs = 4_500, updatedAt = 4_500) + assertEquals(1, dao.updateFavorite(renamed)) + assertEquals(renamed, dao.getFavorite(newer.id)) + + assertEquals(1, dao.deleteFavorite(newer.id)) + assertNull(dao.getFavorite(newer.id)) + assertEquals(listOf(older), dao.getFavorites()) + } + + private fun favorite( + id: String, + name: String?, + startMs: Long, + updatedAt: Long, + ) = FavoriteEntity( + id = id, + boardId = null, + name = name, + startMs = startMs, + endMs = startMs + 1_000, + createdAt = startMs, + updatedAt = updatedAt, + sampleCount = 10, + gpsPointCount = 5, + distanceCm = 120_000, + movingDurationMs = 60_000, + avgSpeedCentiKmh = 2_000, + maxSpeedCentiKmh = 3_000, + batteryUsedWhMilli = 1_500, + ) +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 2112e6fef..026984bee 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -554,6 +554,7 @@ class VescapeCoreModule : Module() { } AsyncFunction("createFavorite") Coroutine { options: Map -> TelemetryRepository.get(context.applicationContext).createFavorite(options) + ?: throw CodedException("ERR_CREATE_FAVORITE", "favorite range is invalid or could not be stored", null) } AsyncFunction("updateFavorite") Coroutine { id: String, options: Map -> TelemetryRepository.get(context.applicationContext).updateFavorite(id, options) diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt index 3e8d51de2..d898e75d9 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt @@ -558,9 +558,9 @@ class TelemetryRepository private constructor(context: Context) { * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `createFavorite` */ suspend fun createFavorite(options: Map): Map? = withContext(Dispatchers.IO) { - val startMs = options.requiredLong("startMs") - val endMs = options.requiredLong("endMs") - require(endMs >= startMs) { "endMs must be greater than or equal to startMs" } + val range = favoriteRange(options) ?: return@withContext null + val startMs = range.startMs + val endMs = range.endMs val deviceId = options["deviceId"] as? String val name = (options["name"] as? String)?.trim()?.ifEmpty { null } flushNow() @@ -604,9 +604,9 @@ class TelemetryRepository private constructor(context: Context) { options: Map, ): Map? = withContext(Dispatchers.IO) { val existing = dao.getFavorite(id) ?: return@withContext null - val startMs = options.requiredLong("startMs") - val endMs = options.requiredLong("endMs") - require(endMs >= startMs) { "endMs must be greater than or equal to startMs" } + val range = favoriteRange(options) ?: return@withContext null + val startMs = range.startMs + val endMs = range.endMs val deviceId = options["deviceId"] as? String val name = (options["name"] as? String)?.trim()?.ifEmpty { null } flushNow() @@ -1293,5 +1293,17 @@ private fun Map.long(key: String): Long? = (this[key] as? Number)? private fun Map.int(key: String): Int? = (this[key] as? Number)?.toInt() +/** + * Favorite ranges are required bridge input. Invalid bounds return through the module's controlled + * `ERR_CREATE_FAVORITE` / `ERR_UPDATE_FAVORITE` path instead of leaking IllegalArgumentException. + * + * @parity /modules/vescape-core/ios/telemetry/TelemetryRepository.swift `favoriteRange` + */ +internal fun favoriteRange(options: Map): TelemetryTimeRange? { + val startMs = options.long("startMs") ?: return null + val endMs = options.long("endMs") ?: return null + return if (endMs >= startMs) TelemetryTimeRange(startMs, endMs) else null +} + private fun Map.requiredLong(key: String): Long = long(key) ?: throw IllegalArgumentException("$key is required") diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteRangeTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteRangeTest.kt new file mode 100644 index 000000000..70e918dc3 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteRangeTest.kt @@ -0,0 +1,17 @@ +package expo.modules.vescapecore.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FavoriteRangeTest { + @Test + fun `favorite range requires valid bridge bounds`() { + assertNull(favoriteRange(emptyMap())) + assertNull(favoriteRange(mapOf("startMs" to 2_000, "endMs" to 1_000))) + assertEquals( + TelemetryTimeRange(1_000, 2_000), + favoriteRange(mapOf("startMs" to 1_000, "endMs" to 2_000)), + ) + } +} diff --git a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift index 982990687..70a160a40 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -303,8 +303,9 @@ internal final class TelemetryRepository { return names } - /// Favorite ranges are required bridge input. Missing or inverted bounds must fail exactly like - /// Android's `requiredLong` path instead of silently pinning epoch zero. + /// Favorite ranges are required bridge input. Missing or inverted bounds must fail instead of + /// silently pinning epoch zero. + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `favoriteRange` internal static func favoriteRange(_ options: [String: Any]) -> TelemetryTimeRange? { guard let startMs = telemetryLong(options["startMs"]), diff --git a/src/modules/history/components/HistoryEmptyState.tsx b/src/modules/history/components/HistoryEmptyState.tsx index 5e12f86fc..152ebfbbb 100644 --- a/src/modules/history/components/HistoryEmptyState.tsx +++ b/src/modules/history/components/HistoryEmptyState.tsx @@ -1,6 +1,6 @@ import { StyleSheet, useWindowDimensions, View } from 'react-native' import { Canvas, Group, RadialGradient, Rect, vec } from '@shopify/react-native-skia' -import { ClockCounterClockwiseIcon } from 'phosphor-react-native' +import { ClockCounterClockwiseIcon, StarIcon } from 'phosphor-react-native' import { Placeholder } from '@/components/base/Placeholder' import { theme } from '@/constants/theme' @@ -31,14 +31,22 @@ function CenterDim() { ) } -export function HistoryEmptyState() { +interface HistoryEmptyStateProps { + favoriteMode?: boolean +} + +export function HistoryEmptyState({ favoriteMode = false }: HistoryEmptyStateProps) { return ( ) diff --git a/src/modules/history/components/HistoryPanelNav.tsx b/src/modules/history/components/HistoryPanelNav.tsx index 57e2df1fd..f58709742 100644 --- a/src/modules/history/components/HistoryPanelNav.tsx +++ b/src/modules/history/components/HistoryPanelNav.tsx @@ -68,6 +68,7 @@ export function HistoryPanelNav({ loading={mediaLoading} size="lg" style={mediaCount > 0 ? styles.mediaEnabled : undefined} + accessibilityLabel="Favorite media" /> {mediaCount > 0 ? ( @@ -108,6 +109,7 @@ export function HistoryPanelNav({ size="lg" testID="history-share-favorite" disabled={actionDisabled} + accessibilityLabel="Share Favorite" /> ) : ( )} @@ -156,11 +159,11 @@ const styles = StyleSheet.create({ justifyContent: 'center', borderRadius: 9, borderWidth: 1, - borderColor: theme.palette.slate.surfaceDeep, - backgroundColor: theme.palette.purple.color, + borderColor: theme.palette.purple.border, + backgroundColor: theme.palette.purple.bg, }, mediaCountText: { - color: theme.palette.slate.bg, + color: theme.palette.purple.text, fontSize: 9, fontWeight: '800', fontVariant: ['tabular-nums'], diff --git a/src/screens/main/history/HistoryOverlay.tsx b/src/screens/main/history/HistoryOverlay.tsx index a105519af..72f34bdd0 100644 --- a/src/screens/main/history/HistoryOverlay.tsx +++ b/src/screens/main/history/HistoryOverlay.tsx @@ -1,10 +1,8 @@ import { useCallback, useRef, useState } from 'react' import { StyleSheet, View } from 'react-native' -import { StarIcon } from 'phosphor-react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import type { Favorite, HistoryGpsSample, HistoryMarker } from 'vescape-core' -import { Placeholder } from '@/components/base/Placeholder' import { Text } from '@/components/base/Text' import { ConfirmModal } from '@/components/modals/ConfirmModal' import { theme } from '@/constants/theme' @@ -135,19 +133,7 @@ export function HistoryOverlay({ {visible && !detailSession && ( <> - {busy ? ( - - ) : favoriteMode ? ( - - - - ) : ( - - )} + {busy ? : } Date: Thu, 30 Jul 2026 13:58:09 +0200 Subject: [PATCH 23/24] Mark favorite routes --- src/app/settings/components/map.tsx | 5 +- src/modules/history/lib/favoriteRoute.test.ts | 70 ++++++++++++++++ src/modules/history/lib/favoriteRoute.ts | 80 +++++++++++++++++++ src/screens/main/MainScreen.tsx | 6 ++ src/screens/main/map/MainMap.tsx | 1 + src/screens/main/map/MainMapLayers.tsx | 51 ++++++++++++ src/screens/main/map/MainMapScene.tsx | 1 + src/screens/showcase/mapShowcaseFixtures.ts | 7 ++ 8 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/modules/history/lib/favoriteRoute.test.ts create mode 100644 src/modules/history/lib/favoriteRoute.ts diff --git a/src/app/settings/components/map.tsx b/src/app/settings/components/map.tsx index 2ac0acd01..757b9a2d9 100644 --- a/src/app/settings/components/map.tsx +++ b/src/app/settings/components/map.tsx @@ -22,6 +22,7 @@ import { FIXTURE_CAMERA_CENTER, FIXTURE_CAMERA_ZOOM, FIXTURE_DIRECTION_POINT, + FIXTURE_FAVORITE_RANGES, FIXTURE_GPS_PUCK_BEARING_DEG, FIXTURE_HISTORY_METRIC_HOT_RANGES, FIXTURE_LIVE_TRAIL_SHAPE, @@ -53,7 +54,7 @@ export default function MapComponentsShowcase() { const [styleExpanded, setStyleExpanded] = useState(false) const [weatherActive, setWeatherActive] = useState(false) const [legalLimitsActive, setLegalLimitsActive] = useState(false) - const [mapPoints, setMapPoints] = useState(FIXTURE_MAP_POINTS) + const [mapPoints] = useState(FIXTURE_MAP_POINTS) const [selectedMapPointId, setSelectedMapPointId] = useState(null) const [activeHistoryMapMetric, setActiveHistoryMapMetric] = useState('speed') const [lastEvent, setLastEvent] = useState(null) @@ -128,6 +129,7 @@ export default function MapComponentsShowcase() { rideMarkers={[]} rideGpsSamples={[]} mediaAssets={[]} + favoriteRanges={[]} mapZoom={FIXTURE_CAMERA_ZOOM} historyMetricGradientsEnabled historyMetricHotRanges={FIXTURE_HISTORY_METRIC_HOT_RANGES} @@ -156,6 +158,7 @@ export default function MapComponentsShowcase() { rideMarkers={FIXTURE_RIDE_MARKERS} rideGpsSamples={FIXTURE_RIDE_GPS_SAMPLES} mediaAssets={FIXTURE_MEDIA_ASSETS} + favoriteRanges={FIXTURE_FAVORITE_RANGES} mapZoom={FIXTURE_CAMERA_ZOOM} historyMetricGradientsEnabled historyMetricHotRanges={FIXTURE_HISTORY_METRIC_HOT_RANGES} diff --git a/src/modules/history/lib/favoriteRoute.test.ts b/src/modules/history/lib/favoriteRoute.test.ts new file mode 100644 index 000000000..1e8b44c53 --- /dev/null +++ b/src/modules/history/lib/favoriteRoute.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import type { HistoryGpsSample } from 'vescape-core' + +import { getFavoriteRouteSegments } from '@/modules/history/lib/favoriteRoute' + +function gps(capturedAtMs: number, longitude: number): HistoryGpsSample { + return { + id: capturedAtMs, + capturedAtMs, + deviceId: 'board', + deviceName: 'Board', + longitude, + latitude: longitude, + speedMps: null, + bearingDeg: null, + accuracyM: null, + altitudeM: null, + timestamp: capturedAtMs, + precise: true, + distanceFromPreviousM: null, + } +} + +const route = [gps(0, 0), gps(1_000, 10), gps(2_000, 20), gps(3_000, 30)] + +describe('favorite route segments', () => { + test('interpolates favorite edges between persisted GPS samples', () => { + expect(getFavoriteRouteSegments(route, [{ startMs: 500, endMs: 1_500 }])).toEqual([ + [ + [5, 5], + [10, 10], + [15, 15], + ], + ]) + }) + + test('merges overlapping favorites into one glow segment', () => { + expect( + getFavoriteRouteSegments(route, [ + { startMs: 250, endMs: 1_250 }, + { startMs: 1_000, endMs: 2_500 }, + ]), + ).toEqual([ + [ + [2.5, 2.5], + [10, 10], + [20, 20], + [25, 25], + ], + ]) + }) + + test('keeps separate favorites as separate route segments', () => { + expect( + getFavoriteRouteSegments(route, [ + { startMs: 0, endMs: 500 }, + { startMs: 2_500, endMs: 3_000 }, + ]), + ).toEqual([ + [ + [0, 0], + [5, 5], + ], + [ + [25, 25], + [30, 30], + ], + ]) + }) +}) diff --git a/src/modules/history/lib/favoriteRoute.ts b/src/modules/history/lib/favoriteRoute.ts new file mode 100644 index 000000000..3de032114 --- /dev/null +++ b/src/modules/history/lib/favoriteRoute.ts @@ -0,0 +1,80 @@ +import type { HistoryGpsSample } from 'vescape-core' + +export interface FavoriteTimeRange { + startMs: number + endMs: number +} + +type Coordinate = [longitude: number, latitude: number] + +function interpolateCoordinate( + from: HistoryGpsSample, + to: HistoryGpsSample, + capturedAtMs: number, +): Coordinate { + const spanMs = to.capturedAtMs - from.capturedAtMs + if (spanMs <= 0) return [from.longitude, from.latitude] + const progress = Math.max(0, Math.min(1, (capturedAtMs - from.capturedAtMs) / spanMs)) + return [ + from.longitude + (to.longitude - from.longitude) * progress, + from.latitude + (to.latitude - from.latitude) * progress, + ] +} + +function appendCoordinate(coordinates: Coordinate[], coordinate: Coordinate) { + const previous = coordinates.at(-1) + if (previous?.[0] === coordinate[0] && previous[1] === coordinate[1]) return + coordinates.push(coordinate) +} + +function mergeRanges(ranges: readonly FavoriteTimeRange[]): FavoriteTimeRange[] { + const sorted = ranges + .map(({ startMs, endMs }) => ({ + startMs: Math.min(startMs, endMs), + endMs: Math.max(startMs, endMs), + })) + .sort((a, b) => a.startMs - b.startMs) + + const merged: FavoriteTimeRange[] = [] + for (const range of sorted) { + const previous = merged.at(-1) + if (!previous || range.startMs > previous.endMs) { + merged.push(range) + } else { + previous.endMs = Math.max(previous.endMs, range.endMs) + } + } + return merged +} + +/** + * Clip the selected ride's GPS polyline to the union of its Favorite ranges. Boundary coordinates + * are time-interpolated so a short Favorite remains visible even when neither edge lands exactly on + * a persisted GPS sample. `gpsSamples` must be sorted ascending by `capturedAtMs`. + */ +export function getFavoriteRouteSegments( + gpsSamples: readonly HistoryGpsSample[], + favoriteRanges: readonly FavoriteTimeRange[], +): Coordinate[][] { + if (gpsSamples.length < 2 || favoriteRanges.length === 0) return [] + + const segments: Coordinate[][] = [] + for (const range of mergeRanges(favoriteRanges)) { + const coordinates: Coordinate[] = [] + for (let index = 0; index < gpsSamples.length - 1; index += 1) { + const from = gpsSamples[index] + const to = gpsSamples[index + 1] + if (to.capturedAtMs < range.startMs) continue + if (from.capturedAtMs > range.endMs) break + + const overlapStartMs = Math.max(from.capturedAtMs, range.startMs) + const overlapEndMs = Math.min(to.capturedAtMs, range.endMs) + if (overlapEndMs < overlapStartMs) continue + + appendCoordinate(coordinates, interpolateCoordinate(from, to, overlapStartMs)) + appendCoordinate(coordinates, interpolateCoordinate(from, to, overlapEndMs)) + } + if (coordinates.length >= 2) segments.push(coordinates) + } + return segments +} diff --git a/src/screens/main/MainScreen.tsx b/src/screens/main/MainScreen.tsx index 05369e188..b8417f70d 100644 --- a/src/screens/main/MainScreen.tsx +++ b/src/screens/main/MainScreen.tsx @@ -387,6 +387,10 @@ export function MainScreen({ telemetrySamples: controller.sessionSamples, markers: controller.sessionMarkers, mediaAssets: controller.mediaHistory.assets, + favoriteRanges: + controller.historyTab === 'history' + ? controller.favorites.map(({ startMs, endMs }) => ({ startMs, endMs })) + : [], onOpenMedia: controller.openMedia, activeMapMetric: controller.activeHistoryMapMetric, }), @@ -395,6 +399,8 @@ export function MainScreen({ controller.historyActive, controller.historyPreview, controller.historyPreviewRoute, + controller.historyTab, + controller.favorites, controller.mediaHistory.assets, controller.openMedia, controller.selectedSession?.id, diff --git a/src/screens/main/map/MainMap.tsx b/src/screens/main/map/MainMap.tsx index e21e96210..72c36b011 100644 --- a/src/screens/main/map/MainMap.tsx +++ b/src/screens/main/map/MainMap.tsx @@ -80,6 +80,7 @@ export interface MainMapHistoryProps { telemetrySamples: TelemetrySample[] markers: HistoryMarker[] mediaAssets: MediaHistoryAsset[] + favoriteRanges: { startMs: number; endMs: number }[] onOpenMedia: (asset: MediaHistoryAsset) => void activeMapMetric: HistoryMetricKey } diff --git a/src/screens/main/map/MainMapLayers.tsx b/src/screens/main/map/MainMapLayers.tsx index 479d1cd50..2dff76eb2 100644 --- a/src/screens/main/map/MainMapLayers.tsx +++ b/src/screens/main/map/MainMapLayers.tsx @@ -31,6 +31,7 @@ import { import { theme } from '@/constants/theme' import { makeCircleFeature, makeTrailLineString } from '@/helpers/mapGeometry' import { findNearestSampleIndexByTime } from '@/modules/history/lib/playback' +import { getFavoriteRouteSegments } from '@/modules/history/lib/favoriteRoute' import { resolveMarkerRenderData } from '@/modules/history/lib/markerOverlap' import type { MapSelection } from '@/modules/map/lib/mapSelection' import { @@ -102,6 +103,7 @@ interface MainMapLayersProps { rideMarkers: HistoryMarker[] rideGpsSamples: HistoryGpsSample[] mediaAssets: MediaHistoryAsset[] + favoriteRanges: { startMs: number; endMs: number }[] mapZoom: number historyMetricGradientsEnabled: boolean historyMetricHotRanges: HistoryMetricHotRanges @@ -330,6 +332,45 @@ function TrimRouteHighlight({ rideGpsSamples }: { rideGpsSamples: HistoryGpsSamp ) } +function FavoriteRouteBorder({ + rideGpsSamples, + favoriteRanges, + highContrastRoutes, + trimming, +}: { + rideGpsSamples: HistoryGpsSample[] + favoriteRanges: MainMapLayersProps['favoriteRanges'] + highContrastRoutes: boolean + trimming: boolean +}) { + const shape = useMemo(() => { + const coordinates = getFavoriteRouteSegments(rideGpsSamples, favoriteRanges) + if (coordinates.length === 0) return null + return { + type: 'Feature', + geometry: { type: 'MultiLineString', coordinates }, + properties: {}, + } as const + }, [favoriteRanges, rideGpsSamples]) + + if (!shape) return null + return ( + + + + ) +} + function PendingNavigationTargetPin({ coordinate, color, @@ -371,6 +412,7 @@ export function HistoryMapLayers({ rideMarkers, rideGpsSamples, mediaAssets, + favoriteRanges, mapZoom, historyMetricGradientsEnabled: gradientsEnabled, historyMetricHotRanges: hotRanges, @@ -386,6 +428,7 @@ export function HistoryMapLayers({ rideMarkers: MainMapLayersProps['rideMarkers'] rideGpsSamples: MainMapLayersProps['rideGpsSamples'] mediaAssets: MainMapLayersProps['mediaAssets'] + favoriteRanges: MainMapLayersProps['favoriteRanges'] mapZoom: MainMapLayersProps['mapZoom'] historyMetricGradientsEnabled: MainMapLayersProps['historyMetricGradientsEnabled'] historyMetricHotRanges: MainMapLayersProps['historyMetricHotRanges'] @@ -447,6 +490,12 @@ export function HistoryMapLayers({ return ( <> + {rideRouteShape && ( { const t = index / (ROUTE_POINT_COUNT - 1) From 7318a2ea9dd3443bf5e7ff53193143a31c162362 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Thu, 30 Jul 2026 15:17:24 +0200 Subject: [PATCH 24/24] Mark favorite chart ranges --- src/app/settings/components/charts.tsx | 7 +++++ src/components/charts/TelemetryLineChart.tsx | 25 ++++++++++++++++++ src/components/charts/chartMath.test.ts | 18 +++++++++++++ src/components/charts/chartMath.ts | 26 +++++++++++++++++++ .../main/history/HistoryRideDetail.tsx | 1 + .../main/history/HistoryTelemetryPanel.tsx | 14 +++++++++- 6 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/app/settings/components/charts.tsx b/src/app/settings/components/charts.tsx index dd197be24..28e964248 100644 --- a/src/app/settings/components/charts.tsx +++ b/src/app/settings/components/charts.tsx @@ -356,6 +356,13 @@ function TrimChartShowcase() { onChange: (startMs, endMs) => setRange({ startMs, endMs }), onCommit: (startMs, endMs) => setRange({ startMs, endMs }), }} + timeRangeHighlights={[ + { + startMs: domainStartMs + span * 0.3, + endMs: domainStartMs + span * 0.55, + color: theme.alpha(theme.status.favorite.color, 0.12), + }, + ]} /> Selected span: {selectedSeconds}s diff --git a/src/components/charts/TelemetryLineChart.tsx b/src/components/charts/TelemetryLineChart.tsx index 9c5fb208b..fc622e5c9 100644 --- a/src/components/charts/TelemetryLineChart.tsx +++ b/src/components/charts/TelemetryLineChart.tsx @@ -18,6 +18,7 @@ import { Line, LinearGradient, Path, + Rect, RoundedRect, Skia, vec, @@ -26,6 +27,7 @@ import { import { theme } from '@/constants/theme' import { getChartPosition, + getChartTimeRangeBands, getChartTimeLabels, getXPosition, splitChartPointSegments, @@ -261,6 +263,12 @@ export interface SecondaryChartSeries { formatValue?: (value: number) => string } +export interface ChartTimeRangeHighlight { + startMs: number + endMs: number + color: string +} + interface TelemetryLineChartProps { label?: string value: string @@ -288,6 +296,8 @@ interface TelemetryLineChartProps { reserveRightAxis?: boolean /** When set, the chart is a range trimmer instead of a scrubber. */ trim?: ChartTrimConfig + /** Solid translucent bands rendered behind the chart lines. */ + timeRangeHighlights?: ChartTimeRangeHighlight[] } interface ChartLineSegmentsProps { @@ -397,6 +407,7 @@ export function TelemetryLineChart({ scrubbable = false, reserveRightAxis = false, trim, + timeRangeHighlights, }: TelemetryLineChartProps) { 'use no memo' const [chartWidth, setChartWidth] = useState(0) @@ -575,6 +586,10 @@ export function TelemetryLineChart({ const timeLabels = useMemo(() => { return getChartTimeLabels(displayPoints, windowMs, timeMode) }, [displayPoints, timeMode, windowMs]) + const timeRangeBands = useMemo( + () => getChartTimeRangeBands(displayPoints, timeRangeHighlights ?? [], chartWidth, windowMs), + [chartWidth, displayPoints, timeRangeHighlights, windowMs], + ) const activeColor = resolveActiveChartColor(currentPoint, color, getPointColor) const valueColorStyle = getPointColor && currentPoint ? { color: activeColor } : undefined @@ -626,6 +641,16 @@ export function TelemetryLineChart({ {chartWidth > 0 && ( + {timeRangeBands.map((band) => ( + + ))} { expect(pos).toEqual({ x: 100, y: 0 }) }) +test('chart time-range bands clip to the visible domain and ignore outside ranges', () => { + expect( + getChartTimeRangeBands( + points, + [ + { startMs: base - 1_000, endMs: base + 500, id: 'left' }, + { startMs: base + 1_500, endMs: base + 3_000, id: 'right' }, + { startMs: base + 3_000, endMs: base + 4_000, id: 'outside' }, + ], + 100, + ), + ).toEqual([ + { startMs: base - 1_000, endMs: base + 500, id: 'left', x: 0, width: 25 }, + { startMs: base + 1_500, endMs: base + 3_000, id: 'right', x: 75, width: 25 }, + ]) +}) + test('findNearestChartPointAtX picks nearest and clamps x', () => { expect(findNearestChartPointAtX(points, 0, 100)).toEqual(points[0]) expect(findNearestChartPointAtX(points, 50, 100)).toEqual(points[1]) diff --git a/src/components/charts/chartMath.ts b/src/components/charts/chartMath.ts index 01570174f..c05a361b0 100644 --- a/src/components/charts/chartMath.ts +++ b/src/components/charts/chartMath.ts @@ -141,6 +141,32 @@ export function getXPosition( return Math.max(0, Math.min(width, x)) } +export interface ChartTimeRange { + startMs: number + endMs: number +} + +export function getChartTimeRangeBands( + points: TelemetryChartPoint[], + ranges: readonly T[], + width: number, + windowMs?: number, +): (T & { x: number; width: number })[] { + if (points.length < 2 || width <= 0) return [] + const domainEndMs = points.at(-1)!.date.getTime() + const domainStartMs = windowMs ? domainEndMs - windowMs : points[0].date.getTime() + + return ranges.flatMap((range) => { + const startMs = Math.max(domainStartMs, Math.min(range.startMs, range.endMs)) + const endMs = Math.min(domainEndMs, Math.max(range.startMs, range.endMs)) + if (endMs <= startMs) return [] + const x1 = getXPosition(points, startMs, width, windowMs) + const x2 = getXPosition(points, endMs, width, windowMs) + if (x1 == null || x2 == null || x2 <= x1) return [] + return [{ ...range, x: x1, width: x2 - x1 }] + }) +} + export function toExcludedRanges( exclusions: Array<{ startMs: number diff --git a/src/screens/main/history/HistoryRideDetail.tsx b/src/screens/main/history/HistoryRideDetail.tsx index 8a7f67ebf..83dbb754f 100644 --- a/src/screens/main/history/HistoryRideDetail.tsx +++ b/src/screens/main/history/HistoryRideDetail.tsx @@ -72,6 +72,7 @@ export function HistoryRideDetail({ } canNext={!trimming && (favoriteMode ? history.canNextFavorite : history.nextRide != null)} favoriteMode={favoriteMode} + favoriteRanges={favoriteMode ? [] : history.favorites} favorited={history.selectedSessionFavorite != null} actionDisabled={busy || history.favoritesSaving} mediaAssets={history.mediaHistory.assets} diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 2e73c962b..84844e3af 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type RefObject } from 'react' +import { useCallback, useMemo, useRef, useState, type RefObject } from 'react' import { StyleSheet, View } from 'react-native' import { useSharedValue } from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import { type TelemetryChartPoint } from '@/components/charts/chartMath' import { TelemetryLineChart, type ChartTrimConfig } from '@/components/charts/TelemetryLineChart' import { InfoModal } from '@/components/modals/InfoModal' +import { theme } from '@/constants/theme' import { OPTIONAL_CHART_METRICS, SPEED_CHART_DEF, @@ -41,6 +42,7 @@ interface HistoryTelemetryPanelProps { canPrevious: boolean canNext: boolean favoriteMode: boolean + favoriteRanges: { startMs: number; endMs: number }[] favorited: boolean actionDisabled: boolean mediaAssets: MediaHistoryAsset[] @@ -75,6 +77,7 @@ export function HistoryTelemetryPanel({ canPrevious, canNext, favoriteMode, + favoriteRanges, favorited, actionDisabled, mediaAssets, @@ -120,6 +123,14 @@ export function HistoryTelemetryPanel({ pointColors, excludedRanges, }) + const favoriteChartHighlights = useMemo( + () => + favoriteRanges.map((range) => ({ + ...range, + color: theme.alpha(theme.status.favorite.color, 0.12), + })), + [favoriteRanges], + ) const rideWindow = rideMovingWindow({ movingStartAtMs, movingEndAtMs }) const titleStartMs = rideWindow?.startMs ?? startAtMs @@ -206,6 +217,7 @@ export function HistoryTelemetryPanel({ scrubTimeMs={scrubTimeMs} onScrubTimeChange={trim ? undefined : handleScrubTimeChange} excludedRanges={excludedRanges.speed} + timeRangeHighlights={favoriteChartHighlights} trim={trim} />