group tag: list a group's first five off a sort-key index
A contact's page listed every member of every group it is in, so its
tag could name the first five. Every card is in sync:*, so every
contact page built the whole book: 190 ms on 1,000 cards.

Migration 015 adds sort_keys, each card's Contact#sort_key, written by
Store#reindex beside its search key. Store#listed_groups_of takes the
first few members of each group off it in SQL and lists only those.
ListedGroup carries them as first_members, and GroupTag counts the
rest off #members. /contacts, which has the whole listing in hand
already, takes the same first few from it.

The contact page went from 190 ms to 20 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZeaKvJp7q4WEwoCPqVkZb
change
commit 6b9b7e80ebb870b22b69e6b10fd3ab0184c55291
author Claude <noreply@anthropic.com>
date
parent f7706b96
diff --git a/db/migrations/015_sort_keys.rb b/db/migrations/015_sort_keys.rb
new file mode 100644
index 0000000..3b25498
--- /dev/null
+++ b/db/migrations/015_sort_keys.rb
@@ -0,0 +1,31 @@
+# Each card's place in a list (Contact#sort_key), so a group's first
+# few members by that order are a query rather than the whole group
+# built and sorted to take them from — `sync:*` holds every card, and
+# a contact's tag for it names five
+# (docs/plans/2026-10-22-perceived-speed.md). Index, like
+# search_keys: filled through the app's own Contact#sort_key, since
+# what it must agree with is the order a list sorts in. A group lends
+# no name, so the stored card alone decides it.
+require "pro_tacts/contact"
+require "pro_tacts/vcard"
+
+Sequel.migration do
+  up do
+    create_table(:sort_keys, strict: true) do
+      foreign_key :card_id, :cards, type: String, text: true, primary_key: true, null: false, on_delete: :cascade
+      String :sort_key, text: true, null: false
+
+      index [:sort_key, :card_id]
+    end
+
+    self[:cards].order(:id).each do |row|
+      id = row.fetch(:id)
+      contact = ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(row.fetch(:vcard)), birthday: nil, inherited: [])
+      self[:sort_keys].insert(card_id: id, sort_key: contact.sort_key)
+    end
+  end
+
+  down do
+    drop_table(:sort_keys)
+  end
+end
diff --git a/lib/pro_tacts/admin/group_tag.rb b/lib/pro_tacts/admin/group_tag.rb
index 7b3132c..c644ca8 100644
--- a/lib/pro_tacts/admin/group_tag.rb
+++ b/lib/pro_tacts/admin/group_tag.rb
@@ -20,9 +20,8 @@ module ProTacts
       end
 
       def view_template
-        members = @group.listed_members
-        # A slice starting past the end is nil rather than empty.
-        shown, rest = members[...SHOWN], members[SHOWN..].to_a
+        shown = @group.first_members
+        rest = @group.members.length - shown.length
         span do
           a(href: "/groups/#{@group.id}", class: "tag") do
             render Icon.new(:image, size: 12) if @group.photo
@@ -32,7 +31,7 @@ module ProTacts
           # Not a ul, which Gloss's .card-body ul sets as prose.
           ol(role: "tooltip") do
             shown.each { |contact| li { Format.name_label(contact) } }
-            li { "and #{rest.length} more" } if rest.any?
+            li { "and #{rest} more" } if rest.positive?
           end
         end
       end
diff --git a/lib/pro_tacts/listed_group.rb b/lib/pro_tacts/listed_group.rb
index 0e984ff..858b73d 100644
--- a/lib/pro_tacts/listed_group.rb
+++ b/lib/pro_tacts/listed_group.rb
@@ -2,18 +2,20 @@ require "pro_tacts/group"
 require "pro_tacts/listed_contact"
 
 module ProTacts
-  # A group read with its members as a list shows them, in Contact#<=>'s
-  # order, for the screens that tag groups (Admin::GroupTag). A group
-  # composed into a card for a client is a plain Group, and reads none.
+  # A group read with its first few members as a list shows them, in
+  # Contact#<=>'s order, for the screens that tag groups
+  # (Admin::GroupTag): the tag names those and counts the rest off
+  # #members. A group composed into a card for a client is a plain
+  # Group, and reads none.
   #
   # Signed in sig/pro_tacts/listed_group.rbs, for Group's reason.
   # @rbs skip
   class ListedGroup < Group
