diff --git a/apps.html b/apps.html
index 00eba4c..ea463a3 100644
--- a/apps.html
+++ b/apps.html
@@ -1424,7 +1424,7 @@
Chaos - Stranger Missions
ai: ['debate-me','voice-memo','future-me','date-pilot','global-news','pantry-ai','name-maker','idea-market','life-trace']
};
const clean = value => value.normalize('NFKC').toLocaleLowerCase().trim();
- let goal = 'all', loggedSearch = false;
+ let goal = 'all', lastReportedSearch = '';
const slug = card => {
const src = card.querySelector('img')?.getAttribute('src') || '';
return src.split('/').pop()?.replace(/\.png$/, '') || '';
@@ -1448,19 +1448,25 @@ Chaos - Stranger Missions
empty.hidden = shown !== 0;
return shown;
}
- input.addEventListener('input', () => {
- const shown = render();
- // Search strings might include personal details. Never transmit them.
- if (input.value.trim() && !loggedSearch) {
- fire('catalog_search_used', {results: shown, search_length_band: input.value.length < 5 ? 'short' : 'long'});
- loggedSearch = true;
- }
- if (!input.value.trim()) loggedSearch = false;
+ // Do not report the unfinished first keystroke as a completed search.
+ // Keep query text local; GA4 receives only a coarse length bucket and result count.
+ const reportSearch = () => {
+ const query = clean(input.value);
+ if (!query || query === lastReportedSearch) return;
+ fire('catalog_search_used', {
+ results: render(), search_length_band: query.length < 5 ? 'short' : 'long'
+ });
+ lastReportedSearch = query;
+ };
+ input.addEventListener('input', render);
+ input.addEventListener('blur', reportSearch);
+ input.addEventListener('keydown', event => {
+ if (event.key === 'Enter') reportSearch();
});
reset.addEventListener('click', () => {
input.value = '';
goal = 'all';
- loggedSearch = false;
+ lastReportedSearch = '';
buttons.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.goal === 'all')));
render();
input.focus();
diff --git a/scripts/check_growth_live.py b/scripts/check_growth_live.py
index 48f3e25..71a9d29 100644
--- a/scripts/check_growth_live.py
+++ b/scripts/check_growth_live.py
@@ -74,6 +74,13 @@ def check(path, key, valid):
check("/apps", "no_stale_live_store_claim", "92 free apps on Google Play" not in catalog)
check("/apps", "store_click_tracking", "catalog_store_click" in catalog and "download_click" in catalog)
check("/apps", "detail_click_tracking", "catalog_app_detail_click" in catalog)
+ check("/apps", "finalized_search_analytics",
+ "input.addEventListener('input', render)" in catalog and
+ "input.addEventListener('blur', reportSearch)" in catalog and
+ "if (event.key === 'Enter') reportSearch()" in catalog and
+ "query === lastReportedSearch" in catalog and
+ "lastReportedSearch = query" in catalog and
+ "loggedSearch" not in catalog)
check("/apps", "search_includes_app_slug",
"const app = slug(card), content = clean((card.textContent || '') + ' ' + app" in catalog
and "app.replace(/-/g, ' ')" in catalog)
diff --git a/scripts/test_growth_live.py b/scripts/test_growth_live.py
index 3eedecc..89205a7 100644
--- a/scripts/test_growth_live.py
+++ b/scripts/test_growth_live.py
@@ -26,6 +26,9 @@ def valid_pages():
pages["/apps"] = (
'' +
cards + " catalog_store_click download_click catalog_app_detail_click " +
+ "input.addEventListener('input', render) input.addEventListener('blur', reportSearch) " +
+ "if (event.key === 'Enter') reportSearch() query === lastReportedSearch " +
+ "lastReportedSearch = query " +
"const app = slug(card), content = clean((card.textContent || '') + ' ' + app; app.replace(/-/g, ' ')" +
'id="finder-reset" reset.addEventListener(\'click\' input.value = \'\' goal = \'all\' render(); catalog_search_reset ' +
" ".join(f'' for slug in SLUGS)
@@ -187,6 +190,24 @@ def test_store_schema_package_mismatch_fails(self):
"id=jp.appdevelopsk.testapp1", "id=jp.appdevelopsk.unlisted", 1)
self.assertFalse(inspect(data)["passed"])
+ def test_first_keystroke_analytics_rejected(self):
+ data = valid_pages()
+ data["/apps"] = data["/apps"].replace(
+ "input.addEventListener('input', render)",
+ "input.addEventListener('input', reportSearch)")
+ self.assertFalse(inspect(data)["passed"])
+
+ def test_missing_blur_search_reporting_rejected(self):
+ data = valid_pages()
+ data["/apps"] = data["/apps"].replace(
+ "input.addEventListener('blur', reportSearch)", "")
+ self.assertFalse(inspect(data)["passed"])
+
+ def test_repeated_search_reporting_rejected(self):
+ data = valid_pages()
+ data["/apps"] = data["/apps"].replace("query === lastReportedSearch", "")
+ self.assertFalse(inspect(data)["passed"])
+
def test_missing_slug_search_fails(self):
data = valid_pages()
data["/apps"] = data["/apps"].replace(