Compute initials from N, not a guess at the display name
Review feedback: Format.initials was splitting FN on whitespace to
guess at a first/last initial, when RFC 2426's N property already
carries Family/Given as structured fields. ContactFields now reads N
directly for initials, falling back to the old FN-splitting guess only
when a card has no N at all (an organization's own entry, say, which
has a name but no person to split in two).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYiigu3aFNaiTP9SyKW9mX
change
commit f4b3253aecf6ed676ff3336bc7e1e3b0e55c4460
author Claude <noreply@anthropic.com>
date
parent cb157136
diff --git a/lib/pro_tacts/admin/contact_fields.rb b/lib/pro_tacts/admin/contact_fields.rb
index 45763a4..5a261d9 100644
--- a/lib/pro_tacts/admin/contact_fields.rb
+++ b/lib/pro_tacts/admin/contact_fields.rb
@@ -1,5 +1,6 @@
 require "date"
 
+require "pro_tacts/admin/format"
 require "pro_tacts/birthday"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
@@ -16,7 +17,7 @@ module ProTacts
       Email = Data.define(:value, :type)
       Address = Data.define(:lines, :type)
 
-      attr_reader :name, :phones, :emails, :addresses, :birthday, :notes
+      attr_reader :name, :initials, :phones, :emails, :addresses, :birthday, :notes
 
       # Lets VCard::ParseError raise rather than showing an empty card:
       # a stored card that fails to parse here is not the ordinary case
@@ -36,6 +37,7 @@ module ProTacts
       def initialize(properties)
         by_name = properties.group_by { it.name.upcase }
         @name = by_name.fetch("FN", []).first&.value
+        @initials = initials_from(by_name.fetch("N", []).first) || (@name && Format.initials(@name))
         @phones = by_name.fetch("TEL", []).map { |p| Phone.new(value: VCard.unescape(p.value), type: type_of(p)) }
         @emails = by_name.fetch("EMAIL", []).map { |p| Email.new(value: VCard.unescape(p.value), type: type_of(p)) }
         @addresses = by_name.fetch("ADR", []).map { |p| address_from(p) }
@@ -46,6 +48,22 @@ module ProTacts
 
       private
 
+      # RFC 2426 section 3.1.2: N is Family;Given;Additional;Prefixes;
+      # Suffixes. A given and family name are structured data — reading
+      # them is the real answer to "what are this contact's initials,"
+      # not guessing at word boundaries in the free-text FN the way
+      # Format.initials does. That guess is kept only as the fallback
+      # for a card with no N at all (an organization's own address-book
+      # entry, say, which has a name but no person to split in two).
+      #: (VCard::Property? property) -> String?
+      def initials_from(property)
+        return nil if property.nil?
+
+        family, given = VCard.split_components(property.value)
+        letters = [given, family].filter_map { |part| part[0] unless part.to_s.empty? }
+        letters.empty? ? nil : letters.join.upcase
+      end
+
       # RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
       # comma-list (TYPE=work,voice) — either arrives here as one
       # parameter per value, so the first is the one this shows.
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 43f95a6..28b587a 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -54,7 +54,7 @@ module ProTacts
       def render_row(row)
         li do
           a(href: "/contacts/#{row.contact.id}") do
-            span(class: "avatar") { Format.initials(row.fields.name || row.contact.id) }
+            span(class: "avatar") { row.fields.initials || Format.initials(row.contact.id) }
             div(style: "flex: 1; min-width: 0;") do
               div(style: "font-weight: 550;") { row.fields.name || row.contact.id }
             end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index ecca1e6..d91d196 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -29,7 +29,7 @@ module ProTacts
           div(class: "card") do
             div(class: "card-body") do
               div(class: "detail-header") do
-                span(class: "avatar", data_size: "lg") { Format.initials(@fields.name || @contact.id) }
+                span(class: "avatar", data_size: "lg") { @fields.initials || Format.initials(@contact.id) }
                 h1(class: "type-h2", style: "margin: 0;") { @fields.name || @contact.id }
               end
               # Only rendered when there's something to show: an empty
diff --git a/test/pro_tacts/admin/test_contact_fields.rb b/test/pro_tacts/admin/test_contact_fields.rb
index f5e44d9..0e68165 100644
--- a/test/pro_tacts/admin/test_contact_fields.rb
+++ b/test/pro_tacts/admin/test_contact_fields.rb
@@ -8,7 +8,7 @@ class ContactFieldsTest < Minitest::Test
     ProTacts::Admin::ContactFields.from(ProTacts::Contact.for(id: "aiden", vcard:))
   end
 
-  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\n" \
+  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
     "TEL;TYPE=mobile:+1-555-0100\r\nTEL;TYPE=work:+1-555-0199\r\n" \
     "EMAIL;TYPE=home:ada@example.com\r\n" \
     "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
@@ -18,6 +18,29 @@ class ContactFieldsTest < Minitest::Test
     assert_equal "Ada Lovelace", fields(CARD).name
   end
 
+  # Initials come from the structured N property (given + family),
+  # not a guess at word boundaries in the free-text FN.
+  def test_initials_come_from_the_structured_name
+    assert_equal "AL", fields(CARD).initials
+  end
+
+  # A card with no N at all — an organization's own entry, say — falls
+  # back to Format.initials' word-splitting guess on FN.
+  def test_initials_fall_back_to_the_display_name_with_no_n_property
+    no_n = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Riverside Elementary\r\nUID:office\r\nEND:VCARD\r\n"
+
+    assert_equal "RE", fields(no_n).initials
+  end
+
+  # N with only one of given/family present (a mononym, or a card that
+  # only bothered with a family name) still gives one initial rather
+  # than raising on the missing half.
+  def test_initials_from_a_partial_n_property
+    family_only = CARD.sub("N:Lovelace;Ada;;;", "N:Lovelace;;;;")
+
+    assert_equal "L", fields(family_only).initials
+  end
+
   def test_reads_every_phone_with_its_type
     assert_equal [["+1-555-0100", "mobile"], ["+1-555-0199", "work"]],
       fields(CARD).phones.map { [it.value, it.type] }