-    def initialize(listed_members:, **)
-      @listed_members = listed_members
+    def initialize(first_members:, **)
+      @first_members = first_members
       super(**)
     end
 
-    attr_reader :listed_members
+    attr_reader :first_members
   end
 end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index d506a71..aaa8ed5 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -370,11 +370,24 @@ module ProTacts
         .all)
     end
 
-    # #groups_of with each group's members listed, for a contact's tags.
-    #: (String id) -> Array[ListedGroup]
-    def listed_groups_of(id)
+    # #groups_of with each group's first few members listed, for a
+    # contact's tags: the `first` of them in Contact#<=>'s order, read
+    # off the sort keys (#reindex), so the tag for `sync:*` lists five
+    # contacts rather than the whole book to take five from. The order
+    # agrees with a list's because the keys are Contact#sort_key and
+    # SQLite compares text bytewise, as Ruby does, with the id behind
+    # it for a tie.
+    #: (String id, first: Integer) -> Array[ListedGroup]
+    def listed_groups_of(id, first:)
       groups = groups_of(id)
-      list_members(groups, listed_contacts(groups.flat_map(&:members).uniq))
+      member = Sequel[:group_members][:card_id]
+      firsts = groups.to_h { |group|
+        ids = group_members.join(:sort_keys, card_id: :card_id).where(group_id: group.id)
+          .order(:sort_key, member).limit(first).select_map(member).map(&:to_s)
+        [group.id, ids] #: [String, Array[String]]
+      }
+      listed = listed_by_id(firsts.values.flatten.uniq)
+      groups.map { listed_group(it, firsts.fetch(it.id).map { listed.fetch(it) }) }
     end
 
     # The cards one user's client syncs: every member of `sync:*` and of
@@ -605,6 +618,7 @@ module ProTacts
             # parameters follow their properties away on the cascade.
             card_properties.where(card_id: id).delete
             search_keys.where(card_id: id).delete
+            sort_keys.where(card_id: id).delete
             after = contact!(new_id)
             record(id, action: Action::DELETE, etag: nil, diff: CardDiff.between(before.vcard, nil))
             record(
@@ -673,12 +687,16 @@ module ProTacts
       load_groups(labeled_groups.order(:id).all)
     end
 
-    # #all_groups with each group's members listed out of a `listing`
-    # the caller already read, since `sync:*` would otherwise read the
-    # whole book again.
-    #: (Array[ListedContact] listing) -> Array[ListedGroup]
-    def all_listed_groups(listing)
-      list_members(all_groups, listing)
+    # #all_groups with each group's first few members listed out of a
+    # `listing` the caller already read, since `sync:*` would otherwise
+    # read the whole book again.
+    #: (Array[ListedContact] listing, first: Integer) -> Array[ListedGroup]
+    def all_listed_groups(listing, first:)
+      sorted = listing.sort
+      all_groups.map { |group|
+        joined = group.members.to_set
+        listed_group(group, sorted.lazy.select { joined.include?(it.id) }.first(first))
+      }
     end
 
     # Every group as a picker needs it: the row, its label and how many
@@ -944,6 +962,7 @@ module ProTacts
       @database.transaction do
         card_properties.delete
         search_keys.delete
+        sort_keys.delete
         cards.order(:id).all.each do
           reindex(it.fetch(:id).to_s, VCard.new(it.fetch(:vcard).to_s))
         end
@@ -972,6 +991,11 @@ module ProTacts
       @database[:search_keys]
     end
 
+    #: () -> Sequel::Dataset
+    def sort_keys
+      @database[:sort_keys]
+    end
+
     #: () -> Sequel::Dataset
     def card_parameters
       @database[:card_parameters]
@@ -1160,19 +1184,12 @@ module ProTacts
       }
     end
 
