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
10 changes: 5 additions & 5 deletions cache-bust.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "234a55185339ec0e",
"version": "3015f39822a48a4e",
"generatedBy": "scripts/updateCacheBusting.mjs",
"assets": {
"assets/app-icon-16.png": "6645731d86da1071",
Expand Down Expand Up @@ -194,11 +194,11 @@
"src/lib/workbook-export/workbookXmlUtils.js": "2cc0fcdda2a1d411",
"src/lib/workbook-export/xlsxZipWriter.js": "5f4c1f6e98fbcb06",
"src/styles/api-settings.css": "c5f76b60fb8684bb",
"src/styles/app.css": "e6b1b3adffb9e823",
"src/styles/app.css": "f45508fee36d3611",
"src/styles/base.css": "8a925806001990b6",
"src/styles/bib-compare.css": "144f2a1093e00dcf",
"src/styles/controls.css": "30d36ccc65181c21",
"src/styles/dashboard.css": "7aed1832e6cabd24",
"src/styles/dashboard.css": "443e234c111fc528",
"src/styles/filters.css": "9e7297e65dd68a3c",
"src/styles/form-mode.css": "7de8927db30d30f6",
"src/styles/help.css": "1e75cc7af857e66a",
Expand Down Expand Up @@ -241,13 +241,13 @@
"src/ui/controls/selectorListPasteInput.js": "4e7f16d036dfd795",
"src/ui/controls/tableNameInput.js": "b6fc884c36961de3",
"src/ui/controls/virtualList.js": "bd7a8d24c7c48493",
"src/ui/dashboard/kpiDashboard.js": "55a7adaaf847950b",
"src/ui/dashboard/kpiDashboard.js": "151ec2ce261f00d2",
"src/ui/dashboard/kpiDashboardModel.js": "fa4c42b18c1245e5",
"src/ui/dashboard/kpiDashboardView.js": "82508c550975ada6",
"src/ui/dashboard/libraryDashboardCoverage.js": "d2da9d84bf50899f",
"src/ui/dashboard/libraryDashboardExport.js": "ae9057c966126624",
"src/ui/dashboard/libraryDashboardModel.js": "1c036eaacc95d14e",
"src/ui/dashboard/libraryDashboardView.js": "63676db31c2a28ad",
"src/ui/dashboard/libraryDashboardView.js": "7238d2a24569a246",
"src/ui/dashboard/reportingPeriodPicker.js": "334aea95b73dee72",
"src/ui/field-picker/buildableFieldPreview.js": "8abd486bb0a87fe2",
"src/ui/field-picker/fieldPicker.js": "526a1bf934f5dd74",
Expand Down
2 changes: 1 addition & 1 deletion src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@import url("./table.css?v=9a49bacfc430ce95");
@import url("./controls.css?v=30d36ccc65181c21");
@import url("./history.css?v=64598bfd565a29af");
@import url("./dashboard.css?v=7aed1832e6cabd24");
@import url("./dashboard.css?v=443e234c111fc528");
@import url("./templates.css?v=1c0a1594bedc65b9");
@import url("./api-settings.css?v=c5f76b60fb8684bb");
@import url("./site-update.css?v=b16b51ef4c7bd942");
Expand Down
46 changes: 46 additions & 0 deletions src/styles/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,52 @@
text-align: right;
}

.kpi-ranking__drill {
padding: 0;
border: 0;
background: transparent;
color: var(--theme-accent);
cursor: pointer;
font: inherit;
font-weight: 800;
text-align: left;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 0.18rem;
}

.kpi-ranking__drill:hover,
.kpi-ranking__drill:focus-visible {
text-decoration-color: currentColor;
}

.kpi-drillback {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 0.8rem;
align-items: center;
padding: 0.65rem 0.8rem;
border: 1px solid color-mix(in srgb, var(--theme-accent) 34%, var(--theme-border));
border-radius: 0.8rem;
background: var(--theme-accent-soft);
}

.kpi-drillback button {
padding: 0;
border: 0;
background: transparent;
color: var(--theme-accent);
cursor: pointer;
font: inherit;
font-size: 0.78rem;
font-weight: 850;
}

