diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5747e8d..490dbc9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.53" +val defaultVersionName = "0.2.54" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 5c5c276..bb35985 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -305,6 +305,7 @@ class EmbyRepository(private val settings: SettingsStore) { /** True when the backend can return the whole home screen in one request. */ val supportsBatchHome: Boolean get() = ServerConfig.isGateway + val supportsMediaRequests: Boolean get() = ServerConfig.isGateway private fun apiFor(serverUrl: String): EmbyApi { val base = normalizeServerUrl(serverUrl) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt index 21175dd..8b79d6c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/analytics/JourneyAnalytics.kt @@ -9,7 +9,8 @@ import java.util.UUID /** * Builds one ordered, user-scoped foreground journey. Callers provide controlled labels; - * this collector has no field for titles, queries, setting values or arbitrary metadata. + * Item names are retained so the operator can recognise what fired an event. Search + * queries, setting values and arbitrary metadata remain outside this collector. */ class JourneyAnalytics( private val userId: String, @@ -30,7 +31,7 @@ class JourneyAnalytics( feature: String = "", source: String = "", target: String = "", - itemId: String = "", + itemName: String = "", itemType: String = "", outcome: String = "", ) = synchronized(lock) { @@ -45,7 +46,7 @@ class JourneyAnalytics( feature = clean(feature), source = clean(source), target = clean(target), - itemId = clean(itemId), + itemName = cleanName(itemName), itemType = clean(itemType), outcome = clean(outcome), occurredAt = timestamp(), @@ -68,6 +69,8 @@ class JourneyAnalytics( it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' || it == ':' } + private fun cleanName(value: String): String = value.trim().take(160).filterNot(Char::isISOControl) + private fun timestamp(): String = iso8601.get()!!.format(Date(now())) companion object { diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index b693e09..0c41292 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -434,6 +434,7 @@ data class GatewayRequestCandidate( val overview: String = "", val posterUrl: String = "", val alreadyAdded: Boolean = false, + val inLibrary: Boolean = false, ) @Serializable @@ -736,7 +737,7 @@ data class GatewayJourneyEvent( val feature: String = "", val source: String = "", val target: String = "", - val itemId: String = "", + val itemName: String = "", val itemType: String = "", val outcome: String = "", val occurredAt: String = "", diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index e211fa3..8b22d04 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -373,21 +373,6 @@ private fun FeaturedMovieCard( ), ), ) - // Play is measured before the words, and that is the whole layout. - // - // This column is the height of a fixed-height card. A Column hands each child - // the height left after the ones before it, so the chip — being last — was - // given whatever a two-line title had not already taken, and rendered as a - // green sliver with its label squeezed out of it. Not clipped: *compressed*, - // which is why it looked malformed rather than missing. - // - // Putting the text in a `weight(1f, fill = false)` child inverts the order: - // weighted children are measured from what is left over, so the spacer and the - // chip take their natural size first and the prose is what gives way. The - // synopsis still stands down on its own when the title wraps, so in practice - // nothing has to be cut at all — but the button can no longer be the thing that - // pays for a long title, whatever the artwork, the ratings strip or the - // viewport do. var titleLines by remember(item.id) { mutableIntStateOf(1) } Column( modifier = Modifier @@ -472,8 +457,6 @@ private fun FeaturedMovieCard( } } } - Spacer(Modifier.height(14.dp)) - MembyPlayChip(label = "Play", focused = focused) } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 8d7125a..08eea88 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -207,9 +207,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { fun trackJourney( category: String, action: String, screen: String = "", feature: String = "", - source: String = "", target: String = "", itemId: String = "", itemType: String = "", + source: String = "", target: String = "", itemName: String = "", itemType: String = "", outcome: String = "", - ) = journey.track(category, action, screen, feature, source, target, itemId, itemType, outcome) + ) = journey.track(category, action, screen, feature, source, target, itemName, itemType, outcome) fun endJourney(screen: String) { journey.end(screen); flushAnalytics() } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 912e037..5bef2a9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -2129,7 +2129,7 @@ private fun HomeScreen( homeViewModel.trackJourney( category = "playback", action = "request", screen = selectedDestination.name.lowercase(), feature = "playback", source = returnRowId.orEmpty(), target = "player", - itemId = item.id, itemType = item.type, + itemName = item.name, itemType = item.type, ) homeViewModel.flushAnalytics() launchingItem = item @@ -2490,7 +2490,7 @@ private fun HomeScreen( homeViewModel.trackJourney( category = "content", action = "open", screen = "search", feature = "search", source = "search_results", target = "details", - itemId = item.id, itemType = item.type, + itemName = item.name, itemType = item.type, ) detailsAiringNotice = null detailsItem = item @@ -2520,7 +2520,7 @@ private fun HomeScreen( homeViewModel.trackJourney( category = "content", action = "open", screen = "calendar", feature = "tv_calendar", source = "calendar_day", - target = "details", itemId = item.id, itemType = item.type, + target = "details", itemName = item.name, itemType = item.type, ) // The same substitution the schedule row makes: a calendar card // is an episode that has not aired, so what was asked for is the @@ -2567,7 +2567,7 @@ private fun HomeScreen( homeViewModel.trackJourney( category = "content", action = "open", screen = "genre_browser", feature = "genre_browse", source = "genre_results", target = "details", - itemId = item.id, itemType = item.type, + itemName = item.name, itemType = item.type, ) detailsAiringNotice = null detailsItem = item @@ -2709,7 +2709,13 @@ private fun HomeScreen( returnRowId = HOME_HERO_ROW_ID returnItemId = item.id homeViewModel.focusItem(item) - playItem(item) + homeViewModel.trackJourney( + category = "content", action = "open", screen = "home", + feature = "hero", source = HOME_HERO_ROW_ID, target = "details", + itemName = item.name, itemType = item.type, + ) + detailsAiringNotice = null + detailsItem = item }, modifier = Modifier.height(metadataHeight), ) @@ -2800,7 +2806,7 @@ private fun HomeScreen( homeViewModel.trackJourney( category = "content", action = "open", screen = "shows", feature = "my_shows", source = "my_shows", target = "my_show_details", - itemId = it.itemId, + itemName = it.title, ) myShowReturnItemId = it.itemId selectedMyShow = it @@ -2971,7 +2977,7 @@ private fun HomeScreen( category = "content", action = "open", screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(), source = row.id, target = if (item.membyPlayable) "details" else "content_action", - itemId = item.id, itemType = item.type, + itemName = item.name, itemType = item.type, ) // A schedule card is an episode that has not aired, so // it is not playable and has no page of its own. What @@ -3223,7 +3229,7 @@ private fun HomeScreen( homeViewModel.trackJourney( category = "recommendations", action = "open", screen = "details", feature = "related", source = "related", target = "details", - itemId = related.id, itemType = related.type, + itemName = related.name, itemType = related.type, ) detailsTrail = detailsTrail + selected restoreDetailPosition = false @@ -3249,7 +3255,7 @@ private fun HomeScreen( onToggleFavorite = { item, saved -> homeViewModel.trackJourney( category = "library", action = if (saved) "favourite" else "unfavourite", - screen = "details", feature = "favorites", itemId = item.id, itemType = item.type, + screen = "details", feature = "favorites", itemName = item.name, itemType = item.type, outcome = "success", ) homeViewModel.setFavorite(item, saved) @@ -3258,7 +3264,7 @@ private fun HomeScreen( onToggleMyShow = { item, saved -> homeViewModel.trackJourney( category = "library", action = if (saved) "follow" else "unfollow", - screen = "details", feature = "my_shows", itemId = item.id, itemType = item.type, + screen = "details", feature = "my_shows", itemName = item.name, itemType = item.type, outcome = "success", ) // Optimistic, the way a favourite already is. Following a show is a @@ -3297,7 +3303,7 @@ private fun HomeScreen( onTogglePlayed = { item, played -> homeViewModel.trackJourney( category = "library", action = if (played) "mark_played" else "mark_unplayed", - screen = "details", feature = "played_status", itemId = item.id, itemType = item.type, + screen = "details", feature = "played_status", itemName = item.name, itemType = item.type, outcome = "success", ) homeViewModel.setPlayed(item, played) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 258d8c3..41027da 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -213,7 +213,8 @@ fun SearchScreen( state.errorMessage != null && state.results.isEmpty() -> true state.isDiscovery -> discoveryItems.isNotEmpty() || state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE } - else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() + else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() || + (state.requestsAvailable && shouldSearch(state.query)) } LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } } @@ -251,6 +252,7 @@ fun SearchScreen( BackHandler { when { + state.requestMode -> viewModel.hideRequests() // A genre is its own level, between discovery and leaving Search. Close that // level first regardless of which card currently owns focus. state.genre != null -> closeGenre() @@ -319,6 +321,7 @@ fun SearchScreen( onItemSelected = onItemSelected, onRetry = viewModel::retry, onRequest = viewModel::request, + onShowRequests = viewModel::showRequests, onSuggestionSelected = viewModel::onQueryChanged, onGenreSelected = viewModel::onGenreSelected, onBackFromGenre = closeGenre, @@ -443,7 +446,7 @@ private fun QueryField( color = if (query.isEmpty()) Muted else Heading, // Fixed size rather than shrinking as the query grows: readable at three // metres matters more than fitting a long query on one line. - fontSize = if (query.isEmpty()) 15.sp else 17.sp, + fontSize = if (query.isEmpty()) 12.sp else 17.sp, fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold, maxLines = 1, // A long query scrolls off the *start*, so the letters just typed stay visible. @@ -701,6 +704,7 @@ private fun ResultsPane( onItemSelected: (BaseItem) -> Unit, onRetry: () -> Unit, onRequest: (GatewayRequestCandidate) -> Unit, + onShowRequests: () -> Unit, onSuggestionSelected: (String) -> Unit, onGenreSelected: (String) -> Unit, onBackFromGenre: () -> Unit, @@ -726,6 +730,9 @@ private fun ResultsPane( state = state, showingDiscovery = showingDiscovery, onBackFromGenre = onBackFromGenre, + onShowRequests = onShowRequests, + requestFocusRequester = resultsEntry, + keyboardReturn = keyboardReturn, ) val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE } val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT } @@ -767,7 +774,7 @@ private fun ResultsPane( resultsEntry = resultsEntry, keyboardReturn = keyboardReturn, ) - !showingDiscovery && state.results.isEmpty() && state.requestCandidates.isNotEmpty() -> + !showingDiscovery && state.requestMode -> RequestOptions( state = state, resultsEntry = resultsEntry, @@ -805,6 +812,9 @@ private fun ResultsHeading( state: SearchUiState, showingDiscovery: Boolean, onBackFromGenre: () -> Unit, + onShowRequests: () -> Unit, + requestFocusRequester: FocusRequester, + keyboardReturn: FocusRequester, ) { val title = when { showingDiscovery -> "Browse your library" @@ -854,6 +864,17 @@ private fun ResultsHeading( Spacer(Modifier.width(10.dp)) Text("${state.results.size}", color = Muted, fontSize = 16.sp) } + if (state.requestsAvailable && state.genre == null && !showingDiscovery && !state.requestMode) { + Spacer(Modifier.width(14.dp)) + MembyChoiceChip( + label = "Request", + selected = false, + onClick = onShowRequests, + modifier = Modifier + .then(if (state.results.isEmpty()) Modifier.focusRequester(requestFocusRequester) else Modifier) + .focusProperties { left = keyboardReturn }, + ) + } } } @@ -1008,7 +1029,7 @@ private fun RequestOptions( Column(Modifier.weight(1f)) { Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold) Text( - "We found a few close matches. Choose the exact movie or series you want added.", + "Search Radarr and Sonarr, then choose the exact movie or series you want added.", color = Muted, fontSize = 13.sp, maxLines = 2, @@ -1022,7 +1043,7 @@ private fun RequestOptions( ) } Spacer(Modifier.height(16.dp)) - LazyVerticalGrid( + if (state.requestCandidates.isNotEmpty()) LazyVerticalGrid( columns = GridCells.Fixed(2), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -1043,6 +1064,15 @@ private fun RequestOptions( ) } } + else { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + if (state.requestLookupLoading) "Finding movies and series…" else "No request matches found.", + color = Muted, + fontSize = 14.sp, + ) + } + } state.requestMessage?.let { message -> val statusColor = if (state.requestMessageIsError) Color(0xFFFF8A80) else Accent Spacer(Modifier.height(12.dp)) @@ -1077,13 +1107,14 @@ private fun RequestCandidateCard( ) { val mediaLabel = if (candidate.mediaType.equals("movie", ignoreCase = true)) "MOVIE" else "TV SERIES" val actionLabel = when { + candidate.inLibrary -> "IN LIBRARY" candidate.alreadyAdded -> "REQUESTED" busy -> "REQUESTING…" else -> "REQUEST" } FocusScaleContainer( onFocused = {}, - onClick = { if (!candidate.alreadyAdded && !busy) onRequest() }, + onClick = { if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) onRequest() }, contentDescription = "${candidate.title}, $mediaLabel, $actionLabel", modifier = modifier, ) { focused -> @@ -1095,7 +1126,7 @@ private fun RequestCandidateCard( .background( when { focused -> MembyAccentMuted - candidate.alreadyAdded -> MembyAccentMuted + candidate.inLibrary || candidate.alreadyAdded -> MembyAccentMuted else -> MembyControlSurface }, ) @@ -1103,7 +1134,7 @@ private fun RequestCandidateCard( width = if (focused) 2.dp else 1.dp, color = when { focused -> Accent - candidate.alreadyAdded -> Accent.copy(alpha = 0.45f) + candidate.inLibrary || candidate.alreadyAdded -> Accent.copy(alpha = 0.45f) else -> Color.White.copy(alpha = 0.10f) }, shape = RoundedCornerShape(12.dp), @@ -1169,17 +1200,19 @@ private fun RequestCandidateCard( } Spacer(Modifier.weight(1f)) Row(verticalAlignment = Alignment.CenterVertically) { - if (candidate.alreadyAdded) { + if (candidate.inLibrary || candidate.alreadyAdded) { Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp)) Spacer(Modifier.width(5.dp)) } Text( actionLabel, - color = if (candidate.alreadyAdded || focused) Accent else KeyLabel, + color = if (candidate.inLibrary || candidate.alreadyAdded || focused) Accent else KeyLabel, fontSize = 11.sp, fontWeight = FontWeight.Bold, ) - if (!candidate.alreadyAdded && !busy) Text(" →", color = Accent, fontSize = 13.sp) + if (!candidate.inLibrary && !candidate.alreadyAdded && !busy) { + Text(" →", color = Accent, fontSize = 13.sp) + } } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index bf4a086..394228b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -66,6 +66,8 @@ data class SearchUiState( val errorMessage: String? = null, val pagingErrorMessage: String? = null, val requestCandidates: List = emptyList(), + val requestsAvailable: Boolean = false, + val requestMode: Boolean = false, val requestLookupLoading: Boolean = false, val requestingCandidateKey: String? = null, val requestMessage: String? = null, @@ -91,7 +93,9 @@ data class SearchUiState( @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { - private val _state = MutableStateFlow(SearchUiState()) + private val _state = MutableStateFlow( + SearchUiState(requestsAvailable = repository.supportsMediaRequests), + ) val state: StateFlow = _state.asStateFlow() private val queryFlow = MutableStateFlow("") @@ -176,6 +180,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { query = "", results = emptyList(), genreOffset = 0, isLoading = false, hasSearched = false, errorMessage = null, requestCandidates = emptyList(), + requestMode = false, pagingErrorMessage = null, requestLookupLoading = false, requestMessage = null, requestMessageIsError = false, genre = null, @@ -199,6 +204,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { isLoading = true, hasSearched = false, errorMessage = null, isLoadingMore = false, canLoadMore = false, requestCandidates = emptyList(), + requestMode = false, pagingErrorMessage = null, requestLookupLoading = false, requestMessage = null, requestMessageIsError = false, @@ -261,7 +267,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { } fun request(candidate: GatewayRequestCandidate) { - if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return + if (candidate.alreadyAdded || candidate.inLibrary || state.value.requestingCandidateKey != null) return val candidateKey = "${candidate.mediaType}:${candidate.foreignId}" val queryAtRequest = state.value.query.trim() val genreAtRequest = state.value.genre @@ -304,6 +310,23 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { } } + fun showRequests() { + val term = state.value.query.trim() + if (!shouldSearch(term)) return + _state.update { + it.copy(requestMode = true, requestCandidates = emptyList(), requestMessage = null, + requestMessageIsError = false) + } + viewModelScope.launch { loadRequestCandidates(term) } + } + + fun hideRequests() { + _state.update { + it.copy(requestMode = false, requestCandidates = emptyList(), requestLookupLoading = false, + requestMessage = null, requestMessageIsError = false) + } + } + /** * Genre chips for the empty state, taken from items the home screen already loaded. * Nothing is fetched: if home has no data yet, the chips simply do not appear. @@ -408,7 +431,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { _state.update { it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null) } - if (cached.isEmpty()) loadRequestCandidates(term) + if (_state.value.requestMode) loadRequestCandidates(term) return } @@ -442,7 +465,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { _state.update { it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null) } - if (ranked.isEmpty()) loadRequestCandidates(term) + if (_state.value.requestMode) loadRequestCandidates(term) } .onFailure { error -> // A cancelled search is the normal case while typing, not a failure. @@ -457,10 +480,19 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { } private suspend fun loadRequestCandidates(term: String) { - _state.update { it.copy(requestLookupLoading = true) } + _state.update { it.copy(requestLookupLoading = true, requestMessage = null) } val candidates = runCatching { repository.lookupMediaRequests(term) } .getOrElse { error -> if (error is kotlinx.coroutines.CancellationException) throw error + if (_state.value.query.trim() == term) { + _state.update { + it.copy( + requestLookupLoading = false, + requestMessage = friendlyEmbyError(error), + requestMessageIsError = true, + ) + } + } emptyList() } if (_state.value.query.trim() == term) { diff --git a/app/src/test/java/com/ponzischeme89/memby/data/analytics/JourneyAnalyticsTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/analytics/JourneyAnalyticsTest.kt index 5c63e11..bd9a191 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/analytics/JourneyAnalyticsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/analytics/JourneyAnalyticsTest.kt @@ -11,14 +11,14 @@ class JourneyAnalyticsTest { val analytics = JourneyAnalytics(userId = "user-1", now = { 1_700_000_000_000L }, journeyId = "journey-1") analytics.track( category = "content", action = "open", screen = "home screen", - feature = "latest_movies", itemId = "item/unsafe", itemType = "Movie", + feature = "latest_movies", itemName = "Whale Rider", itemType = "Movie", ) val events = analytics.drain() assertEquals(listOf(0, 1), events.map { it.sequence }) assertEquals("user-1", events.last().userId) assertEquals("homescreen", events.last().screen) - assertEquals("itemunsafe", events.last().itemId) + assertEquals("Whale Rider", events.last().itemName) } @Test diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt index b530713..4e425a1 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -31,6 +31,7 @@ import com.github.takahirom.roborazzi.captureRoboImage import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.model.BaseItem import org.junit.Before +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -57,8 +58,8 @@ class HomeMovieHeroScreenshotTest { /** * The case the audit reproduced: with a title long enough to wrap, the card's content - * column overflowed its fixed height and the green Play chip — the last thing in the - * column — was clipped away entirely. It has to survive here. + * column used to overflow its fixed height around the individual Play chip. The hero + * now opens the listing page as one clear card, including for a wrapping title. */ @Test fun `home hero with a wrapping title`() { @@ -81,7 +82,7 @@ class HomeMovieHeroScreenshotTest { /** * The hero the gateway composes. Two things are only checkable by looking: the reason - * takes the synopsis's place rather than adding a line above the Play chip, and a + * takes the synopsis's place without crowding the facts beneath it, and a * series premiere leading the launcher has to read as deliberate rather than as a TV * show that wandered into the movie hero. */ @@ -211,7 +212,7 @@ class HomeMovieHeroScreenshotTest { } } - compose.onNodeWithText("Play").fetchSemanticsNode() + assertTrue(compose.onAllNodesWithText("Play").fetchSemanticsNodes().isEmpty()) compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png") } diff --git a/server/internal/api/admin/admin.css b/server/internal/api/admin/admin.css index ebc4625..2f71d5a 100644 --- a/server/internal/api/admin/admin.css +++ b/server/internal/api/admin/admin.css @@ -96,10 +96,40 @@ a { color: var(--accent-ink); } font-size: 10px; letter-spacing: .08em; text-transform: uppercase; } .rail-group { - margin: 16px 12px 6px; color: var(--quiet); - font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; + width: calc(100% - 18px); margin: 14px 9px 5px; padding: 5px 7px; border: 0; + background: transparent; color: var(--quiet); font: inherit; font-size: 10px; + font-weight: 700; letter-spacing: .14em; text-transform: uppercase; + display: flex; align-items: center; justify-content: space-between; cursor: pointer; } +.rail-group:hover { color: var(--text); } +.rail-group-arrow { font-size: 15px; line-height: 1; transition: transform .16s ease; } +.rail-group[aria-expanded="false"] .rail-group-arrow { transform: rotate(-90deg); } .rail-nav { display: grid; gap: 2px; } +.rail-nav[hidden] { display: none; } + +.journey-summary-grid, .journey-report-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; margin-top:18px; } +.journey-summary { border:1px solid var(--line); background:var(--surface-lift); border-radius:14px; padding:16px 18px; } +.journey-summary > b, .journey-summary-head b { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.07em; } +.journey-summary > strong { display:block; font-size:20px; margin-top:9px; } +.journey-summary p { color:var(--muted); margin:8px 0 0; font-size:13px; } +.journey-summary-head { display:flex; align-items:center; justify-content:space-between; } +.journey-summary-head strong { font-size:22px; } +.journey-meter { height:8px; margin-top:13px; overflow:hidden; border-radius:99px; background:var(--line); } +.journey-meter span { display:block; height:100%; border-radius:inherit; background:var(--accent); } +.table-sub { display:block; color:var(--muted); font-size:12px; margin-top:3px; } +.route-arrow { color:var(--accent); padding:0 5px; } +.journey-visits { display:grid; gap:14px; } +.journey-visit { border:1px solid var(--line); border-radius:14px; background:var(--surface-lift); overflow:hidden; } +.journey-visit header { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid var(--line); } +.journey-visit header b, .journey-visit header span { display:block; } +.journey-visit header span { color:var(--muted); font-size:12px; margin-top:3px; } +.journey-visit ol { list-style:none; margin:0; padding:10px 16px 14px; } +.journey-visit li { display:grid; grid-template-columns:12px minmax(0,1fr) auto; gap:10px; align-items:start; padding:8px 0; } +.journey-step-dot { width:8px; height:8px; margin-top:5px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 4px var(--accent-wash); } +.journey-visit li b, .journey-visit li span { display:block; } +.journey-visit li span { color:var(--muted); font-size:12px; margin-top:2px; } +.journey-visit time { color:var(--muted); font-size:11px; } +@media (max-width:900px) { .journey-summary-grid, .journey-report-grid { grid-template-columns:1fr; } } .rail-link { position: relative; display: flex; align-items: center; gap: 11px; min-height: 36px; @@ -490,6 +520,7 @@ details[open] summary { margin-bottom: 8px; } .rail { padding: 16px 7px 10px; } .rail-brand { justify-content: center; padding: 0 0 14px; } .rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy, .rail-logout span { display: none; } + .rail-nav[hidden] { display: grid !important; } .rail-link { justify-content: center; padding: 0; min-height: 38px; } .rail-foot { justify-items: center; padding: 12px 0 0; } .rail-logout { width: auto; padding: 5px; } diff --git a/server/internal/api/admin/core.js b/server/internal/api/admin/core.js index 0ed6622..5de6e7a 100644 --- a/server/internal/api/admin/core.js +++ b/server/internal/api/admin/core.js @@ -281,7 +281,29 @@ const Admin = (() => { if (!document.hidden) refresh(); }); - ready(() => { decorate(); refresh(); schedule(); }); + /* ---- collapsible navigation ----------------------------------------- */ + + function prepareNavigation() { + document.querySelectorAll('[data-nav-group]').forEach((button) => { + const id = button.dataset.navGroup; + const panel = document.querySelector('[data-nav-panel="' + id + '"]'); + if (!panel) return; + const active = panel.querySelector('[aria-current="page"]'); + let expanded = active || localStorage.getItem('memby-admin-nav-' + id) !== 'closed'; + const apply = () => { + button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + panel.hidden = !expanded; + }; + apply(); + button.addEventListener('click', () => { + expanded = !expanded; + localStorage.setItem('memby-admin-nav-' + id, expanded ? 'open' : 'closed'); + apply(); + }); + }); + } + + ready(() => { prepareNavigation(); decorate(); refresh(); schedule(); }); return { page, api, fmt, ui, $, error, settled, fill, check, diff --git a/server/internal/api/admin/pages/journeys.html b/server/internal/api/admin/pages/journeys.html index b7079f2..6ac1324 100644 --- a/server/internal/api/admin/pages/journeys.html +++ b/server/internal/api/admin/pages/journeys.html @@ -16,8 +16,36 @@
+
+
+
+
+
+
+
+

What people do

+

Actions show total use and how many separate visits included them.

+
+
+ + +
ActionUsesVisits
+
+ +
+
+

Where people go

+

The most common steps between screens, including where quiet visits ended.

+
+
+ + +
RouteTimes
+
+
+

Feature use

@@ -29,38 +57,10 @@
-
-
-

Significant actions

-

Actions are counted by event and by distinct journey, so repeated use - inside one visit remains visible without looking like more visits.

-
-
- - -
CategoryActionEventsJourneys
-
- -
-
-

Common paths

-

Repeated transitions reveal routes into playback and places viewers - commonly leave a flow. A quiet unfinished journey becomes abandoned after 30 minutes.

-
-
- - -
FromToTimes
-
- diff --git a/server/internal/api/admin/pages/journeys.js b/server/internal/api/admin/pages/journeys.js index 26d38a3..1bc35d2 100644 --- a/server/internal/api/admin/pages/journeys.js +++ b/server/internal/api/admin/pages/journeys.js @@ -19,22 +19,27 @@ function renderEvents(events) { if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []); grouped.get(event.journeyId).push(event); }); - let journeyNumber = grouped.size; - const rows = []; + const visits = []; grouped.forEach((steps) => { steps.sort((a, b) => a.sequence - b.sequence); - const number = journeyNumber--; - steps.forEach((event) => { + const first = steps[0]; + const completed = steps.some((event) => event.action === 'journey_end'); + const actions = steps.filter((event) => !['journey_start', 'journey_end'].includes(event.action)); + const timeline = actions.map((event) => { const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target) : label(event.target || event.screen); - const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—'; - rows.push('' + fmt.when(event.occurredAt) + '' + - '' + number + '' + fmt.escape(label(event.action)) + '' + - '' + fmt.escape(path) + '' + fmt.escape(label(event.feature)) + '' + - '' + fmt.escape(content) + '' + fmt.escape(label(event.outcome)) + ''); - }); + const content = event.itemName ? label(event.itemType) + ' · ' + event.itemName : '—'; + return '
  • ' + fmt.escape(label(event.action)) + + '' + fmt.escape(path) + (content === '—' ? '' : ' · ' + fmt.escape(content)) + + '
  • '; + }).join(''); + visits.push('
    ' + fmt.when(first.occurredAt) + + '' + actions.length + ' significant step' + (actions.length === 1 ? '' : 's') + + '
    ' + ui.tag(completed ? 'completed' : 'unfinished', completed ? 'ok' : 'warn') + + '
      ' + (timeline || '
    1. No significant actions recorded.
    2. ') + + '
    '); }); - $('journeys-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.'); + $('journeys-events').innerHTML = visits.length ? visits.join('') : '

    No visits in this window.

    '; } Admin.onRefresh(async () => { @@ -58,9 +63,20 @@ Admin.onRefresh(async () => { ['average visit', fmt.duration(stats.averageTimeMs || 0), { icon: 'clock', small: true }], ['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }], ]); + const completion = Math.round((stats.completionRate || 0) * 100); + $('journeys-completion').innerHTML = '
    Visit completion' + + completion + '%

    ' + fmt.number(stats.completed || 0) + ' completed · ' + + fmt.number(stats.abandoned || 0) + ' abandoned · ' + fmt.number(stats.active || 0) + ' active

    '; const used = new Map((payload.features || []).map((feature) => [feature.feature, feature])); - const features = [...new Set([...featureCatalogue, ...used.keys()])]; + const cataloguePosition = new Map(featureCatalogue.map((name, index) => [name, index])); + const features = [...new Set([...featureCatalogue, ...used.keys()])].sort((left, right) => { + const useDifference = (used.get(right)?.uses || 0) - (used.get(left)?.uses || 0); + if (useDifference) return useDifference; + return (cataloguePosition.get(left) ?? Number.MAX_SAFE_INTEGER) - + (cataloguePosition.get(right) ?? Number.MAX_SAFE_INTEGER); + }); $('journeys-features').innerHTML = features.map((name) => { const stat = used.get(name); const uses = stat?.uses || 0; @@ -71,15 +87,21 @@ Admin.onRefresh(async () => { const actions = payload.actions || []; $('journeys-actions').innerHTML = actions.length ? actions.map((action) => - '' + fmt.escape(label(action.category)) + '' + fmt.escape(label(action.action)) + - '' + fmt.number(action.events) + '' + - fmt.number(action.journeys) + '').join('') : ui.emptyRow(4, 'No significant actions in this window.'); + '' + fmt.escape(label(action.action)) + '' + + fmt.escape(label(action.category)) + '' + fmt.number(action.events) + + '' + fmt.number(action.journeys) + '').join('') + : ui.emptyRow(3, 'No significant actions in this window.'); const paths = payload.paths || []; $('journeys-paths').innerHTML = paths.length ? paths.map((path) => - '' + fmt.escape(label(path.from)) + '' + fmt.escape(label(path.to)) + - '' + fmt.number(path.count) + '').join('') - : ui.emptyRow(3, 'No repeated paths in this window.'); + '' + fmt.escape(label(path.from)) + ' ' + + fmt.escape(label(path.to)) + '' + fmt.number(path.count) + '').join('') + : ui.emptyRow(2, 'No repeated paths in this window.'); + const topPath = paths[0]; + $('journeys-insight').innerHTML = 'Most common route' + + (topPath ? fmt.escape(label(topPath.from)) + ' → ' + fmt.escape(label(topPath.to)) : 'Not enough data') + + '

    ' + (topPath ? fmt.number(topPath.count) + ' times in this window' : + 'Journeys will appear here as viewers move through Memby.') + '

    '; $('journeys-events-card').hidden = !selected; if (selected) renderEvents(payload.events || []); diff --git a/server/internal/api/admin/shell.html b/server/internal/api/admin/shell.html index ff31e84..6a3fe5d 100644 --- a/server/internal/api/admin/shell.html +++ b/server/internal/api/admin/shell.html @@ -15,8 +15,9 @@
    {{range .Nav}} - {{if .Label}}

    {{.Label}}

    {{end}} -