diff --git a/analytics/analytics_package/analytics/static_site/template/index.html b/analytics/analytics_package/analytics/static_site/template/index.html index fd3541a86..69a966a1e 100644 --- a/analytics/analytics_package/analytics/static_site/template/index.html +++ b/analytics/analytics_package/analytics/static_site/template/index.html @@ -149,6 +149,8 @@ @media (max-width: 767px) { .fui-card[class*="fui-grid-item"] { grid-column: 1 / -1; } + /* !important: beats the inline grid-template-columns set per-render */ + #access-requests-stats { grid-template-columns: 1fr !important; } } @@ -363,9 +365,20 @@

Filter Selections

let entityPath = '/datasets'; function serviceName(url) { - if (url.includes('duos.org')) return 'DUOS'; - if (url.includes('dbgap.ncbi.nlm.nih.gov')) return 'dbGaP'; - return url; + if (typeof url !== 'string' || !url) return 'Other'; + // Match case-insensitively — the Python generator filters click URLs + // with case=False (fetch.py), so classification must be no stricter. + const normalizedUrl = url.toLowerCase(); + if (normalizedUrl.includes('duos.org')) return 'DUOS'; + if (normalizedUrl.includes('dbgap.ncbi.nlm.nih.gov')) return 'dbGaP'; + try { + // Tolerate schemeless URLs, and strip "www." so host variants + // of one service land in the same bucket. + const withScheme = normalizedUrl.includes('://') ? normalizedUrl : `https://${normalizedUrl}`; + return new URL(withScheme).hostname.replace(/^www\./, '') || 'Other'; + } catch (e) { + return 'Other'; + } } let chartColors = { @@ -384,7 +397,7 @@

Filter Selections

async function loadData() { try { - const [configRes, trafficRes, pageviewsRes, outboundRes, filtersRes, downloadsRes, eventsRes, metaRes, searchRes, fileDownloadEventsRes, eventChartsRes] = await Promise.all([ + const [configRes, trafficRes, pageviewsRes, outboundRes, filtersRes, downloadsRes, eventsRes, metaRes, searchRes, fileDownloadEventsRes, eventChartsRes, accessRequestsRes] = await Promise.all([ fetch('data/config.json'), fetch('data/monthly_traffic.json'), fetch('data/pageviews.json'), @@ -396,6 +409,7 @@

Filter Selections

fetch('data/search_queries.json').catch(() => null), fetch('data/file_download_events.json').catch(() => null), fetch('data/event_charts.json').catch(() => null), + fetch('data/access_requests.json').catch(() => null), ]); const [config, traffic, pageviews, outbound, filters, downloads, events, meta] = await Promise.all([ @@ -405,6 +419,7 @@

Filter Selections

const searchQueries = searchRes ? await searchRes.json().catch(() => null) : null; const fileDownloadEvents = fileDownloadEventsRes ? await fileDownloadEventsRes.json().catch(() => null) : null; const eventCharts = eventChartsRes && eventChartsRes.ok ? await eventChartsRes.json().catch(() => null) : null; + const accessRequests = accessRequestsRes && accessRequestsRes.ok ? await accessRequestsRes.json().catch(() => null) : null; applyConfig(config); renderMeta(meta); @@ -416,7 +431,13 @@

Filter Selections

renderOutboundTable(outbound); renderFiltersTable(filters); renderFileDownloadsTable(downloads); - renderAccessRequestsTable(); + try { + // Access requests are optional/best-effort — a bad payload + // must not blank the rest of the report. + renderAccessRequestsTable(accessRequests); + } catch (accessRequestsError) { + console.error('Error rendering access requests:', accessRequestsError); + } renderSearchQueries(searchQueries); renderFileDownloadEvents(fileDownloadEvents); renderEventCounts(config, events, eventCharts); @@ -464,26 +485,52 @@

Filter Selections

document.getElementById('generated-date').textContent = meta.generated_at; } + // Returns a signed percentage as a number, or null when there is no + // usable baseline. Rounding happens at render time so callers compare + // the true sign — rounding first turns -0.04% into "-0.0", which tests + // as >= 0 and renders as a positive change. function pctChange(current, prior) { - if (prior > 0) return ((current - prior) / prior * 100).toFixed(1); - return null; + if (!(prior > 0)) return null; + const pct = (current - prior) / prior * 100; + return Number.isFinite(pct) ? pct : null; } - function statCard(value, label, change, tooltip) { + // Shared stat-card markup. Options: + // change: omit for no change row; null renders "N/A vs prior month". + // gridClass: fui-grid-item-* class; omit when the container sizes cards itself. + // tooltip: HTML shown on hover/focus of the label. + // Multi-line labels ('\n') render as line breaks. + function statCard(value, label, { change, gridClass, tooltip } = {}) { + // escapeHtml leaves newlines and quotes alone, so escape once and + // then adapt per context:
for markup, " for the attribute. + const escapedLabel = escapeHtml(label); + const labelHtml = escapedLabel.split('\n').join('
'); + const ariaLabel = escapedLabel.replace(/"/g, '"').replace(/\n/g, ' '); const tooltipHtml = tooltip ? `
${tooltip}
` : ''; + let changeHtml = ''; + if (change !== undefined) { + let changeText = 'N/A'; + if (change !== null) { + const pct = Number(change); + changeText = `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%`; + } + changeHtml = ` +
+ ${changeText} vs prior month +
+ `; + } return ` -
+
${value}
- ${escapeHtml(label)} + ${labelHtml} ${tooltipHtml}
-
- ${change === null ? 'N/A vs prior month' : `${change >= 0 ? '+' : ''}${change}% vs prior month`} -
+ ${changeHtml}
`; @@ -510,26 +557,26 @@

Filter Selections

const monthName = new Date(year, month - 1).toLocaleString('default', { month: 'long' }); document.getElementById('stats-grid').innerHTML = - statCard( - formatNumber(latest.users), 'Users', - usersChange, - `Total number of unique individuals who visited your site during ${monthName}.` - ) + - statCard( - formatNumber(sessions.current || 0), 'User Sessions', - sessionsChange, - `Total number of visits to your site during ${monthName}. A single user can have multiple sessions.` - ) + - statCard( - formatNumber(latest.pageviews), 'Pageviews', - pageviewsChange, - `Total number of pages viewed during ${monthName}. This includes repeated views of the same page.` - ) + - statCard( - engagementDisplay, 'Engagement Rate', - engagementChange, - `Percentage of sessions during ${monthName} where users actively engaged with your site (e.g., stayed longer, viewed multiple pages, or triggered events). Higher is better. Learn more.` - ); + statCard(formatNumber(latest.users), 'Users', { + change: usersChange, + gridClass: 'fui-grid-item-3', + tooltip: `Total number of unique individuals who visited your site during ${monthName}.`, + }) + + statCard(formatNumber(sessions.current || 0), 'User Sessions', { + change: sessionsChange, + gridClass: 'fui-grid-item-3', + tooltip: `Total number of visits to your site during ${monthName}. A single user can have multiple sessions.`, + }) + + statCard(formatNumber(latest.pageviews), 'Pageviews', { + change: pageviewsChange, + gridClass: 'fui-grid-item-3', + tooltip: `Total number of pages viewed during ${monthName}. This includes repeated views of the same page.`, + }) + + statCard(engagementDisplay, 'Engagement Rate', { + change: engagementChange, + gridClass: 'fui-grid-item-3', + tooltip: `Percentage of sessions during ${monthName} where users actively engaged with your site (e.g., stayed longer, viewed multiple pages, or triggered events). Higher is better. Learn more.`, + }); } function renderEventCounts(config, events, eventCharts) { @@ -555,8 +602,9 @@

Filter Selections

let chartIndex = 0; for (let i = 0; i < countCards.length; i += 2) { const pair = countCards.slice(i, i + 2); - const rowLabel = pair[0].label.split('\n')[0]; - const pairSharesLabel = pair.length > 1 && pair[1].label.split('\n')[0] === rowLabel; + const pairLabelLines = pair.map(card => card.label.split('\n')); + const rowLabel = pairLabelLines[0][0]; + const pairSharesLabel = pair.length > 1 && pairLabelLines[1][0] === rowLabel; if (pairSharesLabel) { html += `

${escapeHtml(rowLabel)}

`; } @@ -566,28 +614,17 @@

Filter Selections

const e = eventsByKey[card.event_key] || {}; const current = e.current || 0; const prior = e.prior || 0; - let change = 0; - if (prior > 0) { - change = ((current - prior) / prior * 100).toFixed(1); - } - html += ` -
-
-
${formatNumber(current)}
-
${card.label.split('\n').map(l => escapeHtml(l)).join('
')}
-
- ${change >= 0 ? '+' : ''}${change}% vs prior month -
-
-
- `; + html += statCard(formatNumber(current), card.label, { + change: pctChange(current, prior) ?? 0, + gridClass: 'fui-grid-item-6', + }); } // Chart cards row - for (const card of pair) { + for (const [j, card] of pair.entries()) { if (chartDataByKey[card.event_key] && chartDataByKey[card.event_key].length > 0) { const canvasId = `event-trend-chart-${chartIndex++}`; - const parts = card.label.split('\n'); + const parts = pairLabelLines[j]; const cardLabel = parts[0]; const chartTitle = escapeHtml(cardLabel) + ' Over Time' + (parts[1] ? ` (${escapeHtml(parts[1].replace(/[()]/g, ''))})` : ''); html += ` @@ -800,7 +837,7 @@

${escapeHtml(event.label)} ${escapeHtml(row.page || '-')} ${formatNumber(row.views || 0)} - + ${formatChange(row.change)} @@ -819,7 +856,7 @@

${escapeHtml(event.label)} ${escapeHtml(row.link || '-')} ${formatNumber(row.clicks || 0)} - + ${formatChange(row.change)} @@ -838,7 +875,7 @@

${escapeHtml(event.label)} ${escapeHtml(row.filterName || '-')} ${escapeHtml(row.filterValue || '-')} ${formatNumber(row.count || 0)} - + ${formatChange(row.change)} @@ -875,50 +912,36 @@

${escapeHtml(event.label)} document.getElementById('file-downloads-count').textContent = formatNumber(downloads.total); } - async function renderAccessRequestsTable() { - let data; - try { - const res = await fetch('data/access_requests.json'); - if (!res.ok) return; - data = await res.json(); - } catch (e) { - return; - } - if (!data || data.length === 0) return; + function renderAccessRequestsTable(accessRequests) { + if (!Array.isArray(accessRequests)) return; + const data = accessRequests + .filter(r => r && typeof r === 'object') + .map(r => ({ ...r, count: Number(r.count) || 0, service: serviceName(r.click_url) })); + if (data.length === 0) return; const card = document.getElementById('access-requests-card'); const heading = document.getElementById('access-requests-heading'); const tbody = document.querySelector('#access-requests-table tbody'); - const total = data.reduce((sum, r) => sum + (r.count || 0), 0); - const services = new Set(data.map(r => serviceName(r.click_url || ''))); - const showService = services.size > 1; + // Null prototype: a service named like an Object.prototype member + // (e.g. a "constructor" hostname) must not collide with inherited keys. + const countsByService = data.reduce((counts, r) => { + counts[r.service] = (counts[r.service] || 0) + r.count; + return counts; + }, Object.create(null)); + const total = Object.values(countsByService).reduce((sum, count) => sum + count, 0); + const showService = Object.keys(countsByService).length > 1; const statsGrid = document.getElementById('access-requests-stats'); if (showService) { - const duosTotal = data.filter(r => serviceName(r.click_url || '') === 'DUOS').reduce((sum, r) => sum + (r.count || 0), 0); - const dbgapTotal = data.filter(r => serviceName(r.click_url || '') === 'dbGaP').reduce((sum, r) => sum + (r.count || 0), 0); + const statCards = Object.entries(countsByService) + .sort(([a], [b]) => a.localeCompare(b)) + .concat([['Total', total]]); statsGrid.style.display = ''; - statsGrid.innerHTML = ` -
-
-
${formatNumber(dbgapTotal)}
-
dbGaP
-
-
-
-
-
${formatNumber(duosTotal)}
-
DUOS
-
-
-
-
-
${formatNumber(total)}
-
Total
-
-
- `; + statsGrid.style.gridTemplateColumns = `repeat(${statCards.length}, 1fr)`; + statsGrid.innerHTML = statCards + .map(([label, count]) => statCard(formatNumber(count), label)) + .join(''); } card.style.display = ''; @@ -941,12 +964,12 @@

${escapeHtml(event.label)} const datasetCell = link ? `${escapeHtml(label)}` : escapeHtml(label); - const serviceCell = showService ? `${escapeHtml(serviceName(row.click_url || ''))}` : ''; + const serviceCell = showService ? `${escapeHtml(row.service)}` : ''; return ` ${datasetCell} ${serviceCell} - ${formatNumber(row.count || 0)} + ${formatNumber(row.count)} `; }).join(''); @@ -975,6 +998,13 @@

${escapeHtml(event.label)} } } + // Positive/negative styling for a signed change. Works for both the + // fractions the tables carry and the percentages the cards carry. + function changeClass(change) { + if (change == null) return ''; + return change >= 0 ? 'positive' : 'negative'; + } + function formatChange(change) { if (change == null) return '-'; return (change >= 0 ? '+' : '') + (change * 100).toFixed(1) + '%';