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