From c4c300594c90511b60131692655964ff9e599a1f Mon Sep 17 00:00:00 2001 From: Brandon Temple Date: Wed, 2 Sep 2026 00:21:49 -0500 Subject: [PATCH] Expand KPI dashboard for system directors --- cache-bust.json | 8 ++-- src/styles/app.css | 2 +- src/styles/dashboard.css | 8 ++++ src/ui/dashboard/libraryDashboardView.js | 50 ++++++++++++++++----- tests/browser/browserSmoke.mjs | 23 ++++++++-- tests/unit/ui/libraryDashboardViewLogic.mjs | 32 +++++++++++++ 6 files changed, 103 insertions(+), 20 deletions(-) diff --git a/cache-bust.json b/cache-bust.json index f358dd7..46ef789 100644 --- a/cache-bust.json +++ b/cache-bust.json @@ -1,5 +1,5 @@ { - "version": "3015f39822a48a4e", + "version": "e752f58a9508af70", "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": "f45508fee36d3611", + "src/styles/app.css": "770eecbe285bf495", "src/styles/base.css": "8a925806001990b6", "src/styles/bib-compare.css": "144f2a1093e00dcf", "src/styles/controls.css": "30d36ccc65181c21", - "src/styles/dashboard.css": "443e234c111fc528", + "src/styles/dashboard.css": "1d95605bede44acc", "src/styles/filters.css": "9e7297e65dd68a3c", "src/styles/form-mode.css": "7de8927db30d30f6", "src/styles/help.css": "1e75cc7af857e66a", @@ -247,7 +247,7 @@ "src/ui/dashboard/libraryDashboardCoverage.js": "d2da9d84bf50899f", "src/ui/dashboard/libraryDashboardExport.js": "ae9057c966126624", "src/ui/dashboard/libraryDashboardModel.js": "1c036eaacc95d14e", - "src/ui/dashboard/libraryDashboardView.js": "7238d2a24569a246", + "src/ui/dashboard/libraryDashboardView.js": "2c925e3e1e75b9cc", "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 d242dee..b99818c 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=443e234c111fc528"); +@import url("./dashboard.css?v=1d95605bede44acc"); @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 c91058d..0651f8b 100644 --- a/src/styles/dashboard.css +++ b/src/styles/dashboard.css @@ -393,6 +393,10 @@ grid-template-columns: repeat(6, minmax(0, 1fr)); } +.kpi-cards--four { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .kpi-card, .kpi-chart-card { border: 1px solid var(--theme-border); @@ -475,6 +479,10 @@ gap: 0.75rem; } +.kpi-health-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .kpi-breakdown-details { margin-top: 1rem; } diff --git a/src/ui/dashboard/libraryDashboardView.js b/src/ui/dashboard/libraryDashboardView.js index 2fb39f7..0fc92eb 100644 --- a/src/ui/dashboard/libraryDashboardView.js +++ b/src/ui/dashboard/libraryDashboardView.js @@ -102,10 +102,18 @@ function drillButton(label, kind, value, className = 'kpi-drilldown') { return ``; } -function breakdownTable(items, { patronColumns = true, level = 'branch' } = {}) { +function percentage(numerator, denominator) { + const total = Number(denominator || 0); + return total > 0 ? formatPercent(Number(numerator || 0) / total) : '—'; +} + +function breakdownTable(items, { mode = 'overview', 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'); + const headers = mode === 'collection' + ? ['System', 'Branch', 'Titles', 'Items', 'Used recently', 'Never used', 'Open holds', 'Missing / lost', 'Unavailable', 'In transit', 'Inventory coverage', 'Collection value'] + : mode === 'patrons' + ? ['System', 'Branch', 'All patron records', 'Current patrons', 'Active patrons', 'Expired', 'Unknown expiry'] + : ['System', 'Branch', 'Items', 'Checkouts', 'Renewals', 'Total circulation', 'Turnover', 'Open holds']; headers.push('Details'); const patronValue = (item, key) => item.patron_suppressed ? 'Suppressed' : formatNumber(item[key]); const row = item => { @@ -113,18 +121,24 @@ function breakdownTable(items, { patronColumns = true, level = 'branch' } = {}) 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}`; + const identity = `${escapeHtml(item.system || item.label || 'Unassigned')}${escapeHtml(item.system ? item.label : `${formatNumber(item.branches)} branches`)}`; + const metrics = mode === 'collection' + ? `${formatNumber(item.titles)}${formatNumber(item.items)}${formatNumber(item.used_recently)}${formatNumber(item.never_used)}${formatNumber(item.holds)}${formatNumber(item.missing_lost_items)}${formatNumber(item.unavailable_items)}${formatNumber(item.in_transit_items)}${percentage(item.inventoried, item.items)}${formatMoney(item.total_value)}` + : mode === 'patrons' + ? `${patronValue(item, 'patron_records')}${patronValue(item, 'patrons')}${patronValue(item, 'active_patrons')}${patronValue(item, 'expired_patrons')}${patronValue(item, 'expiration_unknown')}` + : `${formatNumber(item.items)}${formatNumber(item.checkouts)}${formatNumber(item.renewals)}${formatNumber(Number(item.checkouts || 0) + Number(item.renewals || 0))}${Number(item.items || 0) > 0 ? Number((Number(item.checkouts || 0) + Number(item.renewals || 0)) / Number(item.items)).toFixed(2) : '—'}${formatNumber(item.holds)}`; + return `${identity}${metrics}${action}`; }; return `
${headers.map(header => ``).join('')}${items.map(row).join('')}
${escapeHtml(header)}
`; } -function systemBranchBreakdown(data) { +function systemBranchBreakdown(data, mode = 'overview') { const systems = data.systemBreakdown || []; const branches = data.libraryBreakdown || []; return `
-

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' })}
+

System and branch totals

Choose a system or branch to narrow every dashboard measure to that scope.${mode === 'patrons' ? ' Small patron groups remain privacy-suppressed.' : ''}

+ ${systems.length ? `
System totals
${breakdownTable(systems, { mode, level: 'system' })}` : ''} +
All ${formatNumber(branches.length)} branch totals${breakdownTable(branches, { mode, level: 'branch' })}
`; } @@ -232,11 +246,13 @@ function renderOverview(data) { ? ' · item type does not apply' : ''; return `${dashboardIntro(data, 'What is being used—and where to act', 'A combined view of circulation demand, collection performance, and community reach. Every number keeps its source and time basis visible.')} -
+
${metricCard('Checkouts', hasCirculation ? formatNumber(circ.checkouts) : '—', hasCirculation ? periodComparisonDetail(circ, 'checkouts', data.scope?.comparison_mode) : 'Period transaction feed not available', hasCirculation ? 'success' : '', data.metricDefinitions.checkouts)} ${metricCard('Renewals', hasCirculation ? formatNumber(circ.renewals) : '—', hasCirculation ? periodComparisonDetail(circ, 'renewals', data.scope?.comparison_mode) : 'Period transaction feed not available', '', data.metricDefinitions.renewals)} + ${metricCard('Total circulation', hasCirculation ? formatNumber(Number(circ.activity || 0) || Number(circ.checkouts || 0) + Number(circ.renewals || 0)) : '—', hasCirculation ? `${circ.period_label || 'Selected period'} · checkouts plus renewals` : 'Period transaction feed not available', '', data.metricDefinitions.turnover)} ${metricCard('Current items', hasCollection ? formatNumber(collection.items) : '—', hasCollection ? (collection.titles ? `${formatNumber(collection.titles)} titles represented` : 'Actual current item records') : 'Current item snapshot not available', '', data.metricDefinitions.items)} ${metricCard('Used recently', hasCollection ? formatPercent(collection.recent_use_rate) : '—', hasCollection ? `${activityWindow} · ${formatNumber(collection.used_recently)} items with recorded use` : 'Current item snapshot not available', hasCollection ? 'success' : '', data.metricDefinitions.used_recently)} + ${metricCard('Current patrons', hasPatrons ? formatNumber(patrons.total) : '—', hasPatrons ? (patrons.eligibility_label || 'Unexpired or non-expiring accounts') : 'Patron aggregate not available', '', data.metricDefinitions.current_patrons)} ${metricCard('Active patrons', hasPatrons ? formatNumber(patrons.active) : '—', hasPatrons ? `${activityWindow} · ${formatPercent(patrons.active_rate)} of current patrons${patronScopeNote}` : 'Patron aggregate not available', '', data.metricDefinitions.active_patrons)} ${metricCard('New patrons', hasPatrons ? formatNumber(patrons.new) : '—', hasPatrons ? `${patrons.new_period_label || 'Created in the selected period'}${patronScopeNote}` : 'Patron aggregate not available', '', data.metricDefinitions.new_patrons)}
@@ -246,7 +262,7 @@ function renderOverview(data) {

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)} + ${systemBranchBreakdown(data, 'overview')}

Recommended follow-up

Actionable groups that can open as an exact Query report.

${opportunityTable(data.opportunities)}
${serviceCoverageSection(data)}${sourceNotes(data)}`; } @@ -272,8 +288,18 @@ function renderCollection(data) {

Collection age

Current items by creation-date band.

${rankedBars(data.ageBands, 'items')}

Hold pressure

Demand indicators for copies and titles currently in scope.

${metricCard('Open holds', formatNumber(circ.holds), `${Number(circ.holds_per_100_items || 0).toFixed(1)} per 100 items`)}

Recently used

Items with a recorded last-use date in the ${activityWindow.toLowerCase()}.

${metricCard('Recent-use rate', formatPercent(collection.recent_use_rate), `${activityWindow} · ${formatNumber(collection.used_recently)} of ${formatNumber(collection.items)} items`)}
+

Inventory and availability health

Current operational risks and inventory coverage for the selected collection.

+
+ ${metricCard('Inventory coverage', formatPercent(collection.inventory_coverage), `${formatNumber(collection.inventoried)} of ${formatNumber(collection.items)} items have an inventory date`, '', data.metricDefinitions.inventory_coverage)} + ${metricCard('Inventoried recently', formatNumber(collection.inventoried_last_365_days), 'Inventory date recorded in the last 12 months')} + ${metricCard('Unavailable', formatNumber(collection.unavailable_items), `${formatPercent(collection.unavailable_rate)} of current items`, collection.unavailable_rate > 0.2 ? 'active' : '')} + ${metricCard('Missing or lost', formatNumber(collection.missing_lost_items), `${percentage(collection.missing_lost_items, collection.items)} of current items`, collection.missing_lost_items > 0 ? 'active' : '')} + ${metricCard('In transit', formatNumber(collection.in_transit_items), 'Items currently assigned an in-transit location')} + ${metricCard('Price coverage', formatPercent(collection.price_coverage), `${formatMoney(collection.total_value)} recorded collection value`)} +
+

Collection-development queue

Open the underlying records to review, sort, or export them.

${opportunityTable(data.opportunities)}
- ${systemBranchBreakdown(data)} + ${systemBranchBreakdown(data, 'collection')} ${sourceNotes(data)}`; } @@ -306,7 +332,7 @@ function renderPatrons(data) {

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')}

Cities served

All-record geography source after privacy suppression; this is not presented as current-patron geography.

${escapeHtml(patronCoverageText(data.patronCityBreakdown, { total: patrons.records_total }, data.privacy))}

${rankedBars(data.patronCityBreakdown, 'patrons')}

States served

All-record geography source after privacy suppression.

${escapeHtml(patronCoverageText(data.patronStateBreakdown, { total: patrons.records_total }, data.privacy))}

${rankedBars(data.patronStateBreakdown, 'patrons')}
- ${systemBranchBreakdown(data)} + ${systemBranchBreakdown(data, 'patrons')} ${metricDefinition(data.metricDefinitions.patron_geography)}${sourceNotes(data)}`; } diff --git a/tests/browser/browserSmoke.mjs b/tests/browser/browserSmoke.mjs index 7719e28..b86bf3e 100644 --- a/tests/browser/browserSmoke.mjs +++ b/tests/browser/browserSmoke.mjs @@ -528,7 +528,8 @@ async function runSmokeTest() { if ( dashboardState.cardValues[0] !== '880,229' || dashboardState.cardValues[1] !== '487,605' - || dashboardState.cardValues[2] !== '2,813,442' + || dashboardState.cardValues[2] !== '1,367,834' + || dashboardState.cardValues[3] !== '2,813,442' || dashboardState.chartCount !== 7 || dashboardState.opportunityRows !== 3 || dashboardState.librarySelection.length !== 0 @@ -539,6 +540,22 @@ async function runSmokeTest() { ) { throw new Error(`Dashboard should reconcile library metrics, charts, filters, and opportunities: ${JSON.stringify(dashboardState)}`); } + await page.locator('[data-kpi-view="collection"]').click(); + await page.waitForFunction(() => document.querySelector('#kpi-dashboard-content')?.textContent?.includes('Inventory and availability health')); + const directorCollectionState = await page.locator('#kpi-dashboard-content').evaluate(content => ({ + healthCards: content.querySelectorAll('.kpi-health-grid .kpi-card').length, + headers: [...content.querySelectorAll('.kpi-breakdown-table th')].map(header => header.textContent.trim()), + hasInventoryCoverage: content.textContent.includes('Inventory coverage'), + hasMissingLost: content.textContent.includes('Missing or lost') + })); + if (directorCollectionState.healthCards !== 6 + || !directorCollectionState.hasInventoryCoverage + || !directorCollectionState.hasMissingLost + || !directorCollectionState.headers.includes('Collection value') + || !directorCollectionState.headers.includes('In transit')) { + throw new Error(`Collection view should expose director-level stewardship and risk measures: ${JSON.stringify(directorCollectionState)}`); + } + await page.locator('[data-kpi-view="overview"]').click(); await expectNoHorizontalOverflow(page, 'Desktop KPI dashboard'); const exportDownloadPromise = page.waitForEvent('download'); await page.locator('#kpi-dashboard-export').click(); @@ -1539,8 +1556,8 @@ async function runSmokeTest() { await expectElementWithinViewport(mobilePage, '#kpi-dashboard-panel', 'Mobile KPI dashboard panel'); await expectNoHorizontalOverflow(mobilePage, 'Mobile KPI dashboard panel'); const mobileDashboardCards = await mobilePage.locator('#kpi-dashboard-panel .kpi-card').count(); - if (mobileDashboardCards !== 6) { - throw new Error(`Mobile KPI dashboard should preserve all six summary cards: ${mobileDashboardCards}`); + if (mobileDashboardCards !== 8) { + throw new Error(`Mobile KPI dashboard should preserve all eight summary cards: ${mobileDashboardCards}`); } await mobilePage.locator('#kpi-dashboard-window .kpi-period-trigger').click(); const mobilePeriodDialog = mobilePage.getByRole('dialog', { name: 'Choose circulation period' }); diff --git a/tests/unit/ui/libraryDashboardViewLogic.mjs b/tests/unit/ui/libraryDashboardViewLogic.mjs index f4bdf30..ad90658 100644 --- a/tests/unit/ui/libraryDashboardViewLogic.mjs +++ b/tests/unit/ui/libraryDashboardViewLogic.mjs @@ -186,3 +186,35 @@ test('dashboard breakdowns expose reversible system, branch, and item-type drill assert.match(html, /data-kpi-back-scope/); assert.match(html, /Back to previous dashboard scope/); }); + +test('director views surface circulation, patron, inventory, risk, and stewardship measures', () => { + const dashboard = normalizeLibraryDashboard({ + circulation: { checkouts: 80, renewals: 20, activity: 100, period_label: 'Recent 365 days' }, + collection: { + items: 200, titles: 150, used_recently: 75, recent_use_rate: 0.375, + inventory_coverage: 0.8, inventoried: 160, inventoried_last_365_days: 60, + unavailable_items: 25, unavailable_rate: 0.125, missing_lost_items: 10, + in_transit_items: 5, price_coverage: 0.9, total_value: 4000 + }, + patrons: { total: 50, active: 25, new: 8 }, + system_breakdown: [{ label: 'MSU', branches: 1, items: 200, titles: 150, checkouts: 80, renewals: 20, holds: 4, inventoried: 160, missing_lost_items: 10, unavailable_items: 25, in_transit_items: 5, total_value: 4000, patron_records: 80, patrons: 50, active_patrons: 25, expired_patrons: 25, expiration_unknown: 5 }], + library_breakdown: [{ label: 'MSU-MAIN', system: 'MSU', items: 200 }] + }); + + const overview = renderLibraryDashboard(dashboard, 'overview'); + assert.match(overview, /Total circulation/); + assert.match(overview, />100