diff --git a/CONTEXT.md b/CONTEXT.md index 79a1b6346..5e624e1f3 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. 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**: +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 globally shared, Account-authored map-visible location that is independent from Ride Recording and Ride History. A Map Point describes a categorized riding place such as a drop, bonk, trail entry, viewpoint, or charging place; a personal navigation target is not a Map Point. Reading a Map Point needs no account; contributing or changing one requires sign-in. @@ -321,7 +325,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..d934577c0 --- /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 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 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 + +- **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 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/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..aa7ebedbf --- /dev/null +++ b/docs/adr/0030-favorite-media-is-curated-copied-storage.md @@ -0,0 +1,25 @@ +# 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 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. +- 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: 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. +- 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. 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/e2e/flows/history.yaml b/e2e/flows/history.yaml index f48465949..bb4e69093 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,50 @@ appId: ${APP_ID} # Close the sheet by tapping the backdrop. - tapOn: id: history-session-sheet-backdrop + +# Favorites is a filtered History tab: newest Favorite opens immediately and keeps the same nav. +- tapOn: + id: history-favorite-ride +- assertVisible: + id: trim-favorite-name +- assertNotVisible: + id: history-ride-list-button +- tapOn: + id: trim-favorite-name +- eraseText +- inputText: Evening ride +- tapOn: + id: trim-save +# Saving opens the new Favorite immediately. +- extendedWaitUntil: + visible: + 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: + visible: + id: history-session-sheet + timeout: 5000 +- tapOn: + id: history-session-sheet-backdrop +- tapOn: + id: history-tab-history +- assertNotVisible: No rides yet +- assertVisible: '1.5' 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 6a6723aee..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 @@ -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 @@ -545,6 +546,29 @@ 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) + ?: 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) + ?: 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) + } + 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/FavoriteSummaryBuilder.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt new file mode 100644 index 000000000..f5089ac22 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilder.kt @@ -0,0 +1,89 @@ +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 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` + * @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 { + if (buckets.isEmpty()) return FavoriteSummary() + + 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 + 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( + 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 1dd05d1c9..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 @@ -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) @@ -214,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 @@ -293,6 +323,30 @@ 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 + + @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 + } + @Query("DELETE FROM telemetry_frames") suspend fun clearFrames() @@ -507,6 +561,51 @@ 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("SELECT * FROM favorites WHERE id = :id") + suspend fun getFavorite(id: String): FavoriteEntity? + + /** 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 + + // 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 5dc643426..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 = 29 +internal const val TELEMETRY_DATABASE_VERSION = 31 @Database( entities = [ @@ -29,6 +29,8 @@ internal const val TELEMETRY_DATABASE_VERSION = 29 DiagnosticEventEntity::class, PrivacyZoneEntity::class, BoardWarningEntity::class, + FavoriteEntity::class, + FavoriteMediaEntity::class, ], version = TELEMETRY_DATABASE_VERSION, exportSchema = false, @@ -502,6 +504,73 @@ abstract class TelemetryDatabase : RoomDatabase() { db.execSQL("DROP TABLE IF EXISTS map_points") } + /** + * 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 `v30_favorites` + */ + internal val MIGRATION_29_30 = object : Migration(29, 30) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS favorites ( + id TEXT NOT NULL PRIMARY KEY, + board_id 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)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_favorites_board_id ON favorites(board_id)") + } + } + + /** + * 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 @@ -558,6 +627,8 @@ abstract class TelemetryDatabase : RoomDatabase() { MIGRATION_26_27, 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 f18dd61e3..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 @@ -454,3 +454,123 @@ 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/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` + */ +@Entity( + tableName = "favorites", + indices = [ + Index(value = ["start_ms", "end_ms"]), + Index(value = ["board_id"]), + ], +) +data class FavoriteEntity( + @PrimaryKey + val id: 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, + @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, +) { + /** + * 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, + "boardId" to boardId, + "boardName" to boardName, + "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, + ) +} + +/** + * 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/TelemetryRangeSubtraction.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt new file mode 100644 index 000000000..eb00e88f5 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtraction.kt @@ -0,0 +1,73 @@ +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) + } +} + +/** + * 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. + * + * @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 904ce9107..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 @@ -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 @@ -90,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() @@ -525,7 +527,163 @@ 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) + } + deleted + } + + // 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/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]) } + } + + /** + * 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 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() + + 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(), + boardId = boardId, + 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(boards.firstOrNull { it.id == boardId }?.name) + } + + /** + * 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 `updateFavorite` + */ + suspend fun updateFavorite( + id: String, + options: Map, + ): Map? = withContext(Dispatchers.IO) { + val existing = dao.getFavorite(id) ?: return@withContext null + 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() + + 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) + } + + /** + * 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) { + 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) + } + + /** + * 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) { @@ -573,7 +731,18 @@ 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() + } synchronized(lock) { pending.clear() pendingMarkers.clear() @@ -585,6 +754,50 @@ 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 { + expandTelemetryRangeToBuckets(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.getDeviceIdsInRange(range.startMs, range.endMs) + } + for (protectedDeviceId in devices) { + val firstFrame = dao.getFirstFrameInRange( + range.startMs, + range.endMs, + protectedDeviceId, + ) ?: continue + val first = getSampleStates( + range.startMs, + firstFrame.capturedAtMs, + protectedDeviceId, + Int.MAX_VALUE, + ).firstOrNull { it.id == firstFrame.id } ?: continue + dao.updateFrame(first.state.toFrame(previous = null, keyframe = true).copy(id = first.id)) + } + } + } + private fun scheduleFlushLocked() { if (flushScheduled) return flushScheduled = true @@ -1080,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/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/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/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..396ed944e --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/FavoriteSummaryBuilderTest.kt @@ -0,0 +1,284 @@ +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) + } + + /** + * 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(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"]) + 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(boardName = null)["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_29_30.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("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") }) + 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)" + }, + ) + assertTrue( + sql.any { it == "CREATE INDEX IF NOT EXISTS index_favorites_board_id ON favorites(board_id)" }, + ) + } + + @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 -> + 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", + boardId = "board-uuid-1", + 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/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..5928ddbea --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/telemetry/TelemetryRangeSubtractionTest.kt @@ -0,0 +1,74 @@ +package expo.modules.vescapecore.telemetry + +import org.junit.Assert.assertEquals +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( + 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/VescapeCoreModule.swift b/modules/vescape-core/ios/VescapeCoreModule.swift index 67f8e0df3..fc59a65d3 100644 --- a/modules/vescape-core/ios/VescapeCoreModule.swift +++ b/modules/vescape-core/ios/VescapeCoreModule.swift @@ -619,6 +619,45 @@ 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("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) + } + + AsyncFunction("deleteFavorite") { (id: String, promise: Promise) in + 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 new file mode 100644 index 000000000..c8aabfbe8 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/FavoriteStore.swift @@ -0,0 +1,264 @@ +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/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` +struct Favorite { + let id: 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 + let createdAtMs: Int64 + let updatedAtMs: Int64 + let summary: FavoriteSummary + + /// Board name is resolved on read from `boards`, not snapshotted, so renames propagate. + func toMap(boardName: String?) -> [String: Any?] { + [ + "id": id, + "boardId": boardId, + "boardName": boardName, + "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 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 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, + board_id 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)") + try db.execute(sql: "CREATE INDEX index_favorites_board_id ON favorites(board_id)") + } + + // 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, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + 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, + favorite.summary.maxSpeedCentiKmh, favorite.summary.batteryUsedWhMilli, + ] + ) + } + return true + } catch { + return false + } + } + + /// 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 = ?, 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: [favorite.id] + ) + .map(Self.favorite) + } + return updated ?? nil + } + + /// 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 + } + + private static func favorite(_ row: Row) -> Favorite { + Favorite( + id: row["id"] 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( + 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..c06a1249f --- /dev/null +++ b/modules/vescape-core/ios/telemetry/FavoriteStoreTests.swift @@ -0,0 +1,326 @@ +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) + try FavoriteMediaStore.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", + boardId: "board-uuid-1", + name: "Dolina single track", + startMs: 1_000, + endMs: 61_000, + summary: FavoriteSummary( + 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.boardId, "board-uuid-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"]) + } + + func testUpdateKeepsIdentityAndCreationTimeWhileReplacingRangeNameAndSummary() throws { + store.insert( + makeFavorite( + id: "fav-1", + name: "Dolina", + startMs: 1_000, + endMs: 61_000, + 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 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(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) + } + + func testUpdateToNilClearsTheName() throws { + store.insert(makeFavorite(id: "fav-1", name: "Dolina", startMs: 1_000, endMs: 2_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 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. + 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 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", + 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(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) + 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(boardName: nil) + + 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() { + 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) + } + + /// 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, + boardId: String? = nil, + name: String? = nil, + startMs: Int64, + endMs: Int64, + updatedAtMs: Int64 = 1_700_000_000_000, + summary: FavoriteSummary = FavoriteSummary() + ) -> Favorite { + Favorite( + id: id, + boardId: boardId, + name: name, + startMs: startMs, + endMs: endMs, + createdAtMs: 1_700_000_000_000, + updatedAtMs: updatedAtMs, + 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..= startMs) + self.startMs = startMs + self.endMs = endMs + } +} + +/// 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. +/// +/// @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..07a3e6555 --- /dev/null +++ b/modules/vescape-core/ios/telemetry/TelemetryRangeSubtractionTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import VescapeCore + +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( + 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 230481641..70a160a40 100644 --- a/modules/vescape-core/ios/telemetry/TelemetryRepository.swift +++ b/modules/vescape-core/ios/telemetry/TelemetryRepository.swift @@ -231,6 +231,186 @@ internal final class TelemetryRepository { Int64(telemetryInt(AppDataRepository.shared.getSettings()["socEstimateWindowSeconds"] ?? nil) ?? 20) * 1000 } + // 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?]] { + FavoriteMediaStore.shared.reconcileAll() + 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 + /// 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 } + 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 } + 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, + boardId: deviceId.flatMap { Self.boardId(forBleId: $0) }, + 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(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 + } + + /// 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"]), + 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` + 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 } + 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 } + 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 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). + /// @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/telemetry/TelemetryRepository.kt `deleteFavorite` + func deleteFavorite(_ id: String) -> Bool { + 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 + /// 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 + ) -> 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 @@ -249,18 +429,27 @@ 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 + return deleted } func rebuildBuckets(onProgress: (Int, Int) -> Void = { _, _ in }) -> Int { @@ -311,12 +500,41 @@ 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 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] + ) + } + } } queue.sync { pendingStates.removeAll() @@ -328,6 +546,18 @@ 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 { + expandTelemetryRangeToBuckets( + 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/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 6b2f35a21..a8eab7ebb 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -777,6 +777,85 @@ 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 + /** + * 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 + 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 +} + +/** + * @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` + * @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 @@ -1426,6 +1505,12 @@ type VescapeCoreNativeModule = NativeEventEmitter & { limit?: number }): Promise getTelemetrySummary(): Promise + getFavorites(): Promise + createFavorite(options: CreateFavoriteOptions): Promise + updateFavorite(id: string, options: UpdateFavoriteOptions): Promise + deleteFavorite(id: string): Promise + getFavoriteMedia(favoriteId: string): Promise + importFavoriteMedia(options: ImportFavoriteMediaOptions): Promise getDiagnosticEvents(options: DiagnosticEventOptions): Promise clearDiagnosticEvents(): Promise getBoardWarnings(): Promise @@ -1915,6 +2000,40 @@ 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) +} + +/** 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). */ +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/app/settings/components/charts.tsx b/src/app/settings/components/charts.tsx index 3ba3993a5..28e964248 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,54 @@ 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.15, endMs: domainStartMs + span * 0.85 }), + [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 }), + }} + timeRangeHighlights={[ + { + startMs: domainStartMs + span * 0.3, + endMs: domainStartMs + span * 0.55, + color: theme.alpha(theme.status.favorite.color, 0.12), + }, + ]} + /> + 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 +452,7 @@ export default function ChartsPage() { + @@ -413,4 +463,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/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/app/settings/components/modals.tsx b/src/app/settings/components/modals.tsx index 280fac864..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' @@ -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 @@ -380,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) @@ -427,6 +521,7 @@ export default function ModalsPage() { + + @@ -462,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/charts/TelemetryChartTrim.tsx b/src/components/charts/TelemetryChartTrim.tsx new file mode 100644 index 000000000..8376f6430 --- /dev/null +++ b/src/components/charts/TelemetryChartTrim.tsx @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { StyleSheet, View } from 'react-native' +import { Gesture } from 'react-native-gesture-handler' +import { + cancelAnimation, + runOnJS, + useDerivedValue, + useSharedValue, + withTiming, + type SharedValue, +} from 'react-native-reanimated' +import { Canvas, LinearGradient, Rect, RoundedRect, vec } from '@shopify/react-native-skia' + +import { + moveTrimHandle, + pickTrimHandle, + type TrimHandle, +} from '@/components/charts/telemetryChartTrimMath' +import { theme } from '@/constants/theme' + +export interface ChartTrimConfig { + startMs: number + endMs: number + onChange: (startMs: number, endMs: number) => void + onCommit: (startMs: number, endMs: number) => void +} + +const TRIM_NOTIFY_THROTTLE_MS = 50 +const TRIM_HINT_ANIMATION_MS = 320 +const HANDLE_WIDTH = 3 + +function setSharedValue(shared: SharedValue, value: T) { + shared.value = value +} + +function createTrimGesture({ + enabled, + chartWidth, + domainStartMs, + domainEndMs, + trimStartMs, + trimEndMs, + activeHandle, + dragOriginMs, + beginDrag, + notifyTrim, + commitTrim, +}: { + enabled: boolean + chartWidth: number + domainStartMs: number + domainEndMs: number + trimStartMs: SharedValue + trimEndMs: SharedValue + activeHandle: SharedValue + dragOriginMs: SharedValue + beginDrag: () => void + 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 + const handle = pickTrimHandle(event.x, xStart, xEnd) + activeHandle.value = handle + dragOriginMs.value = handle === 0 ? trimStartMs.value : trimEndMs.value + runOnJS(beginDrag)() + }) + .onUpdate((event) => { + 'worklet' + if (activeHandle.value === 0) { + trimStartMs.value = moveTrimHandle({ + handle: 0, + originMs: dragOriginMs.value, + translationX: event.translationX, + chartWidth, + domainStartMs, + domainEndMs, + oppositeMs: trimEndMs.value, + }) + } else if (activeHandle.value === 1) { + trimEndMs.value = moveTrimHandle({ + handle: 1, + originMs: dragOriginMs.value, + translationX: event.translationX, + chartWidth, + domainStartMs, + domainEndMs, + oppositeMs: trimStartMs.value, + }) + } + runOnJS(notifyTrim)(trimStartMs.value, trimEndMs.value) + }) + .onFinalize(() => { + 'worklet' + activeHandle.value = null + runOnJS(commitTrim)(trimStartMs.value, trimEndMs.value) + }) +} + +interface UseChartTrimOptions { + trim: ChartTrimConfig | undefined + chartWidth: number + domainStartMs: number + domainEndMs: number +} + +export function useChartTrim({ + trim, + chartWidth, + domainStartMs, + domainEndMs, +}: UseChartTrimOptions) { + const onChangeRef = useRef(trim?.onChange) + const onCommitRef = useRef(trim?.onCommit) + const lastNotifyAtRef = useRef(0) + const startMs = useSharedValue(trim?.startMs ?? 0) + const endMs = useSharedValue(trim?.endMs ?? 0) + 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 + onCommitRef.current = trim?.onCommit + }) + + useEffect(() => { + 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 + lastNotifyAtRef.current = now + onChangeRef.current?.(start, end) + }, []) + const commitTrim = useCallback((start: number, end: number) => { + draggingRef.current = false + lastNotifyAtRef.current = 0 + onCommitRef.current?.(start, end) + }, []) + const enabled = !!trim && chartWidth > 0 && domainEndMs > domainStartMs + const gesture = useMemo( + () => + // eslint-disable-next-line react-hooks/refs -- shared values are only touched inside worklets + createTrimGesture({ + enabled, + chartWidth, + domainStartMs, + domainEndMs, + trimStartMs: startMs, + trimEndMs: endMs, + activeHandle, + dragOriginMs, + beginDrag, + notifyTrim, + commitTrim, + }), + [ + activeHandle, + beginDrag, + chartWidth, + commitTrim, + domainEndMs, + domainStartMs, + dragOriginMs, + enabled, + endMs, + notifyTrim, + startMs, + ], + ) + 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, + startX, + endX, + midpointX, + leftSelectionWidth, + rightSelectionWidth, + rightDimWidth, + startHandleX, + endHandleX, + leftGradientStart, + leftGradientEnd, + rightGradientStart, + rightGradientEnd, + } +} + +interface TelemetryChartTrimOverlayProps { + height: number + chartWidth: number + trimState: ReturnType +} + +export function TelemetryChartTrimOverlay({ + height, + chartWidth, + trimState, +}: TelemetryChartTrimOverlayProps) { + return ( + + + + + + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, + canvas: { + position: 'absolute', + inset: 0, + }, +}) diff --git a/src/components/charts/TelemetryLineChart.tsx b/src/components/charts/TelemetryLineChart.tsx index 2cfdff45c..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,12 +27,22 @@ import { import { theme } from '@/constants/theme' import { getChartPosition, + getChartTimeRangeBands, + getChartTimeLabels, getXPosition, splitChartPointSegments, splitChartLineSegments, type ExcludedRange, + type ChartTimeMode, type TelemetryChartPoint, } from '@/components/charts/chartMath' +import { + TelemetryChartTrimOverlay, + useChartTrim, + type ChartTrimConfig, +} from '@/components/charts/TelemetryChartTrim' + +export type { ChartTrimConfig } from '@/components/charts/TelemetryChartTrim' const DEFAULT_HEIGHT = 54 const Y_AXIS_WIDTH = 34 @@ -153,14 +164,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() @@ -260,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 @@ -274,6 +283,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 @@ -283,6 +294,10 @@ 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 + /** Solid translucent bands rendered behind the chart lines. */ + timeRangeHighlights?: ChartTimeRangeHighlight[] } interface ChartLineSegmentsProps { @@ -384,12 +399,15 @@ export function TelemetryLineChart({ formatValue, getPointColor, windowMs, + timeMode = 'relative', excludedRanges, secondary, scrubTimeMs, onScrubTimeChange, scrubbable = false, reserveRightAxis = false, + trim, + timeRangeHighlights, }: TelemetryLineChartProps) { 'use no memo' const [chartWidth, setChartWidth] = useState(0) @@ -522,7 +540,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( @@ -548,19 +569,27 @@ 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 trimState = useChartTrim({ + trim, + chartWidth, + domainStartMs: trimDomainStartMs, + domainEndMs: trimDomainEndMs, + }) + const activeGesture = trim ? trimState.gesture : panGesture + const yMid = (range.y.min + range.y.max) / 2 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 timeRangeBands = useMemo( + () => getChartTimeRangeBands(displayPoints, timeRangeHighlights ?? [], chartWidth, windowMs), + [chartWidth, displayPoints, timeRangeHighlights, windowMs], + ) const activeColor = resolveActiveChartColor(currentPoint, color, getPointColor) const valueColorStyle = getPointColor && currentPoint ? { color: activeColor } : undefined @@ -608,10 +637,20 @@ export function TelemetryLineChart({ {formatAxisNumber(range.y.min)} - + {chartWidth > 0 && ( + {timeRangeBands.map((band) => ( + + ))} )} - {chartWidth > 0 && hasMarker && ( + {chartWidth > 0 && hasMarker && !trim && ( {isDragging && ( )} + {trim && chartWidth > 0 && ( + + )} diff --git a/src/components/charts/chartMath.test.ts b/src/components/charts/chartMath.test.ts index ef59a801b..3290ae4f5 100644 --- a/src/components/charts/chartMath.test.ts +++ b/src/components/charts/chartMath.test.ts @@ -4,6 +4,8 @@ import { computeAutoRange, findNearestChartPointAtX, getChartPosition, + getChartTimeRangeBands, + getChartTimeLabels, splitChartLineSegments, type TelemetryChartPoint, toExcludedRanges, @@ -33,6 +35,23 @@ test('getChartPosition clamps inside bounds', () => { 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]) @@ -41,6 +60,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..c05a361b0 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 { @@ -116,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/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/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/components/modals/TextPromptModal.tsx b/src/components/modals/TextPromptModal.tsx index 0d5f9ac9a..ca9f44486 100644 --- a/src/components/modals/TextPromptModal.tsx +++ b/src/components/modals/TextPromptModal.tsx @@ -10,6 +10,8 @@ interface TextPromptModalContentProps { placeholder?: string initialValue: string confirmLabel: string + /** Allow confirming with an empty value, for fields that can be cleared (e.g. optional names). */ + allowEmpty?: boolean onConfirm: (value: string) => 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/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({ /> - + ) diff --git a/src/modules/history/components/HistoryPanelNav.tsx b/src/modules/history/components/HistoryPanelNav.tsx index 90756dc08..f58709742 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' @@ -6,21 +6,29 @@ 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 { titleStartMs: number titleEndMs: number deviceName: string + title?: string + subtitle?: string canPrevious: boolean canNext: boolean + favoriteMode: boolean + favorited: boolean + actionDisabled: boolean mediaCount: number mediaLoading: boolean mediaButtonRef: RefObject + listButtonRef: RefObject onPrevious: () => void onNext: () => void onOpenList: () => void onOpenMediaDrawer: () => void + onToggleFavorite: () => void onOpenShareInfo: () => void } @@ -28,35 +36,50 @@ export function HistoryPanelNav({ titleStartMs, titleEndMs, deviceName, + title, + subtitle, canPrevious, canNext, + favoriteMode, + favorited, + actionDisabled, mediaCount, mediaLoading, mediaButtonRef, + listButtonRef, onPrevious, onNext, onOpenList, onOpenMediaDrawer, + onToggleFavorite, onOpenShareInfo, }: HistoryPanelNavProps) { + const primaryLabel = title ?? formatRideTime(titleStartMs, titleEndMs) + const secondaryLabel = subtitle ?? formatRideMeta(titleStartMs, titleEndMs, deviceName) + return ( - 0 ? styles.mediaEnabled : undefined} - /> - {mediaCount > 0 ? ( - - {mediaCount > 99 ? '99+' : mediaCount} - + {favoriteMode ? ( + <> + 0 ? styles.mediaEnabled : undefined} + accessibilityLabel="Favorite media" + /> + {mediaCount > 0 ? ( + + {mediaCount > 99 ? '99+' : mediaCount} + + ) : null} + ) : null} [styles.titleButton, pressed && styles.titleButtonPressed]} android_ripple={interaction.ripple} onPress={onOpenList} > - - - {formatRideTime(titleStartMs, titleEndMs)} - - - {formatRideMeta(titleStartMs, titleEndMs, deviceName)} - - + } /> - + {favoriteMode ? ( + + ) : ( + + )} ) @@ -122,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'], @@ -144,19 +181,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/HistoryRideMediaDrawer.tsx b/src/modules/history/components/HistoryRideMediaDrawer.tsx index 0e90c131a..8c73a16fc 100644 --- a/src/modules/history/components/HistoryRideMediaDrawer.tsx +++ b/src/modules/history/components/HistoryRideMediaDrawer.tsx @@ -34,7 +34,7 @@ export function HistoryRideMediaDrawer({ visible={visible} triggerRef={triggerRef} onClose={onClose} - title="Ride Media" + title="Favorite Media" icon={ImagesSquareIcon} > + favoriteMode: boolean blocks: TelemetryMinuteBucket[] sessions: HistorySession[] + favorites: Favorite[] selectedSessionId: string | null hasMore: boolean loadingMore: boolean @@ -32,17 +32,13 @@ 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, selectedSessionId, hasMore, loadingMore, @@ -50,112 +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) - return ( - [ - styles.row, - selected && styles.rowSelected, - pressed && styles.rowPressed, - ]} - onPress={() => onSelectSession(session)} - > - - - - {new Date(session.startAtMs).toLocaleString()} - - - {session.deviceName} - - - {formatDuration(rideDurationMs(session))} ·{' '} - {formatDistance(session.distanceM)} ·{' '} - {telemetry.speed.formatWithUnit(session.maxSpeedKmh)} · GPS{' '} - {session.gpsPointCount} - - - - - ) - }) - )} - {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 + )} + + )} - + ) } @@ -241,46 +212,8 @@ 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, - 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: { @@ -308,20 +241,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/components/MediaHistoryGallery.tsx b/src/modules/history/components/MediaHistoryGallery.tsx index d44aa776c..ad1e0771b 100644 --- a/src/modules/history/components/MediaHistoryGallery.tsx +++ b/src/modules/history/components/MediaHistoryGallery.tsx @@ -48,7 +48,7 @@ function MediaGrid({ } /** - * Ride media gallery shown inside the history media drawer: matched assets as a + * Favorite Media gallery shown inside the detail drawer: matched assets as a * thumbnail grid, assets outside the ride in their own section, and the picker * entry point. */ @@ -77,7 +77,7 @@ export function MediaHistoryGallery({ - 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..24ebfb73d 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,44 @@ 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)) + 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 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/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..bbc2670c1 --- /dev/null +++ b/src/modules/history/lib/favoritePreview.ts @@ -0,0 +1,130 @@ +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. + * + * @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[], + 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/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/modules/history/lib/favorites.test.ts b/src/modules/history/lib/favorites.test.ts new file mode 100644 index 000000000..1d495f709 --- /dev/null +++ b/src/modules/history/lib/favorites.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from 'bun:test' + +import type { Favorite, TelemetryMinuteBucket } from 'vescape-core' + +import { + favoriteRangeForSession, + favoriteToSession, + findSessionFavorite, + initialFavoriteTrimRangeForSession, + sessionContainsFavorite, +} from '@/modules/history/lib/favorites' + +const session = { + startAtMs: 1_000_000, + endAtMs: 1_600_000, + movingStartAtMs: 1_100_000, + movingEndAtMs: 1_500_000, +} + +function favorite(overrides: Partial): Favorite { + return { + id: 'fav-1', + boardId: 'board-uuid-1', + boardName: 'Onewheel', + 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('canonical ride range uses the Moving Window, not the idle-padded 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('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() + 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 favorite-backed session keeps board identity separate from its name', () => { + expect(favoriteToSession(favorite({ name: 'Dolina single track' }), []).deviceName).toBe( + 'Onewheel', + ) + 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, + ) + 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 new file mode 100644 index 000000000..84ba45a30 --- /dev/null +++ b/src/modules/history/lib/favorites.ts @@ -0,0 +1,128 @@ +import type { Favorite, TelemetryMinuteBucket } from 'vescape-core' + +import { rideMovingWindow, type HistorySession } from '@/modules/history/lib/sessions' + +/** 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 } { + const window = rideMovingWindow(session) + 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 + * a Favorite stores a Board id rather than the ble id a history session carries. + */ +export function findSessionFavorite( + favorites: Favorite[], + session: Pick, +): Favorite | null { + const range = favoriteRangeForSession(session) + return ( + favorites.find( + (favorite) => favorite.startMs === range.startMs && favorite.endMs === range.endMs, + ) ?? null + ) +} + +/** + * 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. + * Favorite identity stays separate from the recording device so each can be presented consistently. + */ +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.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 + // 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[], + session: Pick, +): boolean { + return favorites.some( + (favorite) => favorite.startMs <= session.endAtMs && favorite.endMs >= session.startAtMs, + ) +} 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/lib/rideFormat.test.ts b/src/modules/history/lib/rideFormat.test.ts new file mode 100644 index 000000000..08571ba84 --- /dev/null +++ b/src/modules/history/lib/rideFormat.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'bun:test' + +import { + formatFavoriteName, + formatRideListDateTime, + formatRideListDetails, + suggestFavoriteName, +} 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('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 15a3eecbe..6a49d2b61 100644 --- a/src/modules/history/lib/rideFormat.ts +++ b/src/modules/history/lib/rideFormat.ts @@ -29,3 +29,47 @@ 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, 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 { + 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 new file mode 100644 index 000000000..1e5c8515f --- /dev/null +++ b/src/modules/history/store/favoriteStore.test.ts @@ -0,0 +1,223 @@ +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 { + boardId: 'board-uuid-1', + boardName: 'Onewheel', + 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 updateFavorite = mock(async (): Promise => { + throw new Error('updateFavorite not stubbed') +}) +const deleteFavorite = mock(async () => true) + +const vescapeCoreMock = { + ...actualVescapeCore, + getFavorites, + createFavorite, + updateFavorite, + deleteFavorite, +} + +mock.module('vescape-core', () => vescapeCoreMock) +mock.module('../../modules/vescape-core/src/index', () => vescapeCoreMock) + +beforeEach(async () => { + getFavorites.mockClear() + createFavorite.mockClear() + updateFavorite.mockClear() + deleteFavorite.mockClear() + getFavorites.mockImplementation(async () => []) + createFavorite.mockImplementation(async () => { + throw new Error('createFavorite not stubbed') + }) + updateFavorite.mockImplementation(async () => { + throw new Error('updateFavorite not stubbed') + }) + deleteFavorite.mockImplementation(async () => true) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + useFavoriteStore.setState({ favorites: [], loading: false, saving: 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('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') + }) + 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]) +}) + +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) +}) + +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 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 }), + ]) + updateFavorite.mockImplementation(async () => updated) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + await useFavoriteStore.getState().load() + await useFavoriteStore.getState().update('fav-1', { + startMs: 4_000_000, + endMs: 4_060_000, + name: 'Dolina single track', + }) + + 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 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]) + updateFavorite.mockImplementation(async () => { + throw new Error('favorite does not exist') + }) + const { useFavoriteStore } = await import('@/modules/history/store/favoriteStore') + + await useFavoriteStore.getState().load() + 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 new file mode 100644 index 000000000..4b3b62c6d --- /dev/null +++ b/src/modules/history/store/favoriteStore.ts @@ -0,0 +1,113 @@ +import { create } from 'zustand' +import { + createFavorite, + deleteFavorite, + getFavorites, + updateFavorite, + type Favorite, + type CreateFavoriteOptions, + type UpdateFavoriteOptions, +} from 'vescape-core' + +interface FavoriteState { + favorites: Favorite[] + loading: boolean + /** One create/update/delete at a time; controls must not queue a second mutation. */ + saving: 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 + /** 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 +} + +let favoriteLoadVersion = 0 +let favoriteMutationVersion = 0 + +export const useFavoriteStore = create((set, get) => ({ + favorites: [], + loading: false, + saving: false, + error: undefined, + + async load() { + const loadVersion = ++favoriteLoadVersion + const mutationVersion = favoriteMutationVersion + set({ loading: true, error: undefined }) + try { + const favorites = await getFavorites() + if (loadVersion === favoriteLoadVersion && mutationVersion === favoriteMutationVersion) { + set({ favorites }) + } + } catch (err) { + if (loadVersion === favoriteLoadVersion && mutationVersion === favoriteMutationVersion) { + set({ error: err instanceof Error ? err.message : String(err) }) + } + } finally { + 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) + 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 + } 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) + set({ + 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 { + favoriteMutationVersion++ + set({ saving: false }) + } + }, + + async remove(id) { + if (get().saving) return + favoriteMutationVersion++ + 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 { + favoriteMutationVersion++ + set({ saving: false }) + } + }, +})) + +export type { Favorite } diff --git a/src/modules/history/store/historyStore.test.ts b/src/modules/history/store/historyStore.test.ts index 75d12c833..77e32ec9a 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') @@ -460,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 b4a2f81a4..ccb82a6c7 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,16 @@ 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() + 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 +426,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/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/MainScreen.tsx b/src/screens/main/MainScreen.tsx index 80b6aca55..b8417f70d 100644 --- a/src/screens/main/MainScreen.tsx +++ b/src/screens/main/MainScreen.tsx @@ -27,6 +27,60 @@ interface MainScreenProps { onAddBoard: () => void } +function buildHistoryOverlayProps(controller: ReturnType) { + return { + enterHistoryMode: controller.enterHistoryMode, + selectedSession: controller.selectedSession, + sessionSamples: controller.sessionSamples, + sessionGpsSamples: controller.sessionGpsSamples, + sessionMarkers: controller.sessionMarkers, + nextRide: controller.nextRide, + canPreviousRide: controller.canPreviousRide, + loadingSession: controller.loadingSession, + historyLoading: controller.historyLoading, + historyHasMore: controller.historyHasMore, + historyError: controller.historyError, + blocks: controller.blocks, + sessions: controller.sessions, + historySheetVisible: controller.historySheetVisible, + setHistorySheetVisible: controller.setHistorySheetVisible, + historyTab: controller.historyTab, + selectHistoryTab: controller.selectHistoryTab, + favorites: controller.favorites, + favoritesLoading: controller.favoritesLoading, + favoritesSaving: controller.favoritesSaving, + favoritesError: controller.favoritesError, + selectedSessionFavorite: controller.selectedSessionFavorite, + trimming: controller.trimming, + trimSeed: controller.trimSeed, + beginTrimFavorite: controller.beginTrimFavorite, + beginEditFavorite: controller.beginEditFavorite, + updateTrimRange: controller.updateTrimRange, + cancelTrim: controller.cancelTrim, + saveTrim: controller.saveTrim, + favoriteSessions: controller.favoriteSessions, + canPreviousFavorite: controller.canPreviousFavorite, + canNextFavorite: controller.canNextFavorite, + selectPreviousFavorite: controller.selectPreviousFavorite, + selectNextFavorite: controller.selectNextFavorite, + openFavorite: controller.openFavorite, + selectFavorite: controller.selectFavorite, + removeOpenFavorite: controller.removeOpenFavorite, + loadMoreHistory: controller.loadMoreHistory, + selectPreviousRide: controller.selectPreviousRide, + selectNextRide: controller.selectNextRide, + selectRide: controller.selectRide, + exitHistory: controller.exitHistory, + removeSession: controller.removeSession, + onSeek: controller.onSeek, + setActiveHistoryMapMetric: controller.setActiveHistoryMapMetric, + mediaHistory: controller.mediaHistory, + openMedia: controller.openMedia, + openMediaAssetId: controller.openMediaAssetId, + closeMedia: controller.closeMedia, + } +} + export function MainScreen({ activeBoard, activeBoardId, @@ -333,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, }), @@ -341,6 +399,8 @@ export function MainScreen({ controller.historyActive, controller.historyPreview, controller.historyPreviewRoute, + controller.historyTab, + controller.favorites, controller.mediaHistory.assets, controller.openMedia, controller.selectedSession?.id, @@ -471,34 +531,7 @@ export function MainScreen({ offscreenMapIndicators, onOffscreenIndicatorPress: handleOffscreenIndicatorPress, }} - history={{ - enterHistoryMode: controller.enterHistoryMode, - selectedSession: controller.selectedSession, - sessionSamples: controller.sessionSamples, - sessionMarkers: controller.sessionMarkers, - nextRide: controller.nextRide, - canPreviousRide: controller.canPreviousRide, - loadingSession: controller.loadingSession, - historyLoading: controller.historyLoading, - historyHasMore: controller.historyHasMore, - historyError: controller.historyError, - blocks: controller.blocks, - sessions: controller.sessions, - historySheetVisible: controller.historySheetVisible, - setHistorySheetVisible: controller.setHistorySheetVisible, - loadMoreHistory: controller.loadMoreHistory, - selectPreviousRide: controller.selectPreviousRide, - selectNextRide: controller.selectNextRide, - selectRide: controller.selectRide, - exitHistory: controller.exitHistory, - removeSession: controller.removeSession, - onSeek: controller.onSeek, - setActiveHistoryMapMetric: controller.setActiveHistoryMapMetric, - mediaHistory: controller.mediaHistory, - openMedia: controller.openMedia, - openMediaAssetId: controller.openMediaAssetId, - closeMedia: controller.closeMedia, - }} + history={buildHistoryOverlayProps(controller)} /> ) diff --git a/src/screens/main/history/HistoryControls.tsx b/src/screens/main/history/HistoryControls.tsx index a39507e6c..eab0b96c9 100644 --- a/src/screens/main/history/HistoryControls.tsx +++ b/src/screens/main/history/HistoryControls.tsx @@ -1,31 +1,167 @@ import { StyleSheet, View } from 'react-native' -import { ArrowLeftIcon, TrashIcon } from 'phosphor-react-native' +import { + ArrowLeftIcon, + CheckIcon, + ClockCounterClockwiseIcon, + PencilSimpleIcon, + StarIcon, + TrashIcon, + XIcon, +} 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 { Input } from '@/components/forms/Input' +import { theme } from '@/constants/theme' +import type { HistoryTab } from '@/screens/main/mainScreenStore' interface HistoryControlsProps { loading: boolean + tab: HistoryTab canRemove: boolean + /** Trim mode swaps tabs/star/trash for a cancel/save pair over the range being pinned. */ + trimming: boolean + /** + * Favorite tab actions. Selection stays in the shared history panel below. + */ + favorite?: { + onEdit: () => void + onDelete: () => void + } + saving: boolean + trimName: string + trimNamePlaceholder?: string + onTrimNameChange: (name: string) => void + onSelectTab: (tab: HistoryTab) => void onBack: () => void onRemove: () => void + onCancelTrim: () => void + onSaveTrim: () => void } -export function HistoryControls({ loading, canRemove, onBack, onRemove }: HistoryControlsProps) { +export function HistoryControls({ + loading, + tab, + canRemove, + trimming, + favorite, + saving, + trimName, + trimNamePlaceholder = 'Favorite name', + onTrimNameChange, + onSelectTab, + onBack, + onRemove, + onCancelTrim, + onSaveTrim, +}: HistoryControlsProps) { const insets = useSafeAreaInsets() + + if (trimming) { + return ( + + + + + + + + + + ) + } + return ( - - - + + + + onSelectTab('history')} + /> + onSelectTab('favorites')} + /> + + + + {favorite ? ( + <> + + + + ) : null} + {!favorite && canRemove ? ( + + ) : !favorite ? ( + + ) : null} - {canRemove ? ( - - ) : ( - - )} ) @@ -46,9 +182,36 @@ const styles = StyleSheet.create({ row: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + }, + tabsWrap: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', }, - titleWrap: { + actions: { + marginLeft: 'auto', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + zIndex: 1, + }, + tabs: { + alignSelf: 'center', + }, + tabsContent: { + justifyContent: 'center', + }, + headerTitleWrap: { flex: 1, alignItems: 'center', }, + nameInput: { + width: '100%', + height: 38, + paddingVertical: 0, + textAlign: 'center', + }, }) 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 9a851a1f8..72f34bdd0 100644 --- a/src/screens/main/history/HistoryOverlay.tsx +++ b/src/screens/main/history/HistoryOverlay.tsx @@ -1,7 +1,7 @@ -import { useCallback, useState } from 'react' -import { ActivityIndicator, StyleSheet, View } from 'react-native' +import { useCallback, useRef, useState } from 'react' +import { StyleSheet, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import type { HistoryMarker } from 'vescape-core' +import type { Favorite, HistoryGpsSample, HistoryMarker } from 'vescape-core' import { Text } from '@/components/base/Text' import { ConfirmModal } from '@/components/modals/ConfirmModal' @@ -10,6 +10,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 { favoriteSessionId, sessionContainsFavorite } from '@/modules/history/lib/favorites' import type { HistoryMetricKey } from '@/modules/history/lib/metricColorScale' import type { HistorySession, @@ -17,13 +18,15 @@ 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 { 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' export interface MainHistoryOverlayProps { selectedSession: HistorySession | null sessionSamples: TelemetrySample[] + sessionGpsSamples: HistoryGpsSample[] sessionMarkers: HistoryMarker[] nextRide: HistorySession | null canPreviousRide: boolean @@ -35,6 +38,29 @@ export interface MainHistoryOverlayProps { sessions: HistorySession[] historySheetVisible: boolean setHistorySheetVisible: (visible: boolean) => void + historyTab: HistoryTab + selectHistoryTab: (tab: HistoryTab) => void + favorites: Favorite[] + favoritesLoading: boolean + favoritesSaving: boolean + favoritesError: string | undefined + selectedSessionFavorite: Favorite | null + trimming: boolean + trimSeed: { startMs: number; endMs: number } | null + beginTrimFavorite: () => void + beginEditFavorite: () => Promise + updateTrimRange: (startMs: number, endMs: number) => void + cancelTrim: () => Promise + saveTrim: (name: string) => Promise + favoriteSessions: HistorySession[] + canPreviousFavorite: boolean + canNextFavorite: boolean + selectPreviousFavorite: () => Promise + selectNextFavorite: () => Promise + /** The selected Favorite while the Favorites tab is active. */ + openFavorite: Favorite | null + selectFavorite: (favorite: Favorite) => Promise + removeOpenFavorite: () => Promise loadMoreHistory: () => Promise selectPreviousRide: () => Promise selectNextRide: () => Promise @@ -72,9 +98,19 @@ export function HistoryOverlay({ }: HistoryOverlayProps) { const insets = useSafeAreaInsets() const [removeConfirmVisible, setRemoveConfirmVisible] = useState(false) - const busy = history.loadingSession || history.historyLoading + 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 + const selectedSessionContainsFavorite = + history.selectedSession != null && + sessionContainsFavorite(history.favorites, history.selectedSession) const handleRemoveConfirm = useCallback(() => { setRemoveConfirmVisible(false) @@ -83,89 +119,69 @@ export function HistoryOverlay({ return ( <> - {visible && 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} - /> - - setRemoveConfirmVisible(true)} - /> - + {visible && detailSession && ( + setRemoveConfirmVisible(true)} + onPanelHeightChange={onPanelHeightChange} + listButtonRef={listButtonRef} + /> )} - {visible && !history.selectedSession && ( + {visible && !detailSession && ( <> - {busy ? ( - - - - ) : ( - - )} + {busy ? : } undefined} + onSelectTab={history.selectHistoryTab} onBack={history.exitHistory} onRemove={() => undefined} + onCancelTrim={() => undefined} + onSaveTrim={() => undefined} /> )} 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() }} /> - {visible && history.historyError ? ( + {visible && (history.historyError ?? history.favoritesError) ? ( - {history.historyError} + {history.historyError ?? history.favoritesError} ) : null} @@ -184,7 +200,11 @@ 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. */ +export function HistoryRideDetail({ + history, + session, + favoriteMode, + busy, + onRemoveSession, + onPanelHeightChange, + listButtonRef, +}: HistoryRideDetailProps) { + const [deleteVisible, setDeleteVisible] = useState(false) + const [trimName, setTrimName] = useState('') + const openFavorite = favoriteMode ? history.openFavorite : null + const trimming = history.trimming + + return ( + <> + {busy && } + { + void (favoriteMode ? history.selectPreviousFavorite() : history.selectPreviousRide()) + }} + onNext={() => { + void (favoriteMode ? history.selectNextFavorite() : history.selectNextRide()) + }} + onOpenList={() => history.setHistorySheetVisible(true)} + onAddMedia={() => void history.mediaHistory.add()} + onOpenMedia={history.openMedia} + onToggleFavorite={() => { + setTrimName('') + history.beginTrimFavorite() + }} + 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 ? ( + + ) : ( + + )} + { + setTrimName(openFavorite.name ?? '') + void history.beginEditFavorite() + }, + onDelete: () => setDeleteVisible(true), + } + : undefined + } + onSelectTab={history.selectHistoryTab} + onBack={history.exitHistory} + onRemove={onRemoveSession} + onCancelTrim={() => { + setTrimName('') + void history.cancelTrim() + }} + onSaveTrim={() => { + void history.saveTrim(trimName) + }} + /> + + { + setDeleteVisible(false) + void history.removeOpenFavorite() + }} + onCancel={() => setDeleteVisible(false)} + /> + + ) +} diff --git a/src/screens/main/history/HistoryTelemetryPanel.tsx b/src/screens/main/history/HistoryTelemetryPanel.tsx index 9c99b7bee..84844e3af 100644 --- a/src/screens/main/history/HistoryTelemetryPanel.tsx +++ b/src/screens/main/history/HistoryTelemetryPanel.tsx @@ -1,11 +1,12 @@ -import { useCallback, useRef, useState } 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' import { type TelemetryChartPoint } from '@/components/charts/chartMath' -import { TelemetryLineChart } from '@/components/charts/TelemetryLineChart' +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, @@ -35,21 +36,31 @@ interface HistoryTelemetryPanelProps { movingStartAtMs: number | null movingEndAtMs: number | null deviceName: string + navigationTitle?: string + navigationSubtitle?: string samples: TelemetrySample[] canPrevious: boolean canNext: boolean + favoriteMode: boolean + favoriteRanges: { startMs: number; endMs: number }[] + favorited: boolean + actionDisabled: boolean mediaAssets: MediaHistoryAsset[] mediaUnmatched: MediaAssetInput[] mediaLoading: boolean mediaError: string | null + listButtonRef: RefObject onPrevious: () => void onNext: () => void onOpenList: () => void onAddMedia: () => void onOpenMedia: (asset: MediaAssetInput) => void + onToggleFavorite: () => void 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 MAP_SEEK_THROTTLE_MS = 33 @@ -60,21 +71,30 @@ export function HistoryTelemetryPanel({ movingStartAtMs, movingEndAtMs, deviceName, + navigationTitle, + navigationSubtitle, samples, canPrevious, canNext, + favoriteMode, + favoriteRanges, + favorited, + actionDisabled, mediaAssets, mediaUnmatched, mediaLoading, mediaError, + listButtonRef, onPrevious, onNext, onOpenList, onAddMedia, onOpenMedia, + onToggleFavorite, onSeek, onMetricInteraction, onHeightChange, + trim, }: HistoryTelemetryPanelProps) { const insets = useSafeAreaInsets() const [headTimeMs, setHeadTimeMs] = useState(null) @@ -103,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 @@ -146,21 +174,30 @@ 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 && ( <> onMetricInteraction?.('speed')} - onPointSelected={handlePointSelected} + onPointSelected={trim ? undefined : handlePointSelected} scrubTimeMs={scrubTimeMs} - onScrubTimeChange={handleScrubTimeChange} + onScrubTimeChange={trim ? undefined : handleScrubTimeChange} excludedRanges={excludedRanges.speed} + timeRangeHighlights={favoriteChartHighlights} + trim={trim} /> {OPTIONAL_CHART_METRICS.filter((m) => activeCharts.has(m.key)).map((metric) => { @@ -194,12 +234,13 @@ 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)} - onPointSelected={handlePointSelected} + onPointSelected={trim ? undefined : handlePointSelected} scrubTimeMs={scrubTimeMs} - onScrubTimeChange={handleScrubTimeChange} + onScrubTimeChange={trim ? undefined : handleScrubTimeChange} excludedRanges={cfg.excludedRanges} secondary={cfg.secondary} /> @@ -210,17 +251,19 @@ export function HistoryTelemetryPanel({ )} - setMediaDrawerVisible(false)} - onAdd={onAddMedia} - onOpenMedia={onOpenMedia} - /> + {favoriteMode ? ( + setMediaDrawerVisible(false)} + onAdd={onAddMedia} + onOpenMedia={onOpenMedia} + /> + ) : null} 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/history/useHistoryFavorites.ts b/src/screens/main/history/useHistoryFavorites.ts new file mode 100644 index 000000000..5e490fa21 --- /dev/null +++ b/src/screens/main/history/useHistoryFavorites.ts @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' + +import { + favoriteSessionId, + favoriteToSession, + findSessionFavorite, + initialFavoriteTrimRangeForSession, +} from '@/modules/history/lib/favorites' +import { useFavoriteStore, type Favorite } from '@/modules/history/store/favoriteStore' +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, + blocks: TelemetryMinuteBucket[], +) { + 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 historySessionBeforeFavorite = useRef(null) + const { + favorites, + favoritesLoading, + favoritesSaving, + favoritesError, + loadFavorites, + addFavorite, + updateFavorite, + removeFavorite, + } = useFavoriteStore( + useShallow((state) => ({ + favorites: state.favorites, + favoritesLoading: state.loading, + favoritesSaving: state.saving, + favoritesError: state.error, + loadFavorites: state.load, + addFavorite: state.add, + 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]) + + const selectedSessionFavorite = useMemo( + () => (selectedSession ? findSessionFavorite(favorites, selectedSession) : 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) + 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, selectFavorite, setHistoryTab], + ) + + const beginTrimFavorite = useCallback(() => { + 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) + }, []) + + const updateTrimRange = useCallback((startMs: number, endMs: number) => { + useMainScreenStore.getState().setTrimRange({ startMs, endMs }) + }, []) + + 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, + endMs, + ...(session.deviceId ? { deviceId: session.deviceId } : {}), + ...(name.trim() ? { name: name.trim() } : {}), + }) + if (!favorite) return + + historySessionBeforeFavorite.current = session + useMainScreenStore.getState().endTrim() + setTrimSeed(null) + setHistoryTab('favorites') + await selectFavorite(favorite) + }, + [addFavorite, selectFavorite, setHistoryTab, updateFavorite], + ) + + const selectPreviousFavorite = useCallback(async () => { + const previous = getPreviousRideSession( + favoriteSessions, + useHistoryStore.getState().selectedSession, + ) + if (!previous) return + const favorite = useFavoriteStore + .getState() + .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 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 + 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 + editingFavoriteIdRef.current = null + keepTrimOnNextSelectionRef.current = false + setTrimSeed(null) + setHistoryTab('history') + useMainScreenStore.getState().closeFavorite() + useMainScreenStore.getState().endTrim() + }, [setHistoryTab]) + + return { + historyTab, + selectHistoryTab, + favorites, + favoritesLoading, + favoritesSaving, + favoritesError, + favoriteSessions, + selectedSessionFavorite, + trimming, + trimSeed, + beginTrimFavorite, + beginEditFavorite, + updateTrimRange, + cancelTrim, + saveTrim, + openFavorite, + selectFavorite, + canPreviousFavorite: getPreviousRideSession(favoriteSessions, selectedSession) != null, + canNextFavorite: getNextRideSession(favoriteSessions, selectedSession) != null, + selectPreviousFavorite, + selectNextFavorite, + removeOpenFavorite, + loadFavorites, + resetHistoryFavorites, + } +} 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 e3679246b..2e853a6b3 100644 --- a/src/screens/main/mainScreenStore.ts +++ b/src/screens/main/mainScreenStore.ts @@ -5,12 +5,25 @@ 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' + +/** 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 + /** The Favorite whose detail is open, or null while the Favorites list is showing. */ + openFavoriteId: string | null historySheetVisible: boolean mapSelector: MapSelector perspectiveEnabled: boolean seekTimeMs: number | null + trimRange: TrimRange | null activeHistoryMapMetric: HistoryMetricKey } @@ -21,20 +34,34 @@ interface MainScreenActions { enterWeather: () => void 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 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 } const initialState: MainScreenState = { mode: 'telemetry', + historyTab: 'history', + openFavoriteId: null, historySheetVisible: false, mapSelector: null, perspectiveEnabled: true, seekTimeMs: null, + trimRange: null, activeHistoryMapMetric: 'speed', } @@ -46,7 +73,14 @@ 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, + openFavoriteId: null, + }) }, enterMap() { @@ -65,6 +99,27 @@ export const useMainScreenStore = create((s set({ mode: 'history', mapSelector: null }) }, + setHistoryTab(tab) { + set((state) => + state.historyTab === tab + ? state + : { + historyTab: tab, + historySheetVisible: false, + openFavoriteId: null, + trimRange: null, + }, + ) + }, + + openFavorite(id) { + set({ openFavoriteId: id, historySheetVisible: false, seekTimeMs: null, trimRange: null }) + }, + + closeFavorite() { + set((state) => (state.openFavoriteId === null ? state : { openFavoriteId: null })) + }, + setHistorySheetVisible(visible) { set({ historySheetVisible: visible }) }, @@ -85,6 +140,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/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 ce7070135..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 @@ -292,6 +294,83 @@ 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 ( + + + + ) +} + +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, @@ -333,6 +412,7 @@ export function HistoryMapLayers({ rideMarkers, rideGpsSamples, mediaAssets, + favoriteRanges, mapZoom, historyMetricGradientsEnabled: gradientsEnabled, historyMetricHotRanges: hotRanges, @@ -348,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'] @@ -356,6 +437,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), @@ -407,6 +490,12 @@ export function HistoryMapLayers({ return ( <> + {rideRouteShape && ( @@ -435,10 +526,12 @@ export function HistoryMapLayers({ lineWidth: highContrastRoutes ? 5 : 4, lineCap: 'round', lineJoin: 'round', + lineOpacity: trimming ? 0.3 : 1, }} /> )} + {rideRoute[0] && ( { setOpenMediaAssetId(null) + historyFavorites.resetHistoryFavorites() void selectSession(null) enterTelemetry() requestAnimationFrame(() => mapRef.current?.recenterLive({ resetPadding: true, animationDuration: 0 }), ) - }, [enterTelemetry, mapRef, selectSession]) + }, [enterTelemetry, historyFavorites, mapRef, selectSession]) const loadOlderHistoryPages = useCallback( async (targetSessionCount = TARGET_INITIAL_HISTORY_SESSIONS) => { @@ -271,6 +275,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) const enterHistoryMode = useCallback(async () => { enterHistory() + void historyFavorites.loadFavorites() await loadInitial() await loadOlderHistoryPages() if (useMainScreenStore.getState().mode !== 'history') return @@ -278,7 +283,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) if (latest) { await selectSession(latest) } - }, [enterHistory, loadInitial, loadOlderHistoryPages, selectSession]) + }, [enterHistory, historyFavorites, loadInitial, loadOlderHistoryPages, selectSession]) const selectPreviousRide = useCallback(async () => { setOpenMediaAssetId(null) @@ -312,14 +317,6 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) }, [selectSession]) const removeSession = useCallback(() => { - 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]) @@ -366,6 +363,10 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) useCallback(() => { const handler = BackHandler.addEventListener('hardwareBackPress', () => { if (mode === 'history') { + if (useMainScreenStore.getState().trimRange) { + void cancelHistoryTrim() + return true + } exitHistory() return true } @@ -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 { @@ -454,6 +455,7 @@ export function useMainScreenController({ mapRef }: UseMainScreenControllerArgs) historyError, historySheetVisible, setHistorySheetVisible, + ...historyFavorites, selectSession, loadMoreHistory: loadMore, selectPreviousRide, diff --git a/src/screens/showcase/mapShowcaseFixtures.ts b/src/screens/showcase/mapShowcaseFixtures.ts index 99ce35281..ea253e8ca 100644 --- a/src/screens/showcase/mapShowcaseFixtures.ts +++ b/src/screens/showcase/mapShowcaseFixtures.ts @@ -87,6 +87,13 @@ export const FIXTURE_RIDE_GPS_SAMPLES: HistoryGpsSample[] = rideRouteCoordinates }, ) +export const FIXTURE_FAVORITE_RANGES = [ + { + startMs: FIXTURE_RIDE_GPS_SAMPLES[3].capturedAtMs, + endMs: FIXTURE_RIDE_GPS_SAMPLES[8].capturedAtMs, + }, +] + export const FIXTURE_RIDE_TELEMETRY_SAMPLES: TelemetrySample[] = FIXTURE_RIDE_GPS_SAMPLES.map( (gps, index) => { const t = index / (ROUTE_POINT_COUNT - 1)