import: refuse a birthday no staged card can spell
Steep caught it: Birthday#to_line is nilable, and the save passed
its result straight into VCard#replace. A birthday with no vCard 3.0
spelling — a year on its own, a month without its day — has nowhere
to go on a card that has not landed, there being no model to hold it
yet, so the save says so instead of writing the card without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index 761f945..3a72d3b 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -111,7 +111,13 @@ ordinary edit passes none of them. The birthday is split out of the
card and into the model here exactly as `Store#put` splits it, so the
same row renders over a staged card as over a stored contact, and put
back as a BDAY line on save; a BDAY spelling the model does not read
-stays in the card untouched, that method's own rule.
+stays in the card untouched, that method's own rule. The one birthday
+that cannot make the trip is one no card can spell — a year on its own,
+a month without its day (`2026-08-31-partial-birthdays.md`). A stored
+contact holds that in the model and serves a card without it; a staged
+contact is only its card, so the save says so and asks for the date
+again once the contact has landed, rather than dropping it the moment
+it was typed.
## The import waits on the server
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 46f24be..16b774f 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -205,7 +205,21 @@ module ProTacts
# a BDAY spelling the model does not read: that line stayed in
# the card (#import_contact, Store#put's own rule), no row
# rendered for it, and a replace here would delete it.
- edited = edited.replace("BDAY", birthday ? [birthday.to_line] : []) unless carries_own_bday?(contact)
+ unless carries_own_bday?(contact)
+ line = birthday&.to_line
+ # A birthday with no wire form — a year on its own, a month
+ # without its day — is one a vCard 3.0 card cannot spell
+ # (docs/plans/2026-08-31-partial-birthdays.md). A stored
+ # contact keeps one in the model and serves a card without it;
+ # a staged contact has no model, so the card is the whole of
+ # what it is, and writing it without the date would lose the
+ # date the moment it was typed. Say so instead.
+ if birthday && line.nil?
+ return card_screen(upload, index, notice: "A card cannot hold a birthday that partial until it lands. Land the contact, then add it on its own page.")
+ end
+
+ edited = edited.replace("BDAY", line ? [line] : [])
+ end
landing[index] = edited
Import::Staged.update(upload, joined(landing))
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 7703be3..025b388 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -187,6 +187,45 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # The birthday is the one field the editor holds in the model
+ # rather than in the card, and a staged contact has no model: the
+ # save writes it back as the BDAY line it will land as.
+ def test_a_birthday_typed_on_the_import_lands_with_the_contact
+ with_contacts({}) do |store|
+ id = upload(JANE)
+ get "/import/#{id}/0"
+
+ post "/import/#{id}/0",
+ "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "", "note" => "",
+ "birthday" => { "year" => "1985", "month" => "4", "day" => "12" }
+
+ assert_equal 303, last_response.status
+
+ post "/import/#{id}/land", "group" => ""
+
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), store.contacts.fetch(0).birthday
+ end
+ end
+
+ # And the shape no card can spell is refused rather than dropped:
+ # the model that holds a partial birthday does not exist until the
+ # contact lands (docs/plans/2026-08-31-partial-birthdays.md).
+ def test_a_birthday_no_card_can_spell_is_refused_until_the_contact_lands
+ with_contacts({}) do |_store|
+ id = upload(JANE)
+ get "/import/#{id}/0"
+
+ post "/import/#{id}/0",
+ "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "", "note" => "",
+ "birthday" => { "year" => "1985", "month" => "", "day" => "" }
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "A card cannot hold a birthday that partial until it lands."
+ end
+ end
+
def test_confirming_lands_the_contacts_as_they_stand
with_contacts({}) do |store|
id = upload(JANE + PLAIN)