search: match by word and rank name hits first
The whole query had to fit one field, so "ada london" came back bare;
now each word finds its own, and a phone matches by digits, punctuation spared.
Addresses and notes are searched, and name hits lead the way,
with recency breaking the ties, the order it kept yesterday.

Assisted-by: Claude Opus 5.5 via Claude Code
Issue: ynwtzotq
change qwsnowurvlyywnnykwvunnpsmmsmnrrx
commit 21f1552373b94b8bcdf840050c9811bcab9c7949
author Alpha Chen <alpha@kejadlen.dev>
date
parent rzqtnwvx
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
index 92b66c2..c734693 100644
--- a/lib/pro_tacts/admin/dashboard.rb
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -6,6 +6,7 @@ require "pro_tacts/admin/format"
 require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/list_item"
 require "pro_tacts/admin/layout"
+require "pro_tacts/admin/search"
 require "pro_tacts/admin/upcoming_birthdays"
 
 module ProTacts
@@ -13,7 +14,8 @@ module ProTacts
     # GET / — the dashboard (docs/DESIGN.md): the search in the page
     # header, recency in the primary column, the birthdays the year is
     # about to bring in the ambient one beside it. A query narrows the
-    # contacts column to matches across every contact; an empty query
+    # contacts column to matches across every contact, in the order
+    # Search.contacts ranks them; an empty query
     # shows the ten most recently updated. The create dialog rides
     # along hidden on every render — a popover opens where it sits, so
     # the add button needs nothing from the server but the page it is
@@ -41,9 +43,8 @@ module ProTacts
         @notice = notice
         @labels = {}
         groups.each { |group| group.members.each { (@labels[it] ||= []) << group.label } }
-        q = Format.search_key(@query)
-        @groups = @query.empty? ? [] : groups.select { Format.search_key(it.label).include?(q) }.sort
-        @rows = @query.empty? ? recent.first(RECENT_LIMIT) : recent.select { matches?(it, @query) }
+        @groups = @query.empty? ? [] : Search.groups(groups, query: @query)
+        @rows = @query.empty? ? recent.first(RECENT_LIMIT) : Search.contacts(recent, query: @query, labels: @labels)
       end
 
       def view_template
@@ -79,23 +80,6 @@ module ProTacts
 
       private
 
-      # Match generously (docs/DESIGN.md): a contact is findable by
-      # name or nickname, any of its values, or the groups it
-      # belongs to — every comparison against the fold
-      # Format.search_key makes of both sides, accents with the case.
-      #: (Store::RecentContact row, String query) -> bool
-      def matches?(row, query)
-        q = Format.search_key(query)
-        return true if Format.search_key(row.contact.name.to_s).include?(q)
-        return true if Format.search_key(row.contact.nickname.to_s).include?(q)
-        return true if Format.search_key(row.contact.organization.to_s).include?(q)
-        return true if row.contact.phones.any? { Format.search_key(it.value).include?(q) }
-        return true if row.contact.emails.any? { Format.search_key(it.value).include?(q) }
-        return true if @labels.fetch(row.contact.id, []).any? { Format.search_key(it).include?(q) }
-
-        false
-      end
-
       # The groups whose names match, under the contacts: a group is a
       # record the search reaches as surely as a contact is
       # (docs/DESIGN.md, "When adding something new").
