groups: the listing leads with the sync groups, then reads alphabetically
/groups came out in id order, which is minting order — nothing a
reader can scan. The rows now sort by the label they show, with the
`sync:` groups first, since those are the books a client syncs rather
than anything a person made.

Sorted in the view the way ContactsIndex sorts its rows: Store#all_groups
stays ordered by id, because that order is what keeps a rename from
moving a contact's tags, and the dump, the API and the group dialogs all
read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUpdgWhLPyBKX4GJwYQTGV
change
commit ea8086434757c38b5fd5069043187567b5de160d
author Claude <noreply@anthropic.com>
date
parent qlrvopys
diff --git a/lib/pro_tacts/admin/groups_index.rb b/lib/pro_tacts/admin/groups_index.rb
index fe080d6..faa099f 100644
--- a/lib/pro_tacts/admin/groups_index.rb
+++ b/lib/pro_tacts/admin/groups_index.rb
@@ -19,7 +19,16 @@ module ProTacts
 
       #: (groups: Array[Store::Group], ?notice: String?) -> void
       def initialize(groups:, notice: nil)
-        @groups = groups
+        # The `sync:` groups first — the books a client syncs, which
+        # are the store's own rather than anything a person made — and
+        # the rest alphabetically. Sorted here rather than asked of the
+        # store the way ContactsIndex sorts its rows: #all_groups is
+        # ordered by id so that a rename never moves a tag, and only
+        # this listing wants the reader's order. The key is the label
+        # the row shows (GroupLabel), so a nameless group sorts by the
+        # id it is shown by, and the id breaks a tie between two groups
+        # labelled the same.
+        @groups = groups.sort_by { [it.sync? ? 0 : 1, it.label.downcase, it.id] }
         @notice = notice
       end
 
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index f17220d..5257991 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -112,6 +112,16 @@ module ProTacts
         Contact.new(id:, stored: VCard.new(lines.map { "#{it}\r\n" }.join), birthday: nil, inherited: [])
       end
 
+      # Whether this is one of the `sync:` groups that choose what a
+      # client syncs — #sync_name?'s rule, said of a group a screen
+      # already holds rather than of a row's name, for the listing that
+      # sets those groups apart from the ones a person made
+      # (Admin::GroupsIndex).
+      #: () -> bool
+      def sync?
+        name&.start_with?(SYNC_PREFIX) == true
+      end
+
       # The group's etag, for the editor's snapshot guard: a hash of
       # everything the edit screen shows and the save writes, so a
       # group edited since the page loaded refuses the stale save
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 2296daf..1e93bbe 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -51,6 +51,20 @@ class AdminGroupsPagesTest < Minitest::Test
     end
   end
 
+  # The `sync:` groups are the books a client syncs, so they lead the
+  # list; the rest read alphabetically, whatever their case.
+  def test_the_list_leads_with_the_sync_groups_and_then_reads_alphabetically
+    with_contacts({}) do |store|
+      %w[zoetrope Abacus sync:alpha barometer sync:*].each { store.create_group(name: it) }
+      ids = store.all_groups.to_h { [it.name, it.id] }
+
+      get "/groups"
+
+      assert_equal %w[sync:* sync:alpha Abacus barometer zoetrope].map { ids.fetch(it) },
+                   last_response.body.scan(%r{<a href="/groups/([^"]+)">}).flatten
+    end
+  end
+
   def test_an_empty_list_says_so
     with_contacts({}) do
       get "/groups"