contact card: start a nameless group from the groups row
The groups row's "edit groups" becomes "edit", beside a "new" that
makes a nameless group holding the contact and lands on its members
screen, where the rest of the household is picked. Store#create_group
takes the members to join in its own transaction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Ru2zvdWEwdwGhQ5V9z5X
change
commit 5f0c9c04dd6869c57e8cfcba14506264076a5df0
author Claude <noreply@anthropic.com>
date
parent tpwxvury
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 9a56a03..870bb6b 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -22,23 +22,32 @@ module ProTacts
     #
     # `groups` is the contact's membership (Store#groups_of), the row
     # framing the values under it. `dialog` names whether that row
-    # carries its "edit groups" button, which needs GroupDialog on the
+    # carries its "edit" button, which needs GroupDialog on the
     # page: the contact's page and a walk's saved row both render it,
-    # each telling the save where to land. `card` names
+    # each telling the save where to land. `new` names whether it also
+    # carries "new", which needs the NEW form on the page: the
+    # contact's page alone, its save landing on the group it made
+    # rather than back on a walk. `card` names
     # whether this renders the card around its own body or the body
     # alone for a card someone else supplies — the walk's read screen
     # (ImportSaved), whose trunk RecordCard owns.
     class ContactCard < Phlex::HTML
+      # The form "new" submits, outside the grid for the dialog's
+      # reason (ContactsShow).
+      NEW = "new-group" #: String
+
       # @rbs @contact: Contact
       # @rbs @groups: Array[Group]
       # @rbs @dialog: bool
+      # @rbs @new: bool
       # @rbs @card: bool
 
-      #: (contact: Contact, groups: Array[Group], ?dialog: bool, ?card: bool) -> void
-      def initialize(contact:, groups:, dialog: false, card: true)
+      #: (contact: Contact, groups: Array[Group], ?dialog: bool, ?new: bool, ?card: bool) -> void
+      def initialize(contact:, groups:, dialog: false, new: false, card: true)
         @contact = contact
         @groups = groups
         @dialog = dialog
+        @new = new
         @card = card
         @birthday = Format.birthday(contact)
       end
@@ -138,6 +147,11 @@ module ProTacts
       # exists, because the row holds the way to join or start one.
       # Without the dialog the row stands only when there is a member
       # to name: an empty row with nothing to press is nothing at all.
+      #
+      # "edit" opens the dialog over the groups that exist; "new" makes
+      # a new nameless one holding this contact and opens its members
+      # screen, the household's start — a family needs no name to be
+      # one, and the dialog's offer to make a group is a name typed.
       #: () -> void
       def groups_row
         dt(class: "type-label") { "groups" }
@@ -147,7 +161,8 @@ module ProTacts
               @groups.each { render GroupTag.new(group: it) }
             end
           end
-          button(type: "button", data_size: "sm", popovertarget: GroupDialog::ID) { "edit groups" } if @dialog
+          button(type: "button", data_size: "sm", popovertarget: GroupDialog::ID) { "edit" } if @dialog
+          button(type: "submit", form: NEW, data_size: "sm") { "new" } if @new
         end
       end
 
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 6b60522..7e5c2bb 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -43,7 +43,7 @@ module ProTacts
               a(href: "/", class: "type-label") { "‹ contacts" }
               a(href: "/contacts/#{@contact.id}/edit", class: "btn", data_size: "sm") { "edit" }
             end
-            render ContactCard.new(contact: @contact, groups: @groups, dialog: true)
+            render ContactCard.new(contact: @contact, groups: @groups, dialog: true, new: true)
           end
           # The stored card in its own card under the record, collapsed
           # (docs/DESIGN.md, "The stored card is reachable"). Native
@@ -78,6 +78,7 @@ module ProTacts
           # so the dialog is told which boxes to check rather than
           # reading every group's members to work it out.
           render GroupDialog.new(contact: @contact, groups: @all_groups, joined: @groups.map(&:id))
+          form(id: ContactCard::NEW, action: "/contacts/#{@contact.id}/groups/new", method: "post")
         end
       end
 
diff --git a/lib/pro_tacts/admin/group_dialog.rb b/lib/pro_tacts/admin/group_dialog.rb
index fdf77a0..a290960 100644
--- a/lib/pro_tacts/admin/group_dialog.rb
+++ b/lib/pro_tacts/admin/group_dialog.rb
@@ -6,7 +6,7 @@ require "pro_tacts/admin/group_label"
 module ProTacts
   module Admin
     # The groups dialog on a contact's card, opened by the groups row's
-    # "edit groups": ContactDialog's popover, over every group as a
+    # "edit": ContactDialog's popover, over every group as a
     # checkbox, the contact's own checked. The save sends what the page
     # loaded with beside what it sends back (`was[]`), so the route can
     # move only what was toggled (Web#apply_groups).
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 123982b..8505d5b 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -632,14 +632,20 @@ module ProTacts
     # drawing at once would both find the same id free, so a duplicate
     # comes back as the primary key's own violation and the next
     # attempt draws again.
