From dbb6ea420f2fdca711ca87397e66cea5f2100128 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sat, 11 Jul 2026 18:32:39 +0200 Subject: [PATCH] feat(record): dynamic provenance line on record detail driven by entry source (genesis source carried through fold/encode/decode; label helpers, not hardcoded provider) Claude-Session: https://claude.ai/code/session_01FbSPwZnpmA2gAcXXM5ZFSa --- .../mokkenstorm/crate/shelf/listEntries.json | 4 + server/src/at_record_server/crate.gleam | 7 +- server/test/crate_test.gleam | 76 ++++++++++++++++- web/priv/static/app.css | 7 ++ web/src/at_record_web/effects.gleam | 24 ++++++ web/src/at_record_web/model.gleam | 7 ++ web/src/at_record_web/pages/record.gleam | 9 ++ web/src/at_record_web/provenance.gleam | 42 ++++++++++ web/test/at_record_web_test.gleam | 30 ++++++- web/test/provenance_test.gleam | 84 +++++++++++++++++++ 10 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 web/src/at_record_web/provenance.gleam create mode 100644 web/test/provenance_test.gleam diff --git a/lexicons/dev/mokkenstorm/crate/shelf/listEntries.json b/lexicons/dev/mokkenstorm/crate/shelf/listEntries.json index a0b7154..b5ef915 100644 --- a/lexicons/dev/mokkenstorm/crate/shelf/listEntries.json +++ b/lexicons/dev/mokkenstorm/crate/shelf/listEntries.json @@ -85,6 +85,10 @@ }, "counterparty": { "type": "string" + }, + "source": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#source" } } } diff --git a/server/src/at_record_server/crate.gleam b/server/src/at_record_server/crate.gleam index 953d3c0..8a2dabb 100644 --- a/server/src/at_record_server/crate.gleam +++ b/server/src/at_record_server/crate.gleam @@ -5,7 +5,9 @@ //// every other field last-write-wins. import at_record/gen/catalog/release.{type CatalogRelease} -import at_record/gen/defs.{type CatalogRef, type Price, type Snapshot} +import at_record/gen/defs.{ + type CatalogRef, type Price, type Snapshot, type Source, +} import at_record/gen/shelf/entry.{type ShelfEntry} import at_record/storage.{type StoredItem} import gleam/dict @@ -34,6 +36,7 @@ pub type CrateEntry { release: Option(CatalogRef), price: Option(Price), counterparty: Option(String), + source: Option(Source), created_at: String, updated_at: String, events: List(ShelfEntry), @@ -102,6 +105,7 @@ fn fold_group( release: None, price: None, counterparty: None, + source: genesis.value.source, created_at: genesis.value.created_at, updated_at: genesis.value.created_at, events: [], @@ -162,6 +166,7 @@ pub fn encode_entry(e: CrateEntry) -> Json { opt("notes", e.notes, json.string), opt("price", e.price, defs.encode_price), opt("counterparty", e.counterparty, json.string), + opt("source", e.source, defs.encode_source), ]), ) } diff --git a/server/test/crate_test.gleam b/server/test/crate_test.gleam index 533b2a0..55df65d 100644 --- a/server/test/crate_test.gleam +++ b/server/test/crate_test.gleam @@ -1,4 +1,5 @@ import at_record/gen/catalog/release as catalog_release +import at_record/gen/defs import at_record/gen/repo/strong_ref.{RepoStrongRef} import at_record/gen/shelf/entry.{type ShelfEntry, ShelfEntry} import at_record/storage.{type StoredItem, StoredItem} @@ -20,6 +21,16 @@ fn ev( subject_rkey: option.Option(String), action: String, media_grade: option.Option(String), +) -> StoredItem(ShelfEntry) { + ev_with_source(rkey, subject_rkey, action, media_grade, None) +} + +fn ev_with_source( + rkey: String, + subject_rkey: option.Option(String), + action: String, + media_grade: option.Option(String), + source: option.Option(defs.Source), ) -> StoredItem(ShelfEntry) { StoredItem( uri: event_uri(rkey), @@ -40,7 +51,7 @@ fn ev( release: None, price: None, counterparty: None, - source: None, + source:, created_at: "t", ), ) @@ -85,6 +96,34 @@ pub fn fold_drops_orphan_appends_test() { assert list.map(folded, fn(e) { e.entry_id }) == ["3c"] } +fn a_source(origin: String) -> defs.Source { + defs.Source( + client_agent: None, + external: Some(defs.ExternalId(id: "123", provider: "discogs", url: None)), + origin: Some(origin), + origin_url: None, + record: None, + ) +} + +pub fn fold_carries_genesis_source_test() { + // Provenance describes how the physical item entered the crate, so it + // comes from the genesis event only, never overwritten by later appends. + let folded = + crate.fold([ + ev_with_source("3a", None, "acquired", None, Some(a_source("scan"))), + ev_with_source( + "3b", + Some("3a"), + "regraded", + Some("NM"), + Some(a_source("manual")), + ), + ]) + let assert Ok(e) = list.find(folded, fn(e) { e.entry_id == "3a" }) + assert e.source == Some(a_source("scan")) +} + fn blank_release() -> catalog_release.CatalogRelease { catalog_release.CatalogRelease( title: "Spiderland", @@ -140,3 +179,38 @@ pub fn encode_release_info_omits_absent_fields_test() { assert field_present(body, ["country"]) == False assert field_present(body, ["released"]) == False } + +fn blank_entry() -> crate.CrateEntry { + crate.CrateEntry( + entry_id: "3a", + status: crate.Owned, + snapshot: None, + media_grade: None, + sleeve_grade: None, + rating: None, + folder: None, + notes: None, + release: None, + price: None, + counterparty: None, + source: None, + created_at: "t", + updated_at: "t", + events: [], + ) +} + +pub fn encode_entry_includes_source_when_present_test() { + let body = + crate.encode_entry( + crate.CrateEntry(..blank_entry(), source: Some(a_source("scan"))), + ) + |> json.to_string + assert field_string(body, ["source", "origin"]) == Ok("scan") + assert field_string(body, ["source", "external", "provider"]) == Ok("discogs") +} + +pub fn encode_entry_omits_source_when_absent_test() { + let body = crate.encode_entry(blank_entry()) |> json.to_string + assert field_present(body, ["source"]) == False +} diff --git a/web/priv/static/app.css b/web/priv/static/app.css index fad2aad..deb3b67 100644 --- a/web/priv/static/app.css +++ b/web/priv/static/app.css @@ -784,6 +784,13 @@ input[type="number"]::-webkit-inner-spin-button { color: var(--ink-muted); font-style: italic; } +.titleblock__source { + display: block; + margin-top: 2px; + font: 400 11px/1 var(--mono); + color: var(--ink-muted); + font-style: italic; +} .stars { display: flex; align-items: center; diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 40b9f62..904ccc1 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -91,6 +91,11 @@ fn entry_decoder() -> decode.Decoder(Entry) { None, decode.optional(decode.string), ) + use source <- decode.optional_field( + "source", + None, + decode.optional(source_decoder()), + ) decode.success(Entry( entry_id:, status:, @@ -102,9 +107,28 @@ fn entry_decoder() -> decode.Decoder(Entry) { notes:, price:, counterparty:, + source:, )) } +fn source_decoder() -> decode.Decoder(model.Source) { + use origin <- decode.optional_field( + "origin", + None, + decode.optional(decode.string), + ) + use provider <- decode.optional_field( + "external", + None, + decode.optional(external_provider_decoder()), + ) + decode.success(model.Source(origin:, provider:)) +} + +fn external_provider_decoder() -> decode.Decoder(String) { + decode.field("provider", decode.string, decode.success) +} + pub fn logout() -> Effect(Msg) { rsvp.post( xrpc("server.deleteSession"), diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 64288e2..8b77cdb 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -34,6 +34,12 @@ pub type Display { Rows } +/// Provenance for a crate entry, narrowed to just what the detail page's +/// source line needs: the capture origin and the external provider's slug. +pub type Source { + Source(origin: Option(String), provider: Option(String)) +} + /// A folded crate entry (the BFF reduces the event log into these). pub type Entry { Entry( @@ -47,6 +53,7 @@ pub type Entry { notes: Option(String), price: Option(Price), counterparty: Option(String), + source: Option(Source), ) } diff --git a/web/src/at_record_web/pages/record.gleam b/web/src/at_record_web/pages/record.gleam index f6048ef..154e7c2 100644 --- a/web/src/at_record_web/pages/record.gleam +++ b/web/src/at_record_web/pages/record.gleam @@ -10,6 +10,7 @@ import at_record_web/msg.{ type Msg, AmendField, ArmRemove, CoverFileChosen, EntryAction, Rate, Regrade, SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, } +import at_record_web/provenance import at_record_web/ui/components as c import gleam/dict import gleam/int @@ -57,6 +58,7 @@ fn titleblock(model: Model, entry: Entry) -> Element(Msg) { html.h1([attr.class("titleblock__title")], [text(snap.title)]), html.span([attr.class("titleblock__artist")], [text(snap.artist_display)]), via_handle(model, entry.entry_id), + provenance_line(entry), case entry.rating { Some(value) -> c.stars(value) None -> element.none() @@ -75,6 +77,13 @@ fn via_handle(model: Model, entry_id: String) -> Element(Msg) { } } +fn provenance_line(entry: Entry) -> Element(Msg) { + case provenance.label(entry.source) { + Some(line) -> html.span([attr.class("titleblock__source")], [text(line)]) + None -> element.none() + } +} + fn condition(entry: Entry) -> Element(Msg) { let grade = option.unwrap(entry.media_grade, "—") html.div([], [ diff --git a/web/src/at_record_web/provenance.gleam b/web/src/at_record_web/provenance.gleam new file mode 100644 index 0000000..b56c80f --- /dev/null +++ b/web/src/at_record_web/provenance.gleam @@ -0,0 +1,42 @@ +//// Renders a crate entry's `source` into the short provenance line shown on +//// the record detail page, e.g. "scanned · via Discogs". Provider-agnostic: +//// the copy is driven entirely by the entry's own `origin`/`provider` +//// fields, never a hardcoded provider name. + +import at_record_web/model +import gleam/option.{type Option, None, Some} +import gleam/string + +/// The record detail page's provenance line, or `None` when there's nothing +/// worth showing (no source at all, or an unknown origin with no provider). +pub fn label(source: Option(model.Source)) -> Option(String) { + case source { + None -> None + Some(model.Source(origin:, provider:)) -> line(origin, provider) + } +} + +fn line(origin: Option(String), provider: Option(String)) -> Option(String) { + case origin, provider { + Some("manual"), _ -> Some("added by hand") + Some("discogs-import"), _ -> Some("imported from Discogs") + Some("scan"), Some(p) -> Some("scanned · via " <> provider_label(p)) + Some("scan"), None -> Some("scanned") + Some("promotion"), _ | Some("adoption"), _ -> Some("adopted") + Some("sync"), _ -> Some("synced") + _, Some(p) -> Some("via " <> provider_label(p)) + _, None -> None + } +} + +/// Display name for an external provider slug; unrecognised slugs fall back +/// to a title-cased version of the slug itself. +pub fn provider_label(provider: String) -> String { + case provider { + "discogs" -> "Discogs" + "musicbrainz" -> "MusicBrainz" + // Still Discogs-origin data, just identified via an instance-scoped id. + "discogs-instance" -> "Discogs" + _ -> string.capitalise(provider) + } +} diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam index 9bc0129..cfc9a01 100644 --- a/web/test/at_record_web_test.gleam +++ b/web/test/at_record_web_test.gleam @@ -8,8 +8,9 @@ import at_record_web/model.{ InboxFailed, InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Matched, Model, NetworkMatch, NetworkMatched, NoMatch, Notice, Pending, ProposalApplied, ProposalApplying, ProposalFields, ProposalReviewing, ReleaseInfo, Rows, - ScanRow, ScanState, Settings, ShelfFailed, ShelfLoaded, ShelfLoading, Success, - Suggestion, Warning, blank_amend, blank_discogs, blank_form, blank_scan, + ScanRow, ScanState, Settings, ShelfFailed, ShelfLoaded, ShelfLoading, Source, + Success, Suggestion, Warning, blank_amend, blank_discogs, blank_form, + blank_scan, } import at_record_web/msg.{ AddScannedItem, AddSuggestion, AppliedProposal, ApplyProposal, ArmRemove, @@ -289,6 +290,7 @@ fn an_entry() -> model.Entry { notes: None, price: Some(Price(amount: 4000, currency: "EUR")), counterparty: Some("Record shop"), + source: None, ) } @@ -324,6 +326,30 @@ pub fn record_view_omits_paid_and_from_rows_when_absent_test() { assert !string.contains(html, "FROM") } +pub fn record_view_shows_the_provenance_line_when_source_is_present_test() { + let source = Source(origin: Some("scan"), provider: Some("discogs")) + let html = + record.view(logged_in(), Entry(..an_entry(), source: Some(source))) + |> element.to_string + assert string.contains(html, "class=\"titleblock__source\"") + assert string.contains(html, "scanned · via Discogs") +} + +pub fn record_view_shows_added_by_hand_for_manual_source_test() { + let source = Source(origin: Some("manual"), provider: None) + let html = + record.view(logged_in(), Entry(..an_entry(), source: Some(source))) + |> element.to_string + assert string.contains(html, "added by hand") +} + +pub fn record_view_omits_the_provenance_line_when_source_is_absent_test() { + let html = + record.view(logged_in(), Entry(..an_entry(), source: None)) + |> element.to_string + assert !string.contains(html, "class=\"titleblock__source\"") +} + pub fn oauth_error_copy_maps_known_codes_test() { assert string.contains( login.error_copy("access_denied"), diff --git a/web/test/provenance_test.gleam b/web/test/provenance_test.gleam new file mode 100644 index 0000000..ebae01d --- /dev/null +++ b/web/test/provenance_test.gleam @@ -0,0 +1,84 @@ +//// The record detail page's provenance line: maps a crate entry's `source` +//// (origin + external provider) to short display copy, never hardcoding a +//// provider name. + +import at_record_web/model.{Source} +import at_record_web/provenance +import gleam/option.{None, Some} + +pub fn no_source_shows_nothing_test() { + assert provenance.label(None) == None +} + +pub fn manual_origin_test() { + let source = Source(origin: Some("manual"), provider: None) + assert provenance.label(Some(source)) == Some("added by hand") +} + +pub fn discogs_import_origin_test() { + let source = Source(origin: Some("discogs-import"), provider: None) + assert provenance.label(Some(source)) == Some("imported from Discogs") +} + +pub fn scan_origin_without_provider_test() { + let source = Source(origin: Some("scan"), provider: None) + assert provenance.label(Some(source)) == Some("scanned") +} + +pub fn scan_origin_with_provider_test() { + let source = Source(origin: Some("scan"), provider: Some("discogs")) + assert provenance.label(Some(source)) == Some("scanned · via Discogs") +} + +pub fn promotion_origin_test() { + let source = Source(origin: Some("promotion"), provider: None) + assert provenance.label(Some(source)) == Some("adopted") +} + +pub fn adoption_origin_test() { + let source = Source(origin: Some("adoption"), provider: None) + assert provenance.label(Some(source)) == Some("adopted") +} + +pub fn sync_origin_test() { + let source = Source(origin: Some("sync"), provider: None) + assert provenance.label(Some(source)) == Some("synced") +} + +pub fn unknown_origin_with_provider_falls_back_to_via_provider_test() { + let source = + Source(origin: Some("promotion-batch"), provider: Some("musicbrainz")) + assert provenance.label(Some(source)) == Some("via MusicBrainz") +} + +pub fn unknown_origin_without_provider_shows_nothing_test() { + let source = Source(origin: Some("promotion-batch"), provider: None) + assert provenance.label(Some(source)) == None +} + +pub fn absent_origin_with_provider_falls_back_to_via_provider_test() { + let source = Source(origin: None, provider: Some("discogs")) + assert provenance.label(Some(source)) == Some("via Discogs") +} + +pub fn absent_origin_and_provider_shows_nothing_test() { + let source = Source(origin: None, provider: None) + assert provenance.label(Some(source)) == None +} + +pub fn provider_label_discogs_test() { + assert provenance.provider_label("discogs") == "Discogs" +} + +pub fn provider_label_musicbrainz_test() { + assert provenance.provider_label("musicbrainz") == "MusicBrainz" +} + +pub fn provider_label_discogs_instance_test() { + assert provenance.provider_label("discogs-instance") == "Discogs" +} + +pub fn provider_label_unknown_slug_falls_back_to_capitalised_test() { + assert provenance.provider_label("some-other-provider") + == "Some-other-provider" +} -- 2.51.2