.kpi-drillback span {
color: var(--theme-text-muted);
font-size: 0.72rem;
}

.kpi-service-coverage {
padding: 1rem;
border: 1px solid var(--theme-border);
Expand Down
50 changes: 50 additions & 0 deletions src/ui/dashboard/kpiDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ let loading = false;
let pendingLoad = false;
let pendingForce = false;
let periodComparison = 'previous';
const scopeHistory = [];
const AUTO_REFRESH_MS = 5 * 60 * 1000;

function getElements() {
Expand Down Expand Up @@ -179,6 +180,7 @@ function render() {
});
} else {
libraryData.scope.comparison_mode = periodComparison;
libraryData.canGoBack = scopeHistory.length > 0;
elements.content.innerHTML = renderLibraryDashboard(libraryData, currentView);
}
}
Expand Down Expand Up @@ -276,6 +278,51 @@ function openOpportunityQuery(button) {
}
}

function optionValue(option) {
return typeof option === 'string' ? option : option?.value ?? option?.code ?? option?.RawValue ?? '';
}

function applyDashboardScope(button) {
if (!libraryData) return;
const kind = button.dataset.kpiScopeKind || '';
const value = button.dataset.kpiScopeValue || '';
if (!kind || !value) return;
const elements = getElements();
const libraries = selectedLibraryScopes(elements.library);
const itemTypes = selectedItemTypes(elements.itemType);
let nextLibraries = libraries;
let nextItemTypes = itemTypes;
if (kind === 'system') {
nextLibraries = libraryData.filters.libraries
.map(optionValue)
.filter(code => systemCodeForLibraryScope(code) === value);
} else if (kind === 'branch') {
nextLibraries = [value];
} else if (kind === 'item-type') {
nextItemTypes = [value];
}
if (!nextLibraries.length && kind !== 'item-type') return;
if (JSON.stringify(nextLibraries) === JSON.stringify(libraries)
&& JSON.stringify(nextItemTypes) === JSON.stringify(itemTypes)) return;
scopeHistory.push({ libraries, itemTypes, view: currentView });
elements.library?.setSelectedValues?.(nextLibraries);
elements.itemType?.setSelectedValues?.(nextItemTypes);
syncPeriodOptions(elements);
void loadDashboard();
}

function restoreDashboardScope() {
const previous = scopeHistory.pop();
if (!previous) return;
const elements = getElements();
currentView = previous.view || currentView;
elements.library?.setSelectedValues?.(previous.libraries || []);
elements.itemType?.setSelectedValues?.(previous.itemTypes || []);
syncPeriodOptions(elements);
syncViewChrome(elements);
void loadDashboard();
}

