admin: list upcoming birthdays in arrival order
Store#upcoming_birthdays reads the birthdays table joined to cards —
not a card scan — wraps a passed birthday onto next year, and leaves
the partial shapes with no month-and-day to nobody: they sit on no
calendar day to arrive on.

Assisted-by: GLM-5.3 via pi
change pqztmlumvmwwqyotzyomyzqtkqxzttsy
commit bb99d9be38e16cae5b10a4f5fe0e9a83da85f223
author Alpha Chen <alpha@kejadlen.dev>
date
parent swrktyml
diff --git a/Steepfile b/Steepfile
index b24db0d..d268208 100644
--- a/Steepfile
+++ b/Steepfile
@@ -21,5 +21,5 @@ target :lib do
   ignore "lib/pro_tacts/admin", inline: true
   signature "sig"
 
-  library "digest", "fileutils", "logger", "pathname", "strscan"
+  library "date", "digest", "fileutils", "logger", "pathname", "strscan"
 end
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 7f682d8..54579ed 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -71,6 +71,11 @@ 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?".
+
 ## Records are cards, not forms
 
 A contact or group opens as a full card, not a row in a split pane and not a
diff --git a/lib/pro_tacts/admin/birthdays.rb b/lib/pro_tacts/admin/birthdays.rb
new file mode 100644
index 0000000..cb9d6c3
--- /dev/null
+++ b/lib/pro_tacts/admin/birthdays.rb
@@ -0,0 +1,60 @@
+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
+      LIMIT = 10
+
+      #: (upcoming: Array[Store::UpcomingBirthday]) -> void
+      def initialize(upcoming:)
+        @upcoming = upcoming
+      end
+
+      def view_template
+        render Layout.new(title: "Birthdays") do
+          h1(class: "type-h1") { "Birthdays" }
+          if @upcoming.empty?
+            p(class: "type-body-sm") { "No birthdays to show." }
+          else
+            ul(class: "card") { @upcoming.each { render_row(it) } }
+          end
+        end
+      end
+
+      private
+
+      #: (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) }
+          end
+          span(class: "type-label") { Format.time_until(upcoming.occurs_on) }
+        end
+      end
+
+      # The birthday as the contact's own card shows it, with the age
+      # it turns on the day when the year is there to know it from.
+      #: (Store::UpcomingBirthday upcoming) -> String
+      def date_line(upcoming)
+        birthday = upcoming.contact.birthday
+        text = Format.birthday(upcoming.contact).to_s
+        return text if birthday.nil? || birthday.year.nil?
+
+        "#{text} (turns #{upcoming.occurs_on.year - birthday.year})"
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index a562f92..32d7ef3 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -69,6 +69,21 @@ module ProTacts
 
         "#{(days / 30.0).round}mo ago"
       end
+
+      # time_ago's other half: how far ahead a known day is, rendered
+      # with the same coarseness. A birthday list spans weeks, so days
+      # give way to weeks after a fortnight and to months after a
+      # quarter.
+      #: (Date date) -> String
+      def self.time_until(date)
+        days = (date - Date.today).to_i
+        return "today" if days.zero?
+        return "tomorrow" if days == 1
+        return "in #{days}d" if days < 14
+        return "in #{(days / 7.0).round}w" if days < 90
+
+        "in #{(days / 30.0).round}mo"
+      end
     end
   end
 end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 705bf88..65f8c0b 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -6,7 +6,9 @@ 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. No JavaScript: nothing served here yet needs any.
+    # 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.
     class Layout < Phlex::HTML
       #: (title: String) -> void
       def initialize(title:)
@@ -28,7 +30,10 @@ module ProTacts
             link(rel: "stylesheet", href: "/admin.css")
           end
           body do
-            header(class: "admin-header") { a(href: "/") { "pro-tacts" } }
+            header(class: "admin-header") do
+              a(href: "/") { "pro-tacts" }
+              nav { a(href: "/birthdays", class: "type-label") { "birthdays" } }
+            end
             main(class: "admin-main") { yield }
           end
         end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 4ed95d1..85a7c9b 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,3 +1,4 @@
+require "date"
 require "digest"
 require "pathname"
 require "sequel"
@@ -57,6 +58,12 @@ module ProTacts
     # @rbs skip
     RecentContact = Data.define(:contact, :updated_at)
 
+    # A contact paired with the calendar day its birthday next lands
+    # on — the read behind the admin's upcoming-birthdays list, where
+    # the order is the coming year's, not the stored components'.
+    # @rbs skip
+    UpcomingBirthday = Data.define(:contact, :occurs_on)
+
     # SQLite has no ON UPDATE, so the column default stamps a row on
     # insert and this stamps it again on the way past. Same expression as
     # the migration's, deliberately: the database keeps the clock, so two
@@ -156,6 +163,26 @@ module ProTacts
       Digest::SHA256.hexdigest(contacts.map { "#{it.id} #{it.etag}" }.sort.join("\n"))
     end
 
