photos: a browser keeps them, and an avatar loads when it scrolls in
/contacts drew an <img> for every card with a picture, and the photo
route answered each with the whole picture every time: no
Cache-Control, and an ETag it never compared. On a 1,000-card book
with 200 pictures, a phone-speed link (40 ms, 10 Mbit/s) took 10.6 s
and 12 MB to load /contacts, and as long again on every visit.

The route now sends Cache-Control: private, no-cache with an ETag of
the picture's own bytes, and Roda's caching plugin answers a held
picture with a 304. The avatar is loading=lazy, so only the pictures
near the screen are asked for. The group photo route shares the
helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZeaKvJp7q4WEwoCPqVkZb
change
commit 309438ad00831c1672e2aaff8d34bfb7acb59c44
author Claude <noreply@anthropic.com>
date
parent 62096ce4
diff --git a/lib/pro_tacts/admin/avatar.rb b/lib/pro_tacts/admin/avatar.rb
index 24f3dd0..d4f5ab9 100644
--- a/lib/pro_tacts/admin/avatar.rb
+++ b/lib/pro_tacts/admin/avatar.rb
@@ -15,7 +15,10 @@ module ProTacts
     # bytes in hand, so the page carries a URL where it would otherwise
     # carry megabytes of base64 (see the route in Web). Alt is empty:
     # the contact's name is adjacent text in every slot, so the image
-    # is decoration, not information.
+    # is decoration, not information. Loaded lazily, because /contacts
+    # draws an avatar for every card in the book and a browser would
+    # otherwise fetch every picture before the page counts as loaded,
+    # most of them far below anything on screen.
     class Avatar < Phlex::HTML
       # @rbs @contact: Contact | ListedContact
       # @rbs @size: String?
@@ -28,7 +31,7 @@ module ProTacts
 
       def view_template
         if @contact.photo?
-          img(class: "avatar", src: "/contacts/#{@contact.id}/photo", alt: "", **attributes)
+          img(class: "avatar", src: "/contacts/#{@contact.id}/photo", alt: "", loading: "lazy", **attributes)
         else
           span(class: "avatar", **attributes) { Format.initials(@contact) }
         end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index d362581..a471f4a 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -4,6 +4,7 @@ require "pathname"
 require "pro_tacts"
 require "sentry-ruby"
 
+require "rack/deflater"
 require "rack/rewindable_input"
 require "roda"
 
@@ -48,6 +49,15 @@ module ProTacts
       end
     end
 
+    # The pages and the files they load, gzipped for a browser that
+    # asks: /contacts is half a megabyte of markup that compresses
+    # thirtyfold, and a phone on the tailnet pays for every byte. Only
+    # those types, so a DAV response — XML or a card — goes out as the
+    # bytes the clients were confirmed against (docs/carddav.md), and a
+    # picture, compressed already, is not squeezed again. Outermost,
+    # so the exchange log below records a body as the app wrote it.
+    use Rack::Deflater, include: %w[text/html text/css text/javascript]
+
     # RewindableInput lets the exchange log below read the request body
     # after the application has.
     use Rack::RewindableInput::Middleware
@@ -67,6 +77,7 @@ module ProTacts
     plugin :all_verbs
     plugin :dav_verbs
     plugin :public, root: PUBLIC_ROOT.to_s
+    plugin :caching
     plugin :hash_branches
 
     # A DAV 404 warns through RefusalAlerts; an admin one reports nothing.
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 91c61e0..a742bd6 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -1,3 +1,5 @@
+require "digest"
+
 require "pro_tacts/admin/card_form"
 require "pro_tacts/admin/contact_dialog"
 require "pro_tacts/admin/contacts_edit"
@@ -120,17 +122,14 @@ module ProTacts
         # bytes under their own content type, served from a route
         # rather than inlined as base64 so a page of avatars is a
         # page of cacheable image requests instead of ten 330 KB
-        # payloads stitched into the HTML. The contact's etag, so a
-        # picture changes exactly when its card does.
+        # payloads stitched into the HTML (#serve_photo).
         r.get "photo" do
           contact = store.contact(id)
 
           # The views never point at a photo the card lacks, so a
           # contact with none is the 404 case above.
           if contact && (photo = contact.photo)
-            response["Content-Type"] = photo.mime_type
-            response["ETag"] = contact.etag
-            photo.bytes
+            serve_photo(photo)
           end
         end
 
@@ -144,6 +143,22 @@ module ProTacts
 
     private
 
