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