import: read groups for the pickers without their members
Before: every render of the groups dialog and the import's per-contact
picker called Store#all_groups, which reads each group's lent lines
and every one of its member card ids on top of the group row. The
member read is the one that grows with the book, not the group count:
`sync:*` holds every contact, so listing groups to tick read the
whole address book to show none of it, once per contact on the import
walk.

After: Store#group_choices reads the group row and its label alone —
a new Store::GroupChoice, the group as a picker names it, with no
lines and no members. Both pickers, the filters that guard what they
submit, and the confirm's group lookup take it. The groups dialog no
longer works out which boxes to check from every group's membership;
it is handed the ids the contact is in, which the show screen already
holds from Store#groups_of and so does not read twice.

Store#all_groups stays for the two readers that do invert memberships
— the contact list's chips and the dashboard's search — where the
members are the point.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
change
commit a5f433135a6ff31849c1c52a519e7e67aeeb0d18
author Claude <noreply@anthropic.com>
date
parent 9b672372
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 66b0155..cac5949 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -35,7 +35,7 @@ module ProTacts
       # one: a card with no entries is a card whose history was lost,
       # and an empty default would render that as an ordinary quiet
       # record. Every group is the groups dialog's (GroupDialog).
-      #: (Contact contact, Array[Store::Group] groups, Array[Store::Group] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
+      #: (Contact contact, Array[Store::Group] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
       def initialize(contact:, groups:, all_groups:, changes:, notice: nil)
         @contact = contact
         @groups = groups
@@ -113,8 +113,11 @@ module ProTacts
               end
             end
           end
-          # No row, so outside the record's grid.
-          render GroupDialog.new(contact: @contact, groups: @all_groups)
+          # No row, so outside the record's grid. The groups this
+          # contact is in are the tag row's already (Store#groups_of),
+          # so the dialog is told which boxes to check rather than
+          # reading every group's members to work it out.
+          render GroupDialog.new(contact: @contact, groups: @all_groups, joined: @groups.map(&:id))
         end
       end
 
diff --git a/lib/pro_tacts/admin/group_dialog.rb b/lib/pro_tacts/admin/group_dialog.rb
index 87c45ed..c0882a8 100644
--- a/lib/pro_tacts/admin/group_dialog.rb
+++ b/lib/pro_tacts/admin/group_dialog.rb
@@ -27,18 +27,25 @@ module ProTacts
       ID = "edit-groups" #: String
       FORM = "edit-groups-form" #: String
 
+      # @rbs @contact: Contact
+      # @rbs @groups: Array[Store::GroupChoice]
+      # @rbs @joined: Array[String]
+      # @rbs @capped: Array[String]
+
       # Alphabetical rather than the id order a tag keeps: this is a list
       # to find a name in, and nothing here stays put across a rename.
-      # The groups this contact is in are what the cap spares: those
-      # are the boxes the dialog is opened to untick.
-      #: (contact: Contact, groups: Array[Store::Group]) -> void
-      def initialize(contact:, groups:)
+      # `groups` is every group as a choice — no members read, the
+      # book's whole membership not being this dialog's business
+      # (Store#group_choices) — and `joined` the ids this contact is
+      # already in, which the show screen already holds (Store#groups_of)
+      # and so does not read a second time. `joined` is also what the
+      # cap spares: those are the boxes this is opened to untick.
+      #: (contact: Contact, groups: Array[Store::GroupChoice], joined: Array[String]) -> void
+      def initialize(contact:, groups:, joined:)
         @contact = contact
         @groups = groups.sort_by { it.label.downcase }
-        @capped = GroupFilter.capped(
-          @groups.map(&:id),
-          joined: @groups.select { it.members.include?(contact.id) }.map(&:id),
-        )
+        @joined = joined
+        @capped = GroupFilter.capped(@groups.map(&:id), joined:)
       end
 
       def view_template
@@ -50,7 +57,7 @@ module ProTacts
             form(id: FORM, action: "/contacts/#{@contact.id}/groups", method: "post",
                  class: "field-stack", x_ref: "options") do
               @groups.each do |group|
-                joined = group.members.include?(@contact.id)
+                joined = @joined.include?(group.id)
                 label(**GroupFilter.row(group.label, capped: @capped.include?(group.id))) do
                   input(type: "checkbox", name: "groups[]", value: group.id, checked: joined)
                   render GroupLabel.new(group:)
