contacts: list the whole book by last name
GET /contacts browses every contact, the exception docs/DESIGN.md now
records beside the search-first rule.

Assisted-by: GLM-5.3 via pi
change xsswokpvykzxupxuxwnqpzyzlwuvttsw
commit 0988f798f7dac4e355c1cd870730fec431a07fb9
author Alpha Chen <alpha@kejadlen.dev>
date
parent qlvrkvnw
diff --git a/Steepfile b/Steepfile
index 69108b3..6be4ae5 100644
--- a/Steepfile
+++ b/Steepfile
@@ -21,7 +21,7 @@ target :lib do
   # `check ... inline: true`. A bare `ignore` feeds the other list and
   # the files stay checked.
   ignore(*%w[
-    avatar contact_dialog contacts_edit contacts_index contacts_show device_setup format
+    avatar contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
     group_dialog group_label groups_edit groups_index groups_show icons layout phlex upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 748a375..cde1a95 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -71,11 +71,17 @@ What that inheritance actually means:
 - No gradients, no blur, no translucency, no photography, no brand mark —
   "Pro-tacts" is rendered in type wherever a mark would go.
 
-## The core idea: search first, no browsing
-
-Contacts and groups are both **found**, not browsed. A list of everything is
-not useful past a hundred records and becomes actively hostile past a
-thousand, so no screen exists to scroll the whole set.
+## The core idea: search first
+
+Contacts and groups are both **found**, not browsed — with one
+exception. `GET /contacts` lists every contact in one scrolling list,
+sorted by last name, because the book this serves is a family's: small
+enough that scanning it beats typing into a search box. That is the same
+bet `/groups` makes with its whole set, and it is a bet against growth —
+a list of everything is not useful past a hundred records and becomes
+actively hostile past a thousand. If the book ever gets there, the
+listing goes and the shapes below, which work at any size, are what's
+left.
 
 Every collection screen is the same shape:
 
@@ -95,8 +101,9 @@ by name, by any of its values, and by the groups it belongs to.
 The root screen is a dashboard of two columns. The primary column is the
 shape above. The ambient column beside it is a standing answer to "what's
 coming?": the next ten or so birthdays in arrival order, each row opening
-the contact's card. It is the one list that browses, because its question is
-not "where is X?", and it stands whether or not there is a query. The
+the contact's card. It is a list that browses, like the whole-set listing,
+because its question is not "where is X?", and it stands whether or not
+there is a query. The
 columns are equal and fluid, collapsing to one — birthdays under contacts —
 when the viewport cannot hold two side by side. Every other screen keeps a
 single reading column.
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index e0bd151..46a826c 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -1,139 +1,60 @@
 require "pro_tacts/admin/phlex"
 
 require "pro_tacts/admin/avatar"
-require "pro_tacts/admin/contact_dialog"
 require "pro_tacts/admin/format"
-require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
-require "pro_tacts/admin/upcoming_birthdays"
 
 module ProTacts
   module Admin
-    # GET / — the dashboard (docs/DESIGN.md): the search in the page
-    # header, recency in the primary column, the birthdays the year is
-    # about to bring in the ambient one beside it. A query narrows the
-    # contacts column to matches across every contact; an empty query
-    # shows the ten most recently updated. The create dialog rides
-    # along hidden on every render — a popover opens where it sits, so
-    # the add button needs nothing from the server but the page it is
-    # already on.
+    # 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).
     class ContactsIndex < Phlex::HTML
-      RECENT_LIMIT = 10
-      private_constant :RECENT_LIMIT
+      # @rbs @rows: Array[Contact]
 
-      # @rbs @query: String
-      # @rbs @upcoming: Array[Store::UpcomingBirthday]
-      # @rbs @labels: Hash[String, Array[String]]
-      # @rbs @groups: Array[Store::Group]
-      # @rbs @rows: Array[Store::RecentContact]
-      # @rbs @notice: String?
-
-      # The groups arrive whole because a search answers from both
-      # sides of the relationship: a group is findable by its own name,
-      # and a contact by the names of the groups it is in.
-      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> void
-      def initialize(recent:, upcoming:, query:, groups:, notice: nil)
-        @query = query.to_s.strip
-        @upcoming = upcoming
-        @notice = notice
-        @labels = {}
-        groups.each { |group| group.members.each { (@labels[it] ||= []) << group.label } }
-        q = @query.downcase
-        @groups = @query.empty? ? [] : groups.select { it.label.downcase.include?(q) }
-        @rows = @query.empty? ? recent.first(RECENT_LIMIT) : recent.select { matches?(it, @query) }
+      #: (contacts: Array[Contact]) -> void
+      def initialize(contacts:)
+        @rows = contacts.sort_by { [sort_key(it), it.id] }
       end
 
       def view_template
-        render Layout.new(title: "Contacts", wide: true, query: @query,
-                          autofocus: @query.empty?, notice: @notice) do
-          div(class: "dashboard") do
-            section do
-              div(class: "section-head") do
-                h2(class: "type-label") { @query.empty? ? "recently updated" : "results" }
-                # A button, not a link: it changes the page's state
-                # rather than navigating, and the Popover API is what
-                # it invokes (see ContactDialog).
-                button(data_size: "sm", popovertarget: "new-contact") { "add contact" }
-              end
-              if @rows.empty?
-                p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
-              else
-                ul(class: "card") do
-                  @rows.each do
-                    render_row(it)
-                  end
+        render Layout.new(title: "Contacts") do
+          section do
+            div(class: "section-head") do
+              h2(class: "type-label") { "contacts" }
+            end
+            if @rows.empty?
+              p(class: "type-body-sm") { "No contacts yet." }
+            else
+              ul(class: "card") do
+                @rows.each do
+                  render_row(it)
                 end
               end
-              group_results if @groups.any?
             end
-            render UpcomingBirthdays.new(upcoming: @upcoming)
           end
-          # In the page but out of the columns: a popover opens from
-          # wherever it sits, and hidden is its resting state.
-          render ContactDialog.new(query: @query)
         end
       end
 
       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.
+      # 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 label_of(contact)
-        nickname = contact.nickname
-        name = contact.name
-        return "#{nickname} (#{name})" if nickname && name
-
-        nickname || name || contact.id
+      def sort_key(contact)
+        family, = contact.name_components || []
+        (family || contact.name || contact.id).to_s.downcase
       end
 
-      # Match generously (docs/DESIGN.md): a contact is findable by
-      # name — the nickname too, being the name it lists under — any
-      # of its values, or the groups it belongs to.
-      #: (Store::RecentContact row, String query) -> bool
-      def matches?(row, query)
-        q = query.downcase
-        return true if row.contact.name&.downcase&.include?(q)
-        return true if row.contact.nickname&.downcase&.include?(q)
-        return true if row.contact.phones.any? { it.value.downcase.include?(q) }
-        return true if row.contact.emails.any? { it.value.downcase.include?(q) }
-        return true if @labels.fetch(row.contact.id, []).any? { it.downcase.include?(q) }
-
-        false
-      end
-
-      # The groups whose names match, under the contacts: a group is a
-      # record the search reaches as surely as a contact is
-      # (docs/DESIGN.md, "When adding something new").
-      #: () -> void
-      def group_results
-        div(class: "section-head") do
-          h2(class: "type-label") { "groups" }
-        end
-        ul(class: "card") do
-          @groups.each do |group|
-            li do
-              a(href: "/groups/#{group.id}") do
-                div(style: "flex: 1; min-width: 0; font-weight: 550;") { render GroupLabel.new(group:) }
-              end
-            end
-          end
-        end
-      end
-
-      #: (Store::RecentContact row) -> void
-      def render_row(row)
+      #: (Contact contact) -> void
+      def render_row(contact)
         li do
-          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;") { label_of(row.contact) }
-            end
-            span(class: "type-label") { Format.time_ago(row.updated_at) }
+          a(href: "/contacts/#{contact.id}") do
+            render Avatar.new(contact:, size: "lg")
+            div(style: "flex: 1; min-width: 0; font-weight: 550;") { Format.name_label(contact) }
           end
         end
       end
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
new file mode 100644
index 0000000..aa8f625
--- /dev/null
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -0,0 +1,125 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/contact_dialog"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_label"
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/upcoming_birthdays"
+
+module ProTacts
+  module Admin
+    # GET / — the dashboard (docs/DESIGN.md): the search in the page
+    # header, recency in the primary column, the birthdays the year is
+    # about to bring in the ambient one beside it. A query narrows the
+    # contacts column to matches across every contact; an empty query
+    # shows the ten most recently updated. The create dialog rides
+    # along hidden on every render — a popover opens where it sits, so
+    # the add button needs nothing from the server but the page it is
+    # already on.
+    class Dashboard < Phlex::HTML
+      RECENT_LIMIT = 10
+      private_constant :RECENT_LIMIT
+
+      # @rbs @query: String
+      # @rbs @upcoming: Array[Store::UpcomingBirthday]
+      # @rbs @labels: Hash[String, Array[String]]
+      # @rbs @groups: Array[Store::Group]
+      # @rbs @rows: Array[Store::RecentContact]
+      # @rbs @notice: String?
+
+      # The groups arrive whole because a search answers from both
+      # sides of the relationship: a group is findable by its own name,
+      # and a contact by the names of the groups it is in.
+      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> void
+      def initialize(recent:, upcoming:, query:, groups:, notice: nil)
+        @query = query.to_s.strip
+        @upcoming = upcoming
+        @notice = notice
+        @labels = {}
+        groups.each { |group| group.members.each { (@labels[it] ||= []) << group.label } }
+        q = @query.downcase
+        @groups = @query.empty? ? [] : groups.select { it.label.downcase.include?(q) }
+        @rows = @query.empty? ? recent.first(RECENT_LIMIT) : recent.select { matches?(it, @query) }
+      end
+
+      def view_template
+        render Layout.new(title: "Home", wide: true, query: @query,
+                          autofocus: @query.empty?, notice: @notice) do
+          div(class: "dashboard") do
+            section do
+              div(class: "section-head") do
+                h2(class: "type-label") { @query.empty? ? "recently updated" : "results" }
+                # A button, not a link: it changes the page's state
+                # rather than navigating, and the Popover API is what
+                # it invokes (see ContactDialog).
+                button(data_size: "sm", popovertarget: "new-contact") { "add contact" }
+              end
+              if @rows.empty?
+                p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
+              else
+                ul(class: "card") do
+                  @rows.each do
+                    render_row(it)
+                  end
+                end
+              end
+              group_results if @groups.any?
+            end
+            render UpcomingBirthdays.new(upcoming: @upcoming)
+          end
+          # In the page but out of the columns: a popover opens from
+          # wherever it sits, and hidden is its resting state.
+          render ContactDialog.new(query: @query)
+        end
+      end
+
+      private
+
+      # Match generously (docs/DESIGN.md): a contact is findable by
+      # name — the nickname too, being the name it lists under — any
+      # of its values, or the groups it belongs to.
+      #: (Store::RecentContact row, String query) -> bool
+      def matches?(row, query)
+        q = query.downcase
+        return true if row.contact.name&.downcase&.include?(q)
+        return true if row.contact.nickname&.downcase&.include?(q)
+        return true if row.contact.phones.any? { it.value.downcase.include?(q) }
+        return true if row.contact.emails.any? { it.value.downcase.include?(q) }
+        return true if @labels.fetch(row.contact.id, []).any? { it.downcase.include?(q) }
+
+        false
+      end
+
+      # The groups whose names match, under the contacts: a group is a
+      # record the search reaches as surely as a contact is
+      # (docs/DESIGN.md, "When adding something new").
+      #: () -> void
+      def group_results
+        div(class: "section-head") do
+          h2(class: "type-label") { "groups" }
+        end
+        ul(class: "card") do
+          @groups.each do |group|
+            li do
+              a(href: "/groups/#{group.id}") do
+                div(style: "flex: 1; min-width: 0; font-weight: 550;") { render GroupLabel.new(group:) }
+              end
+            end
+          end
+        end
+      end
+
+      #: (Store::RecentContact row) -> void
+      def render_row(row)
+        li do
+          a(href: "/contacts/#{row.contact.id}") do
+            render Avatar.new(contact: row.contact, size: "lg")
+            div(style: "flex: 1; min-width: 0; font-weight: 550;") { Format.name_label(row.contact) }
+            span(class: "type-label") { Format.time_ago(row.updated_at) }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index ac81509..56ffc1f 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -7,6 +7,21 @@ module ProTacts
     # Small display helpers shared by the admin views. Presentation, not
     # structure: these read a Contact but decide only how it looks.
     module Format
+      # The name a listing row shows: "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 the listing screens', 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 self.name_label(contact)
+        nickname = contact.nickname
+        name = contact.name
+        return "#{nickname} (#{name})" if nickname && name
+
+        nickname || name || contact.id
+      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/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 997ace9..c14f791 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -77,8 +77,11 @@ module ProTacts
               # The groups list rides beside the name because it is
               # where a group is created, and creating one is a thing
               # to reach from anywhere; search still finds one by name.
+              # The contacts list is the browse surface (docs/DESIGN.md,
+              # "The core idea").
               nav do
                 a(href: "/") { "pro-tacts" }
+                a(href: "/contacts") { "contacts" }
                 a(href: "/groups") { "groups" }
               end
               # Every screen's chrome, not the dashboard's alone: the
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index b81b212..24a3b15 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -3,6 +3,7 @@ require "pro_tacts/admin/contact_dialog"
 require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/contacts_index"
 require "pro_tacts/admin/contacts_show"
+require "pro_tacts/admin/dashboard"
 require "pro_tacts/change_id"
 
 module ProTacts
@@ -14,22 +15,29 @@ module ProTacts
     # trusted" is the whole access model this app has, see README's
     # simplifying assumptions.
     hash_branch("contacts") do |r|
-      # The browser's create, from the dashboard's dialog: POST is
-      # the one method the admin UI adds to the DAV set (see
-      # config/puma.rb, whose list Puma replaces rather than
-      # extends), and the collection is the resource a create
-      # names. Stored through Store#put like any client write, so
-      # the change log a sync token counts on lands with the card.
-      # A nameless create is a dashboard re-render with a toast: the
-      # browser cannot produce one (the dialog requires one name box
-      # or the other, Admin::NamePair), so this is the backstop, and
-      # a popover cannot be declared open in markup — the toast is
-      # the refusal the
-      # re-rendered page can actually show. `r.is` because a bare
-      # verb block matches any remaining path in Roda — without it,
-      # the collection's create would swallow the record's apply,
-      # POST /contacts/:id below.
+      # `r.is` because a bare verb block matches any remaining path in
+      # Roda — without it, the collection's create would swallow the
+      # record's apply, POST /contacts/:id below.
       r.is do
+        # The whole-set listing, the one screen that browses the
+        # contacts (docs/DESIGN.md, "The core idea").
+        r.get do
+          response["Content-Type"] = "text/html; charset=utf-8"
+          Admin::ContactsIndex.call(contacts: store.contacts)
+        end
+
+        # The browser's create, from the dashboard's dialog: POST is
+        # the one method the admin UI adds to the DAV set (see
+        # config/puma.rb, whose list Puma replaces rather than
+        # extends), and the collection is the resource a create
+        # names. Stored through Store#put like any client write, so
+        # the change log a sync token counts on lands with the card.
+        # A nameless create is a dashboard re-render with a toast: the
+        # browser cannot produce one (the dialog requires one name box
+        # or the other, Admin::NamePair), so this is the backstop, and
+        # a popover cannot be declared open in markup — the toast is
+        # the refusal the
+        # re-rendered page can actually show.
         r.post do
           first = r.params["first"].to_s.strip
           middle = r.params["middle"].to_s.strip
@@ -114,7 +122,7 @@ module ProTacts
     #: (query: String?, ?notice: String?) -> String
     def dashboard(query:, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ContactsIndex.call(
+      Admin::Dashboard.call(
         recent: store.contacts_by_recency,
         upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
         query:,
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 32a62b7..8c21362 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -14,10 +14,14 @@ module ProTacts
       def self.call: (contact: Contact, ?notice: String?) -> String
     end
 
-    class ContactsIndex
+    class Dashboard
       def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> String
     end
 
+    class ContactsIndex
+      def self.call: (contacts: Array[Contact]) -> String
+    end
+
     class GroupsIndex
       def self.call: (groups: Array[Store::Group], ?notice: String?) -> String
     end
@@ -34,7 +38,7 @@ module ProTacts
       def self.call: (hostname: String, name: String) -> String
     end
 
-    # Rendered by ContactsIndex, not called from checked code; only
+    # Rendered by Dashboard, not called from checked code; only
     # its LIMIT is read in web.rb.
     class UpcomingBirthdays
       LIMIT: Integer
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index ef7c25d..3ecf297 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -88,6 +88,57 @@ class AdminContactsPagesTest < Minitest::Test
     assert_includes last_response.body, "No contacts yet."
   end
 
+  ## The whole-set listing (GET /contacts)
+
+  # Sorted by N's family name whatever its case, and a card with no N
+  # under its FN — the fallback chain the avatar's initials walk too
+  # (Format.initials). Seeded out of order to prove the sort.
+  def test_contacts_lists_every_contact_sorted_by_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"
+    shelley = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Mary Shelley\r\nN:shelley;Mary;;;\r\nUID:shelley\r\nEND:VCARD\r\n"
+    zed = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zed Aileron\r\nUID:zed\r\nEND:VCARD\r\n"
+
+    with_contacts({"shelley" => shelley, "zed" => zed, "boole" => boole}) do
+      get "/contacts"
+
+      assert_equal 200, last_response.status
+      assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
+      assert_includes last_response.body, "<title>pro-tacts — Contacts</title>"
+      assert_includes last_response.body, %(<a href="/contacts/boole">)
+      body = last_response.body
+      assert body.index("George Boole") < body.index("Mary Shelley")
+      assert body.index("Mary Shelley") < body.index("Zed Aileron")
+    end
+  end
+
+  # The row shows the same composition the dashboard's rows do
+  # (Format.name_label).
+  def test_contacts_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 "/contacts"
+
+      assert_includes last_response.body, "Red (Sarah)"
+    end
+  end
+
+  def test_contacts_lists_a_nameless_card_under_its_id
+    nameless = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:nameless\r\nEND:VCARD\r\n"
+
+    with_contacts({"nameless" => nameless}) do
+      get "/contacts"
+
+      assert_includes last_response.body, "nameless"
+    end
+  end
+
+  def test_contacts_with_no_contacts_says_so
+    with_contacts({}) { get "/contacts" }
+
+    assert_includes last_response.body, "No contacts yet."
+  end
+
   # A contact born on `date`'s month and day in 2000, so the
   # birthdays column's ordering and labels are deterministic against
   # the real clock.
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index b27e94c..2296daf 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -63,7 +63,7 @@ class AdminGroupsPagesTest < Minitest::Test
     with_contacts({}) do
       get "/"
 
-      assert_includes last_response.body, %(<a href="/">pro-tacts</a><a href="/groups">groups</a>)
+      assert_includes last_response.body, %(<a href="/">pro-tacts</a><a href="/contacts">contacts</a><a href="/groups">groups</a>)
       refute_includes last_response.body, %(<a href="/groups" class="type-label">)
     end
   end