-    #: (?name: String?) -> String
-    def create_group(name: nil)
+    #
+    # `members` join in the same transaction, for a group made from a
+    # contact's card (Web's add): a group that exists only once its
+    # first member is in it, rather than one left empty and nameless
+    # when the join fails.
+    #: (?name: String?, ?members: Array[String]) -> String
+    def create_group(name: nil, members: [])
       GROUP_ID_ATTEMPTS.times do
         id = ChangeId.mint(4)
         begin
           @database.transaction do
             groups.insert(id:, name:)
             record_group_change(id, action: GroupAction::CREATE, detail: {"name" => name})
+            members.each { add_member(id, it) }
           end
           return id
         rescue Sequel::UniqueConstraintViolation
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 59c306d..ec7fbe3 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -97,6 +97,13 @@ module ProTacts
           apply_groups(r, id)
         end
 
+        # The groups row's "new" (Admin::ContactCard): a nameless group
+        # holding this contact, landing on its members screen, where
+        # the rest of the household is picked.
+        r.post "groups", "new" do
+          r.redirect "/groups/#{store.create_group(members: [id])}/members", 303 if store.contact(id)
+        end
+
         r.post do
           apply_edit(r, id)
         end
diff --git a/public/admin.css b/public/admin.css
index 5ff6cbe..28c2fd2 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -1119,8 +1119,10 @@ span.avatar[data-size="lg"] {
    Baseline-aligned: a grid item blockifies, so the tag's inline-flex
    becomes a flex container, which takes its baseline from its first
    item — the tag's text — and so shares the value's. A contact
-   card's "edit groups" button takes the same place, a flex container
-   like the tag. */
+   card's "edit" button takes the same place, a flex container like
+   the tag, and its "new" the column after it — or, on a phone, the
+   row under it, where side by side the two would take the tags'
+   column down to a word and push a long tag under the buttons. */
 .detail-grid dd:has(> .tag),
 .detail-grid dd:has(> button) {
   display: grid;
@@ -1137,6 +1139,26 @@ span.avatar[data-size="lg"] {
 .detail-grid dd:has(> button) > div {
   grid-column: 1;
 }
+.detail-grid dd:has(> button + button) {
+  grid-template-columns: minmax(0, 1fr) auto auto;
+}
+.detail-grid dd > button + button {
+  grid-column: 3;
+}
+@media (max-width: 48rem) {
+  .detail-grid dd:has(> button + button) {
+    grid-template-columns: minmax(0, 1fr) auto;
+    grid-template-rows: auto 1fr;
+  }
+  .detail-grid dd:has(> button + button) > div {
+    grid-row: span 2;
+  }
+  .detail-grid dd > button + button {
+    grid-column: 2;
+    grid-row: 2;
+    justify-self: end;
+  }
+}
 /* A nameless group shown by its id (Admin::GroupLabel): set in mono,
    a step quieter, and at regular weight even inside a bold heading or
    row, so it reads as an identifier standing in for a name rather than
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 8399155..5d0f671 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -901,6 +901,45 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The groups row's two ways in: "edit" opens the dialog over the
+  # groups that exist, and "new" submits the form that makes one.
+  def test_show_offers_to_edit_the_groups_or_start_a_new_one
+    with_contacts({"ada" => ADA}) do
+      get "/contacts/ada"
+
+      assert_includes last_response.body, %(popovertarget="edit-groups">edit</button>)
+      assert_includes last_response.body, %(<button type="submit" form="new-group" data-size="sm">new</button>)
+      assert_includes last_response.body, %(<form id="new-group" action="/contacts/ada/groups/new" method="post">)
+    end
+  end
+
+  # A family needs no name: the new group is nameless, holds the
+  # contact it was made from, and the save lands on its members screen
+  # for the rest of the household.
+  def test_a_new_group_from_the_card_is_nameless_and_holds_the_contact
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada/groups/new"
+
+      groups = store.all_groups
+      group = groups.fetch(0)
+      assert_equal 1, groups.length
+      assert_equal 303, last_response.status
+      assert_equal "/groups/#{group.id}/members", last_response["Location"]
+      assert_nil group.name
+      assert_equal ["ada"], group.members
+      assert_equal %w[create join], store.group_changes_of(group.id).map(&:action).reverse
+    end
+  end
+
+  def test_a_new_group_for_an_unknown_contact_is_a_404
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/nobody/groups/new"
+
+      assert_equal 404, last_response.status
+      assert_empty store.all_groups
+    end
+  end
+
   def test_show_labels_untyped_values_with_the_property_name
     untyped = ADA.sub("TEL;TYPE=mobile:", "TEL:").sub("EMAIL;TYPE=home:", "EMAIL:")
 
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 3f2f747..af03f38 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -410,7 +410,9 @@ class AdminImportPagesTest < Minitest::Test
       # back offers is the groups dialog, the contact page's own,
       # landed back on the row.
       refute_includes last_response.body, %(name="etag")
-      assert_includes last_response.body, "edit groups"
+      assert_includes last_response.body, %(popovertarget="edit-groups")
+      # And no "new", which would land on the new group, off the walk.
+      refute_includes last_response.body, %(form="new-group")
       assert_includes last_response.body,
                     %(<input type="hidden" name="land" value="/import/#{id}/0">)
       # And the row's verdict is the check landed in its circle.