import: a walk ends on the group it filed under
The upload page doubled as the page a finished import answered with,
listing what came in under the form for the next file. That list is
the import's own group, which holds the same contacts, is a page in
its own right, and is still there tomorrow — so the last Save now
leaves the walk for `/groups/<id>` and the copy under the form is
gone.

Every card saved out of that group leaves no group to land on, and
the book itself is where they went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
change
commit f58f523542f2469f8dd1e4bcdecd1293db6e4484
author Claude <noreply@anthropic.com>
date
parent 2b7f7fdc
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index 736d5f1..4bdd2e6 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -207,18 +207,24 @@ walk is resumable because nothing is being held back — a tab left
 overnight, a window closed, a crash, all read the same way.
 
 That makes the end of the walk a thing to say rather than a thing to
-press, and the last Save is what says it: what came in is listed on the
-same page an import has always ended on. There is no "finish" beside the
-list either, because the only power such a button would have that the
-walk does not already have is throwing away the rows nobody has read —
-nothing to gain by pressing it, and the one irreversible move on a
-screen whose whole posture is that nothing is being held back. What else
-it would have done happens anyway: the list is the running count of what
-is in, and the staged copy of the file ages out on its own
+press, and the last Save is what says it. There is no "finish" beside
+the list either, because the only power such a button would have that
+the walk does not already have is throwing away the rows nobody has
+read — nothing to gain by pressing it, and the one irreversible move on
+a screen whose whole posture is that nothing is being held back. What
+else it would have done happens anyway: the list is the running count
+of what is in, and the staged copy of the file ages out on its own
 (Import::Staged::LIFETIME). So a walk ends by being over or by being
 left, and the rows nobody opened are left behind with this copy of the
 file, which the importer has their own of.
 
+Nor is there a page saying what came in. The group the import filed
+under is that page, listing the same contacts and still there
+tomorrow, so the last Save leaves the walk for it rather than for a
+copy of it rendered once under the upload form. A walk whose every
+card was saved out of that group has none to land on, and the book
+itself is where they went.
+
 One thing is settled up front, and that is the group for the lot:
 every Save files its contact under it. It is named when the import
 opens, `import-<timestamp>`, and nothing renames it. A name typed
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index 8fce52f..1d2c7f3 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -1,14 +1,10 @@
 require "pro_tacts/admin/phlex"
 
-require "pro_tacts/admin/avatar"
-require "pro_tacts/admin/format"
 require "pro_tacts/admin/layout"
-require "pro_tacts/admin/list_item"
 
 module ProTacts
   module Admin
-    # GET /import — the screen an address book arrives on, and the page
-    # a finished import answers with
+    # GET /import — the screen an address book arrives on
     # (docs/plans/2026-09-21-import-a-vcf.md).
     # Until this existed an import meant rake tasks reading Contacts.app
     # on a Mac and carrying a plan to a host over HTTP; now it is the
@@ -19,16 +15,15 @@ module ProTacts
     # (ImportReview, over the walk's list of its contacts and the pair
     # of cards a row there opens).
     #
-    # `imported` is what the import that just ended brought in — the
-    # rows its walk saved, and not the ones it left behind — rendered
-    # below the form that is still there to run another.
+    # Nothing comes back here at the end of a walk. What arrived is
+    # the group the import filed it under, a page that lists the same
+    # contacts and is still there tomorrow, so the walk ends on that
+    # rather than on a copy of it under this form (Web#close_walk).
     class ImportUpload < Phlex::HTML
-      # @rbs @imported: Array[Contact]?
       # @rbs @notice: String?
 
-      #: (?imported: Array[Contact]?, ?notice: String?) -> void
-      def initialize(imported: nil, notice: nil)
-        @imported = imported
+      #: (?notice: String?) -> void
+      def initialize(notice: nil)
         @notice = notice
       end
 
@@ -39,7 +34,6 @@ module ProTacts
               a(href: "/", class: "type-label") { "‹ contacts" }
             end
             upload_card
-            imported_list if @imported
           end
         end
       end
@@ -66,30 +60,6 @@ module ProTacts
           end
         end
       end
-
-      # What came in, contact by contact, each linked to the record it
-      # became — so the next thing to do, reading one or fixing one, is
-      # a click away.
-      #: () -> void
-      def imported_list
-        imported = @imported or return
-
-        section do
-          div(class: "section-head") do
-            h2(class: "type-label") { "imported (#{imported.length})" }
-          end
-          ul(class: "card") do
-            imported.each do |contact|
-              render ListItem.new(
-                href: "/contacts/#{contact.id}",
-                avatar: Avatar.new(contact:, size: "lg"),
-              ) do
-                div { Format.name_label(contact) }
-              end
-            end
-          end
-        end
-      end
     end
   end
 end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index e6c0023..9e0919d 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -362,37 +362,35 @@ module ProTacts
       Import::Staged.record(upload, index.to_s, written.id)
 
       # The last one: there is nothing left to come back to, so the
-      # import ends here rather than on a list of rows that all say
-      # the same thing.
-      return closing_screen(upload) if saved.length + 1 == revised.length
+      # walk is over rather than a list of rows that all say the same
+      # thing.
+      return close_walk(r, upload) if saved.length + 1 == revised.length
 
       r.redirect "/import/#{upload}", 303
     end
 
