admin: a contact's card shows the groups it is in
The card said what a group lent but never said whose crew;
the tag row leads the grid now, and the nameless ones show through.
The household lost a word — plain "Booles" is what it answers to;
one helper spells the label, so a tag and badge stay true.

Assisted-by: Claude Opus 5 via Claude Code
change lxqvyxtkoztqqwqyztquvqszwvwnkmro
commit a737f8742a737ad9535041c2e354ffdceeda6bac
author Alpha Chen <alpha@kejadlen.dev>
date
parent upxsvvnr
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index a78a227..f144bda 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -24,9 +24,15 @@ module ProTacts
       ELIDE_ABOVE = 120 #: Integer
       private_constant :ELIDE_ABOVE
 
-      #: (Contact contact) -> void
-      def initialize(contact:)
+      # The groups come from the store beside the contact rather than
+      # off it: a contact's card carries what its groups lend it, never
+      # which groups those are (Store#groups_of). Required, and not
+      # defaulted to none, so a caller cannot render a contact as
+      # belonging to nothing by forgetting to ask.
+      #: (Contact contact, Array[String] groups) -> void
+      def initialize(contact:, groups:)
         @contact = contact
+        @groups = groups
         @birthday = Format.birthday(contact)
       end
 
@@ -93,7 +99,7 @@ module ProTacts
       #: () -> bool
       def has_data?
         @contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
-          !@birthday.nil? || @contact.notes.any?
+          !@birthday.nil? || @contact.notes.any? || @groups.any?
       end
 
       # A missing TYPE parameter still gets a key: the fallback names
@@ -104,6 +110,7 @@ module ProTacts
       # and a group holds only addresses and notes anyway (see
       # db/migrations/004_groups.rb).
       def rows
+        groups_row if @groups.any?
         @contact.phones.each do |phone|
           row(phone.type || "phone", phone.value, phone.line)
         end
@@ -119,6 +126,27 @@ module ProTacts
         end
       end
 
+      # Membership as its own row, above the card's own: what a contact
+      # belongs to frames the values under it, and several of those
+      # values are the group's rather than the contact's. Gloss' Tag
+      # rather than the Badge the inherited rows carry — a badge marks
+      # the row it sits on, and these are the row's whole value
+      # (docs/DESIGN.md, "Relationships are navigable").
+      #
+      # Tags, not links, for now: the design has every tag opening the
+      # record it names, and a group has no screen to open yet. They
+      # become links with the group card, which is also what gives a
+      # nameless group somewhere for its id to lead.
+      #: () -> void
+      def groups_row
+        dt(class: "type-label") { "groups" }
+        dd(class: "type-body-sm") do
+          div(class: "tag-set") do
+            @groups.each { |group| span(class: "tag") { group } }
+          end
+        end
+      end
+
       # The type in one column and the value in the other, with the
       # group that lends the line named under the value when one does.
       # In the value cell rather than beside the type label: the type
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 8e7a514..5a2cb12 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -222,6 +222,24 @@ module ProTacts
       nil
     end
 
+    # The groups a contact belongs to, by the label each is shown
+    # under, ordered by group id — a rename must not move a tag, the
+    # same reason #inherited_rows orders by it. Membership is the whole
+    # answer here, so a group that lends nothing appears too, where the
+    # inherited reads have nothing of it to carry. Off the contact
+    # 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) -> Array[String]
+    def groups_of(id)
+      group_members
+        .join(:groups, id: Sequel[:group_members][:group_id])
+        .where(card_id: id)
+        .order(Sequel[:groups][:id])
+        .select(group_label.as(:label))
+        .map { it.fetch(:label).to_s }
+    end
+
     # The id of the card whose UID property holds this value, if one
     # does — the read behind the no-uid-conflict precondition (RFC 6352
     # section 6.3.2.1). It reads the index, so a card whose UID line
@@ -640,6 +658,15 @@ module ProTacts
         .transform_values { |rows| rows.map { inherited_from(it) } }
     end
 
+    # What to call a group on a screen: its name, or its id where it
+    # has none (db/migrations/005_group_identity.rb). In SQL rather
+    # than in Ruby so that both reads of it — a tag and an inherited
+    # row's mark — are the one answer.
+    #: () -> untyped
+    def group_label
+      Sequel.function(:coalesce, Sequel[:groups][:name], Sequel[:groups][:id])
+    end
+
     # The join both inherited reads walk: a membership to the property
     # it inherits and to the group's own row for its name, qualified
     # and ordered so the composition neither depends on what SQLite
@@ -660,7 +687,7 @@ module ProTacts
         .join(:groups, id: Sequel[:group_members][:group_id])
         .select(
           Sequel[:group_members][:card_id],
-          Sequel.function(:coalesce, Sequel[:groups][:name], Sequel[:groups][:id]).as(:group_name),
+          group_label.as(:group_name),
           Sequel[:group_properties][:line],
         )
         .order(
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index a2c226d..17bb878 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -183,7 +183,7 @@ module ProTacts
 
             if contact
               response["Content-Type"] = "text/html; charset=utf-8"
-              Admin::ContactsShow.call(contact:)
+              Admin::ContactsShow.call(contact:, groups: store.groups_of(id))
             end
           end
         end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 9dce50b..3371e50 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) -> String
+      def self.call: (contact: Contact, groups: Array[String]) -> String
     end
 
     class ContactsEdit
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 15c0c49..a894dab 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -86,7 +86,7 @@ module FixtureData
   def self.seed_groups(store)
     database = store.instance_variable_get(:@database)
 
