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 ``; +} + +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 => ``).join('')}${items.map(row).join('')}
${escapeHtml(header)}
`; } @@ -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 `
  1. - ${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)}
  2. `; @@ -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 ? '
    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/); +});