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
change xyukntqvtxkxkourztulrmwzmumyqqut
commit 8356f58cb9ffa346557bf9173d21900d58c128c6
author Alpha Chen <alpha@kejadlen.dev>
date
parent lvrsknlu
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")