diff --git a/web/css/18-scan-batch.css b/web/css/18-scan-batch.css index de82fdb..f5c099f 100644 --- a/web/css/18-scan-batch.css +++ b/web/css/18-scan-batch.css @@ -85,6 +85,14 @@ .item-meta--alert { color: var(--danger); } +.scan-suggestions { + margin: 0 0 6px; + padding-left: 12px; + border-left: 2px solid var(--line); +} +.scan-suggestions .section-label { + margin-top: 4px; +} /* Fixed at the bottom of the page-scroll body, above the safe area, so the CTA is always reachable regardless of how many rows are in each bucket. */ .scan-review__cta { diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 4679898..3ba4b5b 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -107,6 +107,7 @@ pub type Msg { BarcodeDetected(String) GotScanResult(barcode: String, result: Result(ScanLookup, rsvp.Error(String))) RemoveScanRow(barcode: String) + AcceptSuggestion(barcode: String, release: DiscogsResult) StartBatchImport BatchItemDone(barcode: String, result: Result(Nil, rsvp.Error(String))) RetryBatchItem(barcode: String) diff --git a/web/src/at_record_web/pages/scan.gleam b/web/src/at_record_web/pages/scan.gleam index 6f983e7..bdee37d 100644 --- a/web/src/at_record_web/pages/scan.gleam +++ b/web/src/at_record_web/pages/scan.gleam @@ -83,7 +83,14 @@ fn tray_row(row: ScanRow) -> Element(Msg) { fn tray_body(outcome: ScanOutcome) -> Element(Msg) { case outcome { Pending -> tray_label("looking up…") - NoMatch(_) -> tray_label("no match") + NoMatch(suggestions: []) -> tray_label("no match") + // A hint that review holds "did you mean?" candidates for this barcode. + NoMatch(suggestions:) -> + tray_label( + "no match · " + <> int.to_string(list.length(suggestions)) + <> " suggestions", + ) Added -> tray_label("✓ added") Adding(item:) -> tray_item(item, "adding…") Failed(item:) -> tray_item(item, "failed") diff --git a/web/src/at_record_web/pages/scan_review.gleam b/web/src/at_record_web/pages/scan_review.gleam index 8762c60..6160fa4 100644 --- a/web/src/at_record_web/pages/scan_review.gleam +++ b/web/src/at_record_web/pages/scan_review.gleam @@ -4,10 +4,12 @@ //// sequentially over the matched bucket. import at_record_web/model.{ - type Model, type ScanRow, Adding, Failed, Matched, NetworkMatched, NoMatch, - Scan, + type Model, type ScanRow, type Suggestion, Adding, Failed, Matched, + NetworkMatched, NoMatch, Scan, +} +import at_record_web/msg.{ + type Msg, AcceptSuggestion, RetryBatchItem, StartBatchImport, } -import at_record_web/msg.{type Msg, RetryBatchItem, StartBatchImport} import at_record_web/pages/scan_common.{ dismiss_button, item_thumb, item_title, via_handle, } @@ -17,6 +19,7 @@ import at_record_web/ui/covers as cov import gleam/int import gleam/list import gleam/option +import gleam/string import lustre/attribute as attr import lustre/element.{type Element, text} import lustre/element/html @@ -45,7 +48,9 @@ fn loaded_review(model: Model, rows: List(ScanRow)) -> Element(Msg) { html.div([attr.class("body page-scroll scan-review")], [ bucket_section("MATCHED", matched, fn(row) { matched_row(row, importing) }), bucket_section("ALREADY IN YOUR CRATE", duplicates, duplicate_row), - bucket_section("NEEDS REVIEW", needs_review, needs_review_row), + bucket_section("NEEDS REVIEW", needs_review, fn(row) { + needs_review_row(row, importing) + }), import_cta(importable_count, importing), ]) } @@ -165,7 +170,7 @@ fn duplicate_row(row: ScanRow) -> Element(Msg) { } } -fn needs_review_row(row: ScanRow) -> Element(Msg) { +fn needs_review_row(row: ScanRow, importing: Bool) -> Element(Msg) { html.li([attr.class("scan-row")], [ html.div([attr.class("scan-row__body")], [ html.div([attr.class("scan-row__main")], [ @@ -176,6 +181,51 @@ fn needs_review_row(row: ScanRow) -> Element(Msg) { ctl.link_button("SEARCH", ctl.Ghost, "/add"), dismiss_button(row.barcode), ]), + suggestions_view(row, importing), + ]) +} + +/// "Did you mean?" rows under a no-match barcode: at most two +/// MusicBrainz-seeded Discogs candidates. ACCEPT fires no write; it promotes +/// the row into the MATCHED bucket so it imports with the batch. +fn suggestions_view(row: ScanRow, importing: Bool) -> Element(Msg) { + case row.outcome { + NoMatch(suggestions: [_, ..] as suggestions) -> + html.div([attr.class("scan-suggestions")], [ + section_label("DID YOU MEAN?"), + ..list.map(list.take(suggestions, 2), suggestion_view( + row.barcode, + _, + importing, + )) + ]) + _ -> element.none() + } +} + +fn suggestion_view( + barcode: String, + suggestion: Suggestion, + importing: Bool, +) -> Element(Msg) { + let result = suggestion.release + let meta = + [result.year |> option.map(int.to_string), result.format] + |> option.values + |> string.join(" · ") + html.div([attr.class("scan-row__body")], [ + cov.thumb(result.thumb_url), + html.div([attr.class("scan-row__main")], [ + html.strong([], [text(result.title)]), + html.div([attr.class("item-meta")], [ + text(result.artist <> " · " <> meta), + ]), + ]), + ctl.chip(int.to_string(suggestion.confidence) <> "%", ctl.Surface), + ctl.button("ACCEPT", ctl.Primary, [ + event.on_click(AcceptSuggestion(barcode, result)), + attr.disabled(importing), + ]), ]) } diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index d5b93f0..e6eb9c4 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -13,15 +13,15 @@ import at_record_web/model.{ } import at_record_web/money import at_record_web/msg.{ - type Msg, type ScanLookup, AmendField, AppliedProposal, ApplyProposal, - ArmLogout, ArmRemove, ArtistSearch, BarcodeDetected, BatchItemDone, BrowseAdd, - BrowseGenre, BrowseQuery, CameraUnsupported, ClearNotice, CoverFileChosen, - CoverUploaded, DiscogsConnect, DiscogsDisconnect, DiscogsImport, - DiscogsImportWantlist, DiscogsLoadMore, DiscogsSearch, DiscogsVinylOnly, - EntryAction, FormArtist, FormCounterparty, FormFolder, FormFormat, - FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, FormStatus, - FormTitle, FormYear, GotAction, GotAdd, GotAmend, GotApplyProposal, GotArtists, - GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, + type Msg, type ScanLookup, AcceptSuggestion, AmendField, AppliedProposal, + ApplyProposal, ArmLogout, ArmRemove, ArtistSearch, BarcodeDetected, + BatchItemDone, BrowseAdd, BrowseGenre, BrowseQuery, CameraUnsupported, + ClearNotice, CoverFileChosen, CoverUploaded, DiscogsConnect, DiscogsDisconnect, + DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, DiscogsSearch, + DiscogsVinylOnly, EntryAction, FormArtist, FormCounterparty, FormFolder, + FormFormat, FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, + FormStatus, FormTitle, FormYear, GotAction, GotAdd, GotAmend, GotApplyProposal, + GotArtists, GotAvatar, GotBrowse, GotBrowseAdd, GotBrowseSearch, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, HandleSearch, IgnoreProposal, Logout, @@ -738,6 +738,23 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model(..model, scan: update_row(model.scan, barcode, model.NoMatch([]))), effect.none(), ) + // Accepting a "did you mean?" candidate fires no write: it just promotes + // the NoMatch row to a regular unowned match, so it imports with the batch. + AcceptSuggestion(barcode, release) -> + case find_row(model.scan, barcode) { + Some(model.ScanRow(outcome: model.NoMatch(_), ..)) -> #( + Model( + ..model, + scan: update_row( + model.scan, + barcode, + model.Matched(result: release, owned: False), + ), + ), + effect.none(), + ) + _ -> #(model, effect.none()) + } // Kicks off the sequential batch import: one write in flight at a time, // picked up again by BatchItemDone until nothing importable remains. // Guarded against double-taps while a run is already going. diff --git a/web/test/scan_review_test.gleam b/web/test/scan_review_test.gleam index ca10f15..26abafc 100644 --- a/web/test/scan_review_test.gleam +++ b/web/test/scan_review_test.gleam @@ -96,3 +96,49 @@ pub fn empty_review_prompts_back_to_scan_test() { let html = rendered([]) assert string.contains(html, "Nothing scanned yet.") } + +// --- "did you mean?" suggestions in the needs-review bucket --- + +fn a_no_match_with_suggestions() -> List(ScanRow) { + [ + ScanRow( + barcode: "b4", + outcome: NoMatch([ + model.Suggestion(release: discogs_result(9), confidence: 62), + model.Suggestion(release: discogs_result(3), confidence: 41), + model.Suggestion(release: discogs_result(7), confidence: 12), + ]), + ), + ] +} + +pub fn needs_review_renders_at_most_two_suggestions_with_accept_test() { + let html = rendered(a_no_match_with_suggestions()) + assert string.contains(html, "DID YOU MEAN?") + assert string.contains(html, "62%") + assert string.contains(html, "41%") + assert !string.contains(html, "12%") + assert string.contains(html, "ACCEPT") +} + +pub fn needs_review_without_suggestions_omits_did_you_mean_test() { + let html = rendered([ScanRow(barcode: "b4", outcome: NoMatch([]))]) + assert !string.contains(html, "DID YOU MEAN?") + assert !string.contains(html, "ACCEPT") +} + +pub fn accept_is_disabled_while_a_run_is_active_test() { + let html = + Model( + ..logged_in(), + scan: ScanState( + ..blank_scan(), + importing: True, + rows: a_no_match_with_suggestions(), + ), + ) + |> scan_review.view + |> element.to_string + assert string.contains(html, "ACCEPT") + assert string.contains(html, "disabled") +} diff --git a/web/test/scan_test.gleam b/web/test/scan_test.gleam index f8336d4..bb147d0 100644 --- a/web/test/scan_test.gleam +++ b/web/test/scan_test.gleam @@ -6,8 +6,8 @@ import at_record_web/model.{ blank_scan, } import at_record_web/msg.{ - BarcodeDetected, BatchItemDone, GotScanResult, GotScanSeen, OnRouteChange, - RemoveScanRow, RetryBatchItem, StartBatchImport, + AcceptSuggestion, BarcodeDetected, BatchItemDone, GotScanResult, GotScanSeen, + OnRouteChange, RemoveScanRow, RetryBatchItem, StartBatchImport, } import at_record_web/pages/scan import at_record_web/update.{update} @@ -117,10 +117,23 @@ pub fn scan_page_renders_no_match_row_without_did_you_mean_test() { ]), ) let html = model |> scan.view |> element.to_string - // The tray never renders per-suggestion ADD affordances; that lives on - // the review page's SEARCH/DISMISS row instead. + // The tray never renders the suggestion cards themselves; it only hints + // that the review page holds candidates for this barcode. assert !string.contains(html, "DID YOU MEAN?") + assert string.contains(html, "no match · 1 suggestions") +} + +pub fn scan_tray_no_match_without_candidates_omits_the_hint_test() { + let model = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "825646350919", outcome: NoMatch([])), + ]), + ) + let html = model |> scan.view |> element.to_string assert string.contains(html, "no match") + assert !string.contains(html, "suggestions") } pub fn scan_tray_dismiss_carries_an_aria_label_test() { @@ -222,6 +235,76 @@ pub fn got_scan_result_network_hit_wins_over_a_discogs_result_test() { == [ScanRow(barcode: "b1", outcome: NetworkMatched(release))] } +// --- accepting a "did you mean?" suggestion --- + +pub fn accept_suggestion_promotes_the_row_to_matched_without_a_write_test() { + let release = discogs_result(9) + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow( + barcode: "b1", + outcome: NoMatch([Suggestion(release:, confidence: 62)]), + ), + ]), + ) + let #(model, effect) = update(seeded, AcceptSuggestion("b1", release)) + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: Matched(result: release, owned: False))] + assert effect == empty_effect() +} + +pub fn accept_suggestion_grows_the_importable_count_test() { + let release = discogs_result(9) + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow( + barcode: "b1", + outcome: NoMatch([Suggestion(release:, confidence: 62)]), + ), + ]), + ) + assert model.scan_importable(seeded.scan.rows) == [] + let #(model, _) = update(seeded, AcceptSuggestion("b1", release)) + assert model.scan_importable(model.scan.rows) + == [ScanRow(barcode: "b1", outcome: Matched(result: release, owned: False))] +} + +pub fn accept_suggestion_ignores_a_row_that_is_not_no_match_test() { + let release = discogs_result(9) + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: model.Added), + ]), + ) + let #(model, _) = update(seeded, AcceptSuggestion("b1", release)) + assert model.scan.rows == seeded.scan.rows +} + +// Accepting stays a pure state change even mid-run (the UI disables the +// button instead); the promoted row simply joins the loop's pending set. +pub fn accept_suggestion_still_promotes_while_a_run_is_active_test() { + let release = discogs_result(9) + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), importing: True, rows: [ + ScanRow( + barcode: "b1", + outcome: NoMatch([Suggestion(release:, confidence: 62)]), + ), + ]), + ) + let #(model, _) = update(seeded, AcceptSuggestion("b1", release)) + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: Matched(result: release, owned: False))] +} + // --- batch import --- fn an_entry() -> model.Entry {