-    household = store.create_group(name: "Boole household")
+    household = store.create_group(name: "Booles")
     HOUSEHOLD.each.with_index do |line, position|
       database[:group_properties].insert(group_id: household, position:, line:)
     end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7a59b71..b51cc88 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -318,10 +318,10 @@ class AdminContactsPagesTest < Minitest::Test
   # marked.
   def test_show_marks_the_rows_a_group_lends
     with_contacts({"ada" => ADA}) do |store|
-      add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+      add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada"
-      marks = last_response.body.scan('<span class="badge">Boole household</span>')
+      marks = last_response.body.scan('<span class="badge">Booles</span>')
 
       assert_equal 2, marks.size
       assert_includes last_response.body, "7 Calculus Close"
@@ -330,6 +330,34 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The card says what the contact belongs to, as tags: the group
+  # lending it two rows and the group lending it nothing both appear,
+  # because the row is membership rather than what came of it.
+  def test_show_tags_the_groups_a_contact_belongs_to
+    with_contacts({"ada" => ADA}) do |store|
+      named = add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
+      nameless = add_group(store, members: ["ada"], lines: [])
+
+      get "/contacts/ada"
+      tags = last_response.body.scan(%r{<span class="tag">([^<]*)</span>}).flatten
+
+      assert_equal({named => "Booles", nameless => nameless}.sort.map { it.last }, tags)
+      # The grid leads with what the contact belongs to, so the values
+      # under it are read already knowing whose they might be.
+      assert_equal "groups", last_response.body[%r{<dt class="type-label">([^<]*)<}, 1]
+    end
+  end
+
+  # No membership, no row: an empty tag set would say a contact
+  # belongs to nothing in the space where belonging is shown.
+  def test_show_omits_the_groups_row_for_a_contact_in_none
+    with_contacts({"ada" => ADA}) do
+      get "/contacts/ada"
+
+      refute_includes last_response.body, "groups</dt>"
+    end
+  end
+
   # A group with no name marks its rows with its id, which is what the
   # mark is for: an empty badge would say a row came from somewhere and
   # then not say where.
@@ -714,7 +742,7 @@ class AdminContactsPagesTest < Minitest::Test
   # page is where an inherited row is read.
   def test_the_edit_screen_omits_the_rows_a_group_lends
     with_contacts({"ada" => ADA}) do |store|
-      add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+      add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada/edit"
 
@@ -731,7 +759,7 @@ class AdminContactsPagesTest < Minitest::Test
     noteless = ADA.sub("NOTE:Countess of Lovelace.\r\n", "")
 
     with_contacts({"ada" => noteless}) do |store|
-      add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+      add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada/edit"
 
@@ -745,7 +773,7 @@ class AdminContactsPagesTest < Minitest::Test
   # is not the screen — matches nothing and writes nothing.
   def test_a_save_naming_a_lent_line_touches_nothing
     with_contacts({"ada" => ADA}) do |store|
-      add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+      add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
       contact = store.contact("ada")
       lent = contact.addresses.find { contact.group_of(it.line) }
 
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index e1f8894..983379a 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -27,7 +27,7 @@ class ContactTest < Minitest::Test
   # Inherited lines are given as bare strings and lent by GROUP, the
   # name a composed row is marked with; a test that needs a second
   # lender passes its own.
-  GROUP = "Boole household" #: String
+  GROUP = "Booles" #: String
 
   def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
     lent = inherited.map { ProTacts::Contact::Inherited.new(group:, line: it) }
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 4283fad..6a6930d 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1078,6 +1078,23 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # Membership is a read of its own: every group a contact is in,
+  # under the label a screen shows it by — the name, or the id where
+  # there is no name — ordered by id so a rename never moves a tag. A
+  # group that lends nothing is a membership like any other; it is the
+  # inherited reads, not this one, that have nothing of it to carry.
+  def test_the_groups_a_contact_is_in_are_read_with_their_labels
+    with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+      named = add_group(store, name: "Booles", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      nameless = add_group(store, members: ["aiden"], lines: [])
+      add_group(store, name: "Neighbours", members: [], lines: [HOUSEHOLD_NOTE])
+
+      expected = {named => "Booles", nameless => nameless}.sort.map { it.last }
+      assert_equal expected, store.groups_of("aiden")
+      assert_empty store.groups_of("znorth")
+    end
+  end
+
   # The schema is the only gate on what a group may hold until
   # authoring exists, so what it admits is worth pinning: an address
   # or a note, bare or parameterized, in whatever case the author
@@ -1194,7 +1211,7 @@ class StoreTest < Minitest::Test
       Sequel.connect("sqlite://#{path}") do |db|
         Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 4)
         db[:cards].insert(id: "aiden", vcard: AIDEN)
-        db[:groups].insert(id: "nous", name: "Boole household")
+        db[:groups].insert(id: "nous", name: "Booles")
         db[:group_properties].insert(group_id: "nous", position: 0, line: HOUSEHOLD_ADDRESS)
         db[:group_members].insert(group_id: "nous", card_id: "aiden")
       end
@@ -1202,7 +1219,7 @@ class StoreTest < Minitest::Test
       ProTacts::Store.connect(path) do |store|
         contact = store.contact("aiden")
         assert_includes contact.vcard.to_s, HOUSEHOLD_ADDRESS
-        assert_equal "Boole household", contact.group_of(contact.addresses.fetch(0).line)
+        assert_equal "Booles", contact.group_of(contact.addresses.fetch(0).line)
 
         database(store)[:cards].where(id: "aiden").delete
         assert_empty database(store)[:group_members].all