admin: make the root a two-column dashboard
The birthdays screen folds into the root as its ambient column, and
the /birthdays route, the header nav, and the page class go with it.
Also fixes the birthdays rows to be li under ul.card — the CardRow
look lives on that selector, and bare anchors under the ul were
getting none of it.
Assisted-by: GLM-5.3 via pi
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 54579ed..916d641 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -71,10 +71,14 @@ Recency is the answer to "what was I doing?", which is the actual question
someone opening this app has. Match generously: a contact should be findable
by name, by any of its values, and by the groups it belongs to.
-One screen is exempt from that shape: the upcoming-birthdays list is not a
-search target but a standing answer to "what's coming?" — a bounded list in
-arrival order, each row opening the contact's card. It is the one screen
-that browses, because its question is not "where is X?".
+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
+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.
## Records are cards, not forms
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index d3621cd..a8a757b 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -2,37 +2,46 @@ require "phlex"
require "pro_tacts/admin/format"
require "pro_tacts/admin/layout"
+require "pro_tacts/admin/upcoming_birthdays"
module ProTacts
module Admin
- # GET / — search first, no browsing (docs/DESIGN.md): a query
- # narrows to matches across every contact, an empty query shows
- # the ten most recently updated. Read-only: no add, no edit,
- # nothing to commit, so there is no state here beyond the query.
+ # GET / — the dashboard (docs/DESIGN.md): search and 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. Read-only: no add, no edit, nothing to
+ # commit, so there is no state here beyond the query.
class ContactsIndex < Phlex::HTML
RECENT_LIMIT = 10
+ private_constant :RECENT_LIMIT
Row = Data.define(:contact, :updated_at)
private_constant :Row
- #: (recent: Array[Store::RecentContact], query: String?) -> void
- def initialize(recent:, query:)
+ #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?) -> void
+ def initialize(recent:, upcoming:, query:)
@query = query.to_s.strip
+ @upcoming = upcoming
rows = recent.map { Row.new(contact: it.contact, updated_at: it.updated_at) }
@rows = @query.empty? ? rows.first(RECENT_LIMIT) : rows.select { matches?(it, @query) }
end
def view_template
- render Layout.new(title: "Contacts") do
- h1(class: "type-h1") { "Contacts" }
- form(action: "/", method: "get", class: "search-form") do
- input(type: "search", name: "q", value: @query, placeholder: "Search contacts", autofocus: @query.empty?)
- end
- span(class: "type-label") { @query.empty? ? "recently updated" : "results" }
- if @rows.empty?
- p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
- else
- ul(class: "card") { @rows.each { render_row(it) } }
+ render Layout.new(title: "Contacts", wide: true) do
+ div(class: "dashboard") do
+ section do
+ form(action: "/", method: "get", class: "search-form") do
+ input(type: "search", name: "q", value: @query, placeholder: "Search contacts", autofocus: @query.empty?)
+ end
+ h2(class: "type-label") { @query.empty? ? "recently updated" : "results" }
+ if @rows.empty?
+ p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
+ else
+ ul(class: "card") { @rows.each { render_row(it) } }
+ end
+ end
+ render UpcomingBirthdays.new(upcoming: @upcoming)
end
end
end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 65f8c0b..b3cd0bd 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -6,13 +6,15 @@ module ProTacts
# vendored Gloss stylesheets (see public/vendor/gloss and
# docs/DESIGN.md), and a one-line header naming the app in type —
# "no brand mark" is one of the rules that document inherits from
- # Gloss — with the screen-to-screen nav pushed to the far edge of
- # it, quieter than the mark. No JavaScript: nothing served here yet
- # needs any.
+ # Gloss. `wide` opts a screen out of the reading width a single
+ # column wants (see admin.css) — the dashboard root is the one
+ # screen that asks. No JavaScript: nothing served here yet needs
+ # any.
class Layout < Phlex::HTML
- #: (title: String) -> void
- def initialize(title:)
+ #: (title: String, ?wide: bool) -> void
+ def initialize(title:, wide: false)
@title = title
+ @wide = wide
end
def view_template
@@ -30,11 +32,8 @@ module ProTacts
link(rel: "stylesheet", href: "/admin.css")
end
body do
- header(class: "admin-header") do
- a(href: "/") { "pro-tacts" }
- nav { a(href: "/birthdays", class: "type-label") { "birthdays" } }
- end
- main(class: "admin-main") { yield }
+ header(class: "admin-header") { a(href: "/") { "pro-tacts" } }
+ main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
end
end
end
diff --git a/lib/pro_tacts/admin/birthdays.rb b/lib/pro_tacts/admin/upcoming_birthdays.rb
similarity index 55%
rename from lib/pro_tacts/admin/birthdays.rb
rename to lib/pro_tacts/admin/upcoming_birthdays.rb
index cb9d6c3..4a7ecc8 100644
--- a/lib/pro_tacts/admin/birthdays.rb
+++ b/lib/pro_tacts/admin/upcoming_birthdays.rb
@@ -1,17 +1,16 @@
require "phlex"
require "pro_tacts/admin/format"
-require "pro_tacts/admin/layout"
module ProTacts
module Admin
- # GET /birthdays — who has a birthday soon, in the order they
- # arrive, each row opening the contact's card. The store's query
- # has already decided the hard parts (arrival order, the year
- # wrap, which partial shapes land on a day at all); this screen
- # only renders what it hands back, and a birthday with no year
- # shows its day without an age.
- class Birthdays < Phlex::HTML
+ # The dashboard's ambient column: who has a birthday soon, in the
+ # order they arrive, each row opening the contact's card. The
+ # store's query has already decided the hard parts (arrival order,
+ # the year wrap, which partial shapes land on a day at all); this
+ # renders what it hands back, and a birthday without a year shows
+ # its day without an age.
+ class UpcomingBirthdays < Phlex::HTML
LIMIT = 10
#: (upcoming: Array[Store::UpcomingBirthday]) -> void
@@ -20,8 +19,8 @@ module ProTacts
end
def view_template
- render Layout.new(title: "Birthdays") do
- h1(class: "type-h1") { "Birthdays" }
+ section do
+ h2(class: "type-label") { "upcoming birthdays" }
if @upcoming.empty?
p(class: "type-body-sm") { "No birthdays to show." }
else
@@ -35,13 +34,15 @@ module ProTacts
#: (Store::UpcomingBirthday upcoming) -> void
def render_row(upcoming)
contact = upcoming.contact
- a(href: "/contacts/#{contact.id}") do
- span(class: "avatar") { Format.initials(contact) }
- div(style: "flex: 1; min-width: 0;") do
- div(style: "font-weight: 550;") { contact.name || contact.id }
- div(class: "type-body-sm") { date_line(upcoming) }
+ li do
+ a(href: "/contacts/#{contact.id}") do
+ span(class: "avatar") { Format.initials(contact) }
+ div(style: "flex: 1; min-width: 0;") do
+ div(style: "font-weight: 550;") { contact.name || contact.id }
+ div(class: "type-body-sm") { date_line(upcoming) }
+ end
+ span(class: "type-label") { Format.time_until(upcoming.occurs_on) }
end
- span(class: "type-label") { Format.time_until(upcoming.occurs_on) }
end
end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 15afb9d..496d650 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -8,7 +8,6 @@ require "rack/rewindable_input"
require "nokogiri"
require "roda"
-require "pro_tacts/admin/birthdays"
require "pro_tacts/admin/contacts_index"
require "pro_tacts/admin/contacts_show"
require "pro_tacts/debug_logger"
@@ -102,24 +101,18 @@ module ProTacts
end
end
- # The upcoming-birthdays screen: the coming-year question the
- # birthdays table answers without a card in sight, one segment
- # down like the browser and under the same auth gate.
- r.on "birthdays" do
- r.get do
- response["Content-Type"] = "text/html; charset=utf-8"
- Admin::Birthdays.call(upcoming: store.upcoming_birthdays(Admin::Birthdays::LIMIT))
- end
- end
-
r.is "" do
- # The contacts page itself: the root is the one human-facing
- # screen's home (docs/DESIGN.md), a GET alongside the PROPFIND
- # below it — same path, disjoint verbs, and a browser's plain
- # GET is no DAV client's bootstrap.
+ # The dashboard home: the one human-facing screen's home
+ # (docs/DESIGN.md), a GET alongside the PROPFIND below it —
+ # same path, disjoint verbs, and a browser's plain GET is no
+ # DAV client's bootstrap.
r.get do
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::ContactsIndex.call(recent: store.contacts_by_recency, query: r.params["q"])
+ Admin::ContactsIndex.call(
+ recent: store.contacts_by_recency,
+ upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
+ query: r.params["q"],
+ )
end
# DAV:current-user-principal (RFC 5397 section 3) — what a client
diff --git a/public/admin.css b/public/admin.css
index 33a2447..8baf1a6 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -15,18 +15,6 @@
text-decoration: none;
}
-/* The header's nav sits at the far edge from the wordmark. Its links
- carry the catalog-label look of .type-label; this rule exists only
- because .admin-header a is more specific than .type-label and would
- otherwise force the wordmark's size and ink back onto them. */
-.admin-header nav {
- margin-inline-start: auto;
-}
-.admin-header nav a {
- font-size: var(--gl-step--2);
- color: var(--gl-color-text-secondary);
-}
-
.admin-main {
max-width: 34rem;
margin: 0 auto;
@@ -36,6 +24,24 @@
gap: var(--gl-space-m);
}
+/* The dashboard's width — two columns beside each other rather than
+ the reading width a single column wants. Set by the layout's `wide`
+ flag, the one screen that asks for it. */
+.admin-main[data-wide] {
+ max-width: 68rem;
+}
+
+/* The dashboard itself: equal fluid columns that collapse to one when
+ the viewport cannot hold two side by side, the ambient birthdays
+ column stacking under the primary one. `align-items: start` keeps a
+ short column from stretching its card to match its neighbor. */
+.dashboard {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
+ gap: var(--gl-space-m);
+ align-items: start;
+}
+
/* field.css's `form { max-width: 28rem; }` is sized for the
settings-form pattern; the search box is a single input that should
span the same width as the list below it. */
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 67be979..f12c894 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -11,13 +11,13 @@ module ProTacts
end
class ContactsIndex
- def self.call: (recent: Array[Store::RecentContact], query: String?) -> String
+ def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?) -> String
end
- class Birthdays
+ # Rendered by ContactsIndex, not called from checked code; only
+ # its LIMIT is read in web.rb.
+ class UpcomingBirthdays
LIMIT: Integer
-
- def self.call: (upcoming: Array[Store::UpcomingBirthday]) -> String
end
end
end
diff --git a/test/pro_tacts/admin/test_birthdays_page.rb b/test/pro_tacts/admin/test_birthdays_page.rb
deleted file mode 100644
index ef0edfc..0000000
--- a/test/pro_tacts/admin/test_birthdays_page.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-require_relative "../../test_helper"
-
-require "date"
-require "pathname"
-require "rack/test"
-require "tmpdir"
-
-require "pro_tacts/store"
-require "pro_tacts/web"
-
-# The upcoming-birthdays screen, exercised like the other admin pages:
-# real requests through the Roda app, against a throwaway store. The
-# query's own behavior — the year wrap, which partial shapes place on
-# a day at all — is test_store.rb's; here what is under test is what
-# the screen makes of what it hands back. The route reads the real
-# clock, so the cards are built around Date.today rather than a fixed
-# date.
-class AdminBirthdaysPageTest < Minitest::Test
- include Rack::Test::Methods
-
- def app
- ProTacts::Web
- end
-
- def setup
- header "Tailscale-User-Login", "test@example.com"
- end
-
- # A contact born on `date`'s month and day in 2000, so the page's
- # ordering and labels are deterministic against the real clock.
- def born(date, name:, id:)
- "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\n" \
- "BDAY:2000-%02d-%02d\r\nUID:#{id}\r\nEND:VCARD\r\n" % [date.month, date.day]
- end
-
- def with_contacts(cards)
- Dir.mktmpdir do |dir|
- original = ProTacts::Web.store
-
- ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
- ProTacts::Web.store = store
- cards.each { |id, card| store.put(id, card) }
- yield store
- ensure
- ProTacts::Web.store = original
- end
- end
- end
-
- # The list answers "who is next": names in arrival order, each row
- # opening the contact's card, the birthday shown with the age it
- # turns and the day counted down beside it.
- def test_lists_birthdays_in_arrival_order_linking_to_the_contact
- tomorrow = born(Date.today + 1, name: "Sooner Person", id: "sooner")
- fortnight = born(Date.today + 13, name: "Later Person", id: "later")
-
- with_contacts({"later" => fortnight, "sooner" => tomorrow}) do
- get "/birthdays"
-
- assert_equal 200, last_response.status
- assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
- body = last_response.body
- assert body.index("Sooner Person") < body.index("Later Person")
- assert_includes body, 'href="/contacts/sooner"'
- assert_includes body, "(turns #{(Date.today + 1).year - 2000})"
- assert_includes body, "tomorrow"
- assert_includes body, "in 13d"
- end
- end
-
- # A birthday without a year is on the day — shown with no age, the
- # one thing a missing year cannot answer.
- def test_a_birthday_without_a_year_shows_no_age
- week = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:No Year\r\n" \
- "BDAY;X-APPLE-OMIT-YEAR=1604:1604-%02d-%02d\r\nUID:noyear\r\nEND:VCARD\r\n" %
- [(Date.today + 7).month, (Date.today + 7).day]
-
- with_contacts({"noyear" => week}) do
- get "/birthdays"
-
- body = last_response.body
- assert_includes body, "No Year"
- assert_includes body, "in 7d"
- refute_includes body, "turns"
- end
- end
-
- def test_no_birthdays_says_so
- with_contacts({}) { get "/birthdays" }
-
- assert_includes last_response.body, "No birthdays to show."
- end
-
- # The screen is reachable from the page shell every screen shares.
- def test_the_header_links_to_birthdays
- with_contacts({}) do
- get "/"
-
- assert_includes last_response.body, 'href="/birthdays"'
- end
- end
-end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 24a36d6..549b227 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1,5 +1,6 @@
require_relative "../../test_helper"
+require "date"
require "pathname"
require "rack/test"
require "tmpdir"
@@ -7,9 +8,11 @@ require "tmpdir"
require "pro_tacts/store"
require "pro_tacts/web"
-# The read-only admin UI (docs/DESIGN.md), exercised the same way
-# WebTest exercises the CardDAV routes: real requests through the Roda
-# app, against a throwaway store.
+# The dashboard root (docs/DESIGN.md), exercised the same way WebTest
+# exercises the CardDAV routes: real requests through the Roda app,
+# against a throwaway store. The birthdays column reads the real
+# clock, so its cards are built around Date.today rather than a fixed
+# date.
class AdminContactsPagesTest < Minitest::Test
include Rack::Test::Methods
@@ -57,6 +60,76 @@ class AdminContactsPagesTest < Minitest::Test
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.
+ def born(date, name:, id:)
+ "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\n" \
+ "BDAY:2000-%02d-%02d\r\nUID:#{id}\r\nEND:VCARD\r\n" % [date.month, date.day]
+ end
+
+ # The ambient column answers "who is next": names in arrival order,
+ # each row opening the contact's card, the birthday shown with the
+ # age it turns and the day counted down beside it.
+ def test_index_lists_birthdays_in_arrival_order_linking_to_the_contact
+ tomorrow = born(Date.today + 1, name: "Sooner Person", id: "sooner")
+ fortnight = born(Date.today + 13, name: "Later Person", id: "later")
+
+ with_contacts({"later" => fortnight, "sooner" => tomorrow}) do
+ get "/"
+
+ body = last_response.body
+ assert_includes body, "upcoming birthdays"
+ # Both contacts also sit in "recently updated", where a
+ # same-millisecond tie can order them either way — arrival
+ # order is asserted inside the birthdays column alone.
+ birthdays = body.split("upcoming birthdays").last
+ assert birthdays.index("Sooner Person") < birthdays.index("Later Person")
+ # The rows are CardRows — li under ul.card, where the row look
+ # lives — not bare anchors.
+ assert_includes body, '<li><a href="/contacts/sooner">'
+ assert_includes body, "(turns #{(Date.today + 1).year - 2000})"
+ assert_includes body, "tomorrow"
+ assert_includes body, "in 13d"
+ end
+ end
+
+ # A birthday without a year is on the day — shown with no age, the
+ # one thing a missing year cannot answer.
+ def test_index_shows_a_birthday_without_a_year_with_no_age
+ week = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:No Year\r\n" \
+ "BDAY;X-APPLE-OMIT-YEAR=1604:1604-%02d-%02d\r\nUID:noyear\r\nEND:VCARD\r\n" %
+ [(Date.today + 7).month, (Date.today + 7).day]
+
+ with_contacts({"noyear" => week}) do
+ get "/"
+
+ body = last_response.body
+ assert_includes body, "No Year"
+ assert_includes body, "in 7d"
+ refute_includes body, "turns"
+ end
+ end
+
+ def test_index_with_no_birthdays_says_so
+ with_contacts({}) { get "/" }
+
+ assert_includes last_response.body, "No birthdays to show."
+ end
+
+ # Searching narrows the contacts column; the ambient column stays
+ # where it is, still answering its own question.
+ def test_search_leaves_the_birthdays_column_standing
+ birthday = born(Date.today + 3, name: "Birthday Person", id: "birthday")
+
+ with_contacts({"ada" => ADA, "birthday" => birthday}) do
+ get "/", q: "hopper"
+
+ assert_includes last_response.body, "No contacts match."
+ assert_includes last_response.body, "Birthday Person"
+ end
+ end
+
def test_search_narrows_to_matches_across_contacts
grace = ADA.sub("Ada Lovelace", "Grace Hopper")
.sub("ada@example.com", "grace@example.com")
@@ -65,8 +138,12 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => ADA, "grace" => grace}) do
get "/", q: "hopper"
- assert_includes last_response.body, "Grace Hopper"
- refute_includes last_response.body, "Ada Lovelace"
+ # The columns split on the birthdays column's label: matching
+ # narrows the contacts column, and Ada stays out of it — she is
+ # still beside it, in the ambient column where she belongs.
+ contacts_column = last_response.body.split("upcoming birthdays").first
+ assert_includes contacts_column, "Grace Hopper"
+ refute_includes contacts_column, "Ada Lovelace"
assert_includes last_response.body, "results"
end
end