+    # Birthdays that land on a coming calendar day, in arrival order:
+    # today's first, and one already passed this year wrapped onto next
+    # year rather than dropped — the wrap that keeps "upcoming" a
+    # year-round answer instead of a January one. Only the shapes with
+    # both a month and a day land anywhere: a year alone, a year and
+    # month, and a month alone sit on no calendar day, and a day with
+    # no month arrives in no week in particular, so none of the four is
+    # this list's to place. A birthday is database state rather than a
+    # line in a card (docs/plans/2026-08-31-partial-birthdays.md), so
+    # this reads the table rather than scanning cards for BDAY.
+    #: (Integer limit, ?today: Date) -> Array[UpcomingBirthday]
+    def upcoming_birthdays(limit, today: Date.today)
+      birthdays
+        .join(:cards, id: :card_id)
+        .map { upcoming_from(it, today) }
+        .compact
+        .sort_by { [it.occurs_on, it.contact.id] }
+        .first(limit)
+    end
+
     # `sole` rather than `first`: the id is the primary key, so a second
     # row is a corruption and not a choice to make quietly. It raises on
     # no row too, which here is the ordinary answer for an href nobody
@@ -514,6 +541,33 @@ module ProTacts
       Birthday.new(year: row[:year], month: row[:month], day: row[:day])
     end
 
