list: show each ranked item's rating
Assisted-by: Claude Sonnet 5.5 via Claude Code
change mluuzlwvprlkqtvplxvnnvkxmozylyny
commit 236072f0779400f2424d18486070a2c4d43aaf27
author Alpha Chen <alpha@kejadlen.dev>
date
parent ukrursrv
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 1a1f498..5f9e7b6 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -42,9 +42,9 @@ module RankKing
     one_to_many :results, class: "RankKing::Result"
 
     # The list's items in order on this axis, then the ones with no
-    # results on it yet. Ranked by OpenSkill ordinal, best first.
-    def ranking
-      ratings = self.ratings
+    # results on it yet. Ranked by OpenSkill ordinal, best first. Pass
+    # the axis's `ratings` when the caller already has them.
+    def ranking(ratings = self.ratings)
       ranked, unranked = list.items.partition { |item| ratings.key?(item.id) }
       model = OpenSkill::Model.new
       [ranked.sort_by { |item| [-model.ordinal(ratings.fetch(item.id)), item.id] }, unranked]
@@ -52,10 +52,9 @@ module RankKing
 
     # Each compared item's rating, from replaying the axis's results in
     # the order they were recorded.
-    #: () -> Hash[Integer, OpenSkill::Rating]
     def ratings
       model = OpenSkill::Model.new
-      ratings = {}
+      ratings = {} #: Hash[Integer, OpenSkill::Rating]
       results_dataset.order(:id).each do |result|
         winner = ratings.fetch(result.winner_id) { model.rating }
         loser = ratings.fetch(result.loser_id) { model.rating }
diff --git a/lib/rank_king/views/list/show.rb b/lib/rank_king/views/list/show.rb
index 5b57ff7..56c1909 100644
--- a/lib/rank_king/views/list/show.rb
+++ b/lib/rank_king/views/list/show.rb
@@ -84,13 +84,20 @@ module RankKing
         end
 
         def render_ranking(axis)
-          ranked, unranked = axis.ranking
+          ratings = axis.ratings
+          ranked, unranked = axis.ranking(ratings)
+          model = OpenSkill::Model.new
 
           ol(class: "card") do
             ranked.each.with_index(1) do |item, rank|
               li do
                 span(class: "type-label") { format("%02d", rank) }
                 plain item.name
+                rating = ratings.fetch(item.id)
+                span(class: "type-label", data_rating: true, title: format("μ %.1f σ %.1f", rating.mu, rating.sigma)) do
+                  # Adding 0.0 turns a rounded -0.0 into 0.0.
+                  format("%.1f", model.ordinal(rating).round(1) + 0.0)
+                end
               end
             end
             unranked.each do |item|
diff --git a/public/css/app.css b/public/css/app.css
index e5c8867..e9d751b 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -174,7 +174,7 @@ main > header [popovertarget] { anchor-name: --rk-list-actions; }
   gap: var(--gl-space-s);
 }
 
-/* The rank sits in a fixed gutter; "Unranked" sits at the far end. */
+/* The rank sits in a fixed gutter; the rating (or "Unranked") sits at the far end. */
 .card > li:has(> .type-label:first-child) { align-items: baseline; }
 
 .card > li > .type-label:first-child {
@@ -187,6 +187,16 @@ main > header [popovertarget] { anchor-name: --rk-list-actions; }
   color: var(--gl-color-text-tertiary);
 }
 
+/* The rating never shrinks or wraps, so a long name wraps in its own space. */
+[data-rating] {
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
+/* Lets a name with no break points (a URL) break mid-word instead of
+   overflowing the row, where the card's overflow: hidden clips the rating. */
+.card > li:has(> [data-rating]) { overflow-wrap: anywhere; }
+
 /* -------------------- */
 /* Compare */
 /* -------------------- */
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
index 62373e2..9f60b86 100644
--- a/sig/rank_king/models.rbs
+++ b/sig/rank_king/models.rbs
@@ -34,7 +34,7 @@ module RankKing
     def list: () -> List
     def results: () -> Array[Result]
     def results_dataset: () -> Sequel::Dataset[Result]
-    def ranking: () -> [Array[Item], Array[Item]]
+    def ranking: (?Hash[Integer, OpenSkill::Rating]) -> [Array[Item], Array[Item]]
     def ratings: () -> Hash[Integer, OpenSkill::Rating]
   end
 
diff --git a/test/test_web.rb b/test/test_web.rb
index 4b3ed9d..e469bf8 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -240,6 +240,24 @@ class TestWeb < Minitest::Test
     assert_includes body, "Compare on Taste"
   end
 
+  def test_show_list_shows_ratings_on_ranked_rows_only
+    list, axis = create_list_with_axis(items: "Pizza\nSushi\nTacos")
+    pizza, sushi, = list.items
+    record_result(list, axis, winner: sushi, loser: pizza)
+
+    get "/lists/#{list.id}"
+    body = last_response.body[/role="tabpanel".*/m]
+    assert_equal 2, body.scan(/<span[^>]*data-rating/).size
+    model = OpenSkill::Model.new
+    winner, loser = [sushi, pizza].map { |i| axis.ratings.fetch(i.id) }
+    assert_operator model.ordinal(loser), :<, 0
+    shown = body.scan(%r{<span[^>]*data-rating[^>]*title="([^"]*)"[^>]*>(-?\d+\.\d)</span>})
+    assert_equal [
+      [format("μ %.1f σ %.1f", winner.mu, winner.sigma), format("%.1f", model.ordinal(winner))],
+      [format("μ %.1f σ %.1f", loser.mu, loser.sigma), format("%.1f", model.ordinal(loser))]
+    ], shown
+  end
+
   def test_ranking_orders_by_rating_not_win_count
     list, axis = create_list_with_axis(items: "A\nB\nC")
     a, b, c = list.items.sort_by(&:id)