admin: list nicknamed contacts as "Nickname (Name)"
The paren form is composed in the view because the card holds FN and
NICKNAME as independent facts; other screens keep the plain name.

Assisted-by: GLM-5.3 via pi
change xuyrzyxnnoyrtnlxxmzznotpkuloovxy
commit 3490ee1894a60299d88e4a4e49b14d390ffba022
author Alpha Chen <alpha@kejadlen.dev>
date
parent kourolvq
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 50b43c3..9e59544 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -46,6 +46,21 @@ module ProTacts
 
       private
 
+      # The row's label: "Nickname (Name)" when the card carries a
+      # NICKNAME — the name someone is known by, the formal one kept
+      # beside it — and the plain name otherwise. The composition is
+      # this screen's, not Contact's: the card holds the two facts
+      # independently, and other screens (the detail card, the
+      # birthdays column) show the plain name.
+      #: (Contact contact) -> String
+      def label_of(contact)
+        nickname = contact.nickname
+        name = contact.name
+        return "#{nickname} (#{name})" if nickname && name
+
+        nickname || name || contact.id
+      end
+
       #: (Row row, String query) -> bool
       def matches?(row, query)
         q = query.downcase
@@ -62,7 +77,7 @@ module ProTacts
           a(href: "/contacts/#{row.contact.id}") do
             render Avatar.new(contact: row.contact, size: "lg")
             div(style: "flex: 1; min-width: 0;") do
-              div(style: "font-weight: 550;") { row.contact.name || row.contact.id }
+              div(style: "font-weight: 550;") { label_of(row.contact) }
             end
             span(class: "type-label") { Format.time_ago(row.updated_at) }
           end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 43eabfc..cf26b28 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -160,6 +160,13 @@ module ProTacts
       property && components_of(property.value)
     end
 
+    # NICKNAME's value (RFC 2426 section 3.1.3), in text form — the
+    # whole value, as the card spells it, when it comma-lists several.
+    #: () -> String?
+    def nickname
+      text_of(properties.find { it.name.casecmp?("NICKNAME") })
+    end
+
     #: () -> Array[Phone]
     def phones
       of_name("TEL").filter_map do |property|
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index dd65688..2d68126 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -55,6 +55,19 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # A card with a NICKNAME lists as "Nickname (Name)" — the name
+  # someone is known by, the formal one kept beside it. The plain-name
+  # row is what every other index test already shows.
+  def test_index_lists_a_nicknamed_contact_as_nickname_and_name
+    red = ADA.sub("FN:Ada Lovelace", "FN:Sarah\r\nNICKNAME:Red").sub("UID:ada", "UID:red")
+
+    with_contacts({"red" => red}) do
+      get "/"
+
+      assert_includes last_response.body, "Red (Sarah)"
+    end
+  end
+
   def test_index_with_no_contacts_says_so
     with_contacts({}) { get "/" }
 
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index c0c1954..a1c8fde 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -66,6 +66,13 @@ class ContactTest < Minitest::Test
     assert_equal ["Lovelace", "Ada", nil, nil, nil], contact(STRUCTURED).name_components
   end
 
+  def test_reads_the_nickname
+    nicknamed = CARD.sub("FN:Aiden\r\n", "FN:Aiden\r\nNICKNAME:Red\r\n")
+
+    assert_equal "Red", contact(nicknamed).nickname
+    assert_nil contact(CARD).nickname
+  end
+
   def test_values_come_back_unescaped
     escaped = STRUCTURED.sub("FN:Ada Lovelace", "FN:Ada\\, Countess of Lovelace")
 
@@ -159,6 +166,7 @@ class ContactTest < Minitest::Test
 
     assert_nil bare.name
     assert_nil bare.name_components
+    assert_nil bare.nickname
     assert_empty bare.phones
     assert_empty bare.emails
     assert_empty bare.addresses