-    # The listing is sorted once here rather than by every tag that
-    # shows a group.
-    #: (Array[Group] groups, Array[ListedContact] listing) -> Array[ListedGroup]
-    def list_members(groups, listing)
-      sorted = listing.sort
-      groups.map { |group|
-        joined = group.members.to_set
-        ListedGroup.new(
-          id: group.id, name: group.name, label: group.label, lines: group.lines,
-          members: group.members, photo: group.photo,
-          listed_members: sorted.select { joined.include?(it.id) },
-        )
-      }
+    #: (Group group, Array[ListedContact] first_members) -> ListedGroup
+    def listed_group(group, first_members)
+      ListedGroup.new(
+        id: group.id, name: group.name, label: group.label, lines: group.lines,
+        members: group.members, photo: group.photo, first_members:,
+      )
     end
 
     # The id of the `sync:*` group, created on first use. One row
@@ -1237,6 +1254,7 @@ module ProTacts
       # not keep the rows from when it did.
       card_properties.where(card_id: id).delete
       search_keys.where(card_id: id).delete
+      sort_keys.where(card_id: id).delete
 
       vcard.properties.each.with_index do |property, position|
         card_properties.insert(
@@ -1250,8 +1268,9 @@ module ProTacts
           card_parameters.insert(card_id: id, position:, name:, value:)
         end
       end
-      key = Admin::Search.key(Contact.new(id:, stored: vcard, birthday: nil, inherited: []))
-      search_keys.insert(card_id: id, key: key.to_json)
+      contact = Contact.new(id:, stored: vcard, birthday: nil, inherited: [])
+      search_keys.insert(card_id: id, key: Admin::Search.key(contact).to_json)
+      sort_keys.insert(card_id: id, sort_key: contact.sort_key)
     end
 
     # Applies whatever migrations the database has not seen. On one it is
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index a742bd6..e097586 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -6,6 +6,7 @@ require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/contacts_index"
 require "pro_tacts/admin/contacts_show"
 require "pro_tacts/admin/dashboard"
+require "pro_tacts/admin/group_tag"
 require "pro_tacts/admin/groups_new"
 require "pro_tacts/change_id"
 
@@ -27,7 +28,8 @@ module ProTacts
         r.get do
           response["Content-Type"] = "text/html; charset=utf-8"
           contacts = store.listed_contacts
-          Admin::ContactsIndex.call(contacts:, groups: store.all_listed_groups(contacts), login: @login)
+          groups = store.all_listed_groups(contacts, first: Admin::GroupTag::SHOWN)
+          Admin::ContactsIndex.call(contacts:, groups:, login: @login)
         end
 
         # The browser's create, from the dashboard's dialog: POST is
@@ -311,7 +313,7 @@ module ProTacts
       Admin::ContactsShow.call(
         contact:,
         login: @login,
-        groups: store.listed_groups_of(contact.id),
+        groups: store.listed_groups_of(contact.id, first: Admin::GroupTag::SHOWN),
         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 45deeba..cd25512 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -1,6 +1,7 @@
 require "pro_tacts/admin/card_form"
 require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_tag"
 require "pro_tacts/admin/import_groups"
 require "pro_tacts/admin/import_original"
 require "pro_tacts/admin/import_saved"
@@ -215,7 +216,7 @@ module ProTacts
         Admin::ImportSaved.call(
           contact:,
           login: @login,
-          groups: store.listed_groups_of(contact.id),
+          groups: store.listed_groups_of(contact.id, first: Admin::GroupTag::SHOWN),
           all_groups: store.group_choices,
           aside: Admin::ImportOriginal.new(card: original, dropped:),
           sidebar:,
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 2b5a1c4..7b5dbe1 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -44,6 +44,12 @@ module ProTacts
       def self.call: (group: Group, contacts: Array[ListedContact], changes: Array[Store::GroupChange], login: String) -> String
     end
 
+    # Rendered by the views that tag groups; the routes read how many
+    # members a tag names, to list that many.
+    class GroupTag
+      SHOWN: Integer
+    end
+
     class GroupsEdit
       def self.call: (group: Group, login: String, ?notice: String?) -> String
     end
diff --git a/sig/pro_tacts/listed_group.rbs b/sig/pro_tacts/listed_group.rbs
index 3c3ac58..0ed6656 100644
--- a/sig/pro_tacts/listed_group.rbs
+++ b/sig/pro_tacts/listed_group.rbs
@@ -1,11 +1,11 @@
 module ProTacts
   class ListedGroup < Group
-    @listed_members: Array[ListedContact]
+    @first_members: Array[ListedContact]
 
-    attr_reader listed_members: Array[ListedContact]
+    attr_reader first_members: Array[ListedContact]
 
-    def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool, listed_members: Array[ListedContact]) -> instance
+    def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool, first_members: Array[ListedContact]) -> instance
 
-    def initialize: (listed_members: Array[ListedContact], **untyped) -> void
+    def initialize: (first_members: Array[ListedContact], **untyped) -> void
   end
 end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 506a2b5..4249a1e 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1376,13 +1376,51 @@ class StoreTest < Minitest::Test
     with_store({"znorth" => ZED, "aiden" => AIDEN}) do |store|
       FixtureData.seed_group(store, name: "Booles", members: ["znorth", "aiden"])
 
-      assert_equal [%w[aiden znorth]], store.listed_groups_of("znorth").map { it.listed_members.map(&:id) }
-      assert_equal [%w[aiden znorth]], store.all_listed_groups(store.listed_contacts).map { it.listed_members.map(&:id) }
+      assert_equal [%w[aiden znorth]], store.listed_groups_of("znorth", first: 5).map { it.first_members.map(&:id) }
+      assert_equal [%w[aiden znorth]], store.all_listed_groups(store.listed_contacts, first: 5).map { it.first_members.map(&:id) }
       assert_instance_of ProTacts::Group, store.groups_of("znorth").fetch(0)
       assert_instance_of ProTacts::Group, store.all_groups.fetch(0)
     end
   end
 
+  # A tag lists only a group's first few, the contact's read taking
+  # them off the sort keys and the whole list's off the listing it
+  # sorted: the two agree with each other and with Contact#<=> — a
+  # family name first, FN for a card with no N, the id behind both —
+  # and every member is still counted.
+  def test_a_group_read_for_a_tag_lists_its_first_few_in_a_lists_order
+    cards = {
+      "znorth" => ZED,
+      "aiden" => AIDEN,
+      "boole" => "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Boole;Mary;;;\r\nFN:Mary Boole\r\nUID:boole\r\nEND:VCARD\r\n",
+      "boole2" => "BEGIN:VCARD\r\nVERSION:3.0\r\nN:boole;George;;;\r\nFN:George Boole\r\nUID:boole2\r\nEND:VCARD\r\n",
+    }
+    with_store(cards) do |store|
+      FixtureData.seed_group(store, name: "Everyone", members: cards.keys)
+      expected = store.listed_contacts.sort.first(3).map(&:id)
+
+      assert_equal %w[aiden boole boole2], expected
+      [store.listed_groups_of("znorth", first: 3), store.all_listed_groups(store.listed_contacts, first: 3)].each do |groups|
+        assert_equal 1, groups.length
+        group = groups.fetch(0)
+        assert_equal expected, group.first_members.map(&:id)
+        assert_equal 4, group.members.length
+      end
+    end
+  end
+
+  # A card's sort key follows its name: a rename moves it in a tag's
+  # first few as it moves in a list.
+  def test_a_rename_moves_a_card_among_a_groups_first_few
+    with_store({"znorth" => ZED, "aiden" => AIDEN}) do |store|
+      FixtureData.seed_group(store, name: "Booles", members: %w[znorth aiden])
+
+      store.put("znorth", ProTacts::VCard.new(ZED.sub("FN:Zed", "FN:Abe")))
+
+      assert_equal %w[znorth], store.listed_groups_of("aiden", first: 1).fetch(0).first_members.map(&:id)
+    end
+  end
+
   def test_a_group_inspects_to_its_label_and_member_count
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
       id = FixtureData.seed_group(store, name: "Household", members: %w[znorth aiden])
@@ -2719,6 +2757,7 @@ class StoreTest < Minitest::Test
       database(store)[:card_properties].order(:card_id, :position).all,
       database(store)[:card_parameters].order(:card_id, :position, :name, :value).all,
       database(store)[:search_keys].order(:card_id).all,
+      database(store)[:sort_keys].order(:card_id).all,
     ]
   end
 
@@ -2733,5 +2772,6 @@ class StoreTest < Minitest::Test
     database(store)[:card_parameters].delete
     database(store)[:card_properties].update(value: "wrong")
     database(store)[:search_keys].delete
+    database(store)[:sort_keys].delete
   end
 end