groups: sort the nameless groups last
A group with no name is shown by its id, and a list of bare ids
would bury the named groups in noise, so the nameless ones go last.
They still sort after the sync: groups, and in the import picker
they come after every named group, however many members they have.

Assisted-by: Claude Opus 5.5 via Claude Code
change stvrrxpvrwzvumnvpmnnruumnulxsqwu
commit 67f0abaa9a68067d0c4b3143c9c8aebffc792efa
author Alpha Chen <alpha@kejadlen.dev>
date
parent umxtxmmk
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index 76e1231..b30ede3 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -44,8 +44,9 @@ module ProTacts
       # @rbs @capped: Array[String]
       # @rbs @was: Array[String]
 
-      # The `sync:` groups lead (GroupChoice#<=>), then largest first
-      # and alphabetical among groups the same size: the groups an
+      # The `sync:` groups lead and the nameless ones trail
+      # (GroupChoice#<=>), then largest first and alphabetical among
+      # groups the same size: the groups an
       # arrival is likeliest to belong in are the ones most of the
       # book already does, so they are the rows the cap leaves
       # standing, and the filter reaches the rest. The groups dialog
@@ -73,7 +74,7 @@ module ProTacts
       # what was toggled (Web#apply_groups). A new contact is in none.
       #: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String], ?was: Array[String]) -> void
       def initialize(groups:, joined:, named:, was: [])
-        @groups = groups.sort_by { [it.sync? ? 0 : 1, -it.member_count, it.label.downcase] }
+        @groups = groups.sort_by { [it.sync? ? 0 : 1, it.name.nil? ? 1 : 0, -it.member_count, it.label.downcase] }
         @joined = joined
         @named = named
         @was = was
diff --git a/lib/pro_tacts/group.rb b/lib/pro_tacts/group.rb
index 43c7104..ca8e6c1 100644
--- a/lib/pro_tacts/group.rb
+++ b/lib/pro_tacts/group.rb
@@ -51,13 +51,15 @@ module ProTacts
 
     # The order every listing of groups reads in: the `sync:` groups
     # — the books a client syncs, the store's own rather than
-    # anything a person made — first, then the rest alphabetically.
+    # anything a person made — first, then the rest alphabetically,
+    # and the nameless ones last, their ids being no names to read.
     # GroupChoice spells this again for the pickers; keep the two
     # alike. Comparable deliberately left out: its `==` would sit in
     # front of Data's whole-value one, and `sort` asks only for `<=>`.
     #: (Group other) -> Integer?
     def <=>(other)
-      [sync? ? 0 : 1, label.downcase, id] <=> [other.sync? ? 0 : 1, other.label.downcase, other.id]
+      [sync? ? 0 : 1, name.nil? ? 1 : 0, label.downcase, id] <=>
+        [other.sync? ? 0 : 1, other.name.nil? ? 1 : 0, other.label.downcase, other.id]
     end
 
     # The group's etag, for the editor's snapshot guard: a hash of
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 820db76..f55814d 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -126,7 +126,8 @@ module ProTacts
       # choices; keep the two alike.
       #: (GroupChoice other) -> Integer?
       def <=>(other)
-        [sync? ? 0 : 1, label.downcase, id] <=> [other.sync? ? 0 : 1, other.label.downcase, other.id]
+        [sync? ? 0 : 1, name.nil? ? 1 : 0, label.downcase, id] <=>
+          [other.sync? ? 0 : 1, other.name.nil? ? 1 : 0, other.label.downcase, other.id]
       end
     end
 
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 9aa0408..40f896f 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -65,6 +65,21 @@ class AdminGroupsPagesTest < Minitest::Test
     end
   end
 
+  # A nameless group is labelled by its id, which reads as noise among
+  # names, so those sink below the named ones.
+  def test_the_list_ends_with_the_nameless_groups
+    with_contacts({}) do |store|
+      nameless = store.create_group
+      zoetrope = store.create_group(name: "zoetrope")
+      abacus = store.create_group(name: "Abacus")
+
+      get "/groups"
+
+      assert_equal [abacus, zoetrope, nameless],
+                   last_response.body.scan(%r{<a href="/groups/([^"]+)">}).flatten
+    end
+  end
+
   def test_an_empty_list_says_so
     with_contacts({}) do
       get "/groups"
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 6979833..e9f2b93 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -820,6 +820,23 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
+  # However many it holds: a nameless group sinks here as it does in
+  # the listings.
+  def test_the_groups_beside_a_card_end_with_the_nameless_groups
+    with_contacts({"jane" => JANE, "sam" => PLAIN}) do |store|
+      nameless = store.create_group
+      store.add_member(nameless, "jane")
+      store.add_member(nameless, "sam")
+      small = store.create_group(name: "small")
+      id = upload(JANE)
+
+      get "/import/#{id}/0"
+
+      assert_equal [small, nameless],
+                   last_response.body.scan(/name="groups\[\]" value="([^"]+)"/).flatten
+    end
+  end
+
   def test_the_groups_beside_a_card_offer_no_such_thing_uncapped
     with_contacts({}) do |store|
       # Seven, the import's own name being the eighth row.