admin: show a reduced no-year birthday as its day
Display-layer on purpose: modeling the reduced spelling would change
what a submitted card keeps and what a served card carries, and those
shapes are recorded decisions (docs/plans/2026-08-31).
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 32d7ef3..0966ea9 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -1,6 +1,8 @@
require "date"
require "time"
+require "pro_tacts/birthday"
+
module ProTacts
module Admin
# Small display helpers shared by the admin views. Presentation, not
@@ -23,13 +25,14 @@ module ProTacts
end
# The birthday a card carries, for display: the model's date when
- # the model holds one, the raw property when it does not — a BDAY
- # in a spelling the model doesn't recompose is kept in the card
- # verbatim on write, so it's shown as stored.
+ # the model holds one, the reduced no-year spelling rendered as its
+ # day, and the raw property for anything else — a BDAY in a spelling
+ # the model doesn't recompose is kept in the card verbatim on write,
+ # so it's shown as stored.
#: (Contact contact) -> String?
def self.birthday(contact)
birthday = contact.birthday
- return raw_birthday(contact) if birthday.nil?
+ return reduced_no_year(contact) || raw_birthday(contact) if birthday.nil?
if birthday.year
Date.new(birthday.year, birthday.month, birthday.day).strftime("%B %-d, %Y")
@@ -50,6 +53,31 @@ module ProTacts
contact.properties.find { it.name.casecmp?("BDAY") }&.value
end
+ # A no-year BDAY in the reduced spelling (Birthday::REDUCED_DATE):
+ # macOS reads it on a card it did not write, but the model
+ # recomposes only the two forms it serves, so the line stays in the
+ # card verbatim and this renders it as the day it names. Bare only,
+ # like the model's own reads — a grouped or parametered BDAY's
+ # pairing belongs to the card, not this row.
+ #: (Contact contact) -> String?
+ def self.reduced_no_year(contact)
+ property = contact.properties.find { it.name.casecmp?("BDAY") }
+ return nil if property.nil? || property.group || !property.parameters.empty?
+
+ match = property.value.match(Birthday::REDUCED_DATE)
+ return nil if match.nil?
+
+ # The pattern matches without capturing (it is a predicate
+ # elsewhere); the matched string carries the components — two
+ # digits of month and two of day, an optional dash between.
+ digits = match[0].delete_prefix("--").delete("-")
+ Date.new(2000, digits[0, 2].to_i, digits[2, 2].to_i).strftime("%B %-d")
+ rescue ArgumentError
+ # Calendar-nonsense in this spelling too (February 30): shown as
+ # stored, like the modeled branch above.
+ nil
+ end
+
# A card's updated_at, UTC ISO 8601 to the millisecond (see
# Store), rendered the way "recently updated" wants it: coarse and
# relative, not a timestamp to read precisely.
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index d222e25..ce08eaa 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -68,6 +68,31 @@ class FormatTest < Minitest::Test
assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(no_year))
end
+ # The reduced no-year spelling (Birthday::REDUCED_DATE), which macOS
+ # reads on a card it did not write: the model does not recompose it,
+ # so the line stays in the card verbatim — display renders it as a
+ # day all the same, not as the stored string.
+ def test_a_reduced_no_year_birthday_displays_its_day
+ reduced = CARD.sub("BDAY:1985-12-10", "BDAY:--12-10")
+
+ assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(reduced))
+ end
+
+ # The undashed variant of the reduced spelling.
+ def test_an_undashed_reduced_no_year_birthday_displays_its_day
+ basic = CARD.sub("BDAY:1985-12-10", "BDAY:--1210")
+
+ assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(basic))
+ end
+
+ # Calendar-nonsense in the reduced spelling, shown as stored like
+ # the modeled one below.
+ def test_a_calendar_nonsense_reduced_no_year_birthday_is_shown_as_stored
+ nonsense = CARD.sub("BDAY:1985-12-10", "BDAY:--02-30")
+
+ assert_equal "--02-30", ProTacts::Admin::Format.birthday(contact(nonsense))
+ end
+
def test_a_birthday_that_will_not_parse_is_shown_as_stored
unparseable = CARD.sub("BDAY:1985-12-10", "BDAY:not-a-date")