feat(discover): snooze affordance on Android + web suggestion cards — #2375
Completes the snooze from slice 3 (#2374), so it's now touchable on both clients (rule #27 — the server side alone was never shippable). Copy is "Not right now" everywhere, never a dislike (rule #101). The parked list even says so out loud: "Nothing here counts against your taste profile." Both clients flip the card in place to a "Not right now" state with an Undo, rather than yanking it out of the grid under the cursor. The row leaves on the next refetch; the persistent way back is a parked-list section below the deck. That list isn't optional garnish — a snoozed candidate is by definition absent from the deck, so without it the DELETE endpoint is unreachable. Android routes the write through the offline MutationQueue per rule #100, as ONE toggle kind (SUGGESTION_SNOOZE_TOGGLE) carrying the desired state rather than two action kinds. That reuses the LIKE_TOGGLE collapse: a queued snooze the user has since undone is dropped unsent instead of replaying after the undo and re-hiding an artist they asked to see. The collapse helper is now a pure top-level function so that rule is unit tested rather than inferred. The repository does NOT enqueue on a 4xx — a permanent rejection would replay to the same failure and would raise a misleading "will sync when online" hint. The common case is a 404 from un-snoozing a row that already lapsed, which is the user's intended end state anyway. Also: an empty deck used to have one meaning (no listening signal yet). It can now also mean "you parked them all", so the empty copy branches — telling that user to go listen to something would be wrong advice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,13 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
@@ -30,4 +34,30 @@ interface DiscoverApi {
|
||||
|
||||
@POST("api/requests")
|
||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||
|
||||
/**
|
||||
* Parks a suggestion — "not right now", NOT a dislike. Time-boxed
|
||||
* server-side (90 days) and never fed into the taste profile.
|
||||
*
|
||||
* [body] must carry the artist's name: candidates are out-of-library, so
|
||||
* the server has no local row to resolve a display name from and returns
|
||||
* 400 without it.
|
||||
*/
|
||||
@POST("api/discover/suggestions/{mbid}/snooze")
|
||||
suspend fun snoozeSuggestion(
|
||||
@Path("mbid") mbid: String,
|
||||
@Body body: SnoozeSuggestionBody,
|
||||
)
|
||||
|
||||
/** Brings a parked suggestion back. 404 when it wasn't snoozed. */
|
||||
@DELETE("api/discover/suggestions/{mbid}/snooze")
|
||||
suspend fun unsnoozeSuggestion(@Path("mbid") mbid: String)
|
||||
|
||||
/**
|
||||
* Currently-parked suggestions. Server filters expired rows, so every
|
||||
* row returned is still snoozed. This is the only route back to an
|
||||
* un-snooze once the card has left the deck.
|
||||
*/
|
||||
@GET("api/discover/snoozes")
|
||||
suspend fun listSnoozes(): List<SuggestionSnoozeWire>
|
||||
}
|
||||
|
||||
+40
@@ -35,6 +35,12 @@ object MutationKind {
|
||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||
// path produced (see 2026-06-11 contract audit).
|
||||
const val PLAY_ENDED: String = "play_ended"
|
||||
|
||||
// #2374 suggestion snooze. ONE toggle kind rather than separate
|
||||
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
||||
// an undo collapses to the latest intent instead of replaying as two
|
||||
// opposed calls whose order decides the outcome.
|
||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +158,25 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Queues a suggestion snooze (or its undo) for replay. [desiredSnoozed]
|
||||
* is the TARGET state, so repeated taps collapse to one replay.
|
||||
*
|
||||
* [name] is carried even for an un-snooze, where the server ignores it,
|
||||
* so a single payload shape serves both directions.
|
||||
*/
|
||||
suspend fun enqueueSuggestionSnoozeToggle(
|
||||
mbid: String,
|
||||
name: String,
|
||||
desiredSnoozed: Boolean,
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||
json.encodeToString(
|
||||
SuggestionSnoozeTogglePayload.serializer(),
|
||||
SuggestionSnoozeTogglePayload(mbid, name, desiredSnoozed),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
@@ -192,6 +217,21 @@ class MutationQueue @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
|
||||
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
||||
* the replayer can collapse repeated toggles for one candidate down to the
|
||||
* last intent. Both directions are idempotent server-side: re-snoozing
|
||||
* extends the window, and un-snoozing something already back is a 404 the
|
||||
* replayer treats as permanent (nothing left to do).
|
||||
*/
|
||||
@Serializable
|
||||
data class SuggestionSnoozeTogglePayload(
|
||||
val mbid: String,
|
||||
val name: String,
|
||||
val desiredSnoozed: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
||||
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||
|
||||
+69
-24
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
@@ -114,11 +115,12 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private suspend fun drain() {
|
||||
val rows = dao.getAll()
|
||||
// Collapse superseded like-toggles: only the latest desired state per
|
||||
// (entity) is replayed; older toggles for the same entity are dropped
|
||||
// unsent. Without this, partial-failure + differential retry could
|
||||
// replay an older toggle last and invert the final like state.
|
||||
val superseded = supersededLikeToggleIds(rows)
|
||||
// Collapse superseded toggles (likes, suggestion snoozes): only the
|
||||
// latest desired state per entity is replayed; older toggles for the
|
||||
// same entity are dropped unsent. Without this, partial-failure +
|
||||
// differential retry could replay an older toggle last and invert the
|
||||
// final state — a snooze the user already undid would come back.
|
||||
val superseded = supersededToggleIds(rows, json)
|
||||
for (row in rows) {
|
||||
if (row.id in superseded) {
|
||||
dao.delete(row.id)
|
||||
@@ -131,25 +133,6 @@ class MutationReplayer @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
||||
val latestByEntity = HashMap<String, Long>()
|
||||
val superseded = HashSet<Long>()
|
||||
rows.asSequence()
|
||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
||||
.forEach { row ->
|
||||
val decoded = runCatching {
|
||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()
|
||||
if (decoded != null) {
|
||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
||||
// `rows` is ascending by id, so a prior entry is always older.
|
||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
||||
}
|
||||
}
|
||||
return superseded
|
||||
}
|
||||
|
||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||
dispatch(row)
|
||||
} catch (e: HttpException) {
|
||||
@@ -182,6 +165,7 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
@@ -277,6 +261,24 @@ class MutationReplayer @Inject constructor(
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a suggestion snooze in whichever direction the payload asks for.
|
||||
*
|
||||
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
|
||||
* previous attempt landed and the response was lost). [outcomeFor] classes
|
||||
* 404 as permanent → DROP, which is right: the user's intended end state
|
||||
* already holds, so there is nothing left to send.
|
||||
*/
|
||||
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
|
||||
if (decoded.desiredSnoozed) {
|
||||
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
|
||||
} else {
|
||||
discoverApi.unsnoozeSuggestion(decoded.mbid)
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -297,3 +299,46 @@ class MutationReplayer @Inject constructor(
|
||||
const val HTTP_TOO_MANY = 429
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||
* replaying a stale one last would invert the final state.
|
||||
*
|
||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||
* instance. [rows] must be ascending by id (FIFO), which is what
|
||||
* `CachedMutationDao.getAll()` returns.
|
||||
*/
|
||||
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
|
||||
val latestByEntity = HashMap<String, Long>()
|
||||
val superseded = HashSet<Long>()
|
||||
rows.asSequence()
|
||||
.mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
|
||||
.forEach { (key, id) ->
|
||||
// Ascending ids mean a prior entry for this key is always older.
|
||||
latestByEntity.put(key, id)?.let(superseded::add)
|
||||
}
|
||||
return superseded
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse key for a toggle row, or null when the row isn't a toggle — or its
|
||||
* payload won't decode. Undecodable rows are deliberately left alone rather
|
||||
* than grouped under a shared "corrupt" key, so one bad row can't suppress a
|
||||
* good one behind it; the dispatcher DROPs it on its own.
|
||||
*
|
||||
* The kind is part of the key so two toggle kinds can never collide on the
|
||||
* same entity id.
|
||||
*/
|
||||
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
|
||||
MutationKind.LIKE_TOGGLE -> runCatching {
|
||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }
|
||||
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
|
||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
+80
@@ -7,10 +7,14 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
|
||||
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||
api.listSuggestions().map { it.toDomain() }
|
||||
|
||||
suspend fun listSnoozes(): List<SuggestionSnoozeRef> =
|
||||
api.listSnoozes().map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
|
||||
* transport failure the target state is queued for the replayer rather
|
||||
* than dropped.
|
||||
*
|
||||
* Always reports success to the caller. Unlike a request, a snooze has no
|
||||
* meaningful failed state to show — the user asked for a card to go away,
|
||||
* and it will, either now or when the queue drains.
|
||||
*/
|
||||
suspend fun snoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
desiredSnoozed = true,
|
||||
) { api.snoozeSuggestion(mbid, SnoozeSuggestionBody(name = name)) }
|
||||
|
||||
/** Brings a parked suggestion back. Same offline-first contract. */
|
||||
suspend fun unsnoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
desiredSnoozed = false,
|
||||
) { api.unsnoozeSuggestion(mbid) }
|
||||
|
||||
private suspend fun toggleSnooze(
|
||||
mbid: String,
|
||||
name: String,
|
||||
desiredSnoozed: Boolean,
|
||||
call: suspend () -> Unit,
|
||||
) {
|
||||
try {
|
||||
call()
|
||||
} catch (e: HttpException) {
|
||||
// A 4xx is the server's considered answer, not a lost call, so
|
||||
// queueing it would be wrong twice over: the replay is guaranteed
|
||||
// to fail again, and the enqueue would raise a "will sync when
|
||||
// online" snackbar for something already settled. The common case
|
||||
// is a 404 from un-snoozing a row that already lapsed — which is
|
||||
// the end state the user wanted anyway.
|
||||
if (!isPermanent(e.code())) {
|
||||
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Transport failure — intentional swallow, same offline-first
|
||||
// rationale as createRequest above. The queue carries the desired
|
||||
// STATE, so a later undo supersedes this rather than fighting it
|
||||
// on replay.
|
||||
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors MutationReplayer's classification so the enqueue decision here
|
||||
* and the drop decision there can't disagree: 4xx is permanent except the
|
||||
* two "retry me" statuses.
|
||||
*/
|
||||
private fun isPermanent(code: Int): Boolean =
|
||||
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||
|
||||
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||
|
||||
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
|
||||
RequestOutcome.QUEUED
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_CLIENT_ERR_MIN = 400
|
||||
const val HTTP_CLIENT_ERR_MAX = 499
|
||||
const val HTTP_TIMEOUT = 408
|
||||
const val HTTP_TOO_MANY = 429
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mappers (internal — wire types stay out of UI) ──
|
||||
@@ -112,6 +186,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
|
||||
isLiked = isLiked,
|
||||
)
|
||||
|
||||
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
snoozedUntil = snoozedUntil,
|
||||
)
|
||||
|
||||
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||
kind = kind,
|
||||
artistMbid = artistMbid,
|
||||
|
||||
@@ -40,6 +40,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import com.fabledsword.minstrel.nav.Discover
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -104,6 +105,18 @@ private fun DiscoverBody(
|
||||
ResultsState.Idle -> SuggestionsPane(
|
||||
state = state.suggestions,
|
||||
locallyRequestedMbids = state.locallyRequestedMbids,
|
||||
snoozeUi = SnoozeUi(
|
||||
locallySnoozedMbids = state.locallySnoozedMbids,
|
||||
snoozes = state.snoozes,
|
||||
// No snackbar on snooze: the row itself flips to "Not
|
||||
// right now" with an Undo, so a snackbar would only
|
||||
// repeat what the user can already see — and cover the
|
||||
// next row while doing it.
|
||||
onSnooze = { s -> scope.launch { viewModel.snoozeSuggestion(s) } },
|
||||
onUnsnooze = { mbid, name ->
|
||||
scope.launch { viewModel.unsnoozeSuggestion(mbid, name) }
|
||||
},
|
||||
),
|
||||
onRequest = { s ->
|
||||
scope.launch {
|
||||
val outcome = viewModel.requestSuggestion(s)
|
||||
@@ -178,10 +191,23 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The snooze surface's data and callbacks, bundled rather than threaded
|
||||
* through as four more parameters — the pane grew from one action to three
|
||||
* with slice 4 and the signatures stopped being readable.
|
||||
*/
|
||||
private data class SnoozeUi(
|
||||
val locallySnoozedMbids: Set<String>,
|
||||
val snoozes: List<SuggestionSnoozeRef>,
|
||||
val onSnooze: (ArtistSuggestionRef) -> Unit,
|
||||
val onUnsnooze: (String, String) -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SuggestionsPane(
|
||||
state: SuggestionState,
|
||||
locallyRequestedMbids: Set<String>,
|
||||
snoozeUi: SnoozeUi,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
@@ -194,6 +220,7 @@ private fun SuggestionsPane(
|
||||
)
|
||||
is SuggestionState.Loaded -> SuggestionsList(
|
||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||
snoozeUi = snoozeUi,
|
||||
onRequest = onRequest,
|
||||
)
|
||||
}
|
||||
@@ -202,6 +229,7 @@ private fun SuggestionsPane(
|
||||
@Composable
|
||||
private fun SuggestionsList(
|
||||
items: List<ArtistSuggestionRef>,
|
||||
snoozeUi: SnoozeUi,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -210,13 +238,61 @@ private fun SuggestionsList(
|
||||
) {
|
||||
item { SuggestionsHeader() }
|
||||
if (items.isEmpty()) {
|
||||
item { CenteredMessage("Listen to or like an artist to fill this in.") }
|
||||
// An empty deck used to mean one thing — no listening signal yet.
|
||||
// With snoozing it can also mean "you parked them all", and telling
|
||||
// that user to go listen to something would be wrong advice.
|
||||
item {
|
||||
CenteredMessage(
|
||||
if (snoozeUi.snoozes.isEmpty()) {
|
||||
"Listen to or like an artist to fill this in."
|
||||
} else {
|
||||
"Nothing new right now — the artists you've parked are below."
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(items = items, key = { it.mbid }) { s ->
|
||||
SuggestionTile(s = s, onRequest = { onRequest(s) })
|
||||
SuggestionTile(
|
||||
s = s,
|
||||
snoozed = s.mbid in snoozeUi.locallySnoozedMbids,
|
||||
onRequest = { onRequest(s) },
|
||||
onSnooze = { snoozeUi.onSnooze(s) },
|
||||
onUnsnooze = { snoozeUi.onUnsnooze(s.mbid, s.name) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
// Parked candidates live at the bottom of the same scroll, not behind a
|
||||
// separate screen: it's a short list the user rarely needs, but it must
|
||||
// be reachable — a snoozed candidate is gone from the deck above, so
|
||||
// this is the only way back to it.
|
||||
if (snoozeUi.snoozes.isNotEmpty()) {
|
||||
item { SnoozedHeader() }
|
||||
items(items = snoozeUi.snoozes, key = { "snoozed-${it.mbid}" }) { row ->
|
||||
SnoozedTile(
|
||||
row = row,
|
||||
onUnsnooze = { snoozeUi.onUnsnooze(row.mbid, row.name) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SnoozedHeader() {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
|
||||
Text(
|
||||
text = "Not right now",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = "These come back on their own. Nothing here counts against your taste profile.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -24,14 +26,22 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Disc3
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
|
||||
@Composable
|
||||
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
internal fun SuggestionTile(
|
||||
s: ArtistSuggestionRef,
|
||||
snoozed: Boolean,
|
||||
onRequest: () -> Unit,
|
||||
onSnooze: () -> Unit,
|
||||
onUnsnooze: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (s.attributionText.isNotEmpty()) {
|
||||
// Once parked, the "because you liked X" line is no longer the
|
||||
// useful thing to say — confirming what just happened is.
|
||||
val secondary =
|
||||
if (snoozed) "Not right now — hidden for a while" else s.attributionText
|
||||
if (secondary.isNotEmpty()) {
|
||||
Text(
|
||||
text = s.attributionText,
|
||||
text = secondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
@@ -58,7 +72,52 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(onClick = onRequest) { Text("Request") }
|
||||
if (snoozed) {
|
||||
TextButton(onClick = onUnsnooze) { Text("Undo") }
|
||||
} else {
|
||||
Button(onClick = onRequest) { Text("Request") }
|
||||
IconButton(onClick = onSnooze) {
|
||||
Icon(
|
||||
imageVector = Lucide.Clock,
|
||||
// Rule #101: the label states what happens, and passes no
|
||||
// judgement on the music. Never "not for me".
|
||||
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the parked list. This exists because a snoozed candidate is by
|
||||
* definition absent from the deck above, so without it there is no route back
|
||||
* to an un-snooze once the card has gone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = row.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "Back ${row.returnsIn()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onUnsnooze) { Text("Bring back") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -27,6 +28,17 @@ data class DiscoverState(
|
||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||
val results: ResultsState = ResultsState.Idle,
|
||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||
/**
|
||||
* Parked candidates, for the manage list under the feed. Empty is the
|
||||
* normal case and hides the section entirely.
|
||||
*/
|
||||
val snoozes: List<SuggestionSnoozeRef> = emptyList(),
|
||||
/**
|
||||
* Just-snoozed MBIDs. These keep their row visible showing an Undo rather
|
||||
* than yanking it out from under the user's finger; the row is gone on the
|
||||
* next load, and [snoozes] is the way back after that.
|
||||
*/
|
||||
val locallySnoozedMbids: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
sealed interface SuggestionState {
|
||||
@@ -96,6 +108,47 @@ class DiscoverViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Refresh the parked list alongside the deck: a snooze made on another
|
||||
// client should show up here, and one whose window lapsed should drop
|
||||
// off. Sequenced after the deck load rather than raced with it so the
|
||||
// two panes can't disagree about a candidate mid-refresh.
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the parked list. Failure is deliberately silent: this is a
|
||||
* secondary pane, and an error banner for it would sit above the suggestion
|
||||
* feed the user actually came for. The list stays as-is and the next
|
||||
* refresh retries.
|
||||
*/
|
||||
private suspend fun loadSnoozes() {
|
||||
try {
|
||||
val rows = repository.listSnoozes()
|
||||
internal.update { it.copy(snoozes = rows) }
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Keep whatever we last showed rather than blanking the section.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parks a suggestion. Flips the row locally first so the tap registers
|
||||
* immediately; the repository handles the offline case, so there is no
|
||||
* failure branch to revert here — unlike the web client, where the fetch
|
||||
* either lands or doesn't.
|
||||
*/
|
||||
suspend fun snoozeSuggestion(s: ArtistSuggestionRef) {
|
||||
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids + s.mbid) }
|
||||
repository.snoozeSuggestion(s.mbid, s.name)
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
/** Brings a parked suggestion back, from either the card or the list. */
|
||||
suspend fun unsnoozeSuggestion(mbid: String, name: String) {
|
||||
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids - mbid) }
|
||||
repository.unsnoozeSuggestion(mbid, name)
|
||||
loadSnoozes()
|
||||
}
|
||||
|
||||
fun runSearch() {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||
* enum name; the helper [wire] keeps that mapping in one place.
|
||||
@@ -67,3 +70,56 @@ data class ArtistSuggestionRef(
|
||||
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggestion the user parked with "not right now" (#2374).
|
||||
*
|
||||
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
|
||||
* its own, and never reaches the taste profile. Anything that treats this as
|
||||
* negative preference signal is a bug.
|
||||
*
|
||||
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
|
||||
* decides whether a snooze is still in effect — every row the client receives
|
||||
* already is — so this is read purely to phrase "back in about 3 months".
|
||||
*/
|
||||
data class SuggestionSnoozeRef(
|
||||
val mbid: String,
|
||||
val name: String,
|
||||
val snoozedUntil: String,
|
||||
) {
|
||||
/**
|
||||
* Relative return phrase for the manage list. Relative rather than a
|
||||
* calendar date because the exact day a 90-day snooze lapses is noise the
|
||||
* user never asked for.
|
||||
*
|
||||
* [nowMs] is injectable so this is testable without freezing the clock.
|
||||
* Returns "shortly" for an unparseable or already-past timestamp: the row
|
||||
* is on screen, so the server still considers it snoozed, and guessing is
|
||||
* better than rendering an empty line.
|
||||
*/
|
||||
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
|
||||
val untilMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
|
||||
.getOrNull() ?: return "shortly"
|
||||
// Already lapsed by our clock, yet the server still returned it — the
|
||||
// two disagree. Say something plausible rather than "today", which
|
||||
// would read as a real prediction.
|
||||
if (untilMs <= nowMs) return "shortly"
|
||||
val days = ((untilMs - nowMs).toDouble() / MILLIS_PER_DAY).roundToInt()
|
||||
return when {
|
||||
days < 1 -> "today"
|
||||
days == 1 -> "tomorrow"
|
||||
days < DAYS_BEFORE_MONTHS -> "in $days days"
|
||||
else -> {
|
||||
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
|
||||
if (months == 1) "in about a month" else "in about $months months"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MILLIS_PER_DAY = 86_400_000.0
|
||||
// Below this, days read more naturally than a rounded month count.
|
||||
const val DAYS_BEFORE_MONTHS = 45
|
||||
const val DAYS_PER_MONTH = 30.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,36 @@ data class SeedContributionWire(
|
||||
@SerialName("is_liked") val isLiked: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of `GET /api/discover/snoozes` — a suggestion the user parked
|
||||
* with "not right now". The server only returns rows that are still in
|
||||
* effect, so the client never compares [snoozedUntil] against the clock to
|
||||
* decide whether to show it; it reads it only to say when the artist comes
|
||||
* back.
|
||||
*/
|
||||
@Serializable
|
||||
data class SuggestionSnoozeWire(
|
||||
val mbid: String = "",
|
||||
val name: String = "",
|
||||
@SerialName("snoozed_until") val snoozedUntil: String = "",
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Body for `POST /api/discover/suggestions/{mbid}/snooze`.
|
||||
*
|
||||
* [name] is required by the server, not decorative: suggestions are
|
||||
* out-of-library, so there is no artists row to resolve a display name from
|
||||
* and the snooze list would have nothing to render. Omitting it is a 400.
|
||||
*
|
||||
* No `days` field. The duration is the server's to own (90 days); pinning it
|
||||
* client-side would freeze the default at whatever this build shipped.
|
||||
*/
|
||||
@Serializable
|
||||
data class SnoozeSuggestionBody(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||
* payload shape. Optional fields are emitted only when non-null
|
||||
|
||||
Vendored
+132
@@ -0,0 +1,132 @@
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Collapse rules for desired-state toggles in the offline queue.
|
||||
*
|
||||
* The hazard this guards against is real and silent: without collapsing, a
|
||||
* queued snooze that replays AFTER the user's undo re-hides an artist they
|
||||
* asked to see again, and nothing surfaces the contradiction.
|
||||
*/
|
||||
class SupersededToggleIdsTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun snoozeRow(id: Long, mbid: String, desiredSnoozed: Boolean) = CachedMutationEntity(
|
||||
id = id,
|
||||
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||
payload = json.encodeToString(
|
||||
SuggestionSnoozeTogglePayload.serializer(),
|
||||
SuggestionSnoozeTogglePayload(mbid, "Name", desiredSnoozed),
|
||||
),
|
||||
)
|
||||
|
||||
private fun likeRow(id: Long, entityId: String, desired: Boolean) = CachedMutationEntity(
|
||||
id = id,
|
||||
kind = MutationKind.LIKE_TOGGLE,
|
||||
payload = json.encodeToString(
|
||||
LikeTogglePayload.serializer(),
|
||||
LikeTogglePayload("artist", entityId, desired),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a snooze followed by its undo drops the snooze`() {
|
||||
val rows = listOf(
|
||||
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||
snoozeRow(2, "mb-a", desiredSnoozed = false),
|
||||
)
|
||||
// Only the later intent (the undo) survives to be replayed.
|
||||
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggles for different candidates never collapse into each other`() {
|
||||
val rows = listOf(
|
||||
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||
snoozeRow(2, "mb-b", desiredSnoozed = true),
|
||||
)
|
||||
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the newest of several toggles for one candidate survives`() {
|
||||
val rows = listOf(
|
||||
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||
snoozeRow(2, "mb-a", desiredSnoozed = false),
|
||||
snoozeRow(3, "mb-a", desiredSnoozed = true),
|
||||
)
|
||||
assertEquals(setOf(1L, 2L), supersededToggleIds(rows, json))
|
||||
}
|
||||
|
||||
// The kind is part of the collapse key, so a snooze and a like that happen
|
||||
// to share an id string must not shadow one another.
|
||||
@Test
|
||||
fun `a like and a snooze on the same id string do not collide`() {
|
||||
val rows = listOf(
|
||||
likeRow(1, "same-id", desired = true),
|
||||
snoozeRow(2, "same-id", desiredSnoozed = true),
|
||||
)
|
||||
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `like toggles still collapse — the pre-existing behaviour is intact`() {
|
||||
val rows = listOf(
|
||||
likeRow(1, "artist-1", desired = true),
|
||||
likeRow(2, "artist-1", desired = false),
|
||||
)
|
||||
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-toggle kinds are never collapsed, even repeated for one entity`() {
|
||||
// Two appends to the same playlist are two real actions, not one
|
||||
// desired state — collapsing them would lose a write.
|
||||
val rows = listOf(
|
||||
CachedMutationEntity(
|
||||
id = 1,
|
||||
kind = MutationKind.PLAYLIST_APPEND,
|
||||
payload = json.encodeToString(
|
||||
PlaylistAppendPayload.serializer(),
|
||||
PlaylistAppendPayload("pl-1", listOf("t1")),
|
||||
),
|
||||
),
|
||||
CachedMutationEntity(
|
||||
id = 2,
|
||||
kind = MutationKind.PLAYLIST_APPEND,
|
||||
payload = json.encodeToString(
|
||||
PlaylistAppendPayload.serializer(),
|
||||
PlaylistAppendPayload("pl-1", listOf("t2")),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||
}
|
||||
|
||||
// A row whose payload won't decode gets no key at all, rather than sharing
|
||||
// a "corrupt" bucket — otherwise one bad row could suppress a good one
|
||||
// behind it. The dispatcher DROPs the bad row on its own.
|
||||
@Test
|
||||
fun `an undecodable payload does not suppress a valid later row`() {
|
||||
val rows = listOf(
|
||||
CachedMutationEntity(
|
||||
id = 1,
|
||||
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||
payload = "{ not json",
|
||||
),
|
||||
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||
)
|
||||
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty queue collapses nothing`() {
|
||||
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* `returnsIn` phrasing for the parked-suggestions list (#2375). The clock is
|
||||
* injected rather than frozen, so these assertions are stable.
|
||||
*/
|
||||
class SuggestionSnoozeRefTest {
|
||||
|
||||
private val now = 1_800_000_000_000L // fixed epoch ms; any value works
|
||||
|
||||
private fun snoozeIn(days: Double) = SuggestionSnoozeRef(
|
||||
mbid = "mb",
|
||||
name = "Parked",
|
||||
snoozedUntil = Instant
|
||||
.fromEpochMilliseconds(now + (days * 86_400_000L).toLong())
|
||||
.toString(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the default 90-day snooze reads as about 3 months`() {
|
||||
assertEquals("in about 3 months", snoozeIn(90.0).returnsIn(now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a month reads in the singular`() {
|
||||
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `under the month threshold it counts days`() {
|
||||
assertEquals("in 14 days", snoozeIn(14.0).returnsIn(now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tomorrow is named, not rendered as 1 days`() {
|
||||
assertEquals("tomorrow", snoozeIn(1.0).returnsIn(now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `later today rounds down to today rather than going negative`() {
|
||||
assertEquals("today", snoozeIn(0.1).returnsIn(now))
|
||||
}
|
||||
|
||||
// The server only ever returns unexpired rows, so a past timestamp means
|
||||
// our clock and the server's disagree. The row is on screen either way, so
|
||||
// say something plausible rather than leaving the line blank.
|
||||
@Test
|
||||
fun `an already-past expiry degrades to shortly`() {
|
||||
assertEquals("shortly", snoozeIn(-5.0).returnsIn(now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unparseable timestamp degrades to shortly`() {
|
||||
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "not-a-date")
|
||||
assertEquals("shortly", row.returnsIn(now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty timestamp degrades to shortly`() {
|
||||
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "")
|
||||
assertEquals("shortly", row.returnsIn(now))
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export const qk = {
|
||||
smtpConfig: () => ['smtpConfig'] as const,
|
||||
suggestions: (limit?: number) =>
|
||||
['suggestions', { limit: limit ?? 12 }] as const,
|
||||
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
|
||||
home: () => ['home'] as const,
|
||||
albumsAlpha: () => ['albumsAlpha'] as const,
|
||||
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: { get: vi.fn() }
|
||||
api: { get: vi.fn(), post: vi.fn(), del: vi.fn() }
|
||||
}));
|
||||
|
||||
import { listSuggestions } from './suggestions';
|
||||
import {
|
||||
listSuggestions,
|
||||
listSnoozes,
|
||||
snoozeSuggestion,
|
||||
unsnoozeSuggestion
|
||||
} from './suggestions';
|
||||
import { qk } from './queries';
|
||||
import { api } from './client';
|
||||
import type { ArtistSuggestion } from './types';
|
||||
import type { ArtistSuggestion, SuggestionSnooze } from './types';
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -40,3 +45,56 @@ describe('suggestions client', () => {
|
||||
expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestion snoozes (#2375)', () => {
|
||||
test('snoozeSuggestion sends the name — the server 400s without it', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await snoozeSuggestion('mb-1', 'Parked Artist');
|
||||
expect(api.post).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze', {
|
||||
name: 'Parked Artist'
|
||||
});
|
||||
});
|
||||
|
||||
test('snoozeSuggestion sends no days, leaving the default to the server', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await snoozeSuggestion('mb-1', 'Parked Artist');
|
||||
const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty('days');
|
||||
});
|
||||
|
||||
// MBIDs are UUIDs today, but the column is free-text and the value comes
|
||||
// from an external similarity feed, so it goes through encodeURIComponent.
|
||||
test('the mbid is URL-encoded into the path', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await snoozeSuggestion('weird/id?x', 'Odd');
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
'/api/discover/suggestions/weird%2Fid%3Fx/snooze',
|
||||
{ name: 'Odd' }
|
||||
);
|
||||
});
|
||||
|
||||
test('unsnoozeSuggestion DELETEs the same path', async () => {
|
||||
(api.del as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await unsnoozeSuggestion('mb-1');
|
||||
expect(api.del).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze');
|
||||
});
|
||||
|
||||
test('listSnoozes hits the snoozes collection', async () => {
|
||||
const fixture: SuggestionSnooze[] = [
|
||||
{
|
||||
mbid: 'mb-1',
|
||||
name: 'Parked Artist',
|
||||
snoozed_until: '2026-11-01T00:00:00Z',
|
||||
created_at: '2026-08-03T00:00:00Z'
|
||||
}
|
||||
];
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
|
||||
const got = await listSnoozes();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/discover/snoozes');
|
||||
expect(got).toEqual(fixture);
|
||||
});
|
||||
|
||||
test('qk.suggestionSnoozes key shape', () => {
|
||||
expect(qk.suggestionSnoozes()).toEqual(['suggestionSnoozes']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
import type { ArtistSuggestion } from './types';
|
||||
import type { ArtistSuggestion, SuggestionSnooze } from './types';
|
||||
|
||||
export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> {
|
||||
return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`);
|
||||
@@ -14,3 +14,36 @@ export function createSuggestionsQuery(limit = 12) {
|
||||
staleTime: 5 * 60_000 // 5 minutes — see M5c spec §5
|
||||
});
|
||||
}
|
||||
|
||||
// Parks a suggestion for the server's default period (90 days). `name` is
|
||||
// REQUIRED by the server and is not optional bookkeeping: candidates are
|
||||
// out-of-library, so there is no artists row to resolve a display name from
|
||||
// and the snooze list would have nothing to render. Omitting it is a 400.
|
||||
//
|
||||
// No `days` is sent. There is deliberately no duration UI yet — that knob is
|
||||
// slice 6 (#2377) — and hardcoding a value here would pin the default to the
|
||||
// client instead of the server that owns it.
|
||||
export async function snoozeSuggestion(mbid: string, name: string): Promise<void> {
|
||||
await api.post<null>(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`, { name });
|
||||
}
|
||||
|
||||
// Brings a parked suggestion back immediately. The server 404s an MBID that
|
||||
// was never snoozed; callers treat that as already-unsnoozed rather than as a
|
||||
// failure, since the end state the user asked for is the one they get.
|
||||
export async function unsnoozeSuggestion(mbid: string): Promise<void> {
|
||||
await api.del(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`);
|
||||
}
|
||||
|
||||
export async function listSnoozes(): Promise<SuggestionSnooze[]> {
|
||||
return api.get<SuggestionSnooze[]>('/api/discover/snoozes');
|
||||
}
|
||||
|
||||
export function createSnoozesQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.suggestionSnoozes(),
|
||||
queryFn: listSnoozes
|
||||
// No staleTime, unlike the suggestions query: this list is the only route
|
||||
// back to an un-snooze, so it must reflect a snooze made seconds ago
|
||||
// rather than a cached view of the world.
|
||||
});
|
||||
}
|
||||
|
||||
@@ -334,6 +334,16 @@ export type ArtistSuggestion = {
|
||||
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
|
||||
};
|
||||
|
||||
// One parked suggestion — "not right now", not a dislike. The server only
|
||||
// ever returns rows whose snoozed_until is still in the future, so the client
|
||||
// never has to compare against the clock to decide what to show.
|
||||
export type SuggestionSnooze = {
|
||||
mbid: string;
|
||||
name: string;
|
||||
snoozed_until: string; // RFC3339
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// Mirrors internal/api/types.go HomePayload. All slices are non-null
|
||||
// per the server contract — empty sections render as [].
|
||||
export type HomePayload = {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script lang="ts" module>
|
||||
export type DiscoverCardKind = 'artist' | 'album' | 'track';
|
||||
export type DiscoverCardState = 'requestable' | 'kept' | 'requested';
|
||||
// 'snoozed' is a transient state the card flips to in place after the user
|
||||
// parks it, so the disappearance is legible and undoable rather than a card
|
||||
// silently vanishing from under the cursor (rule #24). The row is gone on
|
||||
// the next refetch; the persistent way back is the snoozed list.
|
||||
export type DiscoverCardState = 'requestable' | 'kept' | 'requested' | 'snoozed';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Plus, Disc3, Album, Music2 } from 'lucide-svelte';
|
||||
import { Plus, Disc3, Album, Music2, Clock } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
kind,
|
||||
@@ -14,6 +18,8 @@
|
||||
state,
|
||||
attribution,
|
||||
onRequest,
|
||||
onSnooze,
|
||||
onUnsnooze,
|
||||
}: {
|
||||
kind: DiscoverCardKind;
|
||||
title: string;
|
||||
@@ -22,6 +28,10 @@
|
||||
state: DiscoverCardState;
|
||||
attribution?: string;
|
||||
onRequest?: () => void;
|
||||
// Omit both to get a card with no snooze affordance — the Lidarr search
|
||||
// results reuse this component and have nothing to park.
|
||||
onSnooze?: () => void;
|
||||
onUnsnooze?: () => void;
|
||||
} = $props();
|
||||
|
||||
const FallbackIcon = $derived(
|
||||
@@ -65,20 +75,50 @@
|
||||
<div class="badge-row" data-testid="badge-row">
|
||||
{#if state === 'kept'}
|
||||
<span class="kept-pill" role="status">Kept</span>
|
||||
{:else if state === 'snoozed'}
|
||||
<span class="snoozed-pill" role="status">Not right now</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions pt-3" data-testid="actions">
|
||||
{#if state === 'requestable'}
|
||||
{#if state === 'snoozed'}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Request ${title}`}
|
||||
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
|
||||
onclick={handleRequest}
|
||||
aria-label={`Bring ${title} back`}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
onclick={() => onUnsnooze?.()}
|
||||
>
|
||||
<Plus size={16} strokeWidth={1} /> Request
|
||||
Undo
|
||||
</button>
|
||||
{:else if state === 'requestable'}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Request ${title}`}
|
||||
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
|
||||
onclick={handleRequest}
|
||||
>
|
||||
<Plus size={16} strokeWidth={1} /> Request
|
||||
</button>
|
||||
{#if onSnooze}
|
||||
<!--
|
||||
Icon-only to keep Request unambiguously the primary action, with
|
||||
the intent carried by the accessible name. "Not right now" is the
|
||||
whole point of the wording: this parks a suggestion, it does not
|
||||
record an opinion about the artist (rule #101).
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Not right now — hide ${title} for a while`}
|
||||
title="Not right now"
|
||||
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
onclick={() => onSnooze?.()}
|
||||
>
|
||||
<Clock size={16} strokeWidth={1} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if state === 'kept'}
|
||||
<button
|
||||
type="button"
|
||||
@@ -131,4 +171,20 @@
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
/* Muted rather than accented: a parked card should recede, not compete
|
||||
with the live suggestions around it. Same geometry as .kept-pill so the
|
||||
two read as one component in different states. */
|
||||
.snoozed-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
background: var(--fs-slate);
|
||||
color: var(--fs-vellum);
|
||||
}
|
||||
.card[data-state='snoozed'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,4 +125,47 @@ describe('DiscoverResultCard', () => {
|
||||
});
|
||||
expect(screen.queryByTestId('attribution')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- snooze affordance (#2375) ---
|
||||
|
||||
test('snooze button appears only when onSnooze is supplied', () => {
|
||||
// The Lidarr search results reuse this card and have nothing to park, so
|
||||
// the affordance must not appear unconditionally.
|
||||
render(DiscoverResultCard, {
|
||||
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /not right now/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('snooze button calls onSnooze and reads as "not right now", never as a dislike', async () => {
|
||||
const onSnooze = vi.fn();
|
||||
render(DiscoverResultCard, {
|
||||
props: { kind: 'artist', title: 'Outsider', state: 'requestable', onSnooze }
|
||||
});
|
||||
const btn = screen.getByRole('button', { name: /not right now — hide outsider for a while/i });
|
||||
// Rule #101: the accessible name must carry no verdict on the music.
|
||||
expect(btn.getAttribute('aria-label')).not.toMatch(/dislike|not for me|never|hate/i);
|
||||
await fireEvent.click(btn);
|
||||
expect(onSnooze).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('snoozed state swaps Request for Undo and shows a status pill', async () => {
|
||||
const onUnsnooze = vi.fn();
|
||||
const onRequest = vi.fn();
|
||||
render(DiscoverResultCard, {
|
||||
props: {
|
||||
kind: 'artist',
|
||||
title: 'Outsider',
|
||||
state: 'snoozed',
|
||||
onRequest,
|
||||
onUnsnooze
|
||||
}
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /request outsider/i })).not.toBeInTheDocument();
|
||||
const status = screen.getByRole('status');
|
||||
expect(status.textContent).toMatch(/not right now/i);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
|
||||
expect(onUnsnooze).toHaveBeenCalledOnce();
|
||||
expect(onRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
||||
import {
|
||||
createSuggestionsQuery,
|
||||
createSnoozesQuery,
|
||||
snoozeSuggestion,
|
||||
unsnoozeSuggestion
|
||||
} from '$lib/api/suggestions';
|
||||
import { createRequest } from '$lib/api/requests';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import DiscoverResultCard from './DiscoverResultCard.svelte';
|
||||
import type { ArtistSuggestion, SeedContribution } from '$lib/api/types';
|
||||
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '$lib/api/types';
|
||||
|
||||
const client = useQueryClient();
|
||||
const queryStore = createSuggestionsQuery();
|
||||
const query = $derived($queryStore);
|
||||
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
|
||||
|
||||
const snoozeStore = createSnoozesQuery();
|
||||
const snoozeQuery = $derived($snoozeStore);
|
||||
const snoozes = $derived((snoozeQuery.data ?? []) as SuggestionSnooze[]);
|
||||
|
||||
// Track MBIDs the user just requested so the card flips immediately.
|
||||
let optimisticRequested = $state(new Set<string>());
|
||||
// Snoozed-just-now MBIDs. These keep their card in place showing an Undo,
|
||||
// rather than yanking it out of the grid under the cursor — the card is
|
||||
// gone on the next refetch, and the snoozed list below is the way back
|
||||
// after that.
|
||||
let optimisticSnoozed = $state(new Set<string>());
|
||||
|
||||
function visible(s: ArtistSuggestion): boolean {
|
||||
return !optimisticRequested.has(s.mbid);
|
||||
}
|
||||
|
||||
function cardState(s: ArtistSuggestion): 'requestable' | 'snoozed' {
|
||||
return optimisticSnoozed.has(s.mbid) ? 'snoozed' : 'requestable';
|
||||
}
|
||||
|
||||
function withMbid(set: Set<string>, mbid: string, present: boolean): Set<string> {
|
||||
const next = new Set(set);
|
||||
if (present) next.add(mbid);
|
||||
else next.delete(mbid);
|
||||
return next;
|
||||
}
|
||||
|
||||
function attributionText(attribution: SeedContribution[]): string {
|
||||
if (attribution.length === 0) return '';
|
||||
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
|
||||
@@ -32,6 +58,19 @@
|
||||
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
|
||||
}
|
||||
|
||||
// "in 3 months" / "in 12 days" — a relative phrase, because the exact
|
||||
// calendar date of a 90-day snooze is noise the user never asked for.
|
||||
function returnsIn(snoozedUntil: string): string {
|
||||
const ms = new Date(snoozedUntil).getTime() - Date.now();
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 'shortly';
|
||||
const days = Math.round(ms / 86_400_000);
|
||||
if (days < 1) return 'today';
|
||||
if (days === 1) return 'tomorrow';
|
||||
if (days < 45) return `in ${days} days`;
|
||||
const months = Math.round(days / 30);
|
||||
return months === 1 ? 'in about a month' : `in about ${months} months`;
|
||||
}
|
||||
|
||||
async function onRequest(s: ArtistSuggestion) {
|
||||
try {
|
||||
await createRequest({
|
||||
@@ -39,9 +78,7 @@
|
||||
lidarr_artist_mbid: s.mbid,
|
||||
artist_name: s.name
|
||||
});
|
||||
const next = new Set(optimisticRequested);
|
||||
next.add(s.mbid);
|
||||
optimisticRequested = next;
|
||||
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
|
||||
// The server-side filter hides this candidate on next refetch.
|
||||
await client.invalidateQueries({ queryKey: qk.suggestions() });
|
||||
} catch {
|
||||
@@ -49,6 +86,39 @@
|
||||
// stays requestable so the user can retry.
|
||||
}
|
||||
}
|
||||
|
||||
async function onSnooze(s: ArtistSuggestion) {
|
||||
// Flip first so the tap feels instant, then reconcile. On failure the
|
||||
// card goes back to requestable and says so — a snooze that silently
|
||||
// did nothing would leave the user tapping it again.
|
||||
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, true);
|
||||
try {
|
||||
await snoozeSuggestion(s.mbid, s.name);
|
||||
await client.invalidateQueries({ queryKey: qk.suggestionSnoozes() });
|
||||
} catch {
|
||||
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, false);
|
||||
pushToast(`Couldn't hide ${s.name}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnsnooze(mbid: string, name: string) {
|
||||
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, false);
|
||||
try {
|
||||
await unsnoozeSuggestion(mbid);
|
||||
} catch (e) {
|
||||
// 404 means it wasn't snoozed after all — the user's intended end
|
||||
// state, so it isn't an error worth showing them.
|
||||
if ((e as { status?: number })?.status !== 404) {
|
||||
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, true);
|
||||
pushToast(`Couldn't bring ${name} back`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: qk.suggestionSnoozes() }),
|
||||
client.invalidateQueries({ queryKey: qk.suggestions() })
|
||||
]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -58,7 +128,16 @@
|
||||
</header>
|
||||
|
||||
{#if !query.isPending && suggestions.length === 0}
|
||||
<p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p>
|
||||
<!--
|
||||
An empty deck used to mean one thing — no listening signal yet. With
|
||||
snoozing it can also mean "you parked them all", and telling that user to
|
||||
go listen to something would be wrong advice.
|
||||
-->
|
||||
<p class="text-text-secondary">
|
||||
{snoozes.length === 0
|
||||
? 'Listen to something or like an artist to start getting suggestions.'
|
||||
: "Nothing new right now — the artists you've parked are below."}
|
||||
</p>
|
||||
{:else if suggestions.length > 0}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{#each suggestions.filter(visible) as s (s.mbid)}
|
||||
@@ -66,11 +145,49 @@
|
||||
kind="artist"
|
||||
title={s.name}
|
||||
imageUrl={s.image_url}
|
||||
state="requestable"
|
||||
state={cardState(s)}
|
||||
attribution={attributionText(s.attribution)}
|
||||
onRequest={() => onRequest(s)}
|
||||
onSnooze={() => onSnooze(s)}
|
||||
onUnsnooze={() => onUnsnooze(s.mbid, s.name)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!--
|
||||
The snoozed list is not a nicety: a parked suggestion is by definition
|
||||
absent from the deck above, so without this there is no way back. Only
|
||||
rendered when non-empty, so the surface stays quiet for the common case.
|
||||
-->
|
||||
{#if snoozes.length > 0}
|
||||
<section class="mt-8 border-t border-border pt-6" aria-labelledby="snoozed-heading">
|
||||
<h3 id="snoozed-heading" class="font-display text-lg font-medium text-text-primary">
|
||||
Not right now
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
These come back on their own. Nothing here counts against your taste profile.
|
||||
</p>
|
||||
<ul class="mt-3 divide-y divide-border">
|
||||
{#each snoozes as snoozed (snoozed.mbid)}
|
||||
<li class="flex items-center justify-between gap-4 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-text-primary">{snoozed.name}</div>
|
||||
<div class="text-xs text-text-secondary">
|
||||
Back {returnsIn(snoozed.snoozed_until)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Bring ${snoozed.name} back now`}
|
||||
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
onclick={() => onUnsnooze(snoozed.mbid, snoozed.name)}
|
||||
>
|
||||
Bring back
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
|
||||
@@ -9,17 +9,30 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/suggestions', () => ({
|
||||
createSuggestionsQuery: vi.fn()
|
||||
createSuggestionsQuery: vi.fn(),
|
||||
createSnoozesQuery: vi.fn(),
|
||||
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
|
||||
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/requests', () => ({
|
||||
createRequest: vi.fn().mockResolvedValue({})
|
||||
}));
|
||||
|
||||
const pushToastMock = vi.fn();
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({
|
||||
pushToast: (...args: unknown[]) => pushToastMock(...args)
|
||||
}));
|
||||
|
||||
import SuggestionFeed from './SuggestionFeed.svelte';
|
||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
||||
import {
|
||||
createSuggestionsQuery,
|
||||
createSnoozesQuery,
|
||||
snoozeSuggestion,
|
||||
unsnoozeSuggestion
|
||||
} from '$lib/api/suggestions';
|
||||
import { createRequest } from '$lib/api/requests';
|
||||
import type { ArtistSuggestion } from '$lib/api/types';
|
||||
import type { ArtistSuggestion, SuggestionSnooze } from '$lib/api/types';
|
||||
|
||||
const oneSeed: ArtistSuggestion = {
|
||||
mbid: 'mb1',
|
||||
@@ -51,46 +64,50 @@ const threeSeeds: ArtistSuggestion = {
|
||||
]
|
||||
};
|
||||
|
||||
/** Days from now as an RFC3339 string, for snooze fixtures. */
|
||||
function inDays(n: number): string {
|
||||
return new Date(Date.now() + n * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
function setSuggestions(data: ArtistSuggestion[]) {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
|
||||
}
|
||||
|
||||
function setSnoozes(data: SuggestionSnooze[]) {
|
||||
(createSnoozesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
|
||||
}
|
||||
|
||||
beforeEach(() => setSnoozes([]));
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('SuggestionFeed', () => {
|
||||
test('renders one card per suggestion', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [oneSeed, twoSeeds] })
|
||||
);
|
||||
setSuggestions([oneSeed, twoSeeds]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.getByText('Outsider')).toBeInTheDocument();
|
||||
expect(screen.getByText('Outsider Two')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('attribution copy: 1 seed → "Because you liked X."', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [oneSeed] })
|
||||
);
|
||||
setSuggestions([oneSeed]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('attribution copy: 2 seeds → "Because you liked A and played B."', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [twoSeeds] })
|
||||
);
|
||||
setSuggestions([twoSeeds]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('attribution copy: 3 seeds → Oxford comma', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [threeSeeds] })
|
||||
);
|
||||
setSuggestions([threeSeeds]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Request button calls createRequest with artist-kind body', async () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [oneSeed] })
|
||||
);
|
||||
setSuggestions([oneSeed]);
|
||||
render(SuggestionFeed);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
|
||||
expect(createRequest).toHaveBeenCalledWith({
|
||||
@@ -102,8 +119,113 @@ describe('SuggestionFeed', () => {
|
||||
});
|
||||
|
||||
test('empty state when data is []', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||||
setSuggestions([]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SuggestionFeed snooze (#2375)', () => {
|
||||
test('snooze sends BOTH mbid and name — the server 400s without the name', async () => {
|
||||
setSuggestions([oneSeed]);
|
||||
render(SuggestionFeed);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
|
||||
expect(snoozeSuggestion).toHaveBeenCalledWith('mb1', 'Outsider');
|
||||
});
|
||||
|
||||
test('the card stays in place showing Undo, rather than vanishing', async () => {
|
||||
setSuggestions([oneSeed]);
|
||||
render(SuggestionFeed);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
|
||||
// Still on screen — the disappearance happens on refetch, not under the
|
||||
// cursor (rule #24).
|
||||
expect(screen.getByText('Outsider')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /bring outsider back/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Not right now');
|
||||
});
|
||||
|
||||
test('a failed snooze reverts the card and says so', async () => {
|
||||
(snoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('offline'));
|
||||
setSuggestions([oneSeed]);
|
||||
render(SuggestionFeed);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
|
||||
// Back to requestable — a snooze that silently did nothing would leave
|
||||
// the user tapping it again.
|
||||
expect(screen.getByRole('button', { name: /request outsider/i })).toBeInTheDocument();
|
||||
expect(pushToastMock).toHaveBeenCalledWith("Couldn't hide Outsider", 'error');
|
||||
});
|
||||
|
||||
test('undo on the card calls unsnoozeSuggestion', async () => {
|
||||
setSuggestions([oneSeed]);
|
||||
render(SuggestionFeed);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
|
||||
expect(unsnoozeSuggestion).toHaveBeenCalledWith('mb1');
|
||||
});
|
||||
|
||||
test('the snoozed list is the way back once the card is gone', async () => {
|
||||
// Deck empty, one parked artist: exactly the state after a refetch.
|
||||
setSuggestions([]);
|
||||
setSnoozes([
|
||||
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
|
||||
]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.getByRole('heading', { name: /not right now/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Parked')).toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
|
||||
expect(unsnoozeSuggestion).toHaveBeenCalledWith('mbX');
|
||||
});
|
||||
|
||||
test('a 404 from unsnooze is not surfaced as an error', async () => {
|
||||
(unsnoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ status: 404 });
|
||||
setSuggestions([]);
|
||||
setSnoozes([
|
||||
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
|
||||
]);
|
||||
render(SuggestionFeed);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
|
||||
// Already-unsnoozed IS the end state the user asked for.
|
||||
expect(pushToastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a non-404 unsnooze failure does surface', async () => {
|
||||
(unsnoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ status: 500 });
|
||||
setSuggestions([]);
|
||||
setSnoozes([
|
||||
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
|
||||
]);
|
||||
render(SuggestionFeed);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
|
||||
expect(pushToastMock).toHaveBeenCalledWith("Couldn't bring Parked back", 'error');
|
||||
});
|
||||
|
||||
test('return time reads as a relative phrase, not a calendar date', () => {
|
||||
setSuggestions([]);
|
||||
setSnoozes([
|
||||
{ mbid: 'a', name: 'Quarter', snoozed_until: inDays(90), created_at: inDays(0) },
|
||||
{ mbid: 'b', name: 'Fortnight', snoozed_until: inDays(14), created_at: inDays(0) }
|
||||
]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.getByText(/back in about 3 months/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/back in 14 days/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('no snoozed section when nothing is parked', () => {
|
||||
setSuggestions([oneSeed]);
|
||||
setSnoozes([]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.queryByRole('heading', { name: /not right now/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// An empty deck has two causes now, and the advice differs. Telling someone
|
||||
// who parked everything to go listen to music would be wrong.
|
||||
test('empty-deck copy distinguishes "no signal" from "you parked them all"', () => {
|
||||
setSuggestions([]);
|
||||
setSnoozes([
|
||||
{ mbid: 'a', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
|
||||
]);
|
||||
render(SuggestionFeed);
|
||||
expect(screen.getByText(/nothing new right now/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/listen to something or like an artist/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user