group page: list its members and the cards its log names, not the book
GET /groups/:id listed every contact so its change log could name the
cards a join or a leave moved, then picked the members out with
Array#include? per contact. A four-person household took 80 ms on
1,000 cards. It now lists the members and the cards the log names
(GroupsShow.moved), and looks both up by hash: 12 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZeaKvJp7q4WEwoCPqVkZb
change
commit 5ef0c17ef005975fd1d747fe6fd4640df544324a
author Claude <noreply@anthropic.com>
date
parent 6b9b7e80
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 828dcb0..52f0297 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -16,26 +16,38 @@ module ProTacts
     # The members are what a group is, so they are a list rather than a
     # row: /contacts' rows, narrowed to this group and in its order
     # (Contact#<=>). Under them the group's history, the change
-    # log the contact page renders in this page's own shape. The whole
-    # book comes in beside the members (Store#contacts) because that
-    # log names the cards a join or a leave moved, and a name is what
-    # a person reads; the members list is the narrowing of it.
+    # log the contact page renders in this page's own shape. The cards
+    # that log names (.moved) come in beside the members, because a
+    # join or a leave is read by the name of who moved, and one who
+    # left is no member; the members list is the narrowing of them.
+    # Those and no more, rather than the whole book: a household's
+    # page built every card to show four.
     class GroupsShow < Phlex::HTML
       # @rbs @group: Group
       # @rbs @login: String
       # @rbs @reading: Contact
-      # @rbs @contacts: Array[ListedContact]
       # @rbs @members: Array[ListedContact]
+      # @rbs @named: Hash[String, ListedContact]
       # @rbs @changes: Array[Store::GroupChange]
 
       #: (group: Group, contacts: Array[ListedContact], changes: Array[Store::GroupChange], login: String) -> void
       def initialize(group:, contacts:, changes:, login:)
         @group = group
         @login = login
-        @contacts = contacts
         @changes = changes
         @reading = group.reading
-        @members = contacts.select { group.members.include?(it.id) }.sort
+        joined = group.members.to_set
+        @members = contacts.select { joined.include?(it.id) }.sort
+        @named = contacts.to_h { [it.id, it] }
+      end
+
+      # The cards a join or a leave in these changes moved: who the log
+      # names, beside the members, in the contacts this page is handed.
+      #: (Array[Store::GroupChange] changes) -> Array[String]
+      def self.moved(changes)
+        changes.filter_map {
+          it.detail.fetch("card").to_s if [Store::GroupAction::JOIN, Store::GroupAction::LEAVE].include?(it.action)
+        }
       end
 
       def view_template
@@ -193,7 +205,7 @@ module ProTacts
       #: (Hash[String, untyped] detail) -> void
       def member_link(detail)
         id = detail.fetch("card").to_s
-        contact = @contacts.find { it.id == id }
+        contact = @named[id]
         if contact
           a(href: "/contacts/#{id}") { Format.name_label(contact) }
         else
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 8c16d07..70b7298 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -82,11 +82,12 @@ module ProTacts
 
           if group
             response["Content-Type"] = "text/html; charset=utf-8"
+            changes = store.group_changes_of(id)
             Admin::GroupsShow.call(
               group:,
               login: @login,
-              contacts: store.listed_contacts,
-              changes: store.group_changes_of(id),
+              contacts: store.listed_contacts((group.members + Admin::GroupsShow.moved(changes)).uniq),
+              changes:,
             )
           end
         end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 7b5dbe1..ec52973 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -42,6 +42,7 @@ module ProTacts
 
     class GroupsShow
       def self.call: (group: Group, contacts: Array[ListedContact], changes: Array[Store::GroupChange], login: String) -> String
+      def self.moved: (Array[Store::GroupChange] changes) -> Array[String]
     end
 
     # Rendered by the views that tag groups; the routes read how many