diff --git a/cache-bust.json b/cache-bust.json
index 97047a66..f358dd77 100644
--- a/cache-bust.json
+++ b/cache-bust.json
@@ -1,5 +1,5 @@
{
- "version": "234a55185339ec0e",
+ "version": "3015f39822a48a4e",
"generatedBy": "scripts/updateCacheBusting.mjs",
"assets": {
"assets/app-icon-16.png": "6645731d86da1071",
@@ -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",
@@ -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",
diff --git a/src/styles/app.css b/src/styles/app.css
index b5925c7e..d242dee0 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -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");
diff --git a/src/styles/dashboard.css b/src/styles/dashboard.css
index 28073e26..c91058df 100644
--- a/src/styles/dashboard.css
+++ b/src/styles/dashboard.css
@@ -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);
diff --git a/src/ui/dashboard/kpiDashboard.js b/src/ui/dashboard/kpiDashboard.js
index a4c81324..b3c812ca 100644
--- a/src/ui/dashboard/kpiDashboard.js
+++ b/src/ui/dashboard/kpiDashboard.js
@@ -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() {
@@ -179,6 +180,7 @@ function render() {
});
} else {
libraryData.scope.comparison_mode = periodComparison;
+ libraryData.canGoBack = scopeHistory.length > 0;
elements.content.innerHTML = renderLibraryDashboard(libraryData, currentView);
}
}
@@ -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 }));
@@ -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(() => {
diff --git a/src/ui/dashboard/libraryDashboardView.js b/src/ui/dashboard/libraryDashboardView.js
index 27f5bbf3..2fb39f70 100644
--- a/src/ui/dashboard/libraryDashboardView.js
+++ b/src/ui/dashboard/libraryDashboardView.js
@@ -97,12 +97,24 @@ function metricCard(label, value, detail, tone = '', definition = null) {
`;
}
-function breakdownTable(items, { patronColumns = true } = {}) {
+function drillButton(label, kind, value, className = 'kpi-drilldown') {
+ if (!kind || !value) return '';
+ return `${escapeHtml(label)} `;
+}
+
+function breakdownTable(items, { patronColumns = true, level = 'branch' } = {}) {
if (!items.length) return '
No system or branch totals are available for this scope.
';
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 ? 'Suppressed ' : formatNumber(item[key]);
- const row = item => `${escapeHtml(item.system || item.label || 'Unassigned')} ${escapeHtml(item.system ? item.label : `${formatNumber(item.branches)} branches`)} ${formatNumber(item.items)} ${formatNumber(item.checkouts)} ${formatNumber(item.renewals)} ${patronColumns ? `${patronValue(item, 'patrons')} ${patronValue(item, 'active_patrons')} ${patronValue(item, 'expired_patrons')} ${patronValue(item, 'expiration_unknown')} ` : ''} `;
+ const row = item => {
+ const value = item.label || '';
+ const action = value && value !== 'Unassigned'
+ ? drillButton(level === 'system' ? 'View system' : 'View branch', level, value)
+ : '';
+ return `${escapeHtml(item.system || item.label || 'Unassigned')} ${escapeHtml(item.system ? item.label : `${formatNumber(item.branches)} branches`)} ${formatNumber(item.items)} ${formatNumber(item.checkouts)} ${formatNumber(item.renewals)} ${patronColumns ? `${patronValue(item, 'patrons')} ${patronValue(item, 'active_patrons')} ${patronValue(item, 'expired_patrons')} ${patronValue(item, 'expiration_unknown')} ` : ''}${action} `;
+ };
return `${headers.map(header => `${escapeHtml(header)} `).join('')} ${items.map(row).join('')}
`;
}
@@ -110,13 +122,13 @@ function systemBranchBreakdown(data) {
const systems = data.systemBreakdown || [];
const branches = data.libraryBreakdown || [];
return `
- System and branch totals Complete totals at the most granular available library level. Patron values use current-account eligibility; small patron groups remain privacy-suppressed.
- ${systems.length ? `System totals ${breakdownTable(systems)}` : ''}
- All ${formatNumber(branches.length)} branch totals ${breakdownTable(branches)}
+ System and branch totals Choose a system or branch to narrow every dashboard measure to that scope. Patron values use current-account eligibility; small groups remain privacy-suppressed.
+ ${systems.length ? `System totals ${breakdownTable(systems, { level: 'system' })}` : ''}
+ All ${formatNumber(branches.length)} branch totals ${breakdownTable(branches, { level: 'branch' })}
`;
}
-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 `${escapeHtml(emptyText)}
`;
const ranked = [...items].sort((left, right) =>
Number(right[key] || right.value || 0) - Number(left[key] || left.value || 0)
@@ -127,7 +139,7 @@ function rankedBars(items, key, emptyText = 'No data is available for this break
return `${visible.map(item => {
const value = Number(item[key] || item.value || 0);
return `
- ${escapeHtml(item.label || item.code || 'Unknown')}
+ ${drillKind ? drillButton(item.label || item.code || 'Unknown', drillKind, item.code || item.label, 'kpi-ranking__label kpi-ranking__drill') : `${escapeHtml(item.label || item.code || 'Unknown')} `}
${formatNumber(value)}
`;
@@ -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 `
+ return `${data.canGoBack ? '
← Back to previous dashboard scope Filters and reporting period stay intact while you explore.
' : ''}
Library intelligence ${data.isSampleData ? '
Sample data ' : ''}
${escapeHtml(title)} ${escapeHtml(description)}
${escapeHtml(scope)} ${escapeHtml(itemType)} ${sourceFreshnessLines(data)}${escapeHtml(freshnessLabel)} ${freshnessDetail ? ` · ${escapeHtml(freshnessDetail)}` : ''} ${data.freshness?.stale ? 'Refresh will keep the last verified values visible while the delayed source catches up. ' : ''}
`;
@@ -230,9 +242,9 @@ function renderOverview(data) {
Circulation trend Transactions by period; checkout and renewal definitions match the Analytics circulation contract.
${stackedTrend(data.circulationTrend)}
- Demand by library Checkouts for the highest-use libraries in scope.
${hasCirculation ? rankedBars(data.libraryBreakdown, 'checkouts') : circulationUnavailable}
- Demand by item type Checkout volume reveals which formats patrons are choosing.
${hasCirculation ? rankedBars(data.itemTypeBreakdown, 'checkouts') : circulationUnavailable}
- Patrons by home library Aggregated patron reach; small groups are suppressed.${patronScopeNote}
${rankedBars(data.patronLibraryBreakdown, 'patrons')}
+ Demand by library Select a library to see every KPI for that branch.
${hasCirculation ? rankedBars(data.libraryBreakdown, 'checkouts', undefined, 'branch') : circulationUnavailable}
+ Demand by item type Select a format to see every applicable KPI for that item type.
${hasCirculation ? rankedBars(data.itemTypeBreakdown, 'checkouts', undefined, 'item-type') : circulationUnavailable}
+ Patrons by home library Select a library to see its privacy-protected patron totals.${patronScopeNote}
${rankedBars(data.patronLibraryBreakdown, 'patrons', undefined, 'branch')}
Collection use Items grouped by recorded use, including never-used and high-use material.
${rankedBars(data.useBands, 'items')}
${systemBranchBreakdown(data)}
Recommended follow-up Actionable groups that can open as an exact Query report.
${opportunityTable(data.opportunities)}
@@ -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' : '')}
- Home library Registered patrons by assigned library.
${escapeHtml(patronCoverageText(data.patronLibraryBreakdown, patrons, data.privacy))}
${rankedBars(data.patronLibraryBreakdown, 'patrons')}
+ Home library Select a library to inspect its privacy-protected patron totals.
${escapeHtml(patronCoverageText(data.patronLibraryBreakdown, patrons, data.privacy))}
${rankedBars(data.patronLibraryBreakdown, 'patrons', undefined, 'branch')}
User profile Aggregated patron profile distribution.
${escapeHtml(patronCoverageText(data.patronProfileBreakdown, patrons, data.privacy))}
${rankedBars(data.patronProfileBreakdown, 'patrons')}
Age groups Derived from usable birth dates; unknown values remain visible.
${escapeHtml(patronCoverageText(data.patronAgeBands, patrons, data.privacy))}
${rankedBars(data.patronAgeBands, 'patrons')}
ZIP3 reach Broad postal areas from the separate all-record geography aggregate; exact ZIP codes and addresses are never returned.
${escapeHtml(patronCoverageText(data.patronGeoBreakdown, { total: patrons.records_total }, data.privacy))}
${rankedBars(data.patronGeoBreakdown, 'patrons')}
diff --git a/tests/browser/browserSmoke.mjs b/tests/browser/browserSmoke.mjs
index 39fa6429..7719e283 100644
--- a/tests/browser/browserSmoke.mjs
+++ b/tests/browser/browserSmoke.mjs
@@ -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'] },
diff --git a/tests/browser/support/browserSmokeSupport.mjs b/tests/browser/support/browserSmokeSupport.mjs
index fb979970..c4d8f380 100644
--- a/tests/browser/support/browserSmokeSupport.mjs
+++ b/tests/browser/support/browserSmokeSupport.mjs
@@ -384,11 +384,12 @@ function buildDefaultQueryApiResponse(payload) {
collection: { items: 2813442, titles: 1601291, lifetime_checkouts: 12844308, lifetime_renewals: 5160244, in_house_uses: 843108, used_recently: 947835, recent_use_rate: 0.337, never_used: 1023995, never_used_rate: 0.364, checkouts_per_item: 4.6, total_value: 42850300, price_coverage: 0.71 },
patrons: { total: 618420, active: 183804, active_rate: 0.297, new: 42640, with_charges: 74482, with_holds: 18814, expiring_soon: 29711 },
circulation_trend: [{ label: 'Jul', checkouts: 66892, renewals: 35911 }, { label: 'Aug', checkouts: 74822, renewals: 39844 }],
- library_breakdown: [{ label: 'MMRLS', checkouts: 208144 }, { label: 'MSU', checkouts: 30717 }],
- item_type_breakdown: [{ label: 'Books', checkouts: 544810 }, { label: 'Ebooks', checkouts: 94220 }],
+ system_breakdown: [{ label: 'MSU', branches: 2, items: 2813442, checkouts: 880229, renewals: 487605, patrons: 618420 }],
+ library_breakdown: [{ label: 'MSU-MAIN', system: 'MSU', checkouts: 208144 }, { label: 'MSU-MERIDIAN', system: 'MSU', checkouts: 30717 }],
+ item_type_breakdown: [{ label: 'BOOK', checkouts: 544810 }, { label: 'EBOOK', checkouts: 94220 }],
use_bands: [{ label: 'Never used', items: 1023995 }, { label: '1–2 checkouts', items: 731400 }],
age_bands: [{ label: 'Under 1 year', items: 81340 }, { label: '20+ years', items: 714820 }],
- patron_library_breakdown: [{ label: 'MMRLS', patrons: 146820 }, { label: 'MSU', patrons: 31220 }],
+ patron_library_breakdown: [{ label: 'MSU-MAIN', patrons: 146820 }, { label: 'MSU-MERIDIAN', patrons: 31220 }],
patron_profile_breakdown: [{ label: 'Adult', patrons: 359140 }, { label: 'Juvenile', patrons: 143880 }],
patron_age_bands: [{ label: '18–24', patrons: 62330 }, { label: '25–44', patrons: 157880 }],
patron_geo_breakdown: [{ label: '388xx', patrons: 49220 }, { label: 'Other / unknown', patrons: 420700 }],
diff --git a/tests/unit/ui/libraryDashboardViewLogic.mjs b/tests/unit/ui/libraryDashboardViewLogic.mjs
index 703a427f..f4bdf302 100644
--- a/tests/unit/ui/libraryDashboardViewLogic.mjs
+++ b/tests/unit/ui/libraryDashboardViewLogic.mjs
@@ -41,7 +41,7 @@ test('demand rankings put the highest period activity first', () => {
});
const html = renderLibraryDashboard(dashboard, 'overview');
- assert.ok(html.indexOf('>Higher') < html.indexOf('>Lower'));
+ assert.ok(html.indexOf('>Higher') < html.indexOf('>Lower'));
assert.match(html, /Recent 90 days/);
});
@@ -166,3 +166,23 @@ test('patron view explains eligibility, reconciles source records, and protects
assert.match(html, /Suppressed/);
assert.match(html, /not presented as current-patron geography/);
});
+
+test('dashboard breakdowns expose reversible system, branch, and item-type drill-downs', () => {
+ const dashboard = normalizeLibraryDashboard({
+ circulation: { checkouts: 45 },
+ collection: { items: 120 },
+ patrons: { total: 32 },
+ system_breakdown: [{ label: 'MSU', branches: 2, items: 120 }],
+ library_breakdown: [{ label: 'MSU-MAIN', system: 'MSU', items: 100, checkouts: 40 }],
+ item_type_breakdown: [{ label: 'BOOK', items: 90, checkouts: 35 }],
+ patron_library_breakdown: [{ label: 'MSU-MAIN', patrons: 30 }]
+ });
+ dashboard.canGoBack = true;
+
+ const html = renderLibraryDashboard(dashboard, 'overview');
+ assert.match(html, /data-kpi-scope-kind="system" data-kpi-scope-value="MSU">View system/);
+ assert.match(html, /data-kpi-scope-kind="branch" data-kpi-scope-value="MSU-MAIN">View branch/);
+ assert.match(html, /data-kpi-scope-kind="item-type" data-kpi-scope-value="BOOK">BOOK/);
+ assert.match(html, /data-kpi-back-scope/);
+ assert.match(html, /Back to previous dashboard scope/);
+});