import: a walk ends by being over, not by a button
"Finish" sat under the file's list and did one thing the walk could
not already do: throw away the rows nobody had read. That is no
upside and the only irreversible move on a screen whose whole posture
is that nothing is held back. Its other effects were already
someone's: the list is the running count of what is in, the staged
copy of the file ages out on its own, and the contacts written stay
written.

So the button, its route and the staged import's summary form are
gone, and the file's own screen just says how far down it the walk
has got. The closing screen is reached one way now, by the Save that
takes the last card in, and is named for that rather than for a press
that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
change
commit 4cff9f1201c758002e51b59aeac114029daf7717
author Claude <noreply@anthropic.com>
date
parent c12c1404
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index 1ee2a7a..736d5f1 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -207,10 +207,17 @@ 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. Saving the last unsaved row ends it; a "finish" on the list ends
-it early. Either way the rows nobody opened are left behind with this
-copy of the file, which the importer has their own of, and what came in
-is listed on the same page an import has always ended on.
+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
+(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.
 
 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
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
index 3624a83..fa3b362 100644
--- a/lib/pro_tacts/admin/import_review.rb
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -20,10 +20,14 @@ module ProTacts
     #
     # The import happens a row at a time, on the Save of the screen a
     # row opens, so nothing here writes a contact and there is no
-    # "import everything". What this does have is the way out before
-    # the end of the file — a walk can be enough — and what it costs.
+    # "import everything" — and nothing here ends the walk either. A
+    # walk ends by being over, which is the last row's own Save, or by
+    # being left, which needs no button: the staged file expires on
+    # its own (Import::Staged::LIFETIME), and the contacts already
+    # written stay written. A control whose only power is to throw
+    # away the rows nobody has read yet is one with nothing to gain
+    # by pressing it.
     class ImportReview < Phlex::HTML
-      # @rbs @upload: String
       # @rbs @contacts: Integer
       # @rbs @saved: Integer
       # @rbs @unknown: Array[::ProTacts::Import::Vcf::Unknown]
@@ -32,12 +36,11 @@ module ProTacts
       # @rbs @notice: String?
 
       # `contacts` is how many the file holds and `saved` how many of
-      # them are in the book. `upload` is the staged import every link
-      # and form carries, `group` the name of the group this import
-      # files its arrivals under, and `sidebar` the rows themselves.
-      #: (upload: String, contacts: Integer, saved: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, sidebar: Phlex::HTML, ?notice: String?) -> void
-      def initialize(upload:, contacts:, saved:, unknown:, group:, sidebar:, notice: nil)
-        @upload = upload
+      # them are in the book. `group` is the name of the group this
+      # import files its arrivals under, and `sidebar` the rows
+      # themselves.
+      #: (contacts: Integer, saved: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, sidebar: Phlex::HTML, ?notice: String?) -> void
+      def initialize(contacts:, saved:, unknown:, group:, sidebar:, notice: nil)
         @contacts = contacts
         @saved = saved
         @unknown = unknown
@@ -69,7 +72,7 @@ module ProTacts
             h1(class: "type-h2", style: "margin: 0;") { count(@contacts, "contact") }
             losses
             group_line
-            finish_form
+            progress
           end
         end
       end
@@ -91,22 +94,20 @@ module ProTacts
         end
       end
 
-      # The way out before the end of the file. An import is a walk,
-      # and a walk can be enough: what has come in stays, and what
-      # nobody opened is left behind with the copy of the file this
-      # was holding.
+      # How far down the file the walk has got, which is the whole of
+      # what this screen has to say about it: the rows themselves are
+      # beside it, each saying for itself whether it is in the book.
+      # Said and not asked — there is nothing to press here, an import
+      # being over when its last row is saved and left when it is
+      # left.
       #: () -> void
-      def finish_form
-        form(action: "/import/#{@upload}/done", method: "post", class: "field-stack") do
-          p(class: "type-body-sm gl-muted") do
-            if @saved.zero?
-              "Nothing has come in yet. Open a contact to look it over; its Save is what puts it in the book."
-            else
-              "#{count(@saved, "contact")} in the book. " \
-                "Finishing now leaves the other #{@contacts - @saved} behind."
-            end
+      def progress
+        p(class: "type-body-sm gl-muted") do
+          if @saved.zero?
+            "Nothing has come in yet. Open a contact to look it over; its Save is what puts it in the book."
+          else
+            "#{@saved} of #{count(@contacts, "contact")} in the book."
           end
-          button(type: "submit", data: {variant: "primary"}) { "finish" }
         end
       end
 
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
index 1957f50..7407526 100644
--- a/lib/pro_tacts/import/staged.rb
+++ b/lib/pro_tacts/import/staged.rb
@@ -83,9 +83,9 @@ module ProTacts
       end
 
       # One slot's bytes, or none — an import swept out from under a
-      # screen left open overnight, or one already finished, a second
-      # press finding what the first removed. Ordinary enough for the
-      # screen to say so and ask for the file again.
+      # screen left open overnight, or one already closed by the Save
+      # that took its last card in. Ordinary enough for the screen to
+      # say so and ask for the file again.
       #: (String id, String slot) -> String?
       def self.read(id, slot)
         file = path(id, slot)
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 402dfb8..e6c0023 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -47,13 +47,6 @@ module ProTacts
       end
 
       r.on String do |upload|
-        # Before the index below, and distinguishable from one: a
-        # path segment is what says which request this is, not which
-        # fields a form happened to carry.
-        r.post "done" do
-          finish_import(upload)
-        end
-
         r.is do
           r.get do
             review_screen(upload)
@@ -142,7 +135,6 @@ module ProTacts
 
       response["Content-Type"] = "text/html; charset=utf-8"
       Admin::ImportReview.call(
-        upload:,
         contacts: revised.length,
         saved: saved.length,
         unknown: Import::Vcf.unknown(originals),
@@ -371,24 +363,26 @@ module ProTacts
 
       # 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 and a button under them.
-      return finish_import(upload) if saved.length + 1 == revised.length
+      # the same thing.
+      return closing_screen(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 by saving the last card or by
-    # saying so on the list, and the rows nobody opened are simply
-    # left behind — the file they came from is the importer's own, and
-    # this copy of it goes with the button.
+    # 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.
     #
     # 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 finish, and there is no other page holding
+    # has nothing left to close, and there is no other page holding
     # what just arrived.
     #: (String upload) -> String
-    def finish_import(upload)
+    def closing_screen(upload)
       return expired_screen if Import::Staged.read(upload, Import::Staged::SAVED).nil?
 
       _group, saved = Import::Staged.saved(upload)
@@ -402,9 +396,9 @@ module ProTacts
     end
 
     # The import's two readings, or none when it is gone — swept out
-    # from under a screen left open overnight, or finished already, a
-    # second press finding what the first removed. Ordinary enough
-    # for the screen to say so and ask for the file again.
+    # from under a screen left open overnight, or closed already by
+    # the Save that took the last card in. Ordinary enough for the
+    # screen to say so and ask for the file again.
     #
     # The re-read parses files this already parsed, which is not a
     # second judgment of them: the bytes are what was staged, and this
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index bd6fe65..c7d12bd 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -46,7 +46,7 @@ module ProTacts
     end
 
     class ImportReview
-      def self.call: (upload: String, contacts: Integer, saved: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, sidebar: untyped, ?notice: String?) -> String
+      def self.call: (contacts: Integer, saved: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, sidebar: untyped, ?notice: String?) -> String
     end
 
     # Rendered into ContactsEdit rather than called, so these are the
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 39954fd..0ff68e5 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -125,7 +125,7 @@ class AdminImportPagesTest < Minitest::Test
 
   # And the file's own screen is a click back up the list.
   def test_the_file_itself_is_a_screen_the_list_links_to
-    with_contacts({}) do |_store|
+    with_contacts({}) do |store|
       id = upload(JANE + PLAIN)
       follow_redirect!
 
@@ -136,6 +136,7 @@ class AdminImportPagesTest < Minitest::Test
       assert_equal 200, last_response.status
       assert_includes last_response.body, "2 contacts"
       assert_includes last_response.body, "Nothing has come in yet."
+      assert_empty store.contacts
     end
   end
 
@@ -369,21 +370,24 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
-  # A walk can be enough: what came in stays, and the rows nobody
-  # opened are left behind with this copy of the file.
-  def test_finishing_early_leaves_the_rest_behind
+  # 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.
+  def test_the_walk_says_how_far_down_the_file_it_is_and_offers_no_button
     with_contacts({}) do |store|
       id = upload(JANE + PLAIN)
       save(id, 0, first: "Jane", last: "Booles")
-      follow_redirect!
+      summary(id)
 
-      assert_includes last_response.body, "Finishing now leaves the other 1 behind."
+      assert_includes last_response.body, "1 of 2 contacts in the book."
+      # Nothing to press at all: this screen only says things.
+      refute_includes last_response.body, %(type="submit")
+      refute_includes last_response.body, "/done"
+      assert_equal 1, store.contacts.length
 
       post "/import/#{id}/done"
 
-      assert_equal 200, last_response.status
-      assert_includes last_response.body, "imported (1)"
-      assert_equal 1, store.contacts.length
+      assert_equal 404, last_response.status
     end
   end
 
@@ -697,14 +701,15 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
-  # Swept out from under a screen left open, or a finish submitted
-  # twice: the import is gone and only the person has another copy.
-  def test_finishing_an_import_that_is_gone_writes_nothing
+  # Swept out from under a screen left open, or come back to after
+  # its last row was saved: the import is gone and only the person
+  # has another copy.
+  def test_a_walk_that_is_gone_says_so
     with_contacts({}) do |store|
       id = upload(JANE)
       save(id, 0, first: "Jane", last: "Booles")
 
-      post "/import/#{id}/done"
+      summary(id)
 
       assert_includes last_response.body, "That import is no longer here."
       assert_equal 1, store.contacts.length
@@ -717,7 +722,7 @@ class AdminImportPagesTest < Minitest::Test
     with_contacts({}) do |store|
       refute ProTacts::ChangeId.minted?("notminted")
 
-      post "/import/notminted/done"
+      get "/import/notminted"
 
       assert_includes last_response.body, "That import is no longer here."
       assert_empty store.changes