contacts: file the list under each last name's first letter
/contacts cuts its one card into one per first letter of the sort key,
each under its letter: a contact with no family name files under its
FN's, an accent under its base letter, and anything that is no letter
under "#" after Z.

Format.sort_key moves out of ContactsIndex exactly as the group page's
branch moves it, so the two merge without a conflict there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfL6MhttDTmvaQkrMSCska
change
commit 92ff2c516eb3471bf76c5eac2286e65df16fc887
author Claude <noreply@anthropic.com>
date
parent ymzpsmts
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 0663674..444b9cb 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -10,18 +10,16 @@ module ProTacts
   module Admin
     # GET /contacts — every contact in one list, sorted by last name:
     # the one screen that browses the whole contact set (docs/DESIGN.md,
-    # "The core idea"). The sort key is N's family name (RFC 2426
-    # section 3.1.2), falling back the way Format.initials does — FN
-    # for a card with no N, the id for a card with no name at all.
-    # Rows carry the label the dashboard's rows do (Format.name_label)
-    # and the contact's groups as chips under it.
+    # "The core idea"), in Format.sort_key's order. Rows carry the
+    # label the dashboard's rows do (Format.name_label) and the
+    # contact's groups as chips under it.
     class ContactsIndex < Phlex::HTML
       # @rbs @rows: Array[Contact]
       # @rbs @groups_of: Hash[String, Array[Store::Group]]
 
       #: (contacts: Array[Contact], groups: Array[Store::Group]) -> void
       def initialize(contacts:, groups:)
-        @rows = contacts.sort_by { [sort_key(it), it.id] }
+        @rows = contacts.sort_by { [Format.sort_key(it), it.id] }
         # Whole groups inverted into memberships, the way the dashboard
         # inverts them for search: which groups a contact is in is the
         # store's fact, read from that side once instead of per row.
@@ -40,9 +38,14 @@ module ProTacts
             if @rows.empty?
               p(class: "type-body-sm") { "No contacts yet." }
             else
-              ul(class: "card") do
-                @rows.each do
-                  render_row(it)
+              div(class: "letter-groups") do
+                letter_groups.each do |letter, rows|
+                  section do
+                    h3(class: "type-label") { letter }
+                    ul(class: "card") do
+                      rows.each { render_row(it) }
+                    end
+                  end
                 end
               end
             end
@@ -52,14 +55,6 @@ module ProTacts
 
       private
 
