From 0cfdb0c09bb0a9ceb8b6ab53a875375bd795d35a Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 19 Jul 2026 12:04:10 +0200 Subject: [PATCH] refactor(web): hoist shared row meta, flatten feed rows, share pagination merge --- web/src/at_record_web/model.gleam | 35 +++- web/src/at_record_web/pages/crate.gleam | 19 +- web/src/at_record_web/pages/feed.gleam | 182 ++++++++---------- .../at_record_web/pages/public_crate.gleam | 9 +- web/src/at_record_web/pages/record.gleam | 5 +- web/src/at_record_web/ui/covers.gleam | 18 ++ web/src/at_record_web/ui/record_detail.gleam | 15 ++ web/src/at_record_web/update.gleam | 24 +-- web/src/at_record_web/view.gleam | 9 +- 9 files changed, 160 insertions(+), 156 deletions(-) diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 46ba0a6..6b0f9ca 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -5,6 +5,7 @@ import gleam/dict import gleam/int import gleam/list import gleam/option.{type Option, None, Some} +import gleam/order import gleam/result import gleam/set import gleam/string @@ -218,9 +219,16 @@ pub fn feed_item_day( dict.get(cache, #(ref.actor, ref.entry_id)) |> result.map(fn(fe) { string.slice(fe.updated_at, 0, 10) }) }) - |> list.sort(string.compare) - |> list.last - |> option.from_result + |> list.fold(None, fn(newest, day) { + case newest { + None -> Some(day) + Some(current) -> + case string.compare(day, current) { + order.Lt -> newest + _ -> Some(day) + } + } + }) } /// The first entry ref's `#(actor, entry_id)` pair, the item's identity for @@ -232,6 +240,27 @@ pub fn feed_item_key(item: FeedItem) -> Option(#(String, String)) { } } +/// The items from `incoming` whose key doesn't already appear in `existing`: +/// a write can land between pages, so the next fetched page may repeat an +/// item already folded into the list. An item with no key (`key_of` returns +/// `None`) is always kept, on the assumption it can't be reliably deduped. +pub fn append_new( + existing: List(a), + incoming: List(a), + key_of: fn(a) -> Option(b), +) -> List(a) { + let seen = + existing + |> list.filter_map(fn(item) { key_of(item) |> option.to_result(Nil) }) + |> set.from_list + list.filter(incoming, fn(item) { + case key_of(item) { + Some(key) -> !set.contains(seen, key) + None -> True + } + }) +} + /// How prominent/urgent a notice is; drives its icon and colour. pub type NoticeLevel { Success diff --git a/web/src/at_record_web/pages/crate.gleam b/web/src/at_record_web/pages/crate.gleam index 5861364..450b105 100644 --- a/web/src/at_record_web/pages/crate.gleam +++ b/web/src/at_record_web/pages/crate.gleam @@ -3,7 +3,6 @@ //// branches on the shelf's load state (loading skeletons, empty first-run //// panel, offline error panel, or the loaded grid). -import at_record/gen/defs.{type Snapshot} import at_record_web/model.{ type Display, type Entry, type Model, Add, Grid, Record, Rows, Scan, ShelfFailed, ShelfLoaded, ShelfLoading, @@ -18,7 +17,6 @@ import at_record_web/ui/infinite_scroll as scroll import gleam/int import gleam/list import gleam/option.{type Option} -import gleam/string import lustre/attribute as attr import lustre/element.{type Element, text} import lustre/element/html @@ -228,7 +226,7 @@ fn card(entry: Entry) -> Element(Msg) { entry.status, snap.title, snap.artist_display, - format_line(snap), + cov.format_line(snap), snap.thumb_url, ) } @@ -244,24 +242,11 @@ fn row(entry: Entry) -> Element(Msg) { snap.title <> snap.artist_display, entry.status, snap.title, - row_meta(snap), + cov.row_meta(snap), snap.thumb_url, ) } -fn row_meta(snap: Snapshot) -> String { - case format_line(snap) { - "" -> snap.artist_display - fmt -> snap.artist_display <> " · " <> fmt - } -} - -fn format_line(snap: Snapshot) -> String { - [snap.format, option.map(snap.year, int.to_string)] - |> option.values - |> string.join(" / ") -} - fn loading_state( hero_bar: Element(Msg), view: String, diff --git a/web/src/at_record_web/pages/feed.gleam b/web/src/at_record_web/pages/feed.gleam index 0cb4fec..178971f 100644 --- a/web/src/at_record_web/pages/feed.gleam +++ b/web/src/at_record_web/pages/feed.gleam @@ -3,7 +3,6 @@ //// off the skeleton the server returns. Unhydrated refs render a skeleton //// placeholder in place, so the list keeps its shape while it fills in. -import at_record/gen/defs.{type Snapshot} import at_record/gen/feed/get_feed_skeleton.{ type EntryRef, type FeedItem, type ReasonActorBatch, type ReasonImport, type ReasonSingle, type ReasonSubjectConverge, FeedItemReasonReasonActorBatch, @@ -24,10 +23,8 @@ import at_record_web/ui/covers as cov import at_record_web/ui/infinite_scroll as scroll import at_record_web/ui/states import gleam/dict -import gleam/int import gleam/list import gleam/option.{type Option, None, Some} -import gleam/string import lustre/attribute as attr import lustre/element.{type Element, text} import lustre/element/html @@ -94,11 +91,7 @@ fn feed_rows(model: Model, items: List(FeedItem)) -> List(Element(Msg)) { Some(d) if day != current_day -> [day_separator(d), ..out] _ -> out } - let next_day = case day { - Some(_) -> day - None -> current_day - } - #(next_day, [feed_row(model, item), ..out]) + #(option.or(day, current_day), [feed_row(model, item), ..out]) }) list.reverse(rows) } @@ -122,70 +115,69 @@ fn single_row( item: FeedItem, reason: ReasonSingle, ) -> Element(Msg) { - case first_ref(item) { + case first_ref_hydrated(model, item) { None -> skeleton_row() - Some(ref) -> - case lookup(model, ref) { - None -> skeleton_row() - Some(fe) -> { - let snap = fe.entry.snapshot - html.div([attr.class("feed-row")], [ - row_head(fe.handle, "@" <> fe.handle <> " " <> reason.action), - cov.cover_row( - route.to_path(PublicRecord(fe.handle, ref.entry_id)), - snap.title <> snap.artist_display, - fe.entry.status, - snap.title, - row_meta(snap), - snap.thumb_url, - ), - ]) - } - } + Some(#(ref, fe)) -> { + let snap = fe.entry.snapshot + html.div([attr.class("feed-row")], [ + row_head(fe.handle, "@" <> fe.handle <> " " <> reason.action), + cov.cover_row( + route.to_path(PublicRecord(fe.handle, ref.entry_id)), + snap.title <> snap.artist_display, + fe.entry.status, + snap.title, + cov.row_meta(snap), + snap.thumb_url, + ), + ]) + } } } -fn batch_row( +/// `batch_row`/`import_row`'s shared scaffold: a skeleton until the first +/// entry hydrates, else a strip row labeled by `label_for` off that entry. +fn hydrated_strip_row( model: Model, item: FeedItem, - reason: ReasonActorBatch, + label_for: fn(FeedEntry) -> String, ) -> Element(Msg) { case hydrated_entries(model, item) { [] -> skeleton_row() - [#(_, fe), ..] -> { - let label = - "@" - <> fe.handle - <> " " - <> reason.action - <> " " - <> plural.count_noun(list.length(item.entries), "record") - strip_row(model, fe.handle, label, item) - } + [#(_, fe), ..] -> strip_row(model, fe.handle, label_for(fe), item) } } +fn batch_row( + model: Model, + item: FeedItem, + reason: ReasonActorBatch, +) -> Element(Msg) { + hydrated_strip_row(model, item, fn(fe) { + "@" + <> fe.handle + <> " " + <> reason.action + <> " " + <> plural.count_noun(list.length(item.entries), "record") + }) +} + fn import_row( model: Model, item: FeedItem, reason: ReasonImport, ) -> Element(Msg) { - case hydrated_entries(model, item) { - [] -> skeleton_row() - [#(_, fe), ..] -> { - let via = case reason.source { - Some(src) -> " via " <> provenance.provider_label(src) - None -> "" - } - let label = - "@" - <> fe.handle - <> " imported " - <> plural.count_noun(list.length(item.entries), "record") - <> via - strip_row(model, fe.handle, label, item) + hydrated_strip_row(model, item, fn(fe) { + let via = case reason.source { + Some(src) -> " via " <> provenance.provider_label(src) + None -> "" } - } + "@" + <> fe.handle + <> " imported " + <> plural.count_noun(list.length(item.entries), "record") + <> via + }) } /// Header line plus a horizontal strip of small cover thumbs, shared by the @@ -227,38 +219,34 @@ fn converge_row( item: FeedItem, reason: ReasonSubjectConverge, ) -> Element(Msg) { - case first_ref(item) { + case first_ref_hydrated(model, item) { None -> skeleton_row() - Some(ref) -> - case lookup(model, ref) { - None -> skeleton_row() - Some(fe) -> { - let snap = fe.entry.snapshot - let href = case model.split_release_uri(reason.subject.uri) { - Ok(#(did, rkey)) -> route.to_path(PressingDetail(did, rkey)) - Error(_) -> route.to_path(PublicRecord(fe.handle, ref.entry_id)) - } - html.div([attr.class("feed-row")], [ - html.div([attr.class("feed-row__head")], [ - avatar_cluster(model, item), - html.span([attr.class("feed-row__label")], [ - text( - plural.count_noun(list.length(item.entries), "collector") - <> " picked up the same pressing", - ), - ]), - ]), - cov.cover_row( - href, - snap.title <> snap.artist_display, - fe.entry.status, - snap.title, - row_meta(snap), - snap.thumb_url, - ), - ]) - } + Some(#(ref, fe)) -> { + let snap = fe.entry.snapshot + let href = case model.split_release_uri(reason.subject.uri) { + Ok(#(did, rkey)) -> route.to_path(PressingDetail(did, rkey)) + Error(_) -> route.to_path(PublicRecord(fe.handle, ref.entry_id)) } + html.div([attr.class("feed-row")], [ + html.div([attr.class("feed-row__head")], [ + avatar_cluster(model, item), + html.span([attr.class("feed-row__label")], [ + text( + plural.count_noun(list.length(item.entries), "collector") + <> " picked up the same pressing", + ), + ]), + ]), + cov.cover_row( + href, + snap.title <> snap.artist_display, + fe.entry.status, + snap.title, + cov.row_meta(snap), + snap.thumb_url, + ), + ]) + } } } @@ -294,19 +282,6 @@ fn skeleton_row() -> Element(Msg) { ]) } -fn row_meta(snap: Snapshot) -> String { - case format_line(snap) { - "" -> snap.artist_display - fmt -> snap.artist_display <> " · " <> fmt - } -} - -fn format_line(snap: Snapshot) -> String { - [snap.format, option.map(snap.year, int.to_string)] - |> option.values - |> string.join(" / ") -} - fn first_ref(item: FeedItem) -> Option(EntryRef) { case item.entries { [ref, ..] -> Some(ref) @@ -319,6 +294,19 @@ fn lookup(model: Model, ref: EntryRef) -> Option(FeedEntry) { |> option.from_result } +/// The first ref's `#(ref, hydrated entry)` pair, shared by every reason +/// row that only ever fronts with its first entry; `None` while there's no +/// ref at all or its entry hasn't hydrated yet. +fn first_ref_hydrated( + model: Model, + item: FeedItem, +) -> Option(#(EntryRef, FeedEntry)) { + first_ref(item) + |> option.then(fn(ref) { + lookup(model, ref) |> option.map(fn(fe) { #(ref, fe) }) + }) +} + /// The `#(ref, hydrated entry)` pairs for an item, in skeleton order, skipping /// refs that aren't in the cache yet. fn hydrated_entries( diff --git a/web/src/at_record_web/pages/public_crate.gleam b/web/src/at_record_web/pages/public_crate.gleam index 1cf8c18..843de15 100644 --- a/web/src/at_record_web/pages/public_crate.gleam +++ b/web/src/at_record_web/pages/public_crate.gleam @@ -2,7 +2,6 @@ //// per-entry actions. Reuses the crate grid's cover components; each card //// links into the public record route instead of the authed one. -import at_record/gen/defs.{type Snapshot} import at_record_web/model.{ type CrateOverlap, type Entry, type Model, LoggedIn, LoggedOut, PublicRecord, PublicShelfFailed, PublicShelfLoaded, PublicShelfLoading, @@ -156,17 +155,11 @@ fn card(handle: String, entry: Entry) -> Element(Msg) { entry.status, snap.title, snap.artist_display, - format_line(snap), + cov.format_line(snap), snap.thumb_url, ) } -fn format_line(snap: Snapshot) -> String { - [snap.format, option.map(snap.year, int.to_string)] - |> option.values - |> string.join(" / ") -} - fn loading_state(model: Model, handle: String) -> Element(Msg) { html.div([attr.class("list-screen")], [ hero(model, handle, 0), diff --git a/web/src/at_record_web/pages/record.gleam b/web/src/at_record_web/pages/record.gleam index 47f8d7e..3ac86dd 100644 --- a/web/src/at_record_web/pages/record.gleam +++ b/web/src/at_record_web/pages/record.gleam @@ -274,10 +274,7 @@ fn actions( True -> "DONE" False -> "EDIT YOUR RECORD" } - let amend_label = case dict.get(via_handles, entry_id) { - Ok(_) -> "SUGGEST A FIX" - Error(Nil) -> "AMEND" - } + let amend_label = rd.amend_action_label(via_handles, entry_id, "AMEND") let #(remove_label, remove_msg) = case confirm_remove { True -> #("CONFIRM REMOVE", EntryAction(entry_id, "dropped")) False -> #("REMOVE", ArmRemove) diff --git a/web/src/at_record_web/ui/covers.gleam b/web/src/at_record_web/ui/covers.gleam index bb23d2f..1466a71 100644 --- a/web/src/at_record_web/ui/covers.gleam +++ b/web/src/at_record_web/ui/covers.gleam @@ -1,7 +1,9 @@ //// Cover art tiles, cards, and rows: colour-hashed letter tiles with an //// optional real cover image, plus the containers built on top of them. +import at_record/gen/defs.{type Snapshot} import at_record_web/ui/controls as ctl +import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/string @@ -119,3 +121,19 @@ pub fn thumb(url: Option(String)) -> Element(msg) { None -> html.div([attr.class("cover cover-empty")], []) } } + +/// A row's meta line: the format/year line appended to the artist, or just +/// the artist alone when the pressing has neither. +pub fn row_meta(snap: Snapshot) -> String { + case format_line(snap) { + "" -> snap.artist_display + fmt -> snap.artist_display <> " · " <> fmt + } +} + +/// "FORMAT / YEAR", skipping whichever side is absent; empty when both are. +pub fn format_line(snap: Snapshot) -> String { + [snap.format, option.map(snap.year, int.to_string)] + |> option.values + |> string.join(" / ") +} diff --git a/web/src/at_record_web/ui/record_detail.gleam b/web/src/at_record_web/ui/record_detail.gleam index 3a0864f..cf8fd4f 100644 --- a/web/src/at_record_web/ui/record_detail.gleam +++ b/web/src/at_record_web/ui/record_detail.gleam @@ -2,6 +2,7 @@ //// layer-section bands (the pressing vs. your record), provenance lines, //// and the shared record-grade vocabulary. +import gleam/dict import gleam/int import gleam/list import gleam/string @@ -94,6 +95,20 @@ pub fn notes(body: String) -> Element(msg) { ]) } +/// A foreign (adopted) pressing gets the shared "suggest a fix" copy; a +/// self-minted one gets `own_label`, whichever wording the caller's screen +/// uses for its own amend action. +pub fn amend_action_label( + via_handles: dict.Dict(String, String), + entry_id: String, + own_label: String, +) -> String { + case dict.get(via_handles, entry_id) { + Ok(_) -> "SUGGEST A FIX" + Error(Nil) -> own_label + } +} + pub const grades = ["M", "NM", "VG+", "VG", "G+", "G", "F", "P"] pub fn grade_name(grade: String) -> String { diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 97b912d..2fa1399 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -233,13 +233,11 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) } - // Appends the next page, deduping defensively by entry id in case a - // write landed between pages and shifted the fold. GotShelfMore(Ok(data)) -> { - let seen = - model.entries(model) |> list.map(fn(e) { e.entry_id }) |> set.from_list let fresh = - list.filter(data.items, fn(e) { !set.contains(seen, e.entry_id) }) + model.append_new(model.entries(model), data.items, fn(e) { + Some(e.entry_id) + }) #( Model( ..model, @@ -1120,24 +1118,10 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ) _ -> #(model, effect.none()) } - // Appends the next page, deduping by first-entry-ref key in case an item - // straddles the page boundary. GotFeedMore(Ok(data)) -> case model.feed { FeedLoaded(items, _, _) -> { - let seen = - items - |> list.filter_map(fn(item) { - model.feed_item_key(item) |> option.to_result(Nil) - }) - |> set.from_list - let fresh = - list.filter(data.items, fn(item) { - case model.feed_item_key(item) { - Some(key) -> !set.contains(seen, key) - None -> True - } - }) + let fresh = model.append_new(items, data.items, model.feed_item_key) #( Model( ..model, diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index b8fc1f7..fb52f43 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -27,7 +27,7 @@ import at_record_web/pages/settings import at_record_web/route import at_record_web/ui/app_bar as bar import at_record_web/ui/nav -import gleam/dict +import at_record_web/ui/record_detail as rd import gleam/list import gleam/option.{type Option, None, Some} import lustre/attribute as attr @@ -168,13 +168,8 @@ fn sub_page_to( ]) } -/// Mirrors the record page's own amend-label logic: a foreign (adopted) -/// pressing gets "suggest a fix" copy, a self-minted one gets "amend". fn amend_title(model: Model, entry_id: String) -> String { - case dict.get(model.via_handles, entry_id) { - Ok(_) -> "SUGGEST A FIX" - Error(Nil) -> "AMEND PRESSING" - } + rd.amend_action_label(model.via_handles, entry_id, "AMEND PRESSING") } fn edit_proposal_page(model: Model, id: String) -> Element(Msg) { -- 2.51.2