From 3e20e2a355c37469304d9da1b323902a8df45a3a Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 21 Oct 2026 19:25:33 +0100 Subject: [PATCH 1/2] Unify study search and reduce Browse rendering work Index readable author names and share accent, quote, token and DOI matching with Citation Impact. Add citation empty-state recovery and debounce both searches. Cache search text, defer rows and avoid the nested table draw caused by order events. Co-Authored-By: GPT 6.1 Sol --- assets/app.js | 63 +++++++++++++++++++++++++++----------- assets/citation-impact.js | 23 +++++++++----- index.html | 3 +- tests/browser.test.cjs | 64 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 25 deletions(-) diff --git a/assets/app.js b/assets/app.js index 4df66e6a..c7840361 100644 --- a/assets/app.js +++ b/assets/app.js @@ -747,18 +747,18 @@ function formatDetailRow(rowData) { function initDataTable(data) { const tableData = data.map(row => [ null, - { display: truncateText(row.title_o || row.author_o, 50), search: `${row.title_o || ''} ${row.author_o || ''} ${row.journal_o || ''}` }, + { display: truncateText(row.title_o || row.author_o, 50), search: `${row.title_o || ''} ${searchAuthorNames(row.author_o)} ${row.journal_o || ''}` }, row.year_o || '-', - { display: truncateText(row.title_r || row.author_r, 50), search: `${row.title_r || ''} ${row.author_r || ''} ${row.journal_r || ''}` }, + { display: truncateText(row.title_r || row.author_r, 50), search: `${row.title_r || ''} ${searchAuthorNames(row.author_r)} ${row.journal_r || ''}` }, row.year_r || '-', - { display: getOutcomeBadgeShort(row.outcome), search: `${row.outcome || ''} ${row.outcome_quote || ''}` }, + { display: getOutcomeBadgeShort(row.outcome), search: row.outcome_quote || '' }, truncateText(row.type, 15) || '-', { display: formatDOI(row.doi_o, true), search: row.doi_o || '' }, - { display: formatUrlOrDoiShort(row.url_r, row.doi_r), search: `${row.url_r || ''} ${row.doi_r || ''}` } + { display: formatUrlOrDoiShort(row.url_r, row.doi_r), search: row.doi_r || '' } ]); dataTable = $('#flora-table').DataTable({ - data: tableData, responsive: false, autoWidth: false, pageLength: 25, dom: 'lrtip', + data: tableData, responsive: false, autoWidth: false, deferRender: true, pageLength: 25, dom: 'lrtip', lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]], order: [[4, 'desc']], language: { search: "Search:", searchPlaceholder: "Filter studies (searches full references)...", zeroRecords: "No matching reference pairs", emptyTable: "No matching reference pairs" }, @@ -773,7 +773,7 @@ function initDataTable(data) { createdRow: (row, d, dataIndex) => { $(row).attr('data-index', dataIndex); row.querySelector('.study-details').setAttribute('aria-label', 'Show evidence for ' + (data[dataIndex].title_o || 'original report')); } }); - dataTable.on('order.dt', () => { if (bmInitialized) updateBrowseResults(); syncBrowseUrl(); }); + dataTable.on('order.dt', () => { if (bmInitialized) updateBrowseResults(false); syncBrowseUrl(); }); $('#flora-table tbody').on('click', 'button.study-details', function() { const tr = $(this).closest('tr'); @@ -787,12 +787,19 @@ function initDataTable(data) { // ===== Browse mobile ===== const BM_PAGE_SIZE = 20; +const bmSortCollator = new Intl.Collator(undefined, {numeric:true}); let bmFiltered = []; let bmPage = 0; let bmInitialized = false; +const bmSearchText = new WeakMap(); function bmSearchableText(row) { - return [row.title_o, row.author_o, row.journal_o, row.year_o, row.doi_o, - row.title_r, row.author_r, row.journal_r, row.year_r, row.doi_r, row.url_r, - row.outcome, row.outcome_quote, row.type].filter(Boolean).join(' | ').toLowerCase(); + if (!bmSearchText.has(row)) { + // Keep quotations searchable, but omit raw outcome values and non-DOI URLs. + const urlDoi = /^https?:\/\/(?:dx\.)?doi\.org\//i.test(row.url_r || '') ? row.url_r : ''; + bmSearchText.set(row, normalizedQuery([row.title_o, searchAuthorNames(row.author_o), row.journal_o, row.year_o, row.doi_o, + row.title_r, searchAuthorNames(row.author_r), row.journal_r, row.year_r, row.doi_r, urlDoi, + row.outcome_quote, row.type].filter(Boolean).join(' | '))); + } + return bmSearchText.get(row); } function bmHref(doi, url) { return doi ? safeHref(String(doi).startsWith('http') ? doi : 'https://doi.org/' + encodeURI(doi)) : safeHref(url); } function bmAuthorYear(authorData, year) { const a = shortAuthors(authorData); const y = year ? `(${year})` : ''; return [a, y].filter(Boolean).join(' '); } @@ -851,30 +858,52 @@ function setupBrowseMobile(data) { const prev = document.getElementById('bm-prev'); const next = document.getElementById('bm-next'); let debounceTimer; - input.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => bmApplySearch(input.value), 150); }); + input.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => bmApplySearch(input.value), 200); }); prev.addEventListener('click', () => { if (bmPage > 0) { bmPage--; bmRender(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); } }); next.addEventListener('click', () => { bmPage++; bmRender(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); }); input.value = browseQuery; bmApplySearch(browseQuery); } function normalizedQuery(query) { - return String(query || '').toLowerCase().replace(/https?:\/\/(?:dx\.)?doi\.org\//g, '').replace(/\bdoi:\s*/g, '').trim(); + return String(query || '').toLowerCase().normalize('NFD').replace(/\p{M}/gu, '') + .replace(/[‘’]/g, "'").replace(/[“”]/g, '"') + .replace(/https?:\/\/(?:dx\.)?doi\.org\//g, '').replace(/\bdoi:\s*/g, '').replace(/\s+/g, ' ').trim(); +} +// Search all names, without indexing Crossref keys, ORCIDs or affiliations. +function searchAuthorNames(raw) { + if (!raw) return ''; + try { + const authors = typeof raw === 'string' ? JSON.parse(raw) : raw; + const name = a => typeof a === 'string' ? a : a && (a.name || [a.given, a.family].filter(Boolean).join(' ')) || ''; + return (Array.isArray(authors) ? authors : [authors]).map(name).join(', '); + } catch { return String(raw); } +} +let lastSearchQuery, lastSearchTokens = []; +function searchTokens(query) { + if (query !== lastSearchQuery) { + lastSearchQuery = query; + lastSearchTokens = (normalizedQuery(query).match(/"[^"\n]+"|\S+/g) || []).map(token => token.replace(/^"|"$/g, '')); + } + return lastSearchTokens; +} +function matchesSearchTokens(text, tokens) { + return tokens.every(token => text.includes(token)); } function matchesBrowseQuery(row, query) { - const text = bmSearchableText(row); - const tokens = normalizedQuery(query).match(/"[^"\n]+"|\S+/g) || []; - return tokens.every(token => text.includes(token.replace(/^"|"$/g, ''))); + const tokens = searchTokens(query); + return !tokens.length || matchesSearchTokens(bmSearchableText(row), tokens); } let updatingBrowse = false; -function updateBrowseResults() { +function updateBrowseResults(redraw = true) { if (updatingBrowse) return; updatingBrowse = true; bmFiltered = getChartData(); if (dataTable) { const [column, direction] = dataTable.order()[0] || [4, 'desc']; const field = {1:'title_o',2:'year_o',3:'title_r',4:'year_r',5:'outcome',6:'type',7:'doi_o',8:'doi_r'}[column] || 'year_r'; - bmFiltered.sort((a,b) => String(a[field] || '').localeCompare(String(b[field] || ''), undefined, {numeric:true}) * (direction === 'desc' ? -1 : 1)); - dataTable.draw(false); + bmFiltered.sort((a,b) => bmSortCollator.compare(String(a[field] || ''), String(b[field] || '')) * (direction === 'desc' ? -1 : 1)); + // order.dt also fires during a draw: refresh cards/charts without a second draw. + if (redraw) dataTable.draw(false); } bmPage = 0; if (bmInitialized) bmRender(); diff --git a/assets/citation-impact.js b/assets/citation-impact.js index 4cbc086f..3f87d375 100644 --- a/assets/citation-impact.js +++ b/assets/citation-impact.js @@ -88,7 +88,8 @@ // citations — citing works published before any replication existed // could never have co-cited one. const cocit_prop = s.n_citations_post_first_rep > 0 ? s.n_cocitations / s.n_citations_post_first_rep : 0; - return { ...s, cocit_prop }; + const searchText = normalizedQuery([s.title, searchAuthorNames(s.author), s.year, s.venue, s.doi].filter(Boolean).join(' | ')); + return { ...s, cocit_prop, searchText }; }); // Optional: ETWFE results (may not exist on first run) @@ -386,14 +387,12 @@ } function renderTable() { - const q = (document.getElementById('search-input').value || '').toLowerCase(); + const q = document.getElementById('search-input').value || ''; + const tokens = searchTokens(q); const outFilter = document.getElementById('filter-outcome').value; let rows = CI.index.filter(s => { - if (q) { - const hay = `${s.title} ${s.author} ${s.doi}`.toLowerCase(); - if (!hay.includes(q)) return false; - } + if (!matchesSearchTokens(s.searchText, tokens)) return false; if (outFilter && !(s.outcome_mix && s.outcome_mix[outFilter])) return false; return true; }); @@ -416,6 +415,10 @@ }); const total = rows.length; + const empty = document.getElementById('ci-search-empty'); + empty.hidden = total > 0; + document.getElementById('ci-search-empty-message').textContent = q.trim() ? `No originals match “${q.trim()}” with the current outcome selection.` : 'No originals match the current outcome selection.'; + document.getElementById('ci-clear-search').hidden = !q.trim(); const pages = Math.max(1, Math.ceil(total / CI.perPage)); if (CI.page > pages) CI.page = 1; const start = (CI.page - 1) * CI.perPage; @@ -438,6 +441,7 @@ }).join(''); const pag = document.getElementById('pagination'); + if (!total) { pag.innerHTML = ''; return; } const winSize = 5; const winStart = Math.max(1, CI.page - Math.floor(winSize / 2)); const winEnd = Math.min(pages, winStart + winSize - 1); @@ -604,7 +608,12 @@ CI.outcome = btn.dataset.value; renderAggregate(); }); }); - document.getElementById('search-input').addEventListener('input', () => { CI.page = 1; renderTable(); }); + const input = document.getElementById('search-input'); + let searchTimer; + input.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { CI.page = 1; renderTable(); }, 200); }); + document.getElementById('ci-clear-search').addEventListener('click', () => { + clearTimeout(searchTimer); input.value = ''; input.dispatchEvent(new Event('input')); input.focus(); + }); document.getElementById('filter-outcome').addEventListener('change', () => { CI.page = 1; renderTable(); }); function sortBy(col) { if (CI.sortCol === col) { diff --git a/index.html b/index.html index 9245e0d1..125be993 100644 --- a/index.html +++ b/index.html @@ -639,7 +639,7 @@

Browse all originals

- +
+
diff --git a/tests/browser.test.cjs b/tests/browser.test.cjs index 9d81f81d..117aa6e0 100644 --- a/tests/browser.test.cjs +++ b/tests/browser.test.cjs @@ -346,3 +346,67 @@ test('narrow citation timelines number replication events to match the list',asy assert.deepEqual(marks,list); assert.deepEqual(p.errors,[]);await p.close(); }); + +test('Browse and Citation Impact share accent, quote, token and DOI search rules',async()=>{ + const p=await page('?tab=browse'); + async function browseSearch(q){ + await p.fill('#browse-mobile-input',q); + await p.waitForFunction(q=>browseQuery===q,q); + return p.evaluate(()=>getChartData().map(r=>r.doi_o)); + } + for(const [q,doi] of [['Bem 2011','10.1037/a0021524'],['Strack 1988','10.1037/0022-3514.54.5.768']]){ + const rows=await browseSearch(q);assert.ok(rows.length>0,q);assert.ok(rows.includes(doi),q); + } + for(const [a,b] of [['Müller','Muller'],["O'Brien",'O’Brien'],['"power posing"','“power posing”']]){ + const rows=await browseSearch(a);assert.ok(rows.length>0,a);assert.deepEqual(await browseSearch(b),rows,b); + } + for(const q of ['family','given','sequence']) assert.ok((await browseSearch(q)).length<100,`${q} must not match author JSON keys`); + // Search every author's name, including given-only and group names, without metadata. + assert.deepEqual(await p.evaluate(()=>{ + const row={author_o:JSON.stringify([{given:'Zoë',family:'Müller',ORCID:'metadata-only'},{given:'Søren'},{name:'Research collective'}]),outcome:'raw_outcome_code',url_r:'https://url-only.invalid',outcome_quote:'A distinctive quotation'}; + return ['Zoe Muller','Søren','Research collective','metadata-only','raw_outcome_code','url-only.invalid','distinctive quotation'].map(q=>matchesBrowseQuery(row,q)); + }),[true,true,true,false,false,false,true]); + await p.click('#citation-tab');await p.waitForSelector('.timeline-open'); + async function citationSearch(q){ + await p.fill('#search-input',q); + await p.waitForTimeout(300); + return p.locator('#originals-table tbody tr[data-doi]').evaluateAll(rows=>rows.map(r=>r.dataset.doi)); + } + for(const [q,doi] of [['Bem 2011','10.1037/a0021524'],['Strack 1988','10.1037/0022-3514.54.5.768'],['"Feeling the Future"','10.1037/a0021524'],['“Feeling the Future”','10.1037/a0021524'],['https://doi.org/10.1037/a0021524','10.1037/a0021524'],['doi: 10.1037/a0021524','10.1037/a0021524']]){ + assert.ok((await citationSearch(q)).includes(doi),q); + } + for(const [a,b] of [['Müller','Muller'],["O'Brien",'O’Brien']]){ + const rows=await citationSearch(a);assert.ok(rows.length>0,a);assert.deepEqual(await citationSearch(b),rows,b); + } + assert.deepEqual(p.errors,[]);await p.close(); +}); +test('search input is debounced, Show all draws once and Citation Impact offers empty-state recovery',async()=>{ + const p=await page('?tab=browse'); + // deferRender creates only the first page until more rows are requested. + assert.ok(await p.evaluate(()=>dataTable.rows().nodes().length{window.searchDraws=0;dataTable.on('draw.dt',()=>searchDraws++);dataTable.page.len(-1).draw();}); + assert.equal(await p.evaluate(()=>searchDraws),1); + assert.equal(await p.locator('#flora-table tbody tr').count(),await p.evaluate(()=>fullRowData.length)); + const immediate=await p.evaluate(()=>{ + searchDraws=0;const el=document.getElementById('browse-mobile-input'); + for(const q of ['B','Bem','Bem 2011']){el.value=q;el.dispatchEvent(new Event('input'));} + return searchDraws; + }); + assert.equal(immediate,0);await p.waitForFunction(()=>browseQuery==='Bem 2011'); + assert.equal(await p.evaluate(()=>searchDraws),1); + await p.click('#citation-tab');await p.waitForSelector('.timeline-open'); + await p.evaluate(()=>{ + window.citationRenders=0;new MutationObserver(()=>citationRenders++).observe(document.querySelector('#originals-table tbody'),{childList:true}); + const el=document.getElementById('search-input');for(const q of ['z','zz','zz-no-such-original']){el.value=q;el.dispatchEvent(new Event('input'));} + }); + assert.ok(await p.locator('.timeline-open').count()>0); + await p.waitForSelector('#ci-search-empty:not([hidden])'); + assert.equal(await p.evaluate(()=>citationRenders),1); + assert.match(await p.locator('#ci-search-empty').innerText(),/No originals match/); + assert.equal(await p.locator('#pagination button').count(),0); + await p.locator('#ci-clear-search').focus();await p.keyboard.press('Enter'); + await p.waitForSelector('.timeline-open');assert.ok(await p.locator('#ci-search-empty').isHidden()); + assert.equal(await p.inputValue('#search-input'),'');assert.equal(await p.evaluate(()=>document.activeElement.id),'search-input'); + assert.ok(!new URL(p.url()).searchParams.has('search-input')); + assert.deepEqual(p.errors,[]);await p.close(); +}); From 9927dfd58de2e3f127acc6ac3f7e1cc8127e9695 Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 21 Oct 2026 20:12:41 +0100 Subject: [PATCH 2/2] Address review comments Wait for citation search renders rather than a fixed delay. Supplement the truncated citation display names with full FLoRA author lists, including when DOI deep links load before the CSV, and add journal to the search hint. Cover delayed rendering, later authors, metadata exclusion and snapshot fallbacks. Co-Authored-By: GPT 6.1 Sol --- assets/citation-impact.js | 39 ++++++++++++++++++++------ index.html | 2 +- tests/browser.test.cjs | 59 ++++++++++++++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/assets/citation-impact.js b/assets/citation-impact.js index 3f87d375..5835e698 100644 --- a/assets/citation-impact.js +++ b/assets/citation-impact.js @@ -70,6 +70,29 @@ }); } + function buildSearchIndex(index) { + // The citation snapshot's display names stop after six authors. The + // already-loaded FLoRA pairs retain full names; collect all records for + // each original and keep the snapshot names as a fallback. + const authorsByDoi = new Map(); + for (const row of fullRowData) { + const doi = normalizeDoi(row.doi_o); + const names = searchAuthorNames(row.author_o); + if (!doi || !names) continue; + if (!authorsByDoi.has(doi)) authorsByDoi.set(doi, new Set()); + authorsByDoi.get(doi).add(names); + } + return index.map(s => { + // Denominator is citations since the first replication, not lifetime + // citations — citing works published before any replication existed + // could never have co-cited one. + const cocit_prop = s.n_citations_post_first_rep > 0 ? s.n_cocitations / s.n_citations_post_first_rep : 0; + const authors = [...(authorsByDoi.get(normalizeDoi(s.doi)) || [])]; + const searchText = normalizedQuery([s.title, searchAuthorNames(s.author), ...authors, s.year, s.venue, s.doi].filter(Boolean).join(' | ')); + return { ...s, cocit_prop, searchText }; + }); + } + async function init() { if (CI.loaded || CI.loading) return; CI.loading = true; @@ -83,14 +106,7 @@ if (!metaRes.ok || !aggRes.ok || !idxRes.ok) { showPlaceholder(); return; } CI.meta = await metaRes.json(); CI.agg = await aggRes.json(); - CI.index = (await idxRes.json()).map(s => { - // Denominator is citations since the first replication, not lifetime - // citations — citing works published before any replication existed - // could never have co-cited one. - const cocit_prop = s.n_citations_post_first_rep > 0 ? s.n_cocitations / s.n_citations_post_first_rep : 0; - const searchText = normalizedQuery([s.title, searchAuthorNames(s.author), s.year, s.venue, s.doi].filter(Boolean).join(' | ')); - return { ...s, cocit_prop, searchText }; - }); + CI.index = buildSearchIndex(await idxRes.json()); // Optional: ETWFE results (may not exist on first run) try { @@ -717,6 +733,13 @@ // Lazy-load when the user opens the tab document.getElementById('citation-tab').addEventListener('shown.bs.tab', ensureInit); + // A DOI deep link can load citation data before the main CSV finishes. + document.addEventListener('flora-ready', () => { + if (!CI.index) return; + CI.index = buildSearchIndex(CI.index); + if (CI.loaded) renderTable(); + }); + setupStudyTypeSelect('ci-study-type', kind => { ciKind = kind; renderCiKindGate(kind); }); // Open a study popup straight away if arrived via /citations/?doi=… diff --git a/index.html b/index.html index 125be993..0bee7b33 100644 --- a/index.html +++ b/index.html @@ -639,7 +639,7 @@

Browse all originals

- +