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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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:
+
${value}
- ${escapeHtml(label)}
+ ${labelHtml}
${tooltipHtml}
-
- ${change === null ? 'N/A vs prior month' : `${change >= 0 ? '+' : ''}${change}% vs prior month`}
-
+ ${changeHtml}
`;
@@ -510,26 +557,26 @@
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 @@
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 @@
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 @@