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
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