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