Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions js/client-list-runtime.js
Original file line number Diff line number Diff line change
@@ -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<string, any>} bindings */
export function publishClientListWindowBindings(bindings) {
if (typeof window === 'undefined') return;
Object.assign(getRuntimeWindow(), bindings);
}
74 changes: 42 additions & 32 deletions js/client-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {{
Expand Down Expand Up @@ -478,7 +488,7 @@ export function openClientForm(profileId) {
<div class="cl-haplogroup-row">
<select class="cl-form-input cl-haplogroup-select" id="cl-haplogroup" ${_clChangeAttrs('haplogroup-changed')}>
<option value="">Not set</option>
${window.HAPLOGROUP_LIST ? window.HAPLOGROUP_LIST.map(h => '<option value="' + h + '"' + (state.importedData?.genetics?.mtdna?.haplogroup === h ? ' selected' : '') + '>' + h + '</option>').join('') : ''}
${getClientHaplogroupList().map(h => '<option value="' + h + '"' + (state.importedData?.genetics?.mtdna?.haplogroup === h ? ' selected' : '') + '>' + h + '</option>').join('')}
</select>
<span id="cl-hg-coupling" class="cl-hg-coupling">${state.importedData?.genetics?.mtdna?.coupling?.shortLabel || ''}</span>
</div>
Expand Down Expand Up @@ -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' });
});
Expand Down Expand Up @@ -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();
Expand All @@ -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 || '';
Expand Down Expand Up @@ -621,7 +631,7 @@ async function _clAvatarChanged(input) {
}
}
} catch {
window.showNotification('Could not load image', 'error');
showClientListNotification('Could not load image', 'error');
}
input.value = '';
}
Expand Down Expand Up @@ -662,6 +672,7 @@ function _clUpdateLat() {
var cache = getLocationCache();
var cacheKey = (country + '|' + zip).toLowerCase();
var cached = cache[cacheKey];
const hasAIProvider = hasClientListAIProvider();
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Exact cache hit — show immediately, done
if (cached !== undefined) {
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -744,7 +754,7 @@ function _clBackToList() {
// ═══════════════════════════════════════════════
function _clSelect(id) {
switchProfile(id);
window.renderProfileButton();
refreshClientProfileButton();
closeClientList();
}

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion scripts/quality-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"inlineEventAttributes": 0,
"windowReferences": 567,
"windowReferences": 547,
"largeJsFilesOver800Lines": 29,
"maxJsFileLines": 1513
}
1 change: 1 addition & 0 deletions service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions tests/_vitest-legacy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions tests/test-client-list-delegated-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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',
Expand Down
105 changes: 105 additions & 0 deletions tests/test-client-list-runtime.js
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions tests/test-quality-guardrails.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading