Fix contacts_by_recency after the birthday split, vary fixture timestamps
Rebasing onto main's birthday work (composed in/subtracted out of the
stored card, see lib/pro_tacts/birthday.rb) left contacts_by_recency
calling contact_from with its old one-argument shape; it now looks up
each card's birthday the same way #contacts does.

ContactFields' birthday formatting now reuses ProTacts::Birthday
instead of its own regex guess at the no-year spelling, which turned
out to be wrong: a served card's no-year birthday is Apple's
1604-sentinel form, not the "--MM-DD" ISO shape this originally
guessed at.

FixtureData now backdates each seed card to a spread of times after
installing it, so `rake dev`'s recently-updated list shows more than
"just now" for every contact — Store#put itself still only ever asks
SQLite for the current time; this reaches around that the same way a
test does, through the store's own database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYiigu3aFNaiTP9SyKW9mX
change
commit 229ccefed1a99e955b9314c880296ddde017e9a0
author Claude <noreply@anthropic.com>
date
parent qykpwxur
diff --git a/lib/pro_tacts/admin/contact_fields.rb b/lib/pro_tacts/admin/contact_fields.rb
index 6af1dc4..bbf0b29 100644
--- a/lib/pro_tacts/admin/contact_fields.rb
+++ b/lib/pro_tacts/admin/contact_fields.rb
@@ -1,5 +1,6 @@
 require "date"
 
+require "pro_tacts/birthday"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
@@ -15,16 +16,6 @@ module ProTacts
       Email = Data.define(:value, :type)
       Address = Data.define(:lines, :type)
 
-      # RFC 2426 section 3.1.5 defines BDAY as a date or a date-time; the
-      # reduced-precision "--MM-DD" form (ISO 8601, not RFC 2426 itself)
-      # is how a year gets omitted. Matched explicitly rather than
-      # handed to Date.iso8601, which parses "--12-10" by silently
-      # filling in the current year — exactly the wrong answer for a
-      # birthday that deliberately has none. See the open question on
-      # year omission in docs/plans/2026-08-24-vcard-storage-and-groups.md.
-      FULL_DATE = /\A(\d{4})-(\d{2})-(\d{2})/
-      NO_YEAR_DATE = /\A--(\d{2})-(\d{2})/
-
       attr_reader :name, :phones, :emails, :addresses, :birthday, :notes
 
       #: (Contact contact) -> ContactFields
@@ -42,7 +33,7 @@ module ProTacts
         @phones = by_name.fetch("TEL", []).map { |p| Phone.new(value: VCard.unescape(p.value), type: type_of(p)) }
         @emails = by_name.fetch("EMAIL", []).map { |p| Email.new(value: VCard.unescape(p.value), type: type_of(p)) }
         @addresses = by_name.fetch("ADR", []).map { |p| address_from(p) }
-        @birthday = format_birthday(by_name.fetch("BDAY", []).first&.value)
+        @birthday = format_birthday(by_name.fetch("BDAY", []).first)
         note = by_name.fetch("NOTE", []).first&.value
         @notes = note && VCard.unescape(note)
       end
@@ -72,21 +63,25 @@ module ProTacts
         Address.new(lines:, type: type_of(property))
       end
 
-      # A value this cannot recognize is shown as stored rather than
-      # dropped or guessed at.
-      #: (String? value) -> String?
-      def format_birthday(value)
-        return nil if value.nil? || value.empty?
+      # Reuses ProTacts::Birthday, which knows the two BDAY spellings a
+      # served card actually carries (see lib/pro_tacts/birthday.rb): a
+      # plain full date, and Apple's 1604-sentinel no-year form. A BDAY
+      # in any other shape is one the app's model doesn't recompose —
+      # kept in the card verbatim on write — so it's shown as stored.
+      #: (VCard::Property? property) -> String?
+      def format_birthday(property)
+        return nil if property.nil?
+
+        birthday = Birthday.from_property(property)
+        return property.value if birthday.nil?
 
-        if (m = value.match(FULL_DATE))
-          Date.new(m[1].to_i, m[2].to_i, m[3].to_i).strftime("%B %-d, %Y")
-        elsif (m = value.match(NO_YEAR_DATE))
-          Date.new(2000, m[1].to_i, m[2].to_i).strftime("%B %-d")
+        if birthday.year
+          Date.new(birthday.year, birthday.month, birthday.day).strftime("%B %-d, %Y")
         else