diff --git a/lib/pro_tacts/admin/search.rb b/lib/pro_tacts/admin/search.rb
new file mode 100644
index 0000000..fe6d11b
--- /dev/null
+++ b/lib/pro_tacts/admin/search.rb
@@ -0,0 +1,96 @@
+require "pro_tacts/admin/format"
+
+module ProTacts
+  module Admin
+    # What the header's search finds (docs/DESIGN.md: match generously —
+    # by name, any of its values, and the groups a contact is in). A
+    # query is words, and a record matches when every word turns up
+    # somewhere in it, each word free to find a different field: "ada
+    # london" is Ada with a London address. Every comparison is on
+    # Format.search_key's fold of both sides, except a phone's, which
+    # compares digits alone, since a number is spelled with whatever
+    # punctuation its writer liked.
+    #
+    # Matching stays substring-generous rather than typo-tolerant or
+    # indexed: a family's book is small enough to walk whole, and SQLite
+    # FTS5 was weighed and set aside for it (ranger pnrqpyvk).
+    module Search
+      # The punctuation a phone number is written with, which a word made
+      # of nothing else (and at least one digit) is a number spelled.
+      PHONE_WORD = /\A[\d().+-]*\d[\d().+-]*\z/ #: Regexp
+      private_constant :PHONE_WORD
+
+      # The contacts the query matches, in the order a search is read:
+      # a hit on the name first — every word the start of a word of the
+      # name or nickname, "ada" or "love" for Ada Lovelace — then the
+      # hits anywhere else, recency keeping its order within each. The
+      # rows arrive in recency order, and partition keeps it. `labels`
+      # is the names of the groups each contact is in, by id.
+      #: (Array[Store::RecentContact] rows, query: String, labels: Hash[String, Array[String]]) -> Array[Store::RecentContact]
+      def self.contacts(rows, query:, labels:)
+        words = words(query)
+        matched = rows.select { |row| matches?(row.contact, words, labels.fetch(row.contact.id, [])) }
+        names, rest = matched.partition { name_hit?(it.contact, words) }
+        names + rest
+      end
+
+      # The groups whose names the query matches, every word somewhere
+      # in the label, in the order Group sorts.
+      #: (Array[Group] groups, query: String) -> Array[Group]
+      def self.groups(groups, query:)
+        words = words(query)
+        groups.select { |group|
+          label = Format.search_key(group.label)
+          words.all? { label.include?(it) }
+        }.sort
+      end
+
+      #: (String query) -> Array[String]
+      def self.words(query)
+        Format.search_key(query).split
+      end
+      private_class_method :words
+
+      #: (Contact contact, Array[String] words, Array[String] labels) -> bool
+      def self.matches?(contact, words, labels)
+        texts = text_fields(contact, labels).map { Format.search_key(it) }
+        phones = contact.phones.map { digits(it.value) }
+        words.all? { |word|
+          texts.any? { it.include?(word) } ||
+            (word.match?(PHONE_WORD) && phones.any? { it.include?(digits(word)) })
+        }
+      end
+      private_class_method :matches?
+
+      # Every value of the contact a word is matched against as text:
+      # its names, emails, addresses, notes, and groups. An address is
+      # its components run together, so "main st" finds a street.
+      #: (Contact contact, Array[String] labels) -> Array[String]
+      def self.text_fields(contact, labels)
+        [
+          contact.name,
+          contact.nickname,
+          contact.organization,
+          *contact.emails.map(&:value),
+          *contact.addresses.map { [it.extended, it.street, it.locality, it.region, it.postal_code, it.country].compact.join(" ") },
+          *contact.notes.map(&:value),
+          *labels,
+        ].compact
+      end
+      private_class_method :text_fields
+
+      #: (Contact contact, Array[String] words) -> bool
+      def self.name_hit?(contact, words)
+        name_words = [contact.name, contact.nickname].compact.flat_map { Format.search_key(it).split }
+        words.all? { |word| name_words.any? { it.start_with?(word) } }
+      end
+      private_class_method :name_hit?
+
+      #: (String value) -> String
+      def self.digits(value)
+        value.delete("^0-9")
+      end
+      private_class_method :digits
+    end
+  end
+end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 8ffa52c..0cc6be0 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -72,9 +72,11 @@ module ProTacts
     end
 
     # Its sort_key orders the import's list the way the listings of
-    # contacts are ordered (Web#walk_order).
+    # contacts are ordered (Web#walk_order), and its search_key is the
+    # fold Search matches on.
     module Format
       def self.sort_key: (Contact contact) -> String
+      def self.search_key: (String value) -> String
     end
 
     # Rendered by Dashboard, not called from checked code; only
