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
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('"', """)
+ 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