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
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