diff --git a/test/pro_tacts/admin/test_search.rb b/test/pro_tacts/admin/test_search.rb
new file mode 100644
index 0000000..3ec7247
--- /dev/null
+++ b/test/pro_tacts/admin/test_search.rb
@@ -0,0 +1,84 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/admin/search"
+require "pro_tacts/contact"
+require "pro_tacts/store"
+require "pro_tacts/vcard"
+
+class SearchTest < Minitest::Test
+  # A card from its extra lines, and its row in recency order: the
+  # rows arrive most recent first, the way Store#contacts_by_recency
+  # hands them over.
+  def row(id, name, *lines)
+    bytes = ["BEGIN:VCARD", "VERSION:3.0", "FN:#{name}", "UID:#{id}", *lines, "END:VCARD", ""].join("\r\n")
+    contact = ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(bytes), birthday: nil, inherited: [])
+    ProTacts::Store::RecentContact.new(contact:, updated_at: Time.now)
+  end
+
+  def search(rows, query, labels: {})
+    ProTacts::Admin::Search.contacts(rows, query:, labels:).map { it.contact.id }
+  end
+
+  # Each word finds its own field: neither "ada" nor "london" is
+  # the whole of any one value.
+  def test_every_word_matches_somewhere_in_the_contact
+    ada = row("ada", "Ada Lovelace", "ADR:;;12 St James Sq;London;;;UK")
+    grace = row("grace", "Grace Hopper", "ADR:;;1 Main St;London;;;UK")
+
+    assert_equal ["ada"], search([ada, grace], "ada london")
+    assert_equal [], search([ada, grace], "ada arlington")
+  end
+
+  # A number is found by its digits whatever punctuation the card
+  # and the query spell it with.
+  def test_a_phone_compares_digits_only
+    ada = row("ada", "Ada Lovelace", "TEL:(555) 010-0100")
+
+    assert_equal ["ada"], search([ada], "555-010-0100")
+    assert_equal ["ada"], search([ada], "5550100100")
+    assert_equal ["ada"], search([ada], "0100")
+  end
+
+  # A word with letters in it is not a number, so its digits alone do
+  # not reach a phone.
+  def test_a_word_with_letters_does_not_match_a_phone
+    ada = row("ada", "Ada Lovelace", "TEL:555-0100")
+
+    assert_equal [], search([ada], "x555")
+  end
+
+  def test_addresses_and_notes_are_values_a_search_finds
+    ada = row("ada", "Ada Lovelace", "ADR:;;12 St James Sq;London;;;UK", "NOTE:Met at the Analytical Society")
+
+    assert_equal ["ada"], search([ada], "james sq")
+    assert_equal ["ada"], search([ada], "analytical")
+  end
+
+  def test_the_groups_a_contact_is_in_find_it
+    ada = row("ada", "Ada Lovelace")
+
+    assert_equal ["ada"], search([ada], "mathematicians", labels: {"ada" => ["Mathematicians"]})
+  end
+
+  # Charles is the more recent, and only his note says "ada"; Ada's
+  # name does, so she leads anyway. Within a tier, recency stands:
+  # the two notes keep the order they arrived in.
+  def test_a_name_prefix_hit_ranks_above_a_hit_elsewhere
+    charles = row("charles", "Charles Babbage", "NOTE:Worked with Ada")
+    byron = row("byron", "Lord Byron", "NOTE:Father of Ada")
+    ada = row("ada", "Ada Lovelace")
+
+    assert_equal ["ada", "charles", "byron"], search([charles, byron, ada], "ada")
+  end
+
+  # A prefix of a later word of the name counts, and so does the
+  # nickname; a match inside a word is still a match, just not a name
+  # hit.
+  def test_a_name_hit_is_a_prefix_of_any_word_of_the_name_or_nickname
+    lovelace = row("ada", "Ada Lovelace")
+    red = row("red", "Sarah Jones", "NICKNAME:Lovey")
+    clove = row("clove", "Clove Smith")
+
+    assert_equal ["ada", "red", "clove"], search([clove, lovelace, red], "love")
+  end
+end