-          value
+          Date.new(2000, birthday.month, birthday.day).strftime("%B %-d")
         end
       rescue ArgumentError
-        value
+        property.value
       end
     end
   end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index e143dbf..45ae0f6 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -127,8 +127,12 @@ module ProTacts
     # by id.
     #: () -> Array[RecentContact]
     def contacts_by_recency
+      birthdays = birthdays_by_id
       cards.order(Sequel.desc(:updated_at)).map {
-        RecentContact.new(contact: contact_from(it), updated_at: it.fetch(:updated_at).to_s)
+        RecentContact.new(
+          contact: contact_from(it, birthdays[it.fetch(:id).to_s]),
+          updated_at: it.fetch(:updated_at).to_s,
+        )
       }
     end
 
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index f09bcf0..e44b22e 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -17,6 +17,16 @@ require "pro_tacts/store"
 module FixtureData
   CARDS = Pathname.new(__dir__) / "fixtures" / "cards"
 
+  # How long ago each successive card (in #cards' own order) last
+  # changed, cycling if there are ever more cards than entries. Spread
+  # rather than exact: what backdate wants is variety for the admin
+  # UI's recently-updated list, not specific values for anything to
+  # assert on.
+  AGO_SECONDS = [
+    0, 5 * 60, 45 * 60, 3 * 3600, 9 * 3600,
+    1 * 86_400, 3 * 86_400, 8 * 86_400, 15 * 86_400, 30 * 86_400, 75 * 86_400,
+  ].freeze
+
   # Every seed card, keyed by the id its filename gives it.
   def self.cards
     CARDS.glob("*.vcf").sort.to_h { [it.basename(".vcf").to_s, it.read] }
@@ -30,9 +40,30 @@ module FixtureData
     FileUtils.mkdir_p(directory)
 
     store = ProTacts::Store.at(directory / "contacts.db")
-    cards.each do |id, card|
+    ids = cards.each do |id, card|
       store.put(id, card)
-    end
+    end.keys
+    backdate(store, ids)
     store
   end
+
+  # Store#put always asks SQLite for the current time (see the comment
+  # on ProTacts::Store::NOW) — on purpose, since a real write's stamp
+  # has to be the database's clock, not Ruby's idea of one. A fixture
+  # book installed in one quick burst would otherwise land every card
+  # within the same millisecond, and "recently updated" would show
+  # nothing else. Fixture data is synthetic by definition, so backdating
+  # it here is not the write path that invariant protects — reached the
+  # same way a test does, through the store's own database (see
+  # StoreTest#database in test/pro_tacts/test_store.rb), because nothing
+  # public exposes it and nothing public should.
+  def self.backdate(store, ids)
+    database = store.instance_variable_get(:@database)
+    now = Time.now.utc
+    ids.each_with_index do |id, index|
+      ago = AGO_SECONDS.fetch(index % AGO_SECONDS.length)
+      stamp = (now - ago).strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
+      database[:cards].where(id:).update(updated_at: stamp)
+    end
+  end
 end
diff --git a/test/pro_tacts/admin/test_contact_fields.rb b/test/pro_tacts/admin/test_contact_fields.rb
index 23c3dd8..3c67454 100644
--- a/test/pro_tacts/admin/test_contact_fields.rb
+++ b/test/pro_tacts/admin/test_contact_fields.rb
@@ -74,10 +74,10 @@ class ContactFieldsTest < Minitest::Test
     assert_equal "December 10, 1985", fields(with_time).birthday
   end
 
-  # ISO 8601's reduced-precision form for "no year on file", not RFC
-  # 2426 itself — see the comment on ContactFields::NO_YEAR_DATE.
+  # Apple's 1604-sentinel spelling for "no year on file" (see
+  # ProTacts::Birthday), the shape a served card actually carries.
   def test_a_birthday_with_no_year_omits_one
-    no_year = CARD.sub("BDAY:1985-12-10", "BDAY:--12-10")
+    no_year = CARD.sub("BDAY:1985-12-10", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-12-10")
 
     assert_equal "December 10", fields(no_year).birthday
   end