+    # One joined birthday-and-card row as an UpcomingBirthday, or nil
+    # for a shape that lands on no calendar day (see
+    # #upcoming_birthdays).
+    #: (Hash[Symbol, untyped] row, Date today) -> UpcomingBirthday?
+    def upcoming_from(row, today)
+      birthday = birthday_from(row)
+      month, day = birthday.month, birthday.day
+      return if month.nil? || day.nil?
+
+      candidate = date_in(today.year, month, day)
+      UpcomingBirthday.new(
+        contact: contact_from(row, birthday),
+        occurs_on: candidate < today ? date_in(today.year + 1, month, day) : candidate,
+      )
+    end
+
+    # A birthday's month and day as a date in `year`. A day the month
+    # does not have — February 30, April 31 — was stored well-shaped but
+    # calendar-nonsense (see Birthday), and lands on the month's last
+    # day for ordering; what the view shows is the stored value.
+    #: (Integer year, Integer month, Integer day) -> Date
+    def date_in(year, month, day)
+      Date.new(year, month, day)
+    rescue ArgumentError
+      Date.new(year, month, -1)
+    end
+
     #: (Hash[Symbol, untyped] row) -> Change
     def change_from(row)
       Change.new(
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index f5ed746..15afb9d 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -8,6 +8,7 @@ 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"
@@ -101,6 +102,16 @@ 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
diff --git a/public/admin.css b/public/admin.css
index e7db6ba..33a2447 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -15,6 +15,18 @@
   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;
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 20a8b56..bf631a6 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -79,7 +79,11 @@ module Sequel
     def where: (*untyped conditions) -> Dataset
     def order: (*untyped columns) -> Dataset
     def limit: (Integer count) -> Dataset
+    # Hash conditions pair the joined table's column with the source's
+    # (`id: :card_id` is cards.id = birthdays.card_id); the array form
+    # joins same-named columns.
     def join: (Symbol table, Array[Symbol] using) -> Dataset
+           | (Symbol table, Sequel::row conditions) -> Dataset
 
     # The table a dataset selects from; raises when it has no source.
     def first_source: () -> Symbol
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 56af7cb..67be979 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -1,5 +1,5 @@
 # lib/pro_tacts/admin is ignored by the Steepfile — built on Phlex,
-# which ships no signatures — but web.rb still calls its two views, and
+# which ships no signatures — but web.rb still calls its views, and
 # a checked file cannot reference a constant nothing declares. This
 # holds exactly that entry surface; the views' own files carry none.
 # When Phlex gets a stand-in under sig/gems and admin is checked for
@@ -13,5 +13,11 @@ module ProTacts
     class ContactsIndex
       def self.call: (recent: Array[Store::RecentContact], query: String?) -> String
     end
+
+    class Birthdays
+      LIMIT: Integer
+
+      def self.call: (upcoming: Array[Store::UpcomingBirthday]) -> String
+    end
   end
 end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 828316a..69fdbf5 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -19,5 +19,12 @@ module ProTacts
 
       def self.new: (contact: Contact, updated_at: String) -> instance
     end
+
+    class UpcomingBirthday
+      attr_reader contact: Contact
+      attr_reader occurs_on: Date
+
+      def self.new: (contact: Contact, occurs_on: Date) -> instance
+    end
   end
 end
diff --git a/test/pro_tacts/admin/test_birthdays_page.rb b/test/pro_tacts/admin/test_birthdays_page.rb
new file mode 100644
index 0000000..ef0edfc
--- /dev/null
+++ b/test/pro_tacts/admin/test_birthdays_page.rb
@@ -0,0 +1,102 @@
+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 2c2c737..24a36d6 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -128,8 +128,8 @@ class AdminContactsPagesTest < Minitest::Test
       get "/contacts/bare"
 
       assert_equal 200, last_response.status
-      refute_includes last_response.body, "notes"
-      refute_includes last_response.body, "birthday"
+      refute_includes last_response.body, '<dt class="type-label">notes</dt>'
+      refute_includes last_response.body, '<dt class="type-label">birthday</dt>'
     end
   end
 
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 43325e2..7733a62 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -700,6 +700,83 @@ class StoreTest < Minitest::Test
     end
   end
 
+  ## Upcoming birthdays
+
+  NO_YEAR = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:No Year\r\n" \
+    "BDAY;X-APPLE-OMIT-YEAR=1604:1604-06-15\r\nUID:noyear\r\nEND:VCARD\r\n"
+
+  # Arrival order is the coming year's, not the calendar's: a
+  # birthday already passed this year wraps onto next year and sorts
+  # after one still ahead, and one already here today is first.
+  def test_upcoming_birthdays_arrive_in_order_across_the_year_wrap
+    september = Date.new(2026, 9, 3)
+    ahead = AIDEN.sub("FN:Aiden\r\n", "FN:Aiden\r\nBDAY:1985-09-04\r\n")
+    passed = ZED.sub("FN:Zed\r\n", "FN:Zed\r\nBDAY:1990-03-15\r\n")
+    today = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Today\r\nBDAY:2000-09-03\r\nUID:today\r\nEND:VCARD\r\n"
+
+    with_store({"aiden" => ahead, "znorth" => passed, "today" => today}) do |store|
+      upcoming = store.upcoming_birthdays(10, today: september)
+
+      assert_equal %w[today aiden znorth], upcoming.map { it.contact.id }
+      assert_equal Date.new(2026, 9, 3), upcoming.fetch(0).occurs_on
+      assert_equal Date.new(2026, 9, 4), upcoming.fetch(1).occurs_on
+      assert_equal Date.new(2027, 3, 15), upcoming.fetch(2).occurs_on
+    end
+  end
+
+  # The limit keeps the list the length a screen wants, off the front.
+  def test_upcoming_birthdays_take_the_first_n
+    september = Date.new(2026, 9, 3)
+    cards = (1..3).to_h do |n|
+      ["c#{n}", "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Contact #{n}\r\nBDAY:1990-09-0#{n + 4}\r\nUID:c#{n}\r\nEND:VCARD\r\n"]
+    end
+
+    with_store(cards) do |store|
+      assert_equal %w[c1 c2], store.upcoming_birthdays(2, today: september).map { it.contact.id }
+    end
+  end
+
+  # A birthday without a year is on the day and in the list — the age
+  # is the view's question, not the query's.
+  def test_a_birthday_without_a_year_is_upcoming
+    september = Date.new(2026, 9, 3)
+
+    with_store({"noyear" => NO_YEAR}) do |store|
+      upcoming = store.upcoming_birthdays(10, today: september)
+
+      assert_equal 1, upcoming.length
+      assert_equal "noyear", upcoming.fetch(0).contact.id
+      assert_equal Date.new(2027, 6, 15), upcoming.fetch(0).occurs_on
+    end
+  end
+
+  # The shapes on no calendar day are nobody's to place. Planted
+  # directly, because no writer produces one yet.
+  def test_birthdays_on_no_calendar_day_are_left_out
+    september = Date.new(2026, 9, 3)
+
+    with_store({"aiden" => AIDEN_BORN, "noyear" => NO_YEAR, "znorth" => ZED}) do |store|
+      database(store)[:birthdays].where(card_id: "noyear").update(year: 1990, month: 6, day: nil)
+      database(store)[:birthdays].insert(card_id: "znorth", year: 1990)
+
+      assert_equal %w[aiden], store.upcoming_birthdays(10, today: september).map { it.contact.id }
+    end
+  end
+
+  # Well-shaped but calendar-nonsense — February 30 — lands on the
+  # month's last day for ordering, the same value Format.birthday
+  # rescues to a raw display of.
+  def test_a_day_the_month_does_not_have_orders_on_the_months_last_day
+    september = Date.new(2026, 9, 3)
+    nonsense = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Nonsense\r\nBDAY:1980-02-30\r\nUID:nonsense\r\nEND:VCARD\r\n"
+
+    with_store({"nonsense" => nonsense, "noyear" => NO_YEAR}) do |store|
+      upcoming = store.upcoming_birthdays(10, today: september)
+
+      assert_equal Date.new(2027, 2, 28), upcoming.fetch(0).occurs_on
+    end
+  end
+
   # The transaction the whole design turns on, now with a third piece:
   # a birthday must not survive a write whose log entry failed.
   def test_a_failed_birthday_write_leaves_no_birthday