admin: a birthday row on the edit screen, saved to the model
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 182af58..83d710f 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -1,3 +1,5 @@
+require "date"
+
require "pro_tacts/admin/phlex"
require "pro_tacts/admin/layout"
@@ -10,8 +12,9 @@ module ProTacts
# properties the save knows how to address. The cardinality-1 set
# — name, nickname, note — saves through VCard#replace under each
# field; the phone, email, and address rows save through
- # VCard#substitute, each named by its line's digest. Birthday
- # arrives last, with its own doc.
+ # VCard#substitute, each named by its line's digest; the birthday
+ # row saves to the model, no card line existing to address
+ # (docs/plans/2026-09-07-web-birthday-editor.md).
#
# The fields prefill from the accessors' unescaped readings, and
# blank equals absent on the way back (Web#edited_card), so write
@@ -137,6 +140,7 @@ module ProTacts
end
end
@contact.addresses.each { address_row(it) }
+ birthday_row
added_rows
label(class: "field") do
span { "Note" }
@@ -208,6 +212,41 @@ module ProTacts
end
end
+ # The birthday row: the one row that saves to the model rather
+ # than the card's bytes (docs/plans/2026-09-07-web-birthday-editor.md)
+ # — a partial date has no vCard 3.0 spelling, so there is no
+ # line to address and the prefill is the model
+ # (Contact#birthday), not a reading off the card. Three controls
+ # in the value column, month-day-year to match the prose
+ # Birthday#to_s renders; a select for the month because a name
+ # is the friendlier read and constrains the value by
+ # construction, number inputs with native ranges for the day and
+ # year. A blank component is an absence and three blanks remove
+ # the birthday, the form's blank-equals-absent; a day without
+ # its month is refused at the save, the grammar's own rule.
+ # Every shape the grammar admits is editable, including the ones
+ # no client renders — they live here and go nowhere, their
+ # documented fate.
+ #: () -> void
+ def birthday_row
+ birthday = @contact.birthday
+ div(class: "field") do
+ span { "birthday" }
+ div(class: "date-row") do
+ select(name: "birthday[month]", aria_label: "month") do
+ option(value: "", selected: birthday&.month.nil?) { "month" }
+ Date::MONTHNAMES.compact.each_with_index do |name, index|
+ option(value: index + 1, selected: birthday&.month == index + 1) { name }
+ end
+ end
+ input(type: "number", name: "birthday[day]", value: birthday&.day,
+ min: 1, max: 31, placeholder: "day", aria_label: "day")
+ input(type: "number", name: "birthday[year]", value: birthday&.year,
+ min: 1, max: 9999, placeholder: "year", aria_label: "year")
+ end
+ end
+ end
+
# An address row: the type's caption and a stack of component
# fields in the value column — the multi-line value's own lines
# (docs/DESIGN.md: a multi-line value breaks inside the value
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 256d43b..3634288 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -23,16 +23,18 @@ module ProTacts
(contact.name || contact.id).to_s.strip[0].to_s.upcase
end
- # The birthday a card carries, for display: the model's when the
- # card carries one it recomposes, otherwise any well-shaped
- # stored spelling parsed by Birthday.from_value — bare properties
- # only, like the model's own reads — and the raw value for
- # everything else, shown as stored. The prose itself is
+ # A contact's birthday, for display: the model's — composed into
+ # the served card when a client can carry it, held alone when no
+ # form serves it (the editor can save a shape nothing renders,
+ # and the details page still shows what the record holds) — then
+ # any well-shaped stored spelling parsed by Birthday.from_value,
+ # bare properties only, like the model's own reads, and the raw
+ # value for everything else, shown as stored. The prose itself is
# Birthday#to_s, every shape's one rendering.
#: (Contact contact) -> String?
def self.birthday(contact)
property = contact.properties.find { it.name.casecmp?("BDAY") }
- return nil if property.nil?
+ return contact.birthday&.to_s if property.nil?
birthday = contact.birthday
if birthday.nil? && property.group.nil? && property.parameters.empty?
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index ee0654d..c1634a5 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -663,6 +663,27 @@ module ProTacts
last = r.params["last"].to_s.strip
return edit_screen(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
+ # The birthday row's parse — request validity, standing with the
+ # name check rather than the stored-state conditionals below
+ # (write_card's ordering rule). A POST from anything but this
+ # form carries no birthday group and keeps the model, the
+ # phones' is-a-Hash posture; a group of three blanks is the
+ # row's blank-equals-absent, a removal. The browser's number
+ # inputs make garbage a hand-crafted POST's own, so the refusal
+ # is the name toast's backstop sibling: the constructor's
+ # ArgumentError is the grammar's own refusal, caught at the one
+ # boundary with a re-render to fall back to.
+ birthday =
+ if (fields = r.params["birthday"]).is_a?(Hash)
+ begin
+ parse_birthday(fields)
+ rescue ArgumentError
+ return edit_screen(contact, notice: "That birthday is not a shape a date can take.")
+ end
+ else
+ contact.birthday
+ end
+
# The snapshot guard, the lost-update half If-Match gives DAV
# clients (RFC 7232 section 3.1): the form carried the etag of
# the card it was rendered from, and a contact that hashes
@@ -673,7 +694,19 @@ module ProTacts
# race window between check and write, noted there.
return edit_screen(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
- store.rewrite(id, edited_card(contact, first, last, r.params).to_s, birthday: contact.birthday)
+ # A stored card carrying its own BDAY lines is the one state the
+ # birthday row cannot write: the model is empty and the spelling
+ # stayed in the card because no served form recomposes it
+ # (Store#put's case analysis), so a birthday submitted here would
+ # compose a second BDAY beside the card's own. Refused whole —
+ # one submit, one etag check, one write
+ # (docs/plans/2026-09-07-web-birthday-editor.md, which also
+ # records the migration not taken).
+ if birthday && contact.stored.lines.any? { it.names?("BDAY") }
+ return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
+ end
+
+ store.rewrite(id, edited_card(contact, first, last, r.params).to_s, birthday:)
r.redirect "/contacts/#{id}", 303
end
@@ -884,6 +917,38 @@ module ProTacts
"#{header}#{components.join(";")}\r\n"
end
+ # The birthday row's parse: each blank component is an absence
+ # and three blanks are no birthday, so Birthday's constructor is
+ # the whole grammar — six shapes, ranges standing where the
+ # components stand — and the digit check before it keeps to_i
+ # honest (a "19x5" is not the year 19). All three blank returns
+ # before the constructor, the grammar refusing nothing-at-all;
+ # raises ArgumentError otherwise, the constructor's own refusal
+ # class, for the save to catch and re-render — nothing here
+ # reaches Sentry, bad input being ordinary and the toast the
+ # fallback.
+ #: (untyped fields) -> Birthday?
+ def parse_birthday(fields)
+ year = birthday_component(fields["year"])
+ month = birthday_component(fields["month"])
+ day = birthday_component(fields["day"])
+ return if year.nil? && month.nil? && day.nil?
+
+ Birthday.new(year:, month:, day:)
+ end
+
+ # One component off the wire: nil for blank, the integer it names
+ # otherwise. Digits only — a month select and ranged number inputs
+ # are the browser's own constraint, and this is the server's.
+ #: (untyped submitted) -> Integer?
+ def birthday_component(submitted)
+ value = submitted.to_s.strip
+ return nil if value.empty?
+ raise ArgumentError, "not a number a birthday holds: #{value.inspect}" unless value.match?(/\A\d{1,4}\z/)
+
+ value.to_i
+ end
+
# A text property's replacement lines: the escaped value (RFC 2426
# section 2.4.2 — the value is text, the line is structure), or none
# at all — blank equals absent, the whole-property rule.
diff --git a/public/admin.css b/public/admin.css
index 072966f..daa2ab0 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -114,6 +114,26 @@
.card-body form.field-stack .field > .field-stack {
gap: var(--gl-space-2xs);
}
+/* The birthday row's three controls — month, day, year — sit on one
+ line in the value column: a date is one value, not three fields,
+ and the line it reads on matches the prose the details page
+ renders (Birthday#to_s's "December 10, 1985"). The month select
+ grows to carry "September"; the number inputs hold their own
+ widths and shed the same block padding every other record control
+ sheds. */
+.card-body form.field-stack .field > .date-row {
+ display: flex;
+ gap: var(--gl-space-2xs);
+}
+.card-body form.field-stack .field > .date-row > select {
+ flex: 1 1 auto;
+ padding-block: var(--gl-space-2xs);
+}
+.card-body form.field-stack .field > .date-row > input {
+ flex: 0 0 auto;
+ width: 6ch;
+ padding-block: var(--gl-space-2xs);
+}
/* A multiline control has no single line to align a caption to, and
what a browser calls its baseline is not settled — a scroll
container's can be synthesized from its border box, which would
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index ac95caf..a00925f 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1059,6 +1059,164 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ ## Editing the birthday
+
+ # The one row that saves to the model rather than the card: the
+ # prefill is Contact#birthday, three controls on one line — a month
+ # select (names, constrained by construction) and ranged number
+ # inputs — in the value column, month-day-year matching the prose
+ # the details page renders.
+ def test_the_edit_screen_renders_the_birthday_row
+ with_contacts({"ada" => ADA}) do
+ get "/contacts/ada/edit"
+
+ body = last_response.body
+ assert_includes body,
+ '<div class="field"><span>birthday</span><div class="date-row">' \
+ '<select name="birthday[month]" aria-label="month"><option value="">month</option>'
+ assert_includes body, '<option value="12" selected>December</option></select>'
+ assert_includes body,
+ '<input type="number" name="birthday[day]" value="10" min="1" max="31" placeholder="day" aria-label="day">'
+ assert_includes body,
+ '<input type="number" name="birthday[year]" value="1985" min="1" max="9999" placeholder="year" aria-label="year">'
+ end
+ end
+
+ # A birthday edit is a model write: the served card composes the new
+ # BDAY, the stored bytes never move, and the change-log entry carries
+ # the new composed etag — a client's token sees what a device
+ # downloads (Store#rewrite).
+ def test_saving_a_changed_birthday_moves_the_served_card_only
+ with_contacts({"ada" => ADA}) do |store|
+ before = store.contact("ada").stored.to_s
+ post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
+ etag: store.contact("ada").etag,
+ birthday: {"month" => "4", "day" => "12", "year" => "1990"}
+
+ assert_equal 303, last_response.status
+ contact = store.contact("ada")
+ assert_equal before, contact.stored.to_s
+ assert_includes contact.vcard.to_s, "BDAY:1990-04-12\r\n"
+ assert_equal contact.etag, store.changes.last.etag
+
+ follow_redirect!
+ assert_includes last_response.body, "April 12, 1990"
+ end
+ end
+
+ # A birthday without a year is the one partial shape the wire
+ # carries — composed as Apple's sentinel, the form a client has been
+ # verified to accept (docs/plans/2026-08-31-partial-birthdays.md).
+ def test_a_birthday_without_a_year_is_saved_and_served_apples_way
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ birthday: {"month" => "12", "day" => "10", "year" => ""}
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s,
+ "BDAY;X-APPLE-OMIT-YEAR=1604:1604-12-10\r\n"
+ end
+ end
+
+ # The shapes no client renders are editable all the same — the model
+ # holds them, the web UI shows them, and nothing composes on their
+ # behalf: their documented fate.
+ def test_a_month_alone_is_saved_and_serves_nothing
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ birthday: {"month" => "4", "day" => "", "year" => ""}
+
+ assert_equal 303, last_response.status
+ contact = store.contact("ada")
+ assert_equal ProTacts::Birthday.new(month: 4), contact.birthday
+ refute_includes contact.vcard.to_s, "BDAY:"
+
+ follow_redirect!
+ assert_includes last_response.body, "April"
+ end
+ end
+
+ # Three blanks remove the birthday, the form's blank-equals-absent —
+ # the model row goes and the composed card with it.
+ def test_a_blank_birthday_removes_it
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ birthday: {"month" => "", "day" => "", "year" => ""}
+
+ assert_equal 303, last_response.status
+ contact = store.contact("ada")
+ assert_nil contact.birthday
+ refute_includes contact.vcard.to_s, "BDAY"
+ end
+ end
+
+ # A POST from anything but this form carries no birthday group and
+ # keeps the model, the phones' is-a-Hash posture — a group the
+ # request never carried touches nothing.
+ def test_a_save_without_birthday_fields_leaves_the_model_alone
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 12, day: 10),
+ store.contact("ada").birthday
+ end
+ end
+
+ # A shape the grammar refuses — a year and a day with no month
+ # between them — is a toast and nothing written: the constructor's
+ # ArgumentError is the refusal, caught where a re-render is the
+ # fallback. The browser's number inputs make this a hand-crafted
+ # POST's own, the name toast's backstop sibling.
+ def test_a_year_and_day_without_a_month_is_refused
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ birthday: {"month" => "", "day" => "12", "year" => "1985"}
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body,
+ '<div role="status" data-fixed><span>That birthday is not a shape a date can take.</span></div>'
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 12, day: 10),
+ store.contact("ada").birthday
+ end
+ end
+
+ def test_nonsense_in_a_birthday_field_is_refused
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ birthday: {"month" => "4", "day" => "12", "year" => "19x5"}
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "That birthday is not a shape a date can take."
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 12, day: 10),
+ store.contact("ada").birthday
+ end
+ end
+
+ # The one state the row cannot write: a stored card carrying its own
+ # BDAY spelling (the model refused it, so it stayed in the card) — a
+ # submitted birthday would compose a second BDAY beside it. Refused
+ # whole, name edit included: one submit, one write.
+ def test_a_birthday_against_a_cards_own_spelling_is_refused
+ resident = ADA.sub("BDAY:1985-12-10\r\n", "BDAY:1985-04\r\n")
+
+ with_contacts({"ada" => resident}) do |store|
+ assert_nil store.contact("ada").birthday
+
+ post "/contacts/ada", first: "Grace", last: "Hopper", etag: store.contact("ada").etag,
+ birthday: {"month" => "4", "day" => "", "year" => "1985"}
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body,
+ '<div role="status" data-fixed><span>This contact's card carries its own birthday spelling; nothing was saved.</span></div>'
+ contact = store.contact("ada")
+ assert_nil contact.birthday
+ assert_equal 1, contact.vcard.to_s.scan("BDAY").length
+ assert_includes contact.vcard.to_s, "BDAY:1985-04\r\n"
+ assert_includes contact.vcard.to_s, "FN:Ada Lovelace\r\n"
+ end
+ end
+
## Adding a property
# The add affordance: a trigger on the back link's line — the place