card form: a full date in 1604 saves as a month and day
The editor could save a birthday in 1604, which served as a bare 1604
date and read back at the next sync as a month and day: the one shape
the every-birthday plan gave up. The editor now reads the sentinel as
every client's line does, so what it saves is what comes back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
diff --git a/docs/plans/2026-09-11-every-birthday-in-the-model.md b/docs/plans/2026-09-11-every-birthday-in-the-model.md
index 4cd2eba..542579a 100644
--- a/docs/plans/2026-09-11-every-birthday-in-the-model.md
+++ b/docs/plans/2026-09-11-every-birthday-in-the-model.md
@@ -51,9 +51,9 @@ into a 1604 one after an iOS save. A family address book has nobody
born in 1604, so a full date in that year now reads as a month and day,
parameter or not.
-The model can still hold a year of 1604 from the admin editor, and it
-goes out as a bare 1604 date that reads back as a month and day. That
-is the one shape this gives up.
+The admin editor reads a full date in 1604 the same way, as a month
+and day (`Admin::CardForm.birthday`), so the model never holds a year
+that would come back without one.
## One line, three outcomes
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 0592c1c..1b475bd 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -111,6 +111,13 @@ module ProTacts
# class, for the save to catch and re-render — nothing here
# reaches Sentry, bad input being ordinary and the toast the
# fallback.
+ #
+ # A full date in 1604 is a month and day, the sentinel read here as
+ # every client's line reads it (Birthday::NO_YEAR): the model would
+ # otherwise hold a year that serves as a bare 1604 date and reads
+ # back without one at the next sync
+ # (docs/plans/2026-09-11-every-birthday-in-the-model.md, "1604 means
+ # no year").
#: (untyped fields) -> Birthday?
def self.birthday(fields)
year = birthday_component(fields["year"])
@@ -118,6 +125,8 @@ module ProTacts
day = birthday_component(fields["day"])
return if year.nil? && month.nil? && day.nil?
+ year = nil if year == Birthday::NO_YEAR && month && day
+
Birthday.new(year:, month:, day:)
end
diff --git a/test/pro_tacts/admin/test_card_form.rb b/test/pro_tacts/admin/test_card_form.rb
index 8ea85f7..7440f8a 100644
--- a/test/pro_tacts/admin/test_card_form.rb
+++ b/test/pro_tacts/admin/test_card_form.rb
@@ -3,6 +3,7 @@ require_relative "../../test_helper"
require "hegel"
require "pro_tacts/admin/card_form"
+require "pro_tacts/birthday_line"
require "pro_tacts/contact"
require "pro_tacts/vcard"
@@ -92,4 +93,32 @@ class CardFormTest < Minitest::Test
assert_equal stored, saved.to_s
end
end
+
+ ## The birthday row
+
+ # Whatever the birthday row saves, a served birthday reads back from
+ # its line as itself: the next sync cannot change what the editor
+ # wrote. The year range is drawn around 1604, the sentinel a served
+ # full date would lose its year to (Birthday::NO_YEAR).
+ def test_a_saved_birthday_reads_back_from_the_line_it_serves
+ field = ->(range) { optional(integers(min_value: range.min, max_value: range.max).map(&:to_s)) }
+ Hegel.test(test_cases: 1_000) do |tc|
+ fields = {
+ "year" => tc.draw(one_of(field.(1600..1610), field.(0..9999))),
+ "month" => tc.draw(field.(1..12)),
+ "day" => tc.draw(field.(1..31)),
+ }
+ # A shape the grammar refuses is the row's refusal, no save at all.
+ birthday = begin
+ ProTacts::Admin::CardForm.birthday(fields)
+ rescue ArgumentError
+ nil
+ end
+ line = birthday&.to_line
+ tc.assume(!line.nil?)
+
+ read = ProTacts::BirthdayLine.read(ProTacts::VCard.new("#{line}\r\n").lines.fetch(0))
+ assert_equal birthday, read.birthday
+ end
+ end
end