search: list fifty contacts and count the rest
A query matching the whole book still built and rendered every card,
364 ms for "a" at 1,000. The list now stops at
Admin::SearchResults::LIMIT and says how many more matched, so only
the contacts shown are built: 28 ms for the same query.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQvcDxcd8ucY7iVaS6cJB8
diff --git a/docs/plans/2026-10-17-search-keys.md b/docs/plans/2026-10-17-search-keys.md
index 7611720..0dad927 100644
--- a/docs/plans/2026-10-17-search-keys.md
+++ b/docs/plans/2026-10-17-search-keys.md
@@ -22,9 +22,13 @@ with requests sent in process:
| blank (10 recent) | 168 ms | 17 ms |
The browser was never the cause, because the results always came from
-the server (`2026-09-30-live-search.md`). A query that matches every
-card still builds and renders every card. That is what an uncapped
-list costs, and a cap would be a separate decision.
+the server (`2026-09-30-live-search.md`).
+
+A query that matches every card still had to build and render every
+card, at 364 ms for "a". So the contacts now stop at 50, followed by a
+line counting the rest and asking for a longer query. This reverses
+the live-search plan's "uncapped, with no see all": a list of hundreds
+was never read, and the next letter typed narrows it anyway.
## Shape
diff --git a/lib/pro_tacts/admin/search_results.rb b/lib/pro_tacts/admin/search_results.rb
index 0e04be6..81d4e70 100644
--- a/lib/pro_tacts/admin/search_results.rb
+++ b/lib/pro_tacts/admin/search_results.rb
@@ -11,21 +11,27 @@ module ProTacts
# in: the contacts a query matches in the order Search ranks them
# and then the groups — a group is a record the search reaches as
# surely as a contact is (docs/DESIGN.md, "When adding something
- # new"). Uncapped, with no "see all" to send a long list to
- # (docs/plans/2026-09-30-live-search.md).
+ # new"). The contacts stop at LIMIT, and the rest are counted
+ # rather than listed: the answer to a query that long is a longer
+ # query (docs/plans/2026-10-17-search-keys.md).
class SearchResults < Phlex::HTML
+ LIMIT = 50 #: Integer
+
# @rbs @contacts: Array[Store::RecentContact]
# @rbs @groups: Array[Group]
# @rbs @recent: bool
+ # @rbs @more: Integer
# `recent` is the list standing in for results before there is a
# query — the most recently updated contacts, labeled as the
# dashboard labels them (Web#search_results).
- #: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool) -> void
- def initialize(contacts:, groups:, recent: false)
+ # `more` is how many matched past LIMIT.
+ #: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool, ?more: Integer) -> void
+ def initialize(contacts:, groups:, recent: false, more: 0)
@contacts = contacts
@groups = groups
@recent = recent
+ @more = more
end
def view_template
@@ -37,6 +43,7 @@ module ProTacts
if @contacts.any?
h2(class: "type-label") { @recent ? "recently updated" : "contacts" }
ul(class: "card") { @contacts.each { render RecentRow.new(row: it) } }
+ p(class: "type-body-sm") { "#{@more} more. Keep typing to narrow them down." } if @more.positive?
end
return if @groups.empty?
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
index 76812ab..f1da806 100644
--- a/lib/pro_tacts/web/search.rb
+++ b/lib/pro_tacts/web/search.rb
@@ -36,7 +36,7 @@ module ProTacts
# A blank query answers the most recently updated contacts, the
# dashboard's own list, so the search page is never empty before a
- # word is typed. A query builds only the contacts it finds.
+ # word is typed. A query builds only the contacts it shows.
#: (String query) -> Admin::SearchResults
def search_results(query)
if query.empty?
@@ -54,14 +54,15 @@ module ProTacts
step.set_attribute("contacts", it.length)
}
}
+ shown = ids.first(Admin::SearchResults::LIMIT)
contacts = span("search.contacts_by_recency") { |step|
- found = store.contacts_by_recency(ids).to_h {
+ found = store.contacts_by_recency(shown).to_h {
[it.contact.id, it] #: [String, Store::RecentContact]
}
step.set_attribute("rows", found.length)
- ids.map { found.fetch(it) }
+ shown.map { found.fetch(it) }
}
- Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
+ Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:), more: ids.length - shown.length)
end
end
end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 22df518..2a8c35b 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -23,7 +23,8 @@ module ProTacts
end
class SearchResults
- def self.new: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool) -> instance
+ LIMIT: Integer
+ def self.new: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool, ?more: Integer) -> instance
def call: () -> String
end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 762d1d0..6b64f2f 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -540,6 +540,18 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # Past the cap, the rest are counted rather than listed.
+ def test_search_lists_up_to_its_limit_and_counts_the_rest
+ limit = ProTacts::Admin::SearchResults::LIMIT
+ cards = (limit + 2).times.to_h { ["ada#{it}", ADA.sub("Ada Lovelace", "Ada #{it}").sub("UID:ada", "UID:ada#{it}")] }
+ with_contacts(cards) do
+ get "/search/results", q: "ada"
+
+ assert_equal limit, last_response.body.scan("<li").length
+ assert_includes last_response.body, "2 more. Keep typing to narrow them down."
+ end
+ end
+
def test_search_with_no_matches_says_so
with_contacts({"ada" => ADA}) do
get "/search/results", q: "nobody"