diff --git a/lib/pro_tacts/admin/group_label.rb b/lib/pro_tacts/admin/group_label.rb
index a63378d..8305fae 100644
--- a/lib/pro_tacts/admin/group_label.rb
+++ b/lib/pro_tacts/admin/group_label.rb
@@ -7,9 +7,11 @@ module ProTacts
     # its id, marked `data-nameless` so admin.css can set it as an
     # identifier rather than let it pass for a name.
     class GroupLabel < Phlex::HTML
-      # @rbs @group: Store::Group
+      # @rbs @group: Store::Group | Store::GroupChoice
 
-      #: (group: Store::Group) -> void
+      # A Group or the lighter GroupChoice a picker carries: this reads
+      # only the name and the id, which both hold.
+      #: (group: Store::Group | Store::GroupChoice) -> void
       def initialize(group:)
         @group = group
       end
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index 274a525..ebd5704 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -30,15 +30,16 @@ module ProTacts
     # abandoned. So it rides as a name until then, and shows as a
     # ticked box like any other so that it can be unticked.
     class ImportGroups < Phlex::HTML
-      # @rbs @groups: Array[Store::Group]
+      # @rbs @groups: Array[Store::GroupChoice]
       # @rbs @joined: Array[String]
       # @rbs @named: Array[String]
 
       # Alphabetical, the groups dialog's own order: this is a list to
       # find a name in, and nothing here stays put across a rename.
-      # `joined` is the ids ticked so far, `named` the groups this
-      # import is making that do not exist yet.
-      #: (groups: Array[Store::Group], joined: Array[String], named: Array[String]) -> void
+      # `groups` is every group as a choice, no members read
+      # (Store#group_choices); `joined` the ids ticked so far, `named`
+      # the groups this import is making that do not exist yet.
+      #: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String]) -> void
       def initialize(groups:, joined:, named:)
         @groups = groups.sort_by { it.label.downcase }
         @joined = joined
diff --git a/lib/pro_tacts/import/write.rb b/lib/pro_tacts/import/write.rb
index 0d8aa9d..c51390c 100644
--- a/lib/pro_tacts/import/write.rb
+++ b/lib/pro_tacts/import/write.rb
@@ -120,10 +120,12 @@ module ProTacts
       # into an empty store creates `sync:*` (Store#everyone_group_id),
       # and a list read before that would send this to create it again.
       # Memoized, so four hundred cards under one group name read the
-      # list once.
+      # list once. The choices read, not the whole groups read: the
+      # match is on name, and a group's members are no part of it
+      # (Store#group_choices).
       #: (String name) -> String
       def group_id(name)
-        @group_ids[name] ||= @store.all_groups.find { it.name == name }&.id || @store.create_group(name:)
+        @group_ids[name] ||= @store.group_choices.find { it.name == name }&.id || @store.create_group(name:)
       end
     end
   end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index c1f2f9f..db5b3ca 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -141,6 +141,17 @@ module ProTacts
       end
     end
 
+    # A group as a picker names it: enough to show it and to submit it,
+    # and no more. A Group carries the lines it lends and the ids of
+    # its members, two reads beyond the group row itself (#load_groups);
+    # a picker is a list of names to tick and needs neither, so
+    # #group_choices reads only the row. The label is the same SQL one
+    # (#group_label), so a choice and a tag never disagree about what
+    # to call a nameless group. Signed in sig/pro_tacts/store.rbs,
+    # being a Data class.
+    # @rbs skip
+    GroupChoice = Data.define(:id, :name, :label)
+
     # SQLite has no ON UPDATE, so the column default stamps a row on
     # insert and this stamps it again on the way past. Same expression as
     # the migration's, deliberately: the database keeps the clock, so two
@@ -663,6 +674,21 @@ module ProTacts
       load_groups(groups.select(*GROUP_COLUMNS, group_label.as(:label)).order(:id).all)
     end
 
+    # Every group as a picker needs it: the row and its label, without
+    # the lines it lends or the members it holds. #all_groups' one
+    # read where a picker would otherwise pay for two, the members read
+    # among them being the one that grows with the book — `sync:*`
+    # holds every contact, so listing groups to tick would read the
+    # whole address book to show none of it (Admin::GroupDialog,
+    # Admin::ImportGroups). Ordered by id like #all_groups; a picker
+    # sorts by label itself.
+    #: () -> Array[GroupChoice]
+    def group_choices
+      groups.select(*GROUP_COLUMNS, group_label.as(:label)).order(:id).all.map { |row|
+        GroupChoice.new(id: row.fetch(:id).to_s, name: row.fetch(:name)&.to_s, label: row.fetch(:label).to_s)
+      }
+    end
+
     # One group, or nil for an id nobody has: the admin's 404 path,
     # #contact's own shape.
     #: (String id) -> Group?
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 36ede4b..f318e70 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -199,7 +199,7 @@ module ProTacts
       contact = store.contact(id)
       return if contact.nil?
 
