listed group: a Group subclass carrying its listed members
Group goes back to main's shape, with no optional listed_members.
The reads that tag groups (Store#listed_groups_of, #all_listed_groups)
return a ListedGroup, so a tag's type says its members are there and
GroupTag no longer checks.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191V6gdWnYUcU5DvJBAWoMj
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 8131250..f387c4d 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -31,10 +31,10 @@ module ProTacts
# save lands on the group it made rather than back on the walk.
class ContactCard < Phlex::HTML
# @rbs @contact: Contact
- # @rbs @groups: Array[Group]
+ # @rbs @groups: Array[ListedGroup]
# @rbs @walk: bool
- #: (contact: Contact, groups: Array[Group], ?walk: bool) -> void
+ #: (contact: Contact, groups: Array[ListedGroup], ?walk: bool) -> void
def initialize(contact:, groups:, walk: false)
@contact = contact
@groups = groups
@@ -188,7 +188,7 @@ module ProTacts
end
# A lent row's group as the groups row read it, with its members.
- #: (Group lent) -> Group
+ #: (Group lent) -> ListedGroup
def listed(lent) = @groups.find { it.id == lent.id } || raise(ArgumentError, "group #{lent.id} lends a row but is not among the contact's groups")
#: (String | Array[String] value) -> void
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index ee9979e..c06c123 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -20,9 +20,9 @@ module ProTacts
class ContactsIndex < Phlex::HTML
# @rbs @rows: Array[ListedContact]
# @rbs @login: String
- # @rbs @groups_of: Hash[String, Array[Group]]
+ # @rbs @groups_of: Hash[String, Array[ListedGroup]]
- #: (contacts: Array[ListedContact], groups: Array[Group], login: String) -> void
+ #: (contacts: Array[ListedContact], groups: Array[ListedGroup], login: String) -> void
def initialize(contacts:, groups:, login:)
@login = login
@rows = contacts.sort
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 718ab60..dbaf51c 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -18,7 +18,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, String login, Array[Group] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
+ #: (Contact contact, String login, Array[ListedGroup] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
def initialize(contact:, login:, groups:, all_groups:, changes:, notice: nil)
@contact = contact
@login = login
diff --git a/lib/pro_tacts/admin/group_tag.rb b/lib/pro_tacts/admin/group_tag.rb
index f877335..509b3f6 100644
--- a/lib/pro_tacts/admin/group_tag.rb
+++ b/lib/pro_tacts/admin/group_tag.rb
@@ -12,17 +12,15 @@ module ProTacts
class GroupTag < Phlex::HTML
SHOWN = 5 #: Integer
- # @rbs @group: Group
- # @rbs @members: Array[ListedContact]
+ # @rbs @group: ListedGroup
- #: (group: Group) -> void
+ #: (group: ListedGroup) -> void
def initialize(group:)
@group = group
- @members = group.listed_members || raise(ArgumentError, "group #{group.id} was read without its members listed")
end
def view_template
- shown, rest = @members.partition.with_index { |_, index| index < SHOWN }
+ shown, rest = @group.listed_members.partition.with_index { |_, index| index < SHOWN }
span do
a(href: "/groups/#{@group.id}", class: "tag") do
render Icon.new(:image, size: 12) if @group.photo
diff --git a/lib/pro_tacts/admin/import_saved.rb b/lib/pro_tacts/admin/import_saved.rb
index 3082f03..282ae1b 100644
--- a/lib/pro_tacts/admin/import_saved.rb
+++ b/lib/pro_tacts/admin/import_saved.rb
@@ -19,7 +19,7 @@ module ProTacts
class ImportSaved < Phlex::HTML
# @rbs @contact: Contact
# @rbs @login: String
- # @rbs @groups: Array[Group]
+ # @rbs @groups: Array[ListedGroup]
# @rbs @all_groups: Array[Store::GroupChoice]
# @rbs @aside: Phlex::HTML
# @rbs @sidebar: Phlex::HTML
@@ -27,7 +27,7 @@ module ProTacts
# @rbs @land: String
# @rbs @notice: String?
- #: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, land: String, ?notice: String?) -> void
+ #: (contact: Contact, login: String, groups: Array[ListedGroup], all_groups: Array[Store::GroupChoice], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, land: String, ?notice: String?) -> void
def initialize(contact:, login:, groups:, all_groups:, aside:, sidebar:, edit:, land:, notice: nil)
@contact = contact
@login = login
diff --git a/lib/pro_tacts/group.rb b/lib/pro_tacts/group.rb
index d55df22..2b4116c 100644
--- a/lib/pro_tacts/group.rb
+++ b/lib/pro_tacts/group.rb
@@ -8,22 +8,21 @@ module ProTacts
# A group as the admin screens read one: its own row, the lines it
# lends in the order it lends them, its members' card ids, and
# whether it has a photo (docs/plans/2026-10-12-group-photos.md).
- # Only the reads that ask fill in `listed_members`, so a group
- # composed into a card for a client reads none. The label is the SQL one Store#group_label computes, so a tag and a
+ # The label is the SQL one Store#group_label computes, so a tag and a
# heading can never disagree about what to call a nameless group.
#
# The signature lives in sig/pro_tacts/group.rbs, this being a Data
# class.
# @rbs skip
- Group = Data.define(:id, :name, :label, :lines, :members, :photo, :listed_members)
+ Group = Data.define(:id, :name, :label, :lines, :members, :photo)
# Reopened rather than defined in the block above, for the reason
# CardDiff is.
class Group
# A photo is the exception, not the rule, so a group built without
# saying is one without.
- #: (?photo: bool, ?listed_members: Array[ListedContact]?, **untyped) -> void
- def initialize(photo: false, listed_members: nil, **)
+ #: (?photo: bool, **untyped) -> void
+ def initialize(photo: false, **)
super
end
diff --git a/lib/pro_tacts/listed_group.rb b/lib/pro_tacts/listed_group.rb
new file mode 100644
index 0000000..0e984ff
--- /dev/null
+++ b/lib/pro_tacts/listed_group.rb
@@ -0,0 +1,19 @@
+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.
+ #
+ # 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
+ super(**)
+ end
+
+ attr_reader :listed_members
+ end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 141b3dc..da09d3a 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -12,6 +12,7 @@ require "pro_tacts/contact"
require "pro_tacts/edited_contact"
require "pro_tacts/group"
require "pro_tacts/listed_contact"
+require "pro_tacts/listed_group"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -360,17 +361,20 @@ module ProTacts
# rather than on it: what a group lends is in the card and belongs
# to the model of one, and which groups a contact is in is a
# relationship the card never carries.
- #: (String id, ?listed_members: bool) -> Array[Group]
- def groups_of(id, listed_members: false)
- rows = labeled_groups
+ #: (String id) -> Array[Group]
+ def groups_of(id)
+ load_groups(labeled_groups
.join(:group_members, group_id: :id)
.where(card_id: id)
.order(Sequel[:groups][:id])
- .all
- return load_groups(rows) unless listed_members
+ .all)
+ end
- members = group_members.where(group_id: rows.map { it.fetch(:id) }).select_map(:card_id).map(&:to_s).uniq
- load_groups(rows, listing: listed_contacts(members))
+ # #groups_of with each group's members listed, for a contact's tags.
+ #: (String id) -> Array[ListedGroup]
+ def listed_groups_of(id)
+ groups = groups_of(id)
+ list_members(groups, listed_contacts(groups.flat_map(&:members).uniq))
end
# The cards one user's client syncs: every member of `sync:*` and of
@@ -663,12 +667,18 @@ module ProTacts
end
# Every group, ordered by id — the order #groups_of and the
- # composition both use, so a rename never moves one. A `listing`
- # the caller already read lists each group's members out of it,
- # since `sync:*` would otherwise read the whole book again.
- #: (?listing: Array[ListedContact]?) -> Array[Group]
- def all_groups(listing: nil)
- load_groups(labeled_groups.order(:id).all, listing:)
+ # composition both use, so a rename never moves one.
+ #: () -> Array[Group]
+ def all_groups
+ 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)
end
# Every group as a picker needs it: the row, its label and how many
@@ -1128,29 +1138,39 @@ module ProTacts
# Groups rows read whole: the lines and members of every group in
# `rows`, and which have a photo, three reads however many groups
- # there are. The photo read takes ids, never bytes. A `listing` is
- # sorted once here rather than by every tag that shows a group.
- #: (Array[Hash[Symbol, untyped]] rows, ?listing: Array[ListedContact]?) -> Array[Group]
- def load_groups(rows, listing: nil)
+ # there are. The photo read takes ids, never bytes.
+ #: (Array[Hash[Symbol, untyped]] rows) -> Array[Group]
+ def load_groups(rows)
ids = rows.map { it.fetch(:id).to_s }
lines = group_properties.where(group_id: ids).order(:group_id, :position).all
.group_by { it.fetch(:group_id).to_s }
members = group_members.where(group_id: ids).order(:card_id).all
.group_by { it.fetch(:group_id).to_s }
photos = group_photos.where(group_id: ids).select_map(:group_id).map(&:to_s)
- sorted = listing&.sort
rows.map { |row|
id = row.fetch(:id).to_s
- member_ids = members.fetch(id, []).map { it.fetch(:card_id).to_s }
- joined = member_ids.to_set
Group.new(
id:,
name: row.fetch(:name)&.to_s,
label: row.fetch(:label).to_s,
lines: lines.fetch(id, []).map { it.fetch(:line).to_s },
- members: member_ids,
+ members: members.fetch(id, []).map { it.fetch(:card_id).to_s },
photo: photos.include?(id),
- listed_members: sorted&.select { joined.include?(it.id) },
+ )
+ }
+ 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) },
)
}
end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 46dbaf1..91c61e0 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -25,7 +25,7 @@ module ProTacts
r.get do
response["Content-Type"] = "text/html; charset=utf-8"
contacts = store.listed_contacts
- Admin::ContactsIndex.call(contacts:, groups: store.all_groups(listing: contacts), login: @login)
+ Admin::ContactsIndex.call(contacts:, groups: store.all_listed_groups(contacts), login: @login)
end
# The browser's create, from the dashboard's dialog: POST is
@@ -296,7 +296,7 @@ module ProTacts
Admin::ContactsShow.call(
contact:,
login: @login,
- groups: store.groups_of(contact.id, listed_members: true),
+ groups: store.listed_groups_of(contact.id),
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 8d5f38f..45deeba 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -215,7 +215,7 @@ module ProTacts
Admin::ImportSaved.call(
contact:,
login: @login,
- groups: store.groups_of(contact.id, listed_members: true),
+ groups: store.listed_groups_of(contact.id),
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 9f3199c..2b5a1c4 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, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
+ def self.call: (contact: Contact, login: String, groups: Array[ListedGroup], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
end
# `aside` is a Phlex component, which has no type here: phlex
@@ -33,7 +33,7 @@ module ProTacts
end
class ContactsIndex
- def self.call: (contacts: Array[ListedContact], groups: Array[Group], login: String) -> String
+ def self.call: (contacts: Array[ListedContact], groups: Array[ListedGroup], login: String) -> String
end
class GroupsIndex
@@ -83,7 +83,7 @@ module ProTacts
end
class ImportSaved
- def self.call: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
+ def self.call: (contact: Contact, login: String, groups: Array[ListedGroup], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
end
# Its name_label is the import's list rows' (Web#import_row), and
diff --git a/sig/pro_tacts/group.rbs b/sig/pro_tacts/group.rbs
index 8cb6d8a..7c5ebc1 100644
--- a/sig/pro_tacts/group.rbs
+++ b/sig/pro_tacts/group.rbs
@@ -10,8 +10,7 @@ module ProTacts
attr_reader lines: Array[String]
attr_reader members: Array[String]
attr_reader photo: bool
- attr_reader listed_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) -> instance
end
end
diff --git a/sig/pro_tacts/listed_group.rbs b/sig/pro_tacts/listed_group.rbs
new file mode 100644
index 0000000..3c3ac58
--- /dev/null
+++ b/sig/pro_tacts/listed_group.rbs
@@ -0,0 +1,11 @@
+module ProTacts
+ class ListedGroup < Group
+ @listed_members: Array[ListedContact]
+
+ attr_reader listed_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 initialize: (listed_members: Array[ListedContact], **untyped) -> void
+ end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 5ec0610..506a2b5 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1376,10 +1376,10 @@ 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.groups_of("znorth", listed_members: true).map { it.listed_members.to_a.map(&:id) }
- assert_equal [%w[aiden znorth]], store.all_groups(listing: store.listed_contacts).map { it.listed_members.to_a.map(&:id) }
- assert_nil store.groups_of("znorth").fetch(0).listed_members
- assert_nil store.all_groups.fetch(0).listed_members
+ 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_instance_of ProTacts::Group, store.groups_of("znorth").fetch(0)
+ assert_instance_of ProTacts::Group, store.all_groups.fetch(0)
end
end