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
8 changes: 4 additions & 4 deletions cache-bust.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "3015f39822a48a4e",
"version": "e752f58a9508af70",
"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": "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",
Expand Down Expand Up @@ -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",
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=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");
Expand Down
8 changes: 8 additions & 0 deletions src/styles/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -475,6 +479,10 @@
gap: 0.75rem;
}

.kpi-health-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}

.kpi-breakdown-details {
margin-top: 1rem;
}
Expand Down
50 changes: 38 additions & 12 deletions src/ui/dashboard/libraryDashboardView.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,29 +102,43 @@ function drillButton(label, kind, value, className = 'kpi-drilldown') {
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' } = {}) {
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 '<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');
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 ? '<span title="Below the privacy threshold">Suppressed</span>' : formatNumber(item[key]);
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>`;
const identity = `<td>${escapeHtml(item.system || item.label || 'Unassigned')}</td><td>${escapeHtml(item.system ? item.label : `${formatNumber(item.branches)} branches`)}</td>`;
const metrics = mode === 'collection'
? `<td>${formatNumber(item.titles)}</td><td>${formatNumber(item.items)}</td><td>${formatNumber(item.used_recently)}</td><td>${formatNumber(item.never_used)}</td><td>${formatNumber(item.holds)}</td><td>${formatNumber(item.missing_lost_items)}</td><td>${formatNumber(item.unavailable_items)}</td><td>${formatNumber(item.in_transit_items)}</td><td>${percentage(item.inventoried, item.items)}</td><td>${formatMoney(item.total_value)}</td>`
: mode === 'patrons'
? `<td>${patronValue(item, 'patron_records')}</td><td>${patronValue(item, 'patrons')}</td><td>${patronValue(item, 'active_patrons')}</td><td>${patronValue(item, 'expired_patrons')}</td><td>${patronValue(item, 'expiration_unknown')}</td>`
: `<td>${formatNumber(item.items)}</td><td>${formatNumber(item.checkouts)}</td><td>${formatNumber(item.renewals)}</td><td>${formatNumber(Number(item.checkouts || 0) + Number(item.renewals || 0))}</td><td>${Number(item.items || 0) > 0 ? Number((Number(item.checkouts || 0) + Number(item.renewals || 0)) / Number(item.items)).toFixed(2) : '—'}</td><td>${formatNumber(item.holds)}</td>`;
return `<tr>${identity}${metrics}<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) {
function systemBranchBreakdown(data, mode = 'overview') {
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>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>
<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.${mode === 'patrons' ? ' Small patron groups remain privacy-suppressed.' : ''}</p></div></div>
${systems.length ? `<h5>System totals</h5>${breakdownTable(systems, { mode, level: 'system' })}` : ''}
<details class="kpi-breakdown-details"><summary>All ${formatNumber(branches.length)} branch totals</summary>${breakdownTable(branches, { mode, level: 'branch' })}</details>
</section>`;
}

Expand Down Expand Up @@ -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.')}
<section class="kpi-cards kpi-cards--six" aria-label="Key library indicators">
<section class="kpi-cards kpi-cards--four" aria-label="Key library indicators">
${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)}
</section>
Expand All @@ -246,7 +262,7 @@ function renderOverview(data) {
<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)}
${systemBranchBreakdown(data, 'overview')}
<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>
</section>${serviceCoverageSection(data)}${sourceNotes(data)}`;
}
Expand All @@ -272,8 +288,18 @@ function renderCollection(data) {
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Collection age</h4><p>Current items by creation-date band.</p></div></div>${rankedBars(data.ageBands, 'items')}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Hold pressure</h4><p>Demand indicators for copies and titles currently in scope.</p></div></div>${metricCard('Open holds', formatNumber(circ.holds), `${Number(circ.holds_per_100_items || 0).toFixed(1)} per 100 items`)}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Recently used</h4><p>Items with a recorded last-use date in the ${activityWindow.toLowerCase()}.</p></div></div>${metricCard('Recent-use rate', formatPercent(collection.recent_use_rate), `${activityWindow} · ${formatNumber(collection.used_recently)} of ${formatNumber(collection.items)} items`)}</article>
<article class="kpi-chart-card kpi-chart-card--full"><div class="kpi-chart-card__heading"><div><h4>Inventory and availability health</h4><p>Current operational risks and inventory coverage for the selected collection.</p></div></div>
<div class="kpi-patron-reconciliation kpi-health-grid">
${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`)}
</div>
</article>
<article class="kpi-chart-card kpi-chart-card--full"><div class="kpi-chart-card__heading"><div><h4>Collection-development queue</h4><p>Open the underlying records to review, sort, or export them.</p></div></div>${opportunityTable(data.opportunities)}</article>
${systemBranchBreakdown(data)}
${systemBranchBreakdown(data, 'collection')}
</section>${sourceNotes(data)}`;
}

Expand Down Expand Up @@ -306,7 +332,7 @@ function renderPatrons(data) {
<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>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>Cities served</h4><p>All-record geography source after privacy suppression; this is not presented as current-patron geography.</p><p class="kpi-chart-coverage">${escapeHtml(patronCoverageText(data.patronCityBreakdown, { total: patrons.records_total }, data.privacy))}</p></div></div>${rankedBars(data.patronCityBreakdown, 'patrons')}</article>
<article class="kpi-chart-card"><div class="kpi-chart-card__heading"><div><h4>States served</h4><p>All-record geography source after privacy suppression.</p><p class="kpi-chart-coverage">${escapeHtml(patronCoverageText(data.patronStateBreakdown, { total: patrons.records_total }, data.privacy))}</p></div></div>${rankedBars(data.patronStateBreakdown, 'patrons')}</article>
${systemBranchBreakdown(data)}
${systemBranchBreakdown(data, 'patrons')}
</section>${metricDefinition(data.metricDefinitions.patron_geography)}${sourceNotes(data)}`;
}

Expand Down
23 changes: 20 additions & 3 deletions tests/browser/browserSmoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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' });
Expand Down
Loading