-      known = store.all_groups.map(&:id)
+      known = store.group_choices.map(&:id)
       checked = ids_in(r.params["groups"]) & known
       was = ids_in(r.params["was"]) & known
       name = r.params["new"].to_s.strip
@@ -225,7 +225,7 @@ module ProTacts
       Admin::ContactsShow.call(
         contact:,
         groups: store.groups_of(contact.id),
-        all_groups: store.all_groups,
+        all_groups: store.group_choices,
         changes: store.changes_of(contact.id),
         notice:,
       )
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 52d8dc5..180951a 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -119,7 +119,7 @@ module ProTacts
       # By name, because the row is read rather than submitted: a
       # group the walk ticked and someone then deleted is simply not
       # among these.
-      names = store.all_groups.to_h { |group|
+      names = store.group_choices.to_h { |group|
         # A pair is a two-element array until something says
         # otherwise, and an inline annotation needs its own line.
         [group.id, group.label] #: [String, String]
@@ -245,7 +245,7 @@ module ProTacts
       # typed into the new-group box joins the names already standing
       # for this contact, and none of them is made until the confirm.
       chosen, named = Import::Staged.groups(upload)
-      ticked = ids_in(r.params["groups"]) & store.all_groups.map(&:id)
+      ticked = ids_in(r.params["groups"]) & store.group_choices.map(&:id)
       standing = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
       fresh = r.params["new"].to_s.strip
       Import::Staged.update_groups(
@@ -269,7 +269,7 @@ module ProTacts
       # (#save_card): a group can be deleted between the tick and the
       # confirm, and Store#add_member reads a group with `sole`, so a
       # stale id would be a 500 rather than the nothing it means.
-      known = store.all_groups.map(&:id)
+      known = store.group_choices.map(&:id)
       chosen, named = Import::Staged.groups(upload)
       joins = revised.each_index.map { chosen.fetch(it.to_s, []) & known }
       makes = revised.each_index.map { named.fetch(it.to_s, []) }
@@ -287,7 +287,7 @@ module ProTacts
     def import_groups(upload, index)
       chosen, named = Import::Staged.groups(upload)
       Admin::ImportGroups.new(
-        groups: store.all_groups,
+        groups: store.group_choices,
         joined: chosen.fetch(index.to_s, []),
         named: named.fetch(index.to_s, []),
       )
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 77667cd..b4660ba 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -7,7 +7,7 @@
 module ProTacts
   module Admin
     class ContactsShow
-      def self.call: (contact: Contact, groups: Array[Store::Group], all_groups: Array[Store::Group], changes: Array[Store::Change], ?notice: String?) -> String
+      def self.call: (contact: Contact, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
     end
 
     # `aside` is a Phlex component, which has no type here: phlex
@@ -56,7 +56,7 @@ module ProTacts
     end
 
     class ImportGroups
-      def self.new: (groups: Array[::ProTacts::Store::Group], joined: Array[String], named: Array[String]) -> instance
+      def self.new: (groups: Array[::ProTacts::Store::GroupChoice], joined: Array[String], named: Array[String]) -> instance
     end
 
     # Rendered by Dashboard, not called from checked code; only
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 0b42bb8..7263602 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -46,6 +46,14 @@ module ProTacts
       def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String]) -> instance
     end
 
+    class GroupChoice
+      attr_reader id: String
+      attr_reader name: String?
+      attr_reader label: String
+
+      def self.new: (id: String, name: String?, label: String) -> instance
+    end
+
     class Snapshot
       attr_reader cards: Hash[String, String]
       attr_reader birthdays: Hash[String, Birthday]
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 3bbdaee..6702603 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1344,6 +1344,23 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # The pickers' read: id, name and the same coalesced label a tag
+  # shows, and nothing about who is in a group — a nameless group is
+  # its id here as everywhere.
+  def test_group_choices_carry_the_label_without_the_membership
+    with_store do |store|
+      named = store.create_group(name: "Household")
+      nameless = store.create_group
+
+      choices = store.group_choices
+
+      assert_equal({named => ["Household", "Household"], nameless => [nil, nameless]}.sort.to_h,
+                   choices.to_h { [it.id, [it.name, it.label]] })
+      refute choices.first.respond_to?(:members),
+             "a choice is the group row alone, with no membership read behind it"
+    end
+  end
+
   # What a group lends is what every member serves, so setting it logs
   # a `group` entry on each (docs/plans/2026-09-09-group-edits-propagate.md,
   # "The fan-out").