From ab85621e99c8fa8602b4d2131b59ee503ac0118a Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sat, 11 Jul 2026 12:12:57 +0200 Subject: [PATCH] feat(shelf): widen genesis surface to sleeveGrade/rating/folder/price/counterparty with add-form acquisition section and detail rows --- .../dev/mokkenstorm/crate/shelf/addEntry.json | 24 +++ .../mokkenstorm/crate/shelf/listEntries.json | 7 + server/src/at_record_server/crate.gleam | 2 + .../src/at_record_server/handlers/shelf.gleam | 113 ++++++++++--- server/test/shelf_test.gleam | 121 ++++++++++++++ web/src/at_record_web/effects.gleam | 67 +++++++- web/src/at_record_web/model.gleam | 17 +- web/src/at_record_web/money.gleam | 56 +++++++ web/src/at_record_web/msg.gleam | 6 + web/src/at_record_web/pages/add.gleam | 61 ++++++- web/src/at_record_web/pages/record.gleam | 22 ++- web/src/at_record_web/update.gleam | 62 +++++-- web/test/at_record_web_test.gleam | 155 +++++++++++++++++- web/test/money_test.gleam | 70 ++++++++ 14 files changed, 729 insertions(+), 54 deletions(-) create mode 100644 server/test/shelf_test.gleam create mode 100644 web/src/at_record_web/money.gleam create mode 100644 web/test/money_test.gleam diff --git a/lexicons/dev/mokkenstorm/crate/shelf/addEntry.json b/lexicons/dev/mokkenstorm/crate/shelf/addEntry.json index f20a5dd..f98bdd9 100644 --- a/lexicons/dev/mokkenstorm/crate/shelf/addEntry.json +++ b/lexicons/dev/mokkenstorm/crate/shelf/addEntry.json @@ -31,9 +31,33 @@ "mediaGrade": { "type": "string" }, + "sleeveGrade": { + "type": "string", + "knownValues": ["M", "NM", "VG+", "VG", "G+", "G", "F", "P"] + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "folder": { + "type": "string", + "maxGraphemes": 128, + "maxLength": 1280 + }, "notes": { "type": "string" }, + "price": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#price" + }, + "counterparty": { + "type": "string", + "maxGraphemes": 256, + "maxLength": 2560, + "description": "Who you acquired from / sold to." + }, "thumbUrl": { "type": "string", "format": "uri" diff --git a/lexicons/dev/mokkenstorm/crate/shelf/listEntries.json b/lexicons/dev/mokkenstorm/crate/shelf/listEntries.json index 91ef5bb..a0b7154 100644 --- a/lexicons/dev/mokkenstorm/crate/shelf/listEntries.json +++ b/lexicons/dev/mokkenstorm/crate/shelf/listEntries.json @@ -78,6 +78,13 @@ }, "notes": { "type": "string" + }, + "price": { + "type": "ref", + "ref": "dev.mokkenstorm.crate.defs#price" + }, + "counterparty": { + "type": "string" } } } diff --git a/server/src/at_record_server/crate.gleam b/server/src/at_record_server/crate.gleam index 65f2ef7..953d3c0 100644 --- a/server/src/at_record_server/crate.gleam +++ b/server/src/at_record_server/crate.gleam @@ -160,6 +160,8 @@ pub fn encode_entry(e: CrateEntry) -> Json { opt("rating", e.rating, json.int), opt("folder", e.folder, json.string), opt("notes", e.notes, json.string), + opt("price", e.price, defs.encode_price), + opt("counterparty", e.counterparty, json.string), ]), ) } diff --git a/server/src/at_record_server/handlers/shelf.gleam b/server/src/at_record_server/handlers/shelf.gleam index 70f3cbb..f5fb068 100644 --- a/server/src/at_record_server/handlers/shelf.gleam +++ b/server/src/at_record_server/handlers/shelf.gleam @@ -3,7 +3,9 @@ //// add + later actions). Every mutation is a new event, never an edit. import at_record/gen/catalog/release as catalog_release -import at_record/gen/defs.{ExternalId, Snapshot} +import at_record/gen/defs.{ + type ExternalId, type Price, ExternalId, Snapshot, price_decoder, +} import at_record/gen/repo/strong_ref.{RepoStrongRef} import at_record/gen/shelf/entry.{ type ShelfEntry, ShelfEntry, encode_shelf_entry, @@ -232,7 +234,7 @@ fn do_purge( // --- writes (each appends one immutable event) --- -type AddForm { +pub type AddForm { AddForm( title: String, artist: String, @@ -240,14 +242,19 @@ type AddForm { year: Option(Int), status: String, media_grade: Option(String), + sleeve_grade: Option(String), + rating: Option(Int), + folder: Option(String), notes: Option(String), thumb_url: Option(String), cover_url: Option(String), discogs_id: Option(String), + price: Option(Price), + counterparty: Option(String), ) } -fn add_form_decoder() -> decode.Decoder(AddForm) { +pub fn add_form_decoder() -> decode.Decoder(AddForm) { use title <- decode.field("title", decode.string) use artist <- decode.field("artist", decode.string) use format <- decode.optional_field( @@ -262,6 +269,21 @@ fn add_form_decoder() -> decode.Decoder(AddForm) { None, decode.optional(non_empty()), ) + use sleeve_grade <- decode.optional_field( + "sleeveGrade", + None, + decode.optional(non_empty()), + ) + use rating <- decode.optional_field( + "rating", + None, + decode.optional(rating_in_range()), + ) + use folder <- decode.optional_field( + "folder", + None, + decode.optional(non_empty()), + ) use notes <- decode.optional_field( "notes", None, @@ -282,6 +304,16 @@ fn add_form_decoder() -> decode.Decoder(AddForm) { None, decode.optional(non_empty()), ) + use price <- decode.optional_field( + "price", + None, + decode.optional(price_decoder()), + ) + use counterparty <- decode.optional_field( + "counterparty", + None, + decode.optional(non_empty()), + ) decode.success(AddForm( title:, artist:, @@ -289,10 +321,15 @@ fn add_form_decoder() -> decode.Decoder(AddForm) { year:, status:, media_grade:, + sleeve_grade:, + rating:, + folder:, notes:, thumb_url:, cover_url:, discogs_id:, + price:, + counterparty:, )) } @@ -309,6 +346,15 @@ fn non_empty() -> decode.Decoder(String) { } } +/// Rejects (rather than clamps) a rating outside 1-5, matching the lexicon's bounds. +fn rating_in_range() -> decode.Decoder(Int) { + use n <- decode.then(decode.int) + case n >= 1 && n <= 5 { + True -> decode.success(n) + False -> decode.failure(0, "RatingInRange") + } +} + /// Genesis event: a fresh entry, action chosen from the wishlist/owned status. pub fn add_shelf_item(req: Request, ctx: Context) -> Response { use id, session <- require_session(req, ctx) @@ -326,7 +372,6 @@ fn do_add( form: AddForm, ) -> Response { use client, session <- with_pds_client(ctx, id, session) - let action = crate.action_from_status(form.status) let external_ids = case form.discogs_id { Some(id) -> Some([ @@ -368,33 +413,47 @@ fn do_add( Error(Nil) -> error_json(502, "artist records could not be published; try again") Ok(#(release, source)) -> - ShelfEntry( - subject: None, - action:, - snapshot: Some(Snapshot( - title: form.title, - artist_display: form.artist, - year: form.year, - format: form.format, - thumb_url: form.thumb_url, - cover:, - )), - external_ids:, - media_grade: form.media_grade, - sleeve_grade: None, - rating: None, - folder: None, - notes: form.notes, - release:, - price: None, - counterparty: None, - source: Some(source), - created_at: now_rfc3339(), - ) + genesis_event(form, external_ids, release, source, cover, now_rfc3339()) |> write_event(client, session) } } +/// Builds the genesis event from a validated add form plus its resolved +/// catalog/cover/provenance fields; pure so the field threading (and the +/// action derived from status) is testable without the PDS round-trip. +pub fn genesis_event( + form: AddForm, + external_ids: Option(List(ExternalId)), + release: Option(defs.CatalogRef), + source: defs.Source, + cover: option.Option(blob.Blob), + created_at: String, +) -> ShelfEntry { + ShelfEntry( + subject: None, + action: crate.action_from_status(form.status), + snapshot: Some(Snapshot( + title: form.title, + artist_display: form.artist, + year: form.year, + format: form.format, + thumb_url: form.thumb_url, + cover:, + )), + external_ids:, + media_grade: form.media_grade, + sleeve_grade: form.sleeve_grade, + rating: form.rating, + folder: form.folder, + notes: form.notes, + release:, + price: form.price, + counterparty: form.counterparty, + source: Some(source), + created_at:, + ) +} + // A manual add with a discogs id joins the shared catalog like an import // does; Error means an artist mint failed and the item must not be written. fn promote_manual( diff --git a/server/test/shelf_test.gleam b/server/test/shelf_test.gleam new file mode 100644 index 0000000..5d6b10b --- /dev/null +++ b/server/test/shelf_test.gleam @@ -0,0 +1,121 @@ +//// Add-form decoding and genesis-event construction: the write surface the +//// lexicon widened (sleeveGrade, rating, folder, price, counterparty) must +//// decode correctly, reject an out-of-range rating, and land on the written +//// record unchanged. + +import at_record/gen/defs +import at_record_server/handlers/shelf.{ + type AddForm, AddForm, add_form_decoder, genesis_event, +} +import at_record_server/provenance +import gleam/json +import gleam/option.{None, Some} +import gleam/result + +fn decode_form(body: String) -> Result(AddForm, Nil) { + json.parse(body, add_form_decoder()) + |> result.replace_error(Nil) +} + +pub fn add_form_decodes_the_widened_fields_test() { + let body = + "{\"title\":\"Spiderland\",\"artist\":\"Slint\",\"sleeveGrade\":\"VG+\",\"rating\":4,\"folder\":\"Rock A-M\",\"price\":{\"amount\":4000,\"currency\":\"EUR\"},\"counterparty\":\"Record shop\"}" + let assert Ok(form) = decode_form(body) + assert form.sleeve_grade == Some("VG+") + assert form.rating == Some(4) + assert form.folder == Some("Rock A-M") + assert form.price == Some(defs.Price(amount: 4000, currency: "EUR")) + assert form.counterparty == Some("Record shop") +} + +pub fn add_form_omits_absent_widened_fields_test() { + let body = "{\"title\":\"Spiderland\",\"artist\":\"Slint\"}" + let assert Ok(form) = decode_form(body) + assert form.sleeve_grade == None + assert form.rating == None + assert form.folder == None + assert form.price == None + assert form.counterparty == None +} + +pub fn add_form_rejects_rating_below_one_test() { + let body = "{\"title\":\"Spiderland\",\"artist\":\"Slint\",\"rating\":0}" + assert decode_form(body) == Error(Nil) +} + +pub fn add_form_rejects_rating_above_five_test() { + let body = "{\"title\":\"Spiderland\",\"artist\":\"Slint\",\"rating\":6}" + assert decode_form(body) == Error(Nil) +} + +pub fn add_form_accepts_rating_at_bounds_test() { + let assert Ok(low) = + decode_form("{\"title\":\"t\",\"artist\":\"a\",\"rating\":1}") + let assert Ok(high) = + decode_form("{\"title\":\"t\",\"artist\":\"a\",\"rating\":5}") + assert low.rating == Some(1) + assert high.rating == Some(5) +} + +fn blank_form() -> AddForm { + AddForm( + title: "Spiderland", + artist: "Slint", + format: None, + year: None, + status: "owned", + media_grade: None, + sleeve_grade: None, + rating: None, + folder: None, + notes: None, + thumb_url: None, + cover_url: None, + discogs_id: None, + price: None, + counterparty: None, + ) +} + +pub fn genesis_event_threads_the_widened_fields_onto_the_record_test() { + let form = + AddForm( + ..blank_form(), + sleeve_grade: Some("NM"), + rating: Some(5), + folder: Some("Rock A-M"), + price: Some(defs.Price(amount: 4000, currency: "EUR")), + counterparty: Some("Record shop"), + ) + let event = + genesis_event( + form, + None, + None, + provenance.source("manual"), + None, + "2026-01-01T00:00:00Z", + ) + assert event.sleeve_grade == Some("NM") + assert event.rating == Some(5) + assert event.folder == Some("Rock A-M") + assert event.price == Some(defs.Price(amount: 4000, currency: "EUR")) + assert event.counterparty == Some("Record shop") +} + +pub fn genesis_event_omits_absent_widened_fields_test() { + let event = + genesis_event( + blank_form(), + None, + None, + provenance.source("manual"), + None, + "2026-01-01T00:00:00Z", + ) + assert event.sleeve_grade == None + assert event.rating == None + assert event.folder == None + assert event.price == None + assert event.counterparty == None +} diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index a2bb007..0822efa 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -7,6 +7,7 @@ import at_record_web/model.{ type ReleaseInfo, type Suggestion, BrowseRelease, DiscogsResult, DiscogsSearchPage, Entry, HandleSuggestion, ImportRun, ReleaseInfo, Suggestion, } +import at_record_web/money import at_record_web/msg.{ type Msg, BarcodeDetected, CameraUnsupported, CoverUploaded, GotAction, GotAdd, GotAmend, GotAvatar, GotBrowse, GotBrowseAdd, GotDiscogs, GotDiscogsDisconnect, @@ -57,6 +58,11 @@ fn entry_decoder() -> decode.Decoder(Entry) { None, decode.optional(decode.string), ) + use sleeve_grade <- decode.optional_field( + "sleeveGrade", + None, + decode.optional(decode.string), + ) use rating <- decode.optional_field( "rating", None, @@ -72,14 +78,27 @@ fn entry_decoder() -> decode.Decoder(Entry) { None, decode.optional(decode.string), ) + use price <- decode.optional_field( + "price", + None, + decode.optional(defs.price_decoder()), + ) + use counterparty <- decode.optional_field( + "counterparty", + None, + decode.optional(decode.string), + ) decode.success(Entry( entry_id:, status:, snapshot:, media_grade:, + sleeve_grade:, rating:, folder:, notes:, + price:, + counterparty:, )) } @@ -580,6 +599,38 @@ fn add_body(form: Form) -> Json { Ok(value) -> [#("year", json.int(value))] Error(_) -> [] } + let sleeve_grade = case form.sleeve_grade { + "" -> [] + value -> [#("sleeveGrade", json.string(value))] + } + let rating = case int.parse(form.rating) { + Ok(value) -> [#("rating", json.int(value))] + Error(_) -> [] + } + let folder = case string.trim(form.folder) { + "" -> [] + value -> [#("folder", json.string(value))] + } + let price = case string.trim(form.price_amount) { + "" -> [] + trimmed -> + case money.parse_minor_units(trimmed) { + Ok(minor) -> [ + #( + "price", + defs.encode_price(defs.Price( + amount: minor, + currency: string.trim(form.price_currency), + )), + ), + ] + Error(_) -> [] + } + } + let counterparty = case string.trim(form.counterparty) { + "" -> [] + value -> [#("counterparty", json.string(value))] + } let thumb = case form.thumb_url { "" -> [] value -> [#("thumbUrl", json.string(value))] @@ -592,5 +643,19 @@ fn add_body(form: Form) -> Json { "" -> [] value -> [#("discogsId", json.string(value))] } - json.object(list.flatten([base, format, year, thumb, cover, discogs])) + json.object( + list.flatten([ + base, + format, + year, + sleeve_grade, + rating, + folder, + price, + counterparty, + thumb, + cover, + discogs, + ]), + ) } diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 6a13fac..4d3c8f3 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -1,4 +1,4 @@ -import at_record/gen/defs.{type Snapshot} +import at_record/gen/defs.{type Price, type Snapshot} import at_record/gen/shelf/entry.{type ShelfEntry} import gleam/dict import gleam/int @@ -26,9 +26,12 @@ pub type Entry { status: String, snapshot: Snapshot, media_grade: Option(String), + sleeve_grade: Option(String), rating: Option(Int), folder: Option(String), notes: Option(String), + price: Option(Price), + counterparty: Option(String), ) } @@ -67,6 +70,12 @@ pub type Form { format: String, year: String, status: String, + sleeve_grade: String, + rating: String, + folder: String, + price_amount: String, + price_currency: String, + counterparty: String, thumb_url: String, cover_url: String, discogs_id: String, @@ -80,6 +89,12 @@ pub fn blank_form() -> Form { format: "", year: "", status: "owned", + sleeve_grade: "", + rating: "", + folder: "", + price_amount: "", + price_currency: "EUR", + counterparty: "", thumb_url: "", cover_url: "", discogs_id: "", diff --git a/web/src/at_record_web/money.gleam b/web/src/at_record_web/money.gleam new file mode 100644 index 0000000..4a3adfa --- /dev/null +++ b/web/src/at_record_web/money.gleam @@ -0,0 +1,56 @@ +//// Pure decimal-string <-> minor-units conversion for money fields (price), +//// so the add form can submit atproto's integer minor units while the +//// record detail page renders the same value back as a decimal string. + +import gleam/bool +import gleam/int +import gleam/list +import gleam/result +import gleam/string + +/// Parses a decimal amount ("40", "40.5", "40.00") into minor units (e.g. +/// cents): `Error(Nil)` on anything that isn't a plain non-negative decimal. +pub fn parse_minor_units(input: String) -> Result(Int, Nil) { + let trimmed = string.trim(input) + // int.parse would accept "-5"/"+5"; "-0.50" would even lose its sign. + use <- bool.guard(!digits_and_dot_only(trimmed), Error(Nil)) + case string.split(trimmed, ".") { + [whole] -> int.parse(whole) |> result.map(fn(w) { w * 100 }) + [whole, frac] -> { + use w <- result.try(int.parse(whole)) + use f <- result.try(int.parse(pad_frac(frac))) + Ok(w * 100 + f) + } + _ -> Error(Nil) + } +} + +fn digits_and_dot_only(text: String) -> Bool { + text != "" + && string.to_graphemes(text) + |> list.all(fn(g) { g == "." || string.contains("0123456789", g) }) +} + +/// Right-pads/truncates a fractional part to exactly 2 digits. +fn pad_frac(frac: String) -> String { + case string.length(frac) { + 0 -> "00" + 1 -> frac <> "0" + _ -> string.slice(frac, 0, 2) + } +} + +/// Formats minor units back into a "CUR 40.00" display string. +pub fn format_minor_units(amount: Int, currency: String) -> String { + let whole = int.to_string(amount / 100) + let cents = pad2(amount % 100) + currency <> " " <> whole <> "." <> cents +} + +fn pad2(n: Int) -> String { + let s = int.to_string(n) + case string.length(s) { + 1 -> "0" <> s + _ -> s + } +} diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 4cb64c0..51e378a 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -48,6 +48,12 @@ pub type Msg { FormFormat(String) FormYear(String) FormStatus(String) + FormSleeveGrade(String) + FormRating(String) + FormFolder(String) + FormPriceAmount(String) + FormPriceCurrency(String) + FormCounterparty(String) SubmitAdd GotAdd(Result(Nil, rsvp.Error(String))) GotAvatar(Result(Option(String), rsvp.Error(String))) diff --git a/web/src/at_record_web/pages/add.gleam b/web/src/at_record_web/pages/add.gleam index bb768e0..95345fb 100644 --- a/web/src/at_record_web/pages/add.gleam +++ b/web/src/at_record_web/pages/add.gleam @@ -1,14 +1,16 @@ //// The Add page: the title field autocompletes from Discogs (picking a //// suggestion fills the whole form), plus the account connect/import card. -import at_record_web/model.{type DiscogsResult, type Model} +import at_record_web/model.{type DiscogsResult, type Form, type Model} import at_record_web/msg.{ type Msg, DiscogsConnect, DiscogsDisconnect, DiscogsImport, - DiscogsImportWantlist, FormArtist, FormFormat, FormStatus, FormTitle, FormYear, - SubmitAdd, UseDiscogs, + DiscogsImportWantlist, FormArtist, FormCounterparty, FormFolder, FormFormat, + FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, FormStatus, + FormTitle, FormYear, SubmitAdd, UseDiscogs, } import at_record_web/ui/components as c import gleam/int +import gleam/list import gleam/option import gleam/string import lustre/attribute as attr @@ -41,6 +43,12 @@ fn add_form_view(model: Model) -> Element(Msg) { FormStatus, ), ]), + html.div([attr.class("field-row")], [ + sleeve_grade_select(form), + rating_select(form), + ]), + c.field("FOLDER", "Rock A-M", form.folder, FormFolder, "text"), + acquisition_view(form), c.button(save_label(model.busy), c.Primary, [ event.on_click(SubmitAdd), attr.disabled(model.busy), @@ -49,6 +57,53 @@ fn add_form_view(model: Model) -> Element(Msg) { ]) } +fn sleeve_grade_select(form: Form) -> Element(Msg) { + let options = + list.map(["", ..c.grades], fn(g) { + let label = case g { + "" -> "Grade…" + _ -> g + } + html.option([attr.value(g), attr.selected(g == form.sleeve_grade)], label) + }) + html.label([attr.class("field")], [ + html.span([], [text("SLEEVE GRADE")]), + html.select([event.on_change(FormSleeveGrade)], options), + ]) +} + +fn rating_select(form: Form) -> Element(Msg) { + let options = + list.map(["", "1", "2", "3", "4", "5"], fn(r) { + let label = case r { + "" -> "Rating…" + _ -> r + } + html.option([attr.value(r), attr.selected(r == form.rating)], label) + }) + html.label([attr.class("field")], [ + html.span([], [text("RATING")]), + html.select([event.on_change(FormRating)], options), + ]) +} + +fn acquisition_view(form: Form) -> Element(Msg) { + html.div([attr.class("field")], [ + c.section_label("ACQUISITION"), + html.div([attr.class("field-row")], [ + c.field("PRICE", "40.00", form.price_amount, FormPriceAmount, "text"), + c.field("CURRENCY", "EUR", form.price_currency, FormPriceCurrency, "text"), + ]), + c.field( + "FROM", + "Record shop, discogs seller, …", + form.counterparty, + FormCounterparty, + "text", + ), + ]) +} + fn save_label(busy: Bool) -> String { case busy { True -> "SAVING…" diff --git a/web/src/at_record_web/pages/record.gleam b/web/src/at_record_web/pages/record.gleam index 3a00edc..6260bad 100644 --- a/web/src/at_record_web/pages/record.gleam +++ b/web/src/at_record_web/pages/record.gleam @@ -5,6 +5,7 @@ import at_record/gen/defs.{type Snapshot} import at_record/gen/shelf/entry.{type ShelfEntry} import at_record_web/model.{type Entry, type Model, type ReleaseInfo} +import at_record_web/money import at_record_web/msg.{ type Msg, AmendField, ArmRemove, CoverFileChosen, EntryAction, Rate, Regrade, SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, @@ -78,12 +79,23 @@ fn condition(entry: Entry) -> Element(Msg) { let grade = option.unwrap(entry.media_grade, "—") html.div([], [ c.section_label("CONDITION"), - html.div([attr.class("grades")], [ - c.grade_card("MEDIA", grade, c.grade_name(grade)), - ]), + html.div( + [attr.class("grades")], + [ + Some(c.grade_card("MEDIA", grade, c.grade_name(grade))), + sleeve_card(entry), + ] + |> option.values, + ), ]) } +fn sleeve_card(entry: Entry) -> option.Option(Element(Msg)) { + option.map(entry.sleeve_grade, fn(grade) { + c.grade_card("SLEEVE", grade, c.grade_name(grade)) + }) +} + fn detail_rows( entry: Entry, snap: Snapshot, @@ -94,6 +106,10 @@ fn detail_rows( option.map(snap.format, fn(f) { #("FORMAT", f) }), option.map(snap.year, fn(y) { #("YEAR", int.to_string(y)) }), option.map(entry.folder, fn(f) { #("FOLDER", f) }), + option.map(entry.price, fn(p) { + #("PAID", money.format_minor_units(p.amount, p.currency)) + }), + option.map(entry.counterparty, fn(c) { #("FROM", c) }), option.then(release_info, fn(r) { option.map(r.country, fn(c) { #("COUNTRY", c) }) }), diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index cf73905..db6dcd6 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -9,18 +9,20 @@ import at_record_web/model.{ Entry, Failure, Form, LoggedIn, LoggedOut, Model, Notice, Record, Scan, ShelfFailed, ShelfLoaded, ShelfLoading, Success, Warning, blank_form, } +import at_record_web/money import at_record_web/msg.{ type Msg, type ScanLookup, AddScannedItem, AddSuggestion, AmendField, ArmRemove, BarcodeDetected, BrowseAdd, CameraUnsupported, ClearNotice, CoverFileChosen, CoverUploaded, DiscogsConnect, DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, DiscogsLoadMore, DiscogsSearch, - DiscogsVinylOnly, EntryAction, FormArtist, FormFormat, FormStatus, FormTitle, - FormYear, GotAction, GotAdd, GotAmend, GotAvatar, GotBrowse, GotBrowseAdd, - GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, - GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, GotShelf, - GotTimeline, HandleChanged, HandleSearch, Logout, OnRouteChange, Rate, Regrade, - RemoveScanRow, RetryShelf, ScanItemAdded, SetView, StartLogin, SubmitAdd, - SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, ToggleEntry, + DiscogsVinylOnly, EntryAction, FormArtist, FormCounterparty, FormFolder, + FormFormat, FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, + FormStatus, FormTitle, FormYear, GotAction, GotAdd, GotAmend, GotAvatar, + GotBrowse, GotBrowseAdd, GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, + GotDiscogsStatus, GotHandleSuggestions, GotLogout, GotScanResult, GotScanSeen, + GotShelf, GotTimeline, HandleChanged, HandleSearch, Logout, OnRouteChange, + Rate, Regrade, RemoveScanRow, RetryShelf, ScanItemAdded, SetView, StartLogin, + SubmitAdd, SubmitAmend, ToggleAmend, ToggleAmendCover, ToggleEdit, ToggleEntry, ToggleMenu, UseDiscogs, UseHandleSuggestion, } import gleam/bool @@ -233,14 +235,46 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Model(..model, form: Form(..model.form, status: value)), effect.none(), ) + FormSleeveGrade(value) -> #( + Model(..model, form: Form(..model.form, sleeve_grade: value)), + effect.none(), + ) + FormRating(value) -> #( + Model(..model, form: Form(..model.form, rating: value)), + effect.none(), + ) + FormFolder(value) -> #( + Model(..model, form: Form(..model.form, folder: value)), + effect.none(), + ) + FormPriceAmount(value) -> #( + Model(..model, form: Form(..model.form, price_amount: value)), + effect.none(), + ) + FormPriceCurrency(value) -> #( + Model(..model, form: Form(..model.form, price_currency: value)), + effect.none(), + ) + FormCounterparty(value) -> #( + Model(..model, form: Form(..model.form, counterparty: value)), + effect.none(), + ) SubmitAdd -> - case string.trim(model.form.title), string.trim(model.form.artist) { - "", _ | _, "" -> #( + case + string.trim(model.form.title), + string.trim(model.form.artist), + valid_price(model.form.price_amount) + { + "", _, _ | _, "", _ -> #( Model(..model, notice: failed("Title and artist are required.")), effect.none(), ) - _, _ -> #( + _, _, False -> #( + Model(..model, notice: failed("Enter a valid price amount.")), + effect.none(), + ) + _, _, True -> #( Model(..model, busy: True, notice: None), add_item(model.form), ) @@ -870,6 +904,14 @@ fn write_error( } } +/// An empty amount is fine (price is optional); a non-empty one must parse. +fn valid_price(amount: String) -> Bool { + case string.trim(amount) { + "" -> True + trimmed -> result.is_ok(money.parse_minor_units(trimmed)) + } +} + fn succeeded(message: String) -> Option(Notice) { Some(Notice(Success, message)) } diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam index b5fc09c..d5c65e2 100644 --- a/web/test/at_record_web_test.gleam +++ b/web/test/at_record_web_test.gleam @@ -1,20 +1,24 @@ +import at_record/gen/defs.{type Snapshot, Price, Snapshot} import at_record_web/model.{ type BrowseRelease, type DiscogsResult, type Model, type ReleaseInfo, Added, - Adding, BrowseRelease, Crate, Discogs, DiscogsResult, Failure, Form, ImportRun, - LoggedIn, LoggedOut, Matched, Model, NoMatch, Notice, Pending, ReleaseInfo, - ScanRow, ScanState, ShelfFailed, ShelfLoaded, ShelfLoading, Success, - Suggestion, Warning, blank_amend, blank_discogs, blank_form, blank_scan, + Adding, BrowseRelease, Crate, Discogs, DiscogsResult, Entry, Failure, Form, + ImportRun, LoggedIn, LoggedOut, Matched, Model, NoMatch, Notice, Pending, + ReleaseInfo, ScanRow, ScanState, ShelfFailed, ShelfLoaded, ShelfLoading, + Success, Suggestion, Warning, blank_amend, blank_discogs, blank_form, + blank_scan, } import at_record_web/msg.{ AddScannedItem, AddSuggestion, ArmRemove, BarcodeDetected, BrowseAdd, - ClearNotice, DiscogsImportWantlist, EntryAction, FormTitle, GotAdd, GotAvatar, - GotBrowse, GotBrowseAdd, GotDiscogsImport, GotDiscogsStatus, GotLogout, - GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, Logout, - OnRouteChange, RemoveScanRow, RetryShelf, ScanItemAdded, ScanLookup, SetView, - ShelfData, SubmitAdd, TimelineData, + ClearNotice, DiscogsImportWantlist, EntryAction, FormCounterparty, FormFolder, + FormPriceAmount, FormPriceCurrency, FormRating, FormSleeveGrade, FormTitle, + GotAdd, GotAvatar, GotBrowse, GotBrowseAdd, GotDiscogsImport, GotDiscogsStatus, + GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, HandleChanged, + Logout, OnRouteChange, RemoveScanRow, RetryShelf, ScanItemAdded, ScanLookup, + SetView, ShelfData, SubmitAdd, TimelineData, } import at_record_web/pages/crate import at_record_web/pages/login +import at_record_web/pages/record import at_record_web/pages/scan import at_record_web/update.{update} import gleam/dict @@ -197,6 +201,66 @@ pub fn empty_history_view_keeps_toolbar_and_fab_test() { assert !string.contains(html, "NOTHING IN THE CRATE YET") } +// --- record detail page --- + +fn a_snapshot() -> Snapshot { + Snapshot( + artist_display: "Slint", + cover: None, + format: Some("LP"), + thumb_url: None, + title: "Spiderland", + year: Some(1991), + ) +} + +fn an_entry() -> model.Entry { + Entry( + entry_id: "e1", + status: "owned", + snapshot: a_snapshot(), + media_grade: Some("NM"), + sleeve_grade: Some("VG+"), + rating: Some(4), + folder: None, + notes: None, + price: Some(Price(amount: 4000, currency: "EUR")), + counterparty: Some("Record shop"), + ) +} + +pub fn record_view_shows_the_sleeve_grade_card_when_present_test() { + let html = record.view(logged_in(), an_entry()) |> element.to_string + assert string.contains(html, "SLEEVE") + assert string.contains(html, "VG+") +} + +pub fn record_view_omits_the_sleeve_grade_card_when_absent_test() { + let html = + record.view(logged_in(), Entry(..an_entry(), sleeve_grade: None)) + |> element.to_string + assert !string.contains(html, "SLEEVE") +} + +pub fn record_view_shows_paid_and_from_rows_when_present_test() { + let html = record.view(logged_in(), an_entry()) |> element.to_string + assert string.contains(html, "PAID") + assert string.contains(html, "EUR 40.00") + assert string.contains(html, "FROM") + assert string.contains(html, "Record shop") +} + +pub fn record_view_omits_paid_and_from_rows_when_absent_test() { + let html = + record.view( + logged_in(), + Entry(..an_entry(), price: None, counterparty: None), + ) + |> element.to_string + assert !string.contains(html, "PAID") + assert !string.contains(html, "FROM") +} + pub fn oauth_error_copy_maps_known_codes_test() { assert string.contains( login.error_copy("access_denied"), @@ -233,6 +297,79 @@ pub fn submit_add_rejects_whitespace_only_title_test() { assert after.notice == Some(Notice(Failure, "Title and artist are required.")) } +pub fn submit_add_rejects_an_unparseable_price_test() { + let model = + Model( + ..base(), + form: Form( + ..blank_form(), + title: "Spiderland", + artist: "Slint", + price_amount: "free", + ), + ) + let #(after, _) = update(model, SubmitAdd) + assert after.notice == Some(Notice(Failure, "Enter a valid price amount.")) + assert after.busy == False +} + +pub fn submit_add_accepts_a_blank_price_test() { + let model = + Model( + ..base(), + form: Form(..blank_form(), title: "Spiderland", artist: "Slint"), + ) + let #(after, _) = update(model, SubmitAdd) + assert after.busy == True + assert after.notice == None +} + +pub fn submit_add_accepts_a_valid_price_test() { + let model = + Model( + ..base(), + form: Form( + ..blank_form(), + title: "Spiderland", + artist: "Slint", + price_amount: "40.00", + ), + ) + let #(after, _) = update(model, SubmitAdd) + assert after.busy == True + assert after.notice == None +} + +pub fn form_sleeve_grade_updates_form_test() { + let #(model, _) = update(base(), FormSleeveGrade("VG+")) + assert model.form.sleeve_grade == "VG+" +} + +pub fn form_rating_updates_form_test() { + let #(model, _) = update(base(), FormRating("4")) + assert model.form.rating == "4" +} + +pub fn form_folder_updates_form_test() { + let #(model, _) = update(base(), FormFolder("Rock A-M")) + assert model.form.folder == "Rock A-M" +} + +pub fn form_price_amount_updates_form_test() { + let #(model, _) = update(base(), FormPriceAmount("40.00")) + assert model.form.price_amount == "40.00" +} + +pub fn form_price_currency_updates_form_test() { + let #(model, _) = update(base(), FormPriceCurrency("USD")) + assert model.form.price_currency == "USD" +} + +pub fn form_counterparty_updates_form_test() { + let #(model, _) = update(base(), FormCounterparty("Record shop")) + assert model.form.counterparty == "Record shop" +} + pub fn got_add_success_sets_notice_with_title_test() { let model = Model(..logged_in(), form: Form(..blank_form(), title: "Spiderland")) diff --git a/web/test/money_test.gleam b/web/test/money_test.gleam new file mode 100644 index 0000000..15f5204 --- /dev/null +++ b/web/test/money_test.gleam @@ -0,0 +1,70 @@ +//// Decimal-string <-> minor-units conversion for the price field: the add +//// form parses a typed amount into minor units, the record detail page +//// formats it back for display. + +import at_record_web/money + +pub fn parses_a_whole_amount_test() { + assert money.parse_minor_units("40") == Ok(4000) +} + +pub fn parses_two_decimal_places_test() { + assert money.parse_minor_units("40.00") == Ok(4000) +} + +pub fn parses_a_single_decimal_place_test() { + assert money.parse_minor_units("40.5") == Ok(4050) +} + +pub fn parses_zero_test() { + assert money.parse_minor_units("0") == Ok(0) +} + +pub fn trims_surrounding_whitespace_test() { + assert money.parse_minor_units(" 40.00 ") == Ok(4000) +} + +pub fn truncates_extra_decimal_precision_test() { + assert money.parse_minor_units("40.999") == Ok(4099) +} + +pub fn rejects_non_numeric_input_test() { + assert money.parse_minor_units("free") == Error(Nil) +} + +pub fn rejects_multiple_decimal_points_test() { + assert money.parse_minor_units("40.00.00") == Error(Nil) +} + +pub fn rejects_a_bare_decimal_point_test() { + assert money.parse_minor_units(".") == Error(Nil) +} + +pub fn rejects_a_negative_amount_test() { + assert money.parse_minor_units("-5") == Error(Nil) +} + +pub fn rejects_a_negative_amount_with_cents_test() { + assert money.parse_minor_units("-0.50") == Error(Nil) +} + +pub fn rejects_an_explicit_plus_sign_test() { + assert money.parse_minor_units("+5.00") == Error(Nil) +} + +pub fn formats_whole_euros_test() { + assert money.format_minor_units(4000, "EUR") == "EUR 40.00" +} + +pub fn formats_single_digit_cents_with_leading_zero_test() { + assert money.format_minor_units(4005, "EUR") == "EUR 40.05" +} + +pub fn formats_zero_test() { + assert money.format_minor_units(0, "EUR") == "EUR 0.00" +} + +pub fn round_trips_through_parse_and_format_test() { + let assert Ok(minor) = money.parse_minor_units("12.34") + assert money.format_minor_units(minor, "USD") == "USD 12.34" +} -- 2.51.2