+    # A picture, a contact's or a group's, answered so a browser keeps
+    # it: an ETag of its own bytes and `no-cache`, which stores the
+    # picture but asks before each reuse, so a page of avatars costs a
+    # 304 apiece rather than every picture again on every visit, and a
+    # replaced picture shows the moment it is. The bytes' digest rather
+    # than the card's etag, so an edit to a phone number keeps the
+    # picture a browser holds. Roda's caching plugin answers the 304,
+    # halting before the bytes are sent.
+    #: (Contact::Photo photo) -> String
+    def serve_photo(photo)
+      response["Content-Type"] = photo.mime_type
+      response.cache_control(private: true, no_cache: true)
+      request.etag(Digest::SHA256.hexdigest(photo.bytes))
+      photo.bytes
+    end
+
     # The dashboard GET and a refused create render the same page.
     #: (?notice: String?) -> String
     def dashboard(notice: nil)
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index b57b119..8c16d07 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -1,5 +1,3 @@
-require "digest"
-
 require "pro_tacts/admin/card_form"
 require "pro_tacts/admin/groups_edit"
 require "pro_tacts/admin/groups_index"
@@ -60,11 +58,7 @@ module ProTacts
 
             # The page never points at a photo the group lacks, so one
             # with none is the 404 case.
-            if photo
-              response["Content-Type"] = photo.mime_type
-              response["ETag"] = %("#{Digest::SHA256.hexdigest(photo.bytes)}")
-              photo.bytes
-            end
+            serve_photo(photo) if photo
           end
 
           r.post do
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 52466f8..3650e2c 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -83,12 +83,20 @@ class Roda
     # Dispatches to the hash_branch for the next segment, if one is
     # defined, and ends the request with its answer.
     def hash_branches: (?String | Symbol namespace) -> void
+
+    # The caching plugin's: sets the ETag, quoting the value, and halts
+    # with a 304 when the request's If-None-Match already names it.
+    def etag: (String value) -> void
   end
 
   class RodaResponse
     def []=: (String header, String value) -> void
     def status=: (Integer status) -> void
     def write: (String body) -> void
+
+    # The caching plugin's: a Cache-Control header from its
+    # directives, each key spelled with underscores for dashes.
+    def cache_control: (**bool directives) -> void
   end
 
   class RodaError < StandardError
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index edc0a06..2f249bc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1271,7 +1271,7 @@ class AdminContactsPagesTest < Minitest::Test
       get "/contacts/pic"
 
       assert_equal 200, last_response.status
-      assert_includes last_response.body, '<img class="avatar" src="/contacts/pic/photo" alt=""'
+      assert_includes last_response.body, '<img class="avatar" src="/contacts/pic/photo" alt="" loading="lazy"'
       assert_includes last_response.body, 'data-size="xl"'
       refute_includes last_response.body, '<span class="avatar"'
     end
@@ -1291,16 +1291,41 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   # The route the avatars point at: the decoded bytes under the type
-  # their own magic bytes name, with the contact's etag — so a picture
-  # changes exactly when its card does.
+  # their own magic bytes name, with an etag of those bytes.
   def test_the_photo_route_serves_the_decoded_bytes
     with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do |store|
       get "/contacts/pic/photo"
 
+      bytes = store.contact("pic").photo.bytes
       assert_equal 200, last_response.status
       assert_equal "image/jpeg", last_response["Content-Type"]
-      assert_equal store.contact("pic").photo.bytes, last_response.body
-      assert_equal store.contact("pic").etag, last_response["ETag"]
+      assert_equal bytes, last_response.body
+      assert_equal %("#{Digest::SHA256.hexdigest(bytes)}"), last_response["ETag"]
+      assert_equal "private, no-cache", last_response["Cache-Control"]
+    end
+  end
+
+  # A browser holding the picture asks with its etag and is told to
+  # keep it, so a page of avatars it has seen sends no picture again
+  # (Web#serve_photo). An edit that leaves the picture alone keeps
+  # its etag; a new picture is a new one, and is sent.
+  def test_the_photo_route_answers_a_held_picture_with_a_304
+    with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do |store|
+      get "/contacts/pic/photo"
+      etag = last_response["ETag"]
+
+      get "/contacts/pic/photo", {}, "HTTP_IF_NONE_MATCH" => etag
+      assert_equal 304, last_response.status
+      assert_empty last_response.body
+
+      store.put("pic", ProTacts::VCard.new(PhotoCard.photo("pic", bytes: 256, extra: ["NICKNAME:Countess"])))
+      get "/contacts/pic/photo", {}, "HTTP_IF_NONE_MATCH" => etag
+      assert_equal 304, last_response.status
+
+      store.put("pic", ProTacts::VCard.new(PhotoCard.photo("pic", bytes: 512)))
+      get "/contacts/pic/photo", {}, "HTTP_IF_NONE_MATCH" => etag
+      assert_equal 200, last_response.status
+      refute_equal etag, last_response["ETag"]
     end
   end
 
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index c3dda56..b854160 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -381,6 +381,10 @@ class AdminGroupsPagesTest < Minitest::Test
       assert_equal 200, last_response.status
       assert_equal "image/jpeg", last_response["Content-Type"]
       assert_equal JPEG, last_response.body.b
+
+      # Held by a browser the way a contact's is (Web#serve_photo).
+      get "/groups/#{id}/photo", {}, "HTTP_IF_NONE_MATCH" => last_response["ETag"]
+      assert_equal 304, last_response.status
     end
   end