admin: show a contact's change log
The log was load-bearing and no screen would say
what a card had been through, or the etag from that day.
A third card, collapsed, below the bytes on display —
put, edit, group, newest first, in the record they lay.

Assisted-by: Claude Opus 5 via Claude Code
change okuwnzykrsuzykssqkupxwqrzmvrokly
commit 5b586970ae313d29c5cf70e505c9a3dfd8ce7777
author Alpha Chen <alpha@kejadlen.dev>
date
parent urwopnvs
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index f144bda..e39cf13 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -28,11 +28,16 @@ module ProTacts
       # off it: a contact's card carries what its groups lend it, never
       # which groups those are (Store#groups_of). Required, and not
       # defaulted to none, so a caller cannot render a contact as
-      # belonging to nothing by forgetting to ask.
-      #: (Contact contact, Array[String] groups) -> void
-      def initialize(contact:, groups:)
+      # belonging to nothing by forgetting to ask. The change log is
+      # beside it for the same reason and required for the stronger
+      # one: a card with no entries is a card whose history was lost,
+      # and an empty default would render that as an ordinary quiet
+      # record.
+      #: (Contact contact, Array[String] groups, Array[Store::Change] changes) -> void
+      def initialize(contact:, groups:, changes:)
         @contact = contact
         @groups = groups
+        @changes = changes
         @birthday = Format.birthday(contact)
       end
 
@@ -91,6 +96,21 @@ module ProTacts
               end
             end
           end
+          # What this card has done, under the bytes it currently is:
+          # the log the sync tokens count on (Store#changes_of), which
+          # until now nothing here showed. Collapsed and in the raw
+          # card's shape for the raw card's reason — the record is
+          # about the person, and a sequence of etags is about the
+          # wire — and last of the three, because a history is read
+          # after the thing that has one.
+          div(class: "card") do
+            div(class: "card-body") do
+              details do
+                summary(class: "type-label") { "change log" }
+                dl(class: "detail-grid change-log") { change_rows }
+              end
+            end
+          end
         end
       end
 
@@ -192,6 +212,25 @@ module ProTacts
         end
       end
 
+      # One row per entry, on the record's own grid: the action in the
+      # type column, because it is the kind of thing the row is, and
+      # the moment and the etag in the value column. Both of those are
+      # the value — the etag is what the card hashed to at that write
+      # and the one hash in the schema that is stored rather than
+      # derived (AGENTS.md), so it is shown whole. A delete's etag is
+      # null, and the row is its moment alone: a tombstone hashes
+      # nothing.
+      #: () -> void
+      def change_rows
+        @changes.each do |change|
+          dt(class: "type-label") { change.action }
+          dd(class: "type-body-sm") do
+            div { Format.stamp(change.created_at) }
+            div(class: "type-mono") { change.etag } if change.etag
+          end
+        end
+      end
+
       # The stored card for display: byte for byte, except a value long
       # enough to be a wall. The count says exactly what is there; what
       # it stands in for is not readable text.
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 3634288..813086a 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -43,6 +43,19 @@ module ProTacts
         birthday&.to_s || property.value
       end
 
+      # The other way to render one of those stamps: as itself, minus
+      # the milliseconds. What the change log records happened at a
+      # moment someone may need to line up against a client's own logs,
+      # so this one stays absolute where #time_ago goes coarse — and the
+      # fraction is the clock's precision rather than the change's.
+      # String surgery rather than a parse and a reformat, because the
+      # column's spelling is fixed by the expression that writes it (see
+      # db/migrations/001_create_contacts_schema.rb).
+      #: (String iso8601) -> String
+      def self.stamp(iso8601)
+        iso8601.sub(/\.\d+Z\z/, "Z")
+      end
+
       # A card's updated_at, UTC ISO 8601 to the millisecond (see
       # Store), rendered the way "recently updated" wants it: coarse and
       # relative, not a timestamp to read precisely.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index cbd0468..7462d26 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -426,6 +426,19 @@ module ProTacts
       change_log.where(Sequel[:sequence] > after).order(:sequence).map { change_from(it) }
     end
 
+    # One card's entries, newest first — the history the admin's contact
+    # page renders. The order is against #changes' deliberately: that
+    # read is a sync window and hands back the order a client applies,
+    # this one is a record someone reads, where the last thing that
+    # happened is what they came for. A card id and not a foreign key
+    # (see db/migrations/001_create_contacts_schema.rb), so an id whose
+    # card is gone still answers with the history that ends in its
+    # tombstone.
+    #: (String id) -> Array[Change]
+    def changes_of(id)
+      change_log.where(card_id: id).order(Sequel.desc(:sequence)).map { change_from(it) }
+    end
+
     # Drops the index and derives it again from the stored cards alone.
     # Always safe to run: nothing is authoritative here, so if the
     # projection ever disagrees with the cards, this is the repair. Raw
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index b175555..2adc8ea 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -183,7 +183,8 @@ module ProTacts
 
             if contact
               response["Content-Type"] = "text/html; charset=utf-8"
-              Admin::ContactsShow.call(contact:, groups: store.groups_of(id))
+              Admin::ContactsShow.call(contact:, groups: store.groups_of(id),
+                                       changes: store.changes_of(id))
             end
           end
         end
diff --git a/public/admin.css b/public/admin.css
index 3f37299..5a378f6 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -579,3 +579,18 @@ img.avatar {
   margin: var(--gl-space-2xs) 0 0;
   overflow-x: auto;
 }
