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
change
commit 26b5a3a2a6c110def3ed53d8c4ae0d88de9c8e70
author Claude <noreply@anthropic.com>
date
parent rswwskqw
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