From 3abc1a2d8fda4feab633d98b2c7f292969521c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 01:51:53 +0000 Subject: [PATCH] Count iPhone installs and installs that finish in the background On iPhone the install suggestion can only show Add to Home Screen steps, so its only button was Not now and every iPhone install was counted as a dismissal. It now offers I added it beside Not now, which sends the existing install event. A browser install event is also sent when the tab is already in the background, with keepalive like a click. No new event, field or identifier; disclosures updated. --- CHANGELOG.md | 1 + deployments/brockville/site.json | 2 +- deployments/kingston/ANALYTICS.md | 2 +- deployments/kingston/site.json | 2 +- src/analytics.ts | 5 +++-- src/copy/en.ts | 2 +- src/main.ts | 12 ++++++++++-- tests/unit/analytics.test.ts | 13 ++++++++++--- 8 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2ca53..1c90d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Notable user-visible changes to Food Help are recorded here. Community publicati ### Changed +- The install suggestion on iPhone, which can only show Add to Home Screen steps, now has an **I added it** button beside **Not now**, so iPhone installs are counted as installs instead of dismissals. A browser install that finishes after the tab moves to the background is no longer dropped. Kingston's analytics disclosure says so; no new event or field is sent. - Directory cards are easier to scan: schedule status is a coloured badge, today’s hours show one time range per line with shorter twelve-hour times (for example “9–11 a.m.”), field labels are larger, the current view’s tag and a repeated operator name are no longer shown, and the directory introduction is shorter. Resource pages show Call and Directions before the detailed schedule and sources, phone numbers in North American format, and addresses without the country code. The project homepage links straight to the directory from its introduction when one community is available. - Configured community directories can link discreetly back to the Food Help project without changing their resource URLs or provider data. - Category controls now show only primary types represented in the active browsing view, and omit “All types” when only one type is available. diff --git a/deployments/brockville/site.json b/deployments/brockville/site.json index 8e411ae..9d9a2fe 100644 --- a/deployments/brockville/site.json +++ b/deployments/brockville/site.json @@ -58,7 +58,7 @@ "preference_key": "bfh-optional-analytics", "content_type": "text/plain;charset=UTF-8", "click_keepalive": true, - "disclosure": "Optional aggregate analytics is on by default. You can turn it off below at any time. It counts connected Emergency food directory loads, broad call/directions/source-link clicks there, and supported-browser installation signals. Lighthouse, our measurement service, receives only fixed event types and public outreach labels. It never receives which provider you chose, searches, filters, location or visitor identifiers. Daily totals are kept for up to 400 days; no individual activity history is stored. Connection information is briefly used for abuse control, with rotating keyed minute counters kept for about two days and no raw IP retained in analytics. Your yes/no choice is saved in this browser on this website, without an analytics cookie or identifier. Global Privacy Control, Do Not Track and local suppression settings keep collection off. Offline activity is never queued or replayed. Clicks do not establish that anyone received help.", + "disclosure": "Optional aggregate analytics is on by default. You can turn it off below at any time. It counts connected Emergency food directory loads, broad call/directions/source-link clicks there, and installation signals from supported browsers or from tapping “I added it” on iPhone. Lighthouse, our measurement service, receives only fixed event types and public outreach labels. It never receives which provider you chose, searches, filters, location or visitor identifiers. Daily totals are kept for up to 400 days; no individual activity history is stored. Connection information is briefly used for abuse control, with rotating keyed minute counters kept for about two days and no raw IP retained in analytics. Your yes/no choice is saved in this browser on this website, without an analytics cookie or identifier. Global Privacy Control, Do Not Track and local suppression settings keep collection off. Offline activity is never queued or replayed. Clicks do not establish that anyone received help.", "constants": { "site_key": "brockville_food_help", "contract_version": 3, diff --git a/deployments/kingston/ANALYTICS.md b/deployments/kingston/ANALYTICS.md index e961c09..1906fdb 100644 --- a/deployments/kingston/ANALYTICS.md +++ b/deployments/kingston/ANALYTICS.md @@ -8,7 +8,7 @@ Collection defaults on with an off switch under **Your privacy** and on `/privac Preferences on the former `kingstonfoodhelp.ca` domain cannot be read by `kingston.food-help.ca`. The approved restoration uses default-on collection on the new domain, with this limitation disclosed beside the choice. Visitors can turn it off here; no cross-domain identifier or preference-transfer mechanism is introduced. -The existing adapter emits only from `/`, the interactive Emergency food directory. The Affordable food page, resource pages, simple directory and informational pages produce no measurements. Listing openings, install prompt shows and dismissals, and the Yes / Not this time answers to "Did this help you find food?" (`help_feedback` with `event_value` `yes` or `not_this_time`, one answer per page load, never with the listing) are additional unlabelled daily totals. The six original kinds map to page loads, resource-call clicks, 211 clicks, directions clicks, official-source clicks and browser installation signals. The existing finite public outreach labels accompany page loads and the four broad clicks; installation is unattributed. No search/filter/location, provider identity, raw URL/referrer or visitor history is sent. Counts describe observed activity, not people or completed services. +The existing adapter emits only from `/`, the interactive Emergency food directory. The Affordable food page, resource pages, simple directory and informational pages produce no measurements. Listing openings, install prompt shows and dismissals, and the Yes / Not this time answers to "Did this help you find food?" (`help_feedback` with `event_value` `yes` or `not_this_time`, one answer per page load, never with the listing) are additional unlabelled daily totals. The six original kinds map to page loads, resource-call clicks, 211 clicks, directions clicks, official-source clicks and installation signals: the browser's own install event (sent even if the install finishes in a background tab) or, where browsers give none (iPhone), the visitor tapping **I added it** in the install prompt. The existing finite public outreach labels accompany page loads and the four broad clicks; installation is unattributed. No search/filter/location, provider identity, raw URL/referrer or visitor history is sent. Counts describe observed activity, not people or completed services. Lighthouse stores daily totals and independent outreach margins for up to 400 UTC days and rotating abuse counters for about two days. There are no raw Kingston event rows. The adapter omits credentials and referrers, rejects redirects and makes one attempt with a 1,500 ms abort timer. Previously started visible clicks can finish during navigation; privacy revocation and offline transitions still abort them. No queue, retry, worker collection or replay exists. diff --git a/deployments/kingston/site.json b/deployments/kingston/site.json index de18764..85a3fd7 100644 --- a/deployments/kingston/site.json +++ b/deployments/kingston/site.json @@ -59,7 +59,7 @@ "preference_key": "kfh-optional-analytics", "content_type": "text/plain;charset=UTF-8", "click_keepalive": true, - "disclosure": "Optional aggregate analytics is on by default. You can turn it off below at any time. It counts connected Emergency food directory loads, broad call/directions/source-link clicks there, and supported-browser installation signals. It also counts listing detail openings, install prompt shows and dismissals, and Yes or Not this time answers to “Did this help you find food?” as broad totals only. Lighthouse, our measurement service, receives only fixed event types and public outreach labels. It never receives which provider you chose, searches, filters, location or visitor identifiers. Daily totals are kept for up to 400 days; no individual activity history is stored. Connection information is briefly used for abuse control, with rotating keyed minute counters kept for about two days and no raw IP retained in analytics. Your yes/no choice is saved in this browser on this website, without an analytics cookie or identifier. A choice saved on kingstonfoodhelp.ca cannot transfer to this domain; please turn analytics off here if that is your preference. Global Privacy Control, Do Not Track and local suppression settings keep collection off. Offline activity is never queued or replayed. Clicks do not establish that anyone received help.", + "disclosure": "Optional aggregate analytics is on by default. You can turn it off below at any time. It counts connected Emergency food directory loads, broad call/directions/source-link clicks there, and installation signals from supported browsers or from tapping “I added it” on iPhone. It also counts listing detail openings, install prompt shows and dismissals, and Yes or Not this time answers to “Did this help you find food?” as broad totals only. Lighthouse, our measurement service, receives only fixed event types and public outreach labels. It never receives which provider you chose, searches, filters, location or visitor identifiers. Daily totals are kept for up to 400 days; no individual activity history is stored. Connection information is briefly used for abuse control, with rotating keyed minute counters kept for about two days and no raw IP retained in analytics. Your yes/no choice is saved in this browser on this website, without an analytics cookie or identifier. A choice saved on kingstonfoodhelp.ca cannot transfer to this domain; please turn analytics off here if that is your preference. Global Privacy Control, Do Not Track and local suppression settings keep collection off. Offline activity is never queued or replayed. Clicks do not establish that anyone received help.", "constants": { "site_key": "kingston_food_help", "contract_version": 3, diff --git a/src/analytics.ts b/src/analytics.ts index e3fd7c5..90a7cc7 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -82,13 +82,14 @@ export function createAnalytics(site: Site, production: boolean, env: AnalyticsE if (now - (lastAction.get(kind) ?? -Infinity) < 750) return; lastAction.set(kind, now); } - if (!allowed() || !config?.endpoint || !env.online() || !env.visible()) return; + // Browsers finish installing after the tab is backgrounded, so an install is not dropped for being hidden. + if (!allowed() || !config?.endpoint || !env.online() || (kind !== 'install' && !env.visible())) return; // Directory startup/actions only; no resource-page browsing or other page history. try { if (new URL(env.url).pathname !== '/') return; } catch { return; } const event = config.event_payloads ? config.event_payloads[kind] : { event: config.event_names?.[kind] ?? kind }; if (!event) return; const body = JSON.stringify({ ...config.constants, ...event, ...(config.attribution?.events.some(attributed => attributed === kind) ? attribution : {}) }); - const action = kind !== 'page_start' && kind !== 'install' && kind !== 'install_prompt_show', controller = new AbortController(); + const action = kind !== 'page_start' && kind !== 'install_prompt_show', controller = new AbortController(); const timer = setTimeout(() => { controller.abort(); pending.delete(controller); }, 1500); pending.set(controller, { action, timer }); try { diff --git a/src/copy/en.ts b/src/copy/en.ts index fe56309..c0c18b9 100644 --- a/src/copy/en.ts +++ b/src/copy/en.ts @@ -51,7 +51,7 @@ export const copy = { savingFailed: 'The current directory is available, but this browser could not save it for offline use.', appReady: 'Application ready for offline use.', appFailed: 'Offline setup was unavailable. Basic browsing still works.', offline: 'You are offline. Check review dates; details may have changed.', install: 'Install Food Help', installHelp: 'Use your browser’s Install app or Add to Home Screen option. Visit online once to save the directory, then check the offline-ready message.', installPromptTitle: 'Keep this directory on your device', installPromptText: 'Install Food Help to open it like an app and keep the latest reviewed directory available offline.', - installPromptAction: 'Install now', installPromptInstructions: 'To install: open your browser’s Share menu and choose Add to Home Screen.', installPromptDismiss: 'Not now', + installPromptAction: 'Install now', installPromptInstructions: 'To install: open your browser’s Share menu and choose Add to Home Screen.', installPromptDismiss: 'Not now', installPromptAdded: 'I added it', feedbackQuestion: 'Did this help you find food?', feedbackYes: 'Yes', feedbackNo: 'Not this time', feedbackThanks: 'Thanks for letting us know.', updateReady: 'An application update is ready. Apply it when you are ready to reload this page.', update: 'Apply update', updateFailed: 'The update could not be applied. Your current directory is still available.', refresh: 'Refresh listings', usage: 'Public usage', usageIntro: 'Sanitized totals for this deployment. These are event counts, not unique people or measures of demand. Missing totals are shown as unavailable.', diff --git a/src/main.ts b/src/main.ts index 394e78b..e6d3b27 100644 --- a/src/main.ts +++ b/src/main.ts @@ -197,7 +197,9 @@ if (!config.review_drafts) { if (!native && !appleTouchBrowser()) return; try { if (Date.now() - (Number(localStorage.getItem(promptDismissedKey)) || 0) < promptQuietPeriod) return; } catch { return; } byId('install-prompt-instructions')?.toggleAttribute('hidden', native); - byId('install-prompt-action')?.toggleAttribute('hidden', !native); + // Where there is no native prompt (iPhone), the visitor confirms adding the app themselves. + const action = byId('install-prompt-action'); + if (action) action.textContent = native ? c.installPromptAction : c.installPromptAdded; promptDialog.showModal(); collection.emit('install_prompt_show'); } @@ -206,7 +208,13 @@ if (!config.review_drafts) { promptDialog?.addEventListener('click', event => { if (event.target === promptDialog) dismissPrompt(); }); byId('install-prompt-dismiss')?.addEventListener('click', dismissPrompt); byId('install-prompt-action')?.addEventListener('click', async () => { - if (!installPrompt) return; + if (!installPrompt) { + if (!promptDialog?.open) return; + promptDialog.close(); + try { localStorage.setItem(promptDismissedKey, String(Date.now())); } catch { /* A blocked timer simply ends the courtesy period. */ } + collection.emit('install'); + return; + } const choice = installPrompt.userChoice; await installPrompt.prompt(); installPrompt = null; diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 6b1d259..3dad3d9 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -47,7 +47,7 @@ describe('optional aggregate collection', () => { expect(requests[index]).toMatchObject({ method: 'POST', mode: 'cors', credentials: 'omit', cache: 'no-store', redirect: 'error', referrerPolicy: 'no-referrer', headers: { 'Content-Type': 'text/plain;charset=UTF-8' } }); } expect(JSON.stringify(requests.map(request => request.body))).not.toMatch(/secret|private|fbclid|location/); - expect(requests[0]).not.toHaveProperty('keepalive'); expect(requests[1]!.keepalive).toBe(true); expect(requests[5]).not.toHaveProperty('keepalive'); + expect(requests[0]).not.toHaveProperty('keepalive'); expect(requests[1]!.keepalive).toBe(true); expect(requests[5]!.keepalive).toBe(true); expect(requests[6]!.keepalive).toBe(true); expect(requests[7]).not.toHaveProperty('keepalive'); expect(requests[8]!.keepalive).toBe(true); collection.pause(); }); it('keeps the original simple JSON adapter compatible and opt-in', () => { @@ -98,10 +98,17 @@ describe('optional aggregate collection', () => { for (const mode of ['online', 'visible'] as const) { const { collection, env, requests } = fixture(); env[mode] = () => false; collection.emit('page_start'); collection.emit('directions'); collection.emit('install'); env[mode] = () => true; - collection.emit('page_start'); collection.emit('install'); expect(requests).toHaveLength(0); - env.now = () => 1750; collection.emit('directions'); expect(requests).toHaveLength(1); collection.pause(); + // An install finishing in a background tab is still sent once; offline it is dropped. + const installs = mode === 'visible' ? 1 : 0; + collection.emit('page_start'); collection.emit('install'); expect(requests).toHaveLength(installs); + env.now = () => 1750; collection.emit('directions'); expect(requests).toHaveLength(installs + 1); collection.pause(); } }); + it('keeps a background install delivery alive like a click', () => { + const { collection, env, requests } = fixture(); env.fetch = (_url, init) => { requests.push(init!); return new Promise(() => {}); }; + env.visible = () => false; collection.emit('install'); collection.background(); + expect(requests).toHaveLength(1); expect(requests[0]!.signal!.aborted).toBe(false); collection.pause(); + }); it('aborts all pending deliveries and clears attribution on cross-tab revocation', () => { const { collection, env, values, requests } = fixture(); env.fetch = (_url, init) => { requests.push(init!); return new Promise(() => {}); }; collection.emit('page_start'); collection.emit('directions'); values.set('example-choice', 'no'); collection.state();