+
+/* The change log in the third card, on the record's own grid so the
+   two read as one page (ContactsShow#change_rows). The margin is the
+   pre's above, for the same reason: a summary and the block it
+   discloses are one line apart, not a card's gap. An etag is 64 hex
+   digits and a pair of quotes, which is wider than the value column
+   on a phone and the one thing here that must not be shortened — so
+   it breaks mid-hash rather than pushing the card sideways, the whole
+   value on the screen either way. */
+.change-log {
+  margin-top: var(--gl-space-2xs);
+}
+.change-log dd > .type-mono {
+  overflow-wrap: anywhere;
+}
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 3371e50..edacc9a 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -7,7 +7,7 @@
 module ProTacts
   module Admin
     class ContactsShow
-      def self.call: (contact: Contact, groups: Array[String]) -> String
+      def self.call: (contact: Contact, groups: Array[String], changes: Array[Store::Change]) -> String
     end
 
     class ContactsEdit
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index a4d5c2a..dac4641 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -442,6 +442,42 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The seeded card has been written once, so its log is one put, at
+  # the etag that write recorded — which for a card whose birthday is
+  # composed back in on read is the served card's, not the stored
+  # bytes'. Collapsed like the card above it, and the stamp is the
+  # store's own minus the milliseconds.
+  def test_show_renders_the_change_log_collapsed
+    with_contacts({"ada" => ADA}) do |store|
+      get "/contacts/ada"
+
+      body = last_response.body
+      assert_includes body, '<details><summary class="type-label">change log</summary>'
+      assert_includes body, '<dt class="type-label">put</dt>'
+      # An entity-tag's quotes are part of it (Contact.etag_for), so
+      # they reach the page as the escape Phlex writes them with.
+      assert_includes body, store.contact("ada").etag.gsub('"', "&quot;")
+      assert_includes body, store.changes.last.created_at.sub(/\.\d+Z\z/, "Z")
+      refute_includes body, "<details open"
+    end
+  end
+
+  # Newest first, and an edit is logged as an edit rather than a put —
+  # the distinction db/migrations/003_change_log_edit_action.rb added
+  # the action for, rendered here.
+  def test_the_change_log_leads_with_the_latest_entry
+    with_contacts({"ada" => ADA}) do |store|
+      store.rewrite("ada", ProTacts::VCard.new(ADA.sub("Ada Lovelace", "Ada L.")),
+                    birthday: store.contact("ada").birthday)
+
+      get "/contacts/ada"
+
+      body = last_response.body
+      actions = body.scan(/<dt class="type-label">(put|edit|delete|group)<\/dt>/).flatten
+      assert_equal %w[edit put], actions
+    end
+  end
+
   # A photo card's raw section elides the base64 wall to its octet
   # count; the property's name and parameters, and every other line,
   # stay byte for byte. The refuted chunk is a continuation line of
@@ -493,13 +529,15 @@ class AdminContactsPagesTest < Minitest::Test
   # A card that will not parse is served from its bytes regardless —
   # the raw section is the one thing such a card has to show, so it
   # renders with no grid under the header, not instead of the page.
+  # The record card, specifically: the change log rides the same grid
+  # in a card of its own, and every card has a history.
   def test_show_of_a_bare_contact_renders_no_grid
     bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bare Contact\r\nUID:bare\r\nEND:VCARD\r\n"
 
     with_contacts({"bare" => bare}) do
       get "/contacts/bare"
 
-      refute_includes last_response.body, "detail-grid"
+      refute_includes last_response.body, '<dl class="detail-grid">'
       assert_includes last_response.body, "<details"
     end
   end
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index d71f257..8d5dad1 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -137,6 +137,16 @@ class FormatTest < Minitest::Test
     assert_nil ProTacts::Admin::Format.birthday(contact(bare))
   end
 
+  def test_a_stamp_drops_the_milliseconds_and_nothing_else
+    assert_equal "2026-09-04T20:35:22Z",
+                 ProTacts::Admin::Format.stamp("2026-09-04T20:35:22.481Z")
+  end
+
+  def test_a_stamp_without_milliseconds_is_left_alone
+    assert_equal "2026-09-04T20:35:22Z",
+                 ProTacts::Admin::Format.stamp("2026-09-04T20:35:22Z")
+  end
+
   def test_time_ago_buckets_by_how_long_ago
     now = Time.now.utc
 
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index bf452e5..a9d7da7 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -423,6 +423,34 @@ class StoreTest < Minitest::Test
     end
   end
 
+  def test_one_cards_changes_come_back_newest_first
+    with_store({"aiden" => AIDEN}) do |store|
+      store.put("znorth", vcard(ZED))
+      store.delete("aiden")
+
+      log = store.changes_of("aiden")
+      assert_equal %w[delete put], log.map { it.action }
+      assert_operator log.first.sequence, :>, log.last.sequence
+    end
+  end
+
+  # The tombstone outlives the card, which is the point of a card id
+  # rather than a foreign key — and the history stays readable for an
+  # id the cards table no longer has a row for.
+  def test_a_deleted_cards_changes_still_read
+    with_store({"aiden" => AIDEN}) do |store|
+      store.delete("aiden")
+
+      assert_equal %w[delete put], store.changes_of("aiden").map { it.action }
+    end
+  end
+
+  def test_an_unknown_cards_changes_are_empty
+    with_store({"aiden" => AIDEN}) do |store|
+      assert_empty store.changes_of("nobody")
+    end
+  end
+
   # A sequence number is never reused, so a client holding an old token
   # cannot be handed changes numbered below ones it has already seen.
   def test_sequence_numbers_are_not_reused_after_a_delete