-      # The order a contact lists under, case-insensitive so the card's
-      # spelling of a family name does not move it.
-      #: (Contact contact) -> String
-      def sort_key(contact)
-        family, = contact.name_components || []
-        (family || contact.name || contact.id).to_s.downcase
-      end
-
       #: (Contact contact) -> void
       def render_row(contact)
         render ListItem.new(
@@ -78,6 +73,26 @@ module ProTacts
           end
         end
       end
+
+      # The rows cut at each first letter of Format.sort_key, the way
+      # a phone's contacts list is: a contact files under the letter
+      # it sorts by, so one with no family name files under its FN's.
+      # An accent files with its base letter, whose rows it follows,
+      # and anything that is no letter at all files under "#", after
+      # Z. The rows arrive sorted, and group_by keeps their order
+      # within each letter.
+      #: () -> Array[[String, Array[Contact]]]
+      def letter_groups
+        @rows
+          .group_by { letter_of(it) }
+          .sort_by { |letter, _| [letter == "#" ? 1 : 0, letter] }
+      end
+
+      #: (Contact contact) -> String
+      def letter_of(contact)
+        base = Format.sort_key(contact).unicode_normalize(:nfd)[0].to_s
+        base.match?(/[a-z]/) ? base.upcase : "#"
+      end
     end
   end
 end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 3f0a7db..b216b33 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -22,6 +22,18 @@ module ProTacts
         nickname || name || contact.id
       end
 
+      # The order a contact lists under: N's family name (RFC 2426
+      # section 3.1.2), falling back the way #initials does — FN for a
+      # card with no N, the id for a card with no name at all — and
+      # case-insensitive, so the card's spelling of a family name does
+      # not move it. Every listing of contacts sorts by it, the id
+      # breaking a tie.
+      #: (Contact contact) -> String
+      def self.sort_key(contact)
+        family, = contact.name_components || []
+        (family || contact.name || contact.id).to_s.downcase
+      end
+
       # A contact's initials. N's given and family names (RFC 2426
       # section 3.1.2) are structured data and the real answer to "what
       # are this contact's initials" — falling back, when the card
diff --git a/public/admin.css b/public/admin.css
index 16abecb..3b41fe9 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -648,6 +648,20 @@ section > h2 {
   translate: 0 -50%;
 }
 
+/* /contacts' rows, one card per first letter (ContactsIndex's
+   letter_groups). The letter is a caption for its card the way a
+   section's heading is for its list, so it keeps a tighter step to
+   its own card than the cards keep to each other: each letter reads
+   as the head of the card under it, not the foot of the one above. */
+.letter-groups {
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-m);
+}
+.letter-groups h3 {
+  margin-bottom: var(--gl-space-2xs);
+}
+
 /* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
    is a prose-list rule (Markdown bullet lists) that also matches
    Card's row list, adding a stray gap below every row but the last. */
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 8343b27..23828a8 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -112,6 +112,59 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  def test_contacts_files_each_contact_under_the_first_letter_of_its_last_name
+    boole = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:George Boole\r\nN:Boole;George;;;\r\nUID:boole\r\nEND:VCARD\r\n"
+    babbage = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Charles Babbage\r\nN:Babbage;Charles;;;\r\nUID:babbage\r\nEND:VCARD\r\n"
+    shelley = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Mary Shelley\r\nN:shelley;Mary;;;\r\nUID:shelley\r\nEND:VCARD\r\n"
+
+    with_contacts({"shelley" => shelley, "boole" => boole, "babbage" => babbage}) do
+      get "/contacts"
+
+      body = last_response.body
+      assert_equal 2, body.scan(%(<ul class="card">)).length
+      assert body.index(%(<h3 class="type-label">B</h3>)) < body.index("Charles Babbage")
+      assert body.index("George Boole") < body.index(%(<h3 class="type-label">S</h3>))
+      assert body.index(%(<h3 class="type-label">S</h3>)) < body.index("Mary Shelley")
+    end
+  end
+
+  def test_contacts_files_a_card_with_no_last_name_under_its_name
+    zed = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zed Aileron\r\nUID:zed\r\nEND:VCARD\r\n"
+
+    with_contacts({"zed" => zed}) do
+      get "/contacts"
+
+      assert_includes last_response.body, %(<h3 class="type-label">Z</h3>)
+    end
+  end
+
+  def test_contacts_files_an_accented_last_name_under_its_base_letter
+    eve = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Eve Evans\r\nN:Evans;Eve;;;\r\nUID:eve\r\nEND:VCARD\r\n"
+    emile = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Émile Écrivain\r\nN:Écrivain;Émile;;;\r\nUID:emile\r\nEND:VCARD\r\n"
+
+    with_contacts({"emile" => emile, "eve" => eve}) do
+      get "/contacts"
+
+      body = last_response.body
+      assert_equal 1, body.scan(%(<ul class="card">)).length
+      assert_includes body, %(<h3 class="type-label">E</h3>)
+      assert body.index("Eve Evans") < body.index("Émile Écrivain")
+    end
+  end
+
+  def test_contacts_files_a_last_name_that_is_no_letter_under_a_hash_after_z
+    agency = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:3M\r\nUID:agency\r\nEND:VCARD\r\n"
+    zed = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zed Aileron\r\nN:Zuse;Zed;;;\r\nUID:zed\r\nEND:VCARD\r\n"
+
+    with_contacts({"agency" => agency, "zed" => zed}) do
+      get "/contacts"
+
+      body = last_response.body
+      assert body.index(%(<h3 class="type-label">Z</h3>)) < body.index(%(<h3 class="type-label">#</h3>))
+      assert body.index(%(<h3 class="type-label">#</h3>)) < body.index("3M")
+    end
+  end
+
   # The row shows the same composition the dashboard's rows do
   # (Format.name_label).
   def test_contacts_lists_a_nicknamed_contact_as_name_and_nickname