From f6974ca062854a25c1bb0f7217349afbec04b2aa Mon Sep 17 00:00:00 2001 From: elkimek <36666630+elkimek@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:32:48 +0200 Subject: [PATCH] Extract client list runtime wiring --- js/client-list-runtime.js | 73 ++++++++++++++ js/client-list.js | 74 ++++++++------ scripts/quality-baseline.json | 2 +- service-worker.js | 1 + tests/_vitest-legacy.test.js | 1 + tests/test-client-list-delegated-actions.js | 8 ++ tests/test-client-list-runtime.js | 105 ++++++++++++++++++++ tests/test-quality-guardrails.js | 1 + tests/verify-modules.js | 1 + tsconfig.checkjs.json | 1 + tsconfig.json | 1 + version.js | 2 +- 12 files changed, 236 insertions(+), 34 deletions(-) create mode 100644 js/client-list-runtime.js create mode 100644 tests/test-client-list-runtime.js diff --git a/js/client-list-runtime.js b/js/client-list-runtime.js new file mode 100644 index 00000000..7d17b417 --- /dev/null +++ b/js/client-list-runtime.js @@ -0,0 +1,73 @@ +// @ts-check +// client-list-runtime.js - Browser runtime adapters for client-list UI shell hooks. + +function getRuntimeWindow() { + return typeof window !== 'undefined' + ? /** @type {any} */ (window) + : /** @type {any} */ (globalThis); +} + +/** + * @param {string} name + * @returns {Function | null} + */ +function getRuntimeFunction(name) { + const runtime = getRuntimeWindow(); + return typeof runtime[name] === 'function' ? runtime[name].bind(runtime) : null; +} + +/** + * @param {string} name + * @returns {any} + */ +function getRuntimeValue(name) { + return getRuntimeWindow()[name]; +} + +export function getClientHaplogroupList() { + const list = getRuntimeValue('HAPLOGROUP_LIST'); + return Array.isArray(list) ? list : []; +} + +/** @param {() => void} [fallback] */ +export function closeClientListFromRuntime(fallback) { + const close = getRuntimeFunction('closeClientList') || fallback; + close?.(); +} + +/** @param {string} route */ +export function navigateClientListRoute(route) { + getRuntimeFunction('navigate')?.(route); +} + +export function refreshClientProfileButton() { + getRuntimeFunction('renderProfileButton')?.(); +} + +/** + * @param {string} message + * @param {string} type + */ +export function showClientListNotification(message, type) { + getRuntimeFunction('showNotification')?.(message, type); +} + +/** @param {string} haplogroup */ +export function setClientManualHaplogroup(haplogroup) { + const setManualHaplogroup = getRuntimeFunction('setManualHaplogroup'); + return setManualHaplogroup ? setManualHaplogroup(haplogroup) : false; +} + +export function hasClientListAIProvider() { + try { + return getRuntimeFunction('hasAIProvider')?.() === true; + } catch { + return false; + } +} + +/** @param {Record} bindings */ +export function publishClientListWindowBindings(bindings) { + if (typeof window === 'undefined') return; + Object.assign(getRuntimeWindow(), bindings); +} diff --git a/js/client-list.js b/js/client-list.js index e3ea4fb3..05422575 100644 --- a/js/client-list.js +++ b/js/client-list.js @@ -7,6 +7,16 @@ import { getProfiles, getActiveProfileId, createProfile, switchProfile, deletePr import { LATITUDE_BANDS } from './constants.js'; import { getAvatarColor } from './nav.js'; import { closeModalOverlay, openModalOverlay } from './modal-lifecycle.js'; +import { + closeClientListFromRuntime, + getClientHaplogroupList, + hasClientListAIProvider, + navigateClientListRoute, + publishClientListWindowBindings, + refreshClientProfileButton, + setClientManualHaplogroup, + showClientListNotification, +} from './client-list-runtime.js'; /** * @typedef {{ @@ -478,7 +488,7 @@ export function openClientForm(profileId) {
${state.importedData?.genetics?.mtdna?.coupling?.shortLabel || ''}
@@ -521,8 +531,8 @@ export function openClientForm(profileId) { function _clGoToHealthMetrics(event) { if (event) event.preventDefault(); - if (window.closeClientList) window.closeClientList(); - if (window.navigate) window.navigate('dashboard'); + closeClientListFromRuntime(closeClientList); + navigateClientListRoute('dashboard'); requestAnimationFrame(() => { document.getElementById('wearable-strip')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); @@ -570,14 +580,14 @@ function _clSaveForm(e) { if (sex !== undefined) state.profileSex = sex; if (dob !== undefined) state.profileDob = dob; } - window.renderProfileButton(); - window.showNotification(`"${name}" updated`, 'info'); + refreshClientProfileButton(); + showClientListNotification(`"${name}" updated`, 'info'); } else { // Create new profile const id = createProfile(name, { sex, dob, location: { country, zip }, tags, notes, status, height, heightUnit, ...avatarUpdate }); switchProfile(id); - window.renderProfileButton(); - window.showNotification(`"${name}" created`, 'success'); + refreshClientProfileButton(); + showClientListNotification(`"${name}" created`, 'success'); } _editingId = null; renderClientList(); @@ -592,7 +602,7 @@ async function _clHaplogroupChanged() { if (label) label.textContent = ''; return; } - await window.setManualHaplogroup(hg); + await setClientManualHaplogroup(hg); // Update coupling label const mt = state.importedData?.genetics?.mtdna; if (label) label.textContent = mt?.coupling?.shortLabel || ''; @@ -621,7 +631,7 @@ async function _clAvatarChanged(input) { } } } catch { - window.showNotification('Could not load image', 'error'); + showClientListNotification('Could not load image', 'error'); } input.value = ''; } @@ -662,6 +672,7 @@ function _clUpdateLat() { var cache = getLocationCache(); var cacheKey = (country + '|' + zip).toLowerCase(); var cached = cache[cacheKey]; + const hasAIProvider = hasClientListAIProvider(); // Exact cache hit — show immediately, done if (cached !== undefined) { @@ -681,8 +692,8 @@ function _clUpdateLat() { var bandLabel = getLatitudeFromLocation(country, zip); if (bandLabel) { el.style.color = 'var(--green)'; - el.textContent = '\u2713 ' + bandLabel + (window.hasAIProvider() ? ' \u2014 refining\u2026' : ''); - } else if (window.hasAIProvider()) { + el.textContent = '\u2713 ' + bandLabel + (hasAIProvider ? ' \u2014 refining\u2026' : ''); + } else if (hasAIProvider) { el.style.color = 'var(--text-muted)'; el.textContent = 'Detecting\u2026'; } else { @@ -693,21 +704,20 @@ function _clUpdateLat() { // Debounced AI refinement if (_clLatTimer) clearTimeout(_clLatTimer); - if (window.hasAIProvider()) { - _clLatTimer = setTimeout(function() { - detectLatitudeWithAI(country, zip).then(() => { - var freshCache = getLocationCache(); - var updated = freshCache[(country + '|' + zip).toLowerCase()]; - if (updated !== undefined) { - var cOnly = zip ? freshCache[(country + '|').toLowerCase()] : undefined; - var zSuffix = ''; - if (zip && cOnly !== undefined) zSuffix = Math.round(updated) !== Math.round(cOnly) ? ' (ZIP-refined)' : ' (ZIP \u2014 same area)'; - var display = document.getElementById('cl-lat-display'); - if (display) _clShowLat(display, updated, zSuffix); - } - }); - }, 1500); - } + _clLatTimer = setTimeout(function() { + if (!hasClientListAIProvider()) return; + detectLatitudeWithAI(country, zip).then(() => { + var freshCache = getLocationCache(); + var updated = freshCache[(country + '|' + zip).toLowerCase()]; + if (updated !== undefined) { + var cOnly = zip ? freshCache[(country + '|').toLowerCase()] : undefined; + var zSuffix = ''; + if (zip && cOnly !== undefined) zSuffix = Math.round(updated) !== Math.round(cOnly) ? ' (ZIP-refined)' : ' (ZIP \u2014 same area)'; + var display = document.getElementById('cl-lat-display'); + if (display) _clShowLat(display, updated, zSuffix); + } + }); + }, 1500); } function _clTagKeydown(e) { @@ -744,7 +754,7 @@ function _clBackToList() { // ═══════════════════════════════════════════════ function _clSelect(id) { switchProfile(id); - window.renderProfileButton(); + refreshClientProfileButton(); closeClientList(); } @@ -830,10 +840,10 @@ function _clToggleMenu(e, id, buttonEl = null) { function _clEdit(id) { _closeMenus(); openClientForm(id); } function _clPin(id) { updateProfileMeta(id, { pinned: true }); renderClientList(); } function _clUnpin(id) { updateProfileMeta(id, { pinned: false }); renderClientList(); } -function _clFlag(id) { updateProfileMeta(id, { status: 'flagged' }); renderClientList(); window.renderProfileButton(); } -function _clUnflag(id) { updateProfileMeta(id, { status: 'active' }); renderClientList(); window.renderProfileButton(); } -function _clArchive(id) { updateProfileMeta(id, { status: 'archived' }); renderClientList(); window.renderProfileButton(); } -function _clUnarchive(id) { updateProfileMeta(id, { status: 'active' }); renderClientList(); window.renderProfileButton(); } +function _clFlag(id) { updateProfileMeta(id, { status: 'flagged' }); renderClientList(); refreshClientProfileButton(); } +function _clUnflag(id) { updateProfileMeta(id, { status: 'active' }); renderClientList(); refreshClientProfileButton(); } +function _clArchive(id) { updateProfileMeta(id, { status: 'archived' }); renderClientList(); refreshClientProfileButton(); } +function _clUnarchive(id) { updateProfileMeta(id, { status: 'active' }); renderClientList(); refreshClientProfileButton(); } function _closeMenus() { const m = document.getElementById('cl-active-menu'); const tools = document.getElementById('cl-tools-menu'); @@ -1061,7 +1071,7 @@ function openProfileLocationEditor() { installClientListDelegates(); -Object.assign(window, { +publishClientListWindowBindings({ openClientList, closeClientList, openClientForm, openProfileLocationEditor, _clSearch, _clSort, _clStatusFilter, _clTagFilter, _clSelect, _clSaveForm, _clSetSex, _clUpdateLat, _clTagKeydown, _clRemoveTag, _clBackToList, diff --git a/scripts/quality-baseline.json b/scripts/quality-baseline.json index 16775386..9dd1fd2d 100644 --- a/scripts/quality-baseline.json +++ b/scripts/quality-baseline.json @@ -1,6 +1,6 @@ { "inlineEventAttributes": 0, - "windowReferences": 567, + "windowReferences": 547, "largeJsFilesOver800Lines": 29, "maxJsFileLines": 1513 } diff --git a/service-worker.js b/service-worker.js index 99adab25..3431b9df 100755 --- a/service-worker.js +++ b/service-worker.js @@ -180,6 +180,7 @@ const APP_SHELL = [ '/js/feedback.js', '/js/tour.js', '/js/changelog.js', + '/js/client-list-runtime.js', '/js/client-list.js', '/js/nav.js', '/js/views-router.js', diff --git a/tests/_vitest-legacy.test.js b/tests/_vitest-legacy.test.js index 5a5a2d54..b49a7604 100644 --- a/tests/_vitest-legacy.test.js +++ b/tests/_vitest-legacy.test.js @@ -102,6 +102,7 @@ const LEGACY_TESTS = [ './test-light-devices.js', './test-sun-context.js', './test-sun-uvdata.js', + './test-client-list-runtime.js', // Batch 11 — cycle + change-history + light-tools + biometrics. './test-light-tools.js', './test-cycle-improvements.js', diff --git a/tests/test-client-list-delegated-actions.js b/tests/test-client-list-delegated-actions.js index 92a7e093..504c11cf 100644 --- a/tests/test-client-list-delegated-actions.js +++ b/tests/test-client-list-delegated-actions.js @@ -56,6 +56,10 @@ assert('client-list routes app-shell actions through injectable runtime', clientListSrc.includes('clientListRuntime.exportClientJSON(id, true)') && clientListSrc.includes('clientListRuntime.importDataJSON(file)') && clientListSrc.includes('clientListRuntime.loadDemoData(actionEl.dataset.clDemo')); +assert('client-list routes browser globals through client-list runtime adapter', + clientListSrc.includes("from './client-list-runtime.js'") && + clientListSrc.includes('publishClientListWindowBindings') && + !/\bwindow(?:\.|\s*\[)/.test(clientListSrc)); assert('client-list modal uses shared overlay lifecycle helpers', clientListSrc.includes("from './modal-lifecycle.js'") && clientListUsesScrollLockedOverlay && @@ -69,6 +73,10 @@ assert('height display rounds cm to whole numbers and inches to one decimal', clientListSrc.includes("unit === 'in' ? (heightCm / 2.54).toFixed(1) : String(Math.round(heightCm))") && clientListSrc.includes("next === 'in' ? '0.1' : '1'") && clientListSrc.includes("heightUnit === 'in' ? Math.round(heightRaw * 2.54 * 10) / 10 : Math.round(heightRaw)")); +assert('latitude AI refinement rechecks provider availability inside debounce', + clientListSrc.includes('const hasAIProvider = hasClientListAIProvider();') && + clientListSrc.includes('if (!hasClientListAIProvider()) return;') && + /_clLatTimer\s*=\s*setTimeout/.test(clientListSrc)); [ 'close', diff --git a/tests/test-client-list-runtime.js b/tests/test-client-list-runtime.js new file mode 100644 index 00000000..e6d1123f --- /dev/null +++ b/tests/test-client-list-runtime.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// test-client-list-runtime.js - Client-list browser runtime adapter behavior. + +import './_node-shim.js'; +import { + closeClientListFromRuntime, + getClientHaplogroupList, + hasClientListAIProvider, + navigateClientListRoute, + publishClientListWindowBindings, + refreshClientProfileButton, + setClientManualHaplogroup, + showClientListNotification, +} from '../js/client-list-runtime.js'; + +let pass = 0, fail = 0; +function assert(name, condition, detail) { + if (condition) { pass++; console.log(` PASS: ${name}`); } + else { fail++; console.log(` FAIL: ${name}${detail ? ' — ' + detail : ''}`); } +} + +console.log('=== Client List Runtime Tests ===\n'); + +const runtimeKeys = [ + 'HAPLOGROUP_LIST', + 'closeClientList', + 'navigate', + 'renderProfileButton', + 'showNotification', + 'setManualHaplogroup', + 'hasAIProvider', + '__clientListRuntimeProbe', +]; +const saved = Object.fromEntries(runtimeKeys.map(key => [key, globalThis[key]])); + +function restoreRuntime() { + for (const key of runtimeKeys) { + if (typeof saved[key] === 'undefined') delete globalThis[key]; + else globalThis[key] = saved[key]; + } +} + +try { + const calls = []; + + globalThis.HAPLOGROUP_LIST = ['H1', 'J2']; + assert('getClientHaplogroupList reads runtime haplogroup list', + getClientHaplogroupList().join(',') === 'H1,J2'); + globalThis.HAPLOGROUP_LIST = 'H1'; + assert('getClientHaplogroupList falls back to empty array for invalid runtime value', + getClientHaplogroupList().length === 0); + + globalThis.closeClientList = () => calls.push(['close']); + closeClientListFromRuntime(() => calls.push(['fallback-close'])); + delete globalThis.closeClientList; + closeClientListFromRuntime(() => calls.push(['fallback-close'])); + assert('closeClientListFromRuntime prefers runtime close and falls back when missing', + calls.filter(call => call[0] === 'close').length === 1 && + calls.filter(call => call[0] === 'fallback-close').length === 1); + + globalThis.navigate = route => calls.push(['navigate', route]); + navigateClientListRoute('dashboard'); + assert('navigateClientListRoute delegates to runtime navigate', + calls.some(call => call[0] === 'navigate' && call[1] === 'dashboard')); + + globalThis.renderProfileButton = () => calls.push(['render-profile-button']); + refreshClientProfileButton(); + assert('refreshClientProfileButton delegates to runtime profile refresh', + calls.some(call => call[0] === 'render-profile-button')); + + globalThis.showNotification = (message, type) => calls.push(['notification', message, type]); + showClientListNotification('"Ada" updated', 'info'); + assert('showClientListNotification delegates runtime notification', + calls.some(call => call[0] === 'notification' && call[1] === '"Ada" updated' && call[2] === 'info')); + + globalThis.setManualHaplogroup = async haplogroup => { + calls.push(['set-haplogroup', haplogroup]); + return true; + }; + assert('setClientManualHaplogroup delegates runtime haplogroup setter', + await setClientManualHaplogroup('H1') === true && + calls.some(call => call[0] === 'set-haplogroup' && call[1] === 'H1')); + delete globalThis.setManualHaplogroup; + assert('setClientManualHaplogroup returns false when hook is missing', + await setClientManualHaplogroup('J2') === false); + + globalThis.hasAIProvider = () => true; + assert('hasClientListAIProvider delegates truthy runtime provider state', + hasClientListAIProvider() === true); + globalThis.hasAIProvider = () => { throw new Error('provider unavailable'); }; + assert('hasClientListAIProvider returns false when runtime hook throws', + hasClientListAIProvider() === false); + delete globalThis.hasAIProvider; + assert('hasClientListAIProvider returns false when hook is missing', + hasClientListAIProvider() === false); + + publishClientListWindowBindings({ __clientListRuntimeProbe: () => 'ok' }); + assert('publishClientListWindowBindings installs legacy globals', + globalThis.__clientListRuntimeProbe?.() === 'ok'); +} finally { + restoreRuntime(); +} + +console.log(`\nResults: ${pass} passed, ${fail} failed, ${pass + fail} total`); +process.exit(fail > 0 ? 1 : 0); diff --git a/tests/test-quality-guardrails.js b/tests/test-quality-guardrails.js index 0c1a00f4..e1d506f6 100644 --- a/tests/test-quality-guardrails.js +++ b/tests/test-quality-guardrails.js @@ -79,6 +79,7 @@ assert('CI enforces quality guardrails', testWorkflowSrc.includes('run: npm run quality')); const highValueCheckJsModules = [ 'js/api.js', + 'js/client-list-runtime.js', 'js/client-list.js', 'js/dashboard-widget-renderers.js', 'js/dna.js', diff --git a/tests/verify-modules.js b/tests/verify-modules.js index 4a06313d..62ef4883 100644 --- a/tests/verify-modules.js +++ b/tests/verify-modules.js @@ -55,6 +55,7 @@ '/js/category-page-view.js', '/js/category-customization.js', '/js/commit-hash.js', + '/js/client-list-runtime.js', '/js/focus-card.js', '/js/onboarding-view.js', '/js/marker-detail-modal.js', diff --git a/tsconfig.checkjs.json b/tsconfig.checkjs.json index a55eb89f..6333d4dd 100644 --- a/tsconfig.checkjs.json +++ b/tsconfig.checkjs.json @@ -68,6 +68,7 @@ "js/chat-threads.js", "js/chat-window-bindings.js", "js/chat.js", + "js/client-list-runtime.js", "js/client-list.js", "js/constants.js", "js/context-card-dashboard-ai-actions.js", diff --git a/tsconfig.json b/tsconfig.json index a800a42a..fee1d110 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -65,6 +65,7 @@ "js/chat-threads.js", "js/chat-window-bindings.js", "js/chat.js", + "js/client-list-runtime.js", "js/client-list.js", "js/changelog.js", "js/commit-hash.js", diff --git a/version.js b/version.js index 082824c0..09c3bb78 100644 --- a/version.js +++ b/version.js @@ -2,4 +2,4 @@ // Classic script (not ES module) so it works in both browser and service worker. // Browser: