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
change
commit 17ff52624d3bea49f829b8a4d1813b96e0f99632
author Claude <noreply@anthropic.com>
date
parent 39490738
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)