-    # The end of the walk: what it wrote, listed, and the file and
-    # the walk's own notes gone. Reached one way only, by saving the
-    # last card there was to save, because that is the only end a walk
-    # has. Leaving one needs no screen and no button: the staged file
-    # expires on its own (Import::Staged::LIFETIME), and what has come
-    # in has come in. A control whose only power is to throw away the
-    # rows nobody has read yet has nothing to gain by being pressed.
+    # The end of the walk: the file and the walk's own notes gone, and
+    # the group the import filed under standing where the walk stood.
+    # Reached one way only, by saving the last card there was to save,
+    # because that is the only end a walk has. Leaving one needs no
+    # screen and no button: the staged file expires on its own
+    # (Import::Staged::LIFETIME), and what has come in has come in.
     #
-    # Answered with the page rather than a 303, and has to be: the
-    # staged import is gone, so the re-submission a back button offers
-    # has nothing left to close, and there is no other page holding
-    # what just arrived.
-    #: (String upload) -> String
-    def closing_screen(upload)
+    # There is no screen of its own for what arrived, because the
+    # group for the import is that screen and outlives this one — a
+    # list of the same contacts, rendered once rather than twice, on a
+    # page that is still there tomorrow. A walk whose every card was
+    # saved out of that group has none, and the whole book is where
+    # they went.
+    #: (untyped r, String upload) -> untyped
+    def close_walk(r, upload)
       return expired_screen if Import::Staged.read(upload, Import::Staged::SAVED).nil?
 
-      _group, saved = Import::Staged.saved(upload)
-      # In the file's own order rather than the order the walk got to
-      # them in, which is the order the list they are read back
-      # instead of was in.
-      imported = saved.keys.sort_by(&:to_i).filter_map { store.contact(saved.fetch(it)) }
+      group, _saved = Import::Staged.saved(upload)
       Import::Staged.close(upload)
+      filed = store.group_choices.find { it.name == group }
 
-      import_screen(imported:)
+      r.redirect(filed ? "/groups/#{filed.id}" : "/contacts", 303)
     end
 
     # The import's two readings, or none when it is gone — swept out
@@ -457,12 +455,13 @@ module ProTacts
       param if param.is_a?(Hash) && param[:tempfile]
     end
 
-    # The screen a file is chosen on, and the page a finished import
-    # answers with.
-    #: (?imported: Array[Contact]?, ?notice: String?) -> String
-    def import_screen(imported: nil, notice: nil)
+    # The screen a file is chosen on, which is all this path is: a
+    # walk that has ended has a group to land on rather than a page
+    # here saying it ended (#close_walk).
+    #: (?notice: String?) -> String
+    def import_screen(notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ImportUpload.call(imported:, notice:)
+      Admin::ImportUpload.call(notice:)
     end
 
     #: () -> String
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index c7d12bd..c528025 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -42,7 +42,7 @@ module ProTacts
     end
 
     class ImportUpload
-      def self.call: (?imported: Array[Contact]?, ?notice: String?) -> String
+      def self.call: (?notice: String?) -> String
     end
 
     class ImportReview
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index e207247..269acbe 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -345,22 +345,29 @@ class AdminImportPagesTest < Minitest::Test
   end
 
   # Saving the last row ends the walk: there is nothing left to come
-  # back to, so the import closes itself and says what came in.
+  # back to, so the import closes itself and leaves the walk for the
+  # group it filed everything under, which is the list of what came
+  # in and is still there tomorrow.
   def test_saving_the_last_row_ends_the_import
     with_contacts({}) do |store|
       id = upload(JANE + PLAIN)
       save(id, 0, first: "Jane", last: "Booles")
       save(id, 1, first: "Sam", last: "Booles")
 
-      assert_equal 200, last_response.status
-      assert_includes last_response.body, "imported (2)"
       assert_equal 2, store.contacts.length
+      filed = store.all_groups.find { it.name.to_s.start_with?("import-") }
+
+      assert_equal "/groups/#{filed.id}", last_response.headers["location"]
+      assert_equal store.contacts.map(&:id).sort, filed.members.sort
 
       jane = store.contacts.find { it.name == "Jane Booles" }
 
       assert_includes jane.vcard.to_s, "TEL;type=CELL:+1 555 0100"
       refute_includes jane.vcard.to_s, "X-SOCIALPROFILE"
       refute_includes jane.vcard.to_s, "X-ABRELATEDNAMES"
+
+      follow_redirect!
+
       assert_includes last_response.body, %(href="/contacts/#{jane.id}")
 
       # And the walk is gone with it.
@@ -370,6 +377,19 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
+  # Every card saved out of the group the import named leaves no group
+  # to land on, so the walk ends on the book itself.
+  def test_a_walk_that_filed_nothing_ends_on_the_contacts
+    with_contacts({}) do |store|
+      id = upload(JANE)
+      save(id, 0, first: "Jane", last: "Booles", "named" => [], "groups" => [])
+
+      assert_equal "/contacts", last_response.headers["location"]
+      assert_equal 1, store.contacts.length
+      assert_empty store.all_groups.map(&:name).grep(/\Aimport-/)
+    end
+  end
+
   # A walk is over when its last row is saved, and left when it is
   # left: there is nothing to press, and nothing whose only power
   # would be to throw away the rows nobody has read.