onDOMReady(() => {
const elements = getElements();
elements.refresh?.addEventListener('click', () => loadDashboard({ force: true }));
Expand All @@ -302,6 +349,9 @@ onDOMReady(() => {
elements.content?.addEventListener('click', event => {
const button = event.target.closest('[data-kpi-query]');
if (button) openOpportunityQuery(button);
const scopeButton = event.target.closest('[data-kpi-scope-kind]');
if (scopeButton) applyDashboardScope(scopeButton);
if (event.target.closest('[data-kpi-back-scope]')) restoreDashboardScope();
});
window.addEventListener('query-dashboard:open', () => loadDashboard());
window.setInterval(() => {
Expand Down
36 changes: 24 additions & 12 deletions src/ui/dashboard/libraryDashboardView.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,26 +97,38 @@ function metricCard(label, value, detail, tone = '', definition = null) {
</article>`;
}

function breakdownTable(items, { patronColumns = true } = {}) {
function drillButton(label, kind, value, className = 'kpi-drilldown') {
if (!kind || !value) return '';
return `<button type="button" class="${className}" data-kpi-scope-kind="${escapeHtml(kind)}" data-kpi-scope-value="${escapeHtml(value)}">${escapeHtml(label)}</button>`;
}

function breakdownTable(items, { patronColumns = true, level = 'branch' } = {}) {
if (!items.length) return '<p class="kpi-chart-empty">No system or branch totals are available for this scope.</p>';
const headers = ['System', 'Branch', 'Items', 'Checkouts', 'Renewals'];
if (patronColumns) headers.push('Current patrons', 'Active patrons', 'Expired', 'Unknown expiry');
headers.push('Details');
const patronValue = (item, key) => item.patron_suppressed ? '<span title="Below the privacy threshold">Suppressed</span>' : formatNumber(item[key]);
const row = item => `<tr><td>${escapeHtml(item.system || item.label || 'Unassigned')}</td><td>${escapeHtml(item.system ? item.label : `${formatNumber(item.branches)} branches`)}</td><td>${formatNumber(item.items)}</td><td>${formatNumber(item.checkouts)}</td><td>${formatNumber(item.renewals)}</td>${patronColumns ? `<td>${patronValue(item, 'patrons')}</td><td>${patronValue(item, 'active_patrons')}</td><td>${patronValue(item, 'expired_patrons')}</td><td>${patronValue(item, 'expiration_unknown')}</td>` : ''}</tr>`;
const row = item => {
const value = item.label || '';
const action = value && value !== 'Unassigned'
? drillButton(level === 'system' ? 'View system' : 'View branch', level, value)
: '';
return `<tr><td>${escapeHtml(item.system || item.label || 'Unassigned')}</td><td>${escapeHtml(item.system ? item.label : `${formatNumber(item.branches)} branches`)}</td><td>${formatNumber(item.items)}</td><td>${formatNumber(item.checkouts)}</td><td>${formatNumber(item.renewals)}</td>${patronColumns ? `<td>${patronValue(item, 'patrons')}</td><td>${patronValue(item, 'active_patrons')}</td><td>${patronValue(item, 'expired_patrons')}</td><td>${patronValue(item, 'expiration_unknown')}</td>` : ''}<td>${action}</td></tr>`;
};
return `<div class="kpi-recent-table-wrap"><table class="kpi-recent-table kpi-breakdown-table"><thead><tr>${headers.map(header => `<th>${escapeHtml(header)}</th>`).join('')}</tr></thead><tbody>${items.map(row).join('')}</tbody></table></div>`;
}

function systemBranchBreakdown(data) {
const systems = data.systemBreakdown || [];
const branches = data.libraryBreakdown || [];
return `<section class="kpi-chart-card kpi-chart-card--full" aria-labelledby="kpi-system-branch-title">
<div class="kpi-chart-card__heading"><div><h4 id="kpi-system-branch-title">System and branch totals</h4><p>Complete totals at the most granular available library level. Patron values use current-account eligibility; small patron groups remain privacy-suppressed.</p></div></div>
${systems.length ? `<h5>System totals</h5>${breakdownTable(systems)}` : ''}
<details class="kpi-breakdown-details"><summary>All ${formatNumber(branches.length)} branch totals</summary>${breakdownTable(branches)}</details>
<div class="kpi-chart-card__heading"><div><h4 id="kpi-system-branch-title">System and branch totals</h4><p>Choose a system or branch to narrow every dashboard measure to that scope. Patron values use current-account eligibility; small groups remain privacy-suppressed.</p></div></div>
${systems.length ? `<h5>System totals</h5>${breakdownTable(systems, { level: 'system' })}` : ''}
<details class="kpi-breakdown-details"><summary>All ${formatNumber(branches.length)} branch totals</summary>${breakdownTable(branches, { level: 'branch' })}</details>
</section>`;
}

function rankedBars(items, key, emptyText = 'No data is available for this breakdown.') {
function rankedBars(items, key, emptyText = 'No data is available for this breakdown.', drillKind = '') {
if (!items.length) return `<p class="kpi-chart-empty">${escapeHtml(emptyText)}</p>`;
const ranked = [...items].sort((left, right) =>
Number(right[key] || right.value || 0) - Number(left[key] || left.value || 0)
Expand All @@ -127,7 +139,7 @@ function rankedBars(items, key, emptyText = 'No data is available for this break
return `<ol class="kpi-ranking">${visible.map(item => {
const value = Number(item[key] || item.value || 0);
return `<li>
<span class="kpi-ranking__label" title="${escapeHtml(item.label || item.code || 'Unknown')}">${escapeHtml(item.label || item.code || 'Unknown')}</span>
${drillKind ? drillButton(item.label || item.code || 'Unknown', drillKind, item.code || item.label, 'kpi-ranking__label kpi-ranking__drill') : `<span class="kpi-ranking__label" title="${escapeHtml(item.label || item.code || 'Unknown')}">${escapeHtml(item.label || item.code || 'Unknown')}</span>`}
<span class="kpi-ranking__track"><span style="width:${(value / maximum) * 100}%"></span></span>
<strong>${formatNumber(value)}</strong>
</li>`;
Expand Down Expand Up @@ -176,7 +188,7 @@ function dashboardIntro(data, title, description) {
Number.isFinite(ageSeconds) ? `Response assembled ${compactDuration(ageSeconds)} ago` : '',
Number.isFinite(staleAfterSeconds) ? `response expected within ${compactDuration(staleAfterSeconds)}` : ''
].filter(Boolean).join(' · ');
return `<section class="kpi-dashboard__intro" aria-labelledby="kpi-dashboard-title"><div>
return `${data.canGoBack ? '<div class="kpi-drillback"><button type="button" data-kpi-back-scope>← Back to previous dashboard scope</button><span>Filters and reporting period stay intact while you explore.</span></div>' : ''}<section class="kpi-dashboard__intro" aria-labelledby="kpi-dashboard-title"><div>
<span class="kpi-dashboard__eyebrow">Library intelligence</span>${data.isSampleData ? '<span class="kpi-dashboard__sample">Sample data</span>' : ''}
<h3 id="kpi-dashboard-title">${escapeHtml(title)}</h3><p>${escapeHtml(description)}</p>
</div><div class="kpi-dashboard__freshness${data.freshness?.stale ? ' kpi-dashboard__freshness--stale' : ''}"><strong>${escapeHtml(scope)}</strong><small>${escapeHtml(itemType)}</small>${sourceFreshnessLines(data)}<small><strong>${escapeHtml(freshnessLabel)}</strong>${freshnessDetail ? ` · ${escapeHtml(freshnessDetail)}` : ''}</small>${data.freshness?.stale ? '<small>Refresh will keep the last verified values visible while the delayed source catches up.</small>' : ''}</div></section>`;
Expand Down Expand Up @@ -230,9 +242,9 @@ function renderOverview(data) {
</section>
<section class="kpi-dashboard__grid">
<article class="kpi-chart-card kpi-chart-card--wide"><div class="kpi-chart-card__heading"><div><h4>Circulation trend</h4><p>Transactions by period; checkout and renewal definitions match the Analytics circulation contract.</p></div></div>${stackedTrend(data.circulationTrend)}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Demand by library</h4><p>Checkouts for the highest-use libraries in scope.</p></div></div>${hasCirculation ? rankedBars(data.libraryBreakdown, 'checkouts') : circulationUnavailable}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Demand by item type</h4><p>Checkout volume reveals which formats patrons are choosing.</p></div></div>${hasCirculation ? rankedBars(data.itemTypeBreakdown, 'checkouts') : circulationUnavailable}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Patrons by home library</h4><p>Aggregated patron reach; small groups are suppressed.${patronScopeNote}</p></div></div>${rankedBars(data.patronLibraryBreakdown, 'patrons')}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Demand by library</h4><p>Select a library to see every KPI for that branch.</p></div></div>${hasCirculation ? rankedBars(data.libraryBreakdown, 'checkouts', undefined, 'branch') : circulationUnavailable}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Demand by item type</h4><p>Select a format to see every applicable KPI for that item type.</p></div></div>${hasCirculation ? rankedBars(data.itemTypeBreakdown, 'checkouts', undefined, 'item-type') : circulationUnavailable}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Patrons by home library</h4><p>Select a library to see its privacy-protected patron totals.${patronScopeNote}</p></div></div>${rankedBars(data.patronLibraryBreakdown, 'patrons', undefined, 'branch')}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Collection use</h4><p>Items grouped by recorded use, including never-used and high-use material.</p></div></div>${rankedBars(data.useBands, 'items')}</article>
${systemBranchBreakdown(data)}
<article class="kpi-chart-card kpi-chart-card--full"><div class="kpi-chart-card__heading"><div><h4>Recommended follow-up</h4><p>Actionable groups that can open as an exact Query report.</p></div></div>${opportunityTable(data.opportunities)}</article>
Expand Down Expand Up @@ -288,7 +300,7 @@ function renderPatrons(data) {
${metricCard('Unknown expiration', available ? formatNumber(patrons.expiration_unknown) : '—', 'Excluded from current; review source values', patrons.expiration_unknown > 0 ? 'active' : '')}
</div>
</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Home library</h4><p>Registered patrons by assigned library.</p><p class="kpi-chart-coverage">${escapeHtml(patronCoverageText(data.patronLibraryBreakdown, patrons, data.privacy))}</p></div></div>${rankedBars(data.patronLibraryBreakdown, 'patrons')}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Home library</h4><p>Select a library to inspect its privacy-protected patron totals.</p><p class="kpi-chart-coverage">${escapeHtml(patronCoverageText(data.patronLibraryBreakdown, patrons, data.privacy))}</p></div></div>${rankedBars(data.patronLibraryBreakdown, 'patrons', undefined, 'branch')}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>User profile</h4><p>Aggregated patron profile distribution.</p><p class="kpi-chart-coverage">${escapeHtml(patronCoverageText(data.patronProfileBreakdown, patrons, data.privacy))}</p></div></div>${rankedBars(data.patronProfileBreakdown, 'patrons')}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Age groups</h4><p>Derived from usable birth dates; unknown values remain visible.</p><p class="kpi-chart-coverage">${escapeHtml(patronCoverageText(data.patronAgeBands, patrons, data.privacy))}</p></div></div>${rankedBars(data.patronAgeBands, 'patrons')}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>ZIP3 reach</h4><p>Broad postal areas from the separate all-record geography aggregate; exact ZIP codes and addresses are never returned.</p><p class="kpi-chart-coverage">${escapeHtml(patronCoverageText(data.patronGeoBreakdown, { total: patrons.records_total }, data.privacy))}</p></div></div>${rankedBars(data.patronGeoBreakdown, 'patrons')}</article>
Expand Down
13 changes: 13 additions & 0 deletions tests/browser/browserSmoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,19 @@ async function runSmokeTest() {
if (JSON.stringify(itemTypeRequest?.item_types) !== JSON.stringify(['BOOK', 'EBOOK'])) {
throw new Error(`Dashboard should send every selected item type through the shared aggregate request: ${JSON.stringify(itemTypeRequest)}`);
}
await page.locator('#kpi-dashboard-content .kpi-chart-card')
.filter({ hasText: 'Demand by library' })
.getByRole('button', { name: 'MSU-MAIN' })
.click();
await page.waitForFunction(() => document.querySelector('#kpi-dashboard-library')?.getSelectedValues?.()?.length === 1
&& document.querySelector('#kpi-dashboard-library')?.getSelectedValues?.()?.[0] === 'MSU-MAIN');
const backToPreviousScope = page.getByRole('button', { name: 'Back to previous dashboard scope' });
await backToPreviousScope.waitFor({ state: 'visible', timeout: 5000 });
await backToPreviousScope.click();
await page.waitForFunction(() => {
const values = document.querySelector('#kpi-dashboard-library')?.getSelectedValues?.() || [];
return values.length === 2 && values.includes('MSU-MAIN') && values.includes('MSU-MERIDIAN');
});
const expectedDrilldowns = [
{ label: 'Older items with no recorded use', name: 'Older items with no use', fields: ['Item Library', 'Item Type', 'Item Date Created', 'Total Checkouts'] },
{ label: 'High-use items with active holds', name: 'High-use items with holds', fields: ['Item Library', 'Item Type', 'Total Checkouts', 'Copy Hold Count'] },
Expand Down
Loading