Move the upcoming-birthday date math onto Birthday
Where a birthday next lands, which shapes land nowhere, and where a
February 30 falls are facts about a Birthday, not reads of the table.
Birthday#next_on(today) now answers them, and Store#upcoming_from keeps
only the query's part. The unit tests for the date math move with it.
Also rewraps a line of Store's header comment that #18 left unwrapped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi55bFkB6wo4jguNtsase4
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index d1f61de..07effdb 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -121,6 +121,23 @@ module ProTacts
!to_line.nil?
end
+ # The day this birthday next lands on, today included, so one
+ # already passed this year wraps onto next year rather than going
+ # missing. 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 the four answer nil. A day the month does not
+ # have, February 30 or April 31, is well-shaped but calendar-
+ # nonsense (see .new), and lands on the month's last day; #to_s
+ # still names the day it says.
+ def next_on(today)
+ month, day = self.month, self.day
+ return if month.nil? || day.nil?
+
+ this_year = date_in(today.year, month, day)
+ this_year < today ? date_in(today.year + 1, month, day) : this_year
+ end
+
# One parsed BDAY property as a birthday, or nil for a line the model
# does not take — which then stays in the card verbatim (RFC 6352
# section 6.3.2.2). A grouped `item1.BDAY` is not taken, because its
@@ -171,5 +188,13 @@ module ProTacts
end
end
private_class_method :from_value, :apple_no_year?
+
+ private
+
+ def date_in(year, month, day)
+ Date.new(year, month, day)
+ rescue ArgumentError
+ Date.new(year, month, -1)
+ end
end
end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 3f92b2e..304d1eb 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -34,9 +34,10 @@ module ProTacts
# Every Contact this store hands out is composed, never the stored
# card alone: a birthday and the lines a contact inherits from its
# groups are both subtracted out of a card on the way in
- # (EditedContact), and Contact composes them back in on read — so the vcard and the etag a caller sees, and the etag
- # the change log records, describe the card a client downloads, not
- # the bytes on disk.
+ # (EditedContact), and Contact composes them back in on read — so
+ # the vcard and the etag a caller sees, and the etag the change log
+ # records, describe the card a client downloads, not the bytes on
+ # disk.
#
# The signature lives in sig/pro_tacts/store.rbs, for the Change Data
# class the inline syntax cannot read.
@@ -263,13 +264,11 @@ module ProTacts
# 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.
+ # year-round answer instead of a January one. Where each lands, and
+ # which shapes land nowhere, is Birthday#next_on's to say. 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)
inherited = inherited_by_id
@@ -1239,30 +1238,14 @@ module ProTacts
end
# One joined birthday-and-card row as an UpcomingBirthday, or nil
- # for a shape that lands on no calendar day (see
- # #upcoming_birthdays).
+ # for a shape that lands on no calendar day (Birthday#next_on).
#: (Hash[Symbol, untyped] row, Date today, Array[Contact::Inherited] inherited) -> UpcomingBirthday?
def upcoming_from(row, today, inherited)
birthday = birthday_from(row)
- month, day = birthday.month, birthday.day
- return if month.nil? || day.nil?
+ occurs_on = birthday.next_on(today)
+ return if occurs_on.nil?
- candidate = date_in(today.year, month, day)
- UpcomingBirthday.new(
- contact: contact_from(row, birthday, inherited),
- 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)
+ UpcomingBirthday.new(contact: contact_from(row, birthday, inherited), occurs_on:)
end
#: (Hash[Symbol, untyped] row) -> Change
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index 100f552..6259fad 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -25,6 +25,8 @@ module ProTacts
def served?: () -> bool
+ def next_on: (Date today) -> Date?
+
def self.from_property: (VCard::Parser::Property property) -> Birthday?
def self.significant_parameters: (VCard::Parser::Property property) -> Array[[String, String]]
@@ -32,5 +34,9 @@ module ProTacts
def self.apple_no_year?: (Array[[String, String]] parameters) -> bool
def self.from_value: (String value) -> Birthday?
+
+ private
+
+ def date_in: (Integer year, Integer month, Integer day) -> Date
end
end
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index ad1b713..2ae03e8 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -81,6 +81,43 @@ class BirthdayTest < Minitest::Test
assert_equal "February 30, 1985", ProTacts::Birthday.new(year: 1985, month: 2, day: 30).to_s
end
+ ## next_on
+
+ # A birthday still ahead this year lands this year, and one already
+ # here today lands today rather than a year out.
+ def test_next_on_lands_this_year_from_today_on
+ today = Date.new(2026, 9, 3)
+
+ assert_equal Date.new(2026, 9, 4), ProTacts::Birthday.new(year: 1985, month: 9, day: 4).next_on(today)
+ assert_equal Date.new(2026, 9, 3), ProTacts::Birthday.new(month: 9, day: 3).next_on(today)
+ end
+
+ # One already passed wraps onto next year rather than going missing.
+ def test_next_on_wraps_a_passed_birthday_onto_next_year
+ assert_equal Date.new(2027, 3, 15), ProTacts::Birthday.new(year: 1990, month: 3, day: 15).next_on(Date.new(2026, 9, 3))
+ end
+
+ # Only a month and a day together name a calendar day.
+ def test_next_on_is_nil_for_the_shapes_on_no_calendar_day
+ today = Date.new(2026, 9, 3)
+
+ [
+ ProTacts::Birthday.new(year: 1985, month: 4),
+ ProTacts::Birthday.new(year: 1985),
+ ProTacts::Birthday.new(month: 4),
+ ProTacts::Birthday.new(day: 12),
+ ].each { |birthday| assert_nil birthday.next_on(today) }
+ end
+
+ # Well-shaped but calendar-nonsense lands on the month's last day,
+ # the year's own February deciding which: 29 in a leap year.
+ def test_next_on_puts_a_day_the_month_does_not_have_on_its_last_day
+ nonsense = ProTacts::Birthday.new(year: 1980, month: 2, day: 30)
+
+ assert_equal Date.new(2027, 2, 28), nonsense.next_on(Date.new(2026, 9, 3))
+ assert_equal Date.new(2028, 2, 29), nonsense.next_on(Date.new(2027, 9, 3))
+ end
+
## from_property
# A parsed property, the shape a read out of a card takes.
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 4654046..603ec83 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1059,20 +1059,6 @@ class StoreTest < Minitest::Test
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