diff --git a/cache-bust.json b/cache-bust.json
index be82e05f..c1969bc1 100644
--- a/cache-bust.json
+++ b/cache-bust.json
@@ -1,5 +1,5 @@
{
- "version": "7a4f5965baa713ed",
+ "version": "620804a06d67d4c1",
"generatedBy": "scripts/updateCacheBusting.mjs",
"assets": {
"assets/app-icon-16.png": "6645731d86da1071",
@@ -190,11 +190,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": "0b5978c276c2585f",
+ "src/styles/app.css": "5c1c02d1bf7eaf03",
"src/styles/base.css": "8a925806001990b6",
"src/styles/bib-compare.css": "144f2a1093e00dcf",
"src/styles/controls.css": "30d36ccc65181c21",
- "src/styles/dashboard.css": "d79fa67fe87bb96e",
+ "src/styles/dashboard.css": "7aed1832e6cabd24",
"src/styles/filters.css": "9e7297e65dd68a3c",
"src/styles/form-mode.css": "7de8927db30d30f6",
"src/styles/help.css": "1e75cc7af857e66a",
@@ -240,10 +240,10 @@
"src/ui/dashboard/kpiDashboard.js": "55a7adaaf847950b",
"src/ui/dashboard/kpiDashboardModel.js": "fa4c42b18c1245e5",
"src/ui/dashboard/kpiDashboardView.js": "82508c550975ada6",
- "src/ui/dashboard/libraryDashboardCoverage.js": "d2da9d84bf50899f",
+ "src/ui/dashboard/libraryDashboardCoverage.js": "ba9e61d94c9af271",
"src/ui/dashboard/libraryDashboardExport.js": "ae9057c966126624",
- "src/ui/dashboard/libraryDashboardModel.js": "34c866b3a028dbe2",
- "src/ui/dashboard/libraryDashboardView.js": "c6debdb8b5e1ae1f",
+ "src/ui/dashboard/libraryDashboardModel.js": "237c98c1c1b5d332",
+ "src/ui/dashboard/libraryDashboardView.js": "6f86add644395314",
"src/ui/dashboard/reportingPeriodPicker.js": "334aea95b73dee72",
"src/ui/field-picker/buildableFieldPreview.js": "8abd486bb0a87fe2",
"src/ui/field-picker/fieldPicker.js": "526a1bf934f5dd74",
diff --git a/docs/LIBRARY_DASHBOARD.md b/docs/LIBRARY_DASHBOARD.md
index efbd0720..5d3acded 100644
--- a/docs/LIBRARY_DASHBOARD.md
+++ b/docs/LIBRARY_DASHBOARD.md
@@ -38,13 +38,14 @@ The private MLP environment owns:
- `collection`: current items and titles, lifetime checkout and renewal counters, recent-use rate, never-used items, item age, and price coverage;
- `patrons`: current, active, newly registered, currently borrowing, currently holding, and soon-expiring patron counts;
- `circulation_trend`: reporting-period transaction points;
-- `library_breakdown` and `item_type_breakdown`: comparable scoped aggregates;
+- `system_breakdown`, `library_breakdown`, and `item_type_breakdown`: complete comparable totals at consortium, system, branch, and item-type grain;
- `use_bands` and `age_bands`: current-collection distributions;
- `patron_*_breakdown`: privacy-suppressed home-library, profile, age-band, ZIP3, city/state, and state aggregates; exact addresses and full ZIP codes are never returned;
- `previous_*`, `*_change`, and `*_change_rate`: the immediately preceding equivalent period when retained-log coverage is complete;
- `filters.fiscal_periods_by_system`: current fiscal year-to-date and completed fiscal years using each MLP system's documented reporting calendar;
- `opportunities`: aggregate action groups with optional backend-generated Query configurations;
- `service_coverage`: optional source-connection metadata for visits, programs, technology, electronic resources, ILL, acquisitions/budget, and hold fulfillment; absent metrics remain visibly unavailable rather than being estimated;
+- `metric_definitions`: calculation, public-safe source fields, source, grain, and time basis for each KPI;
- `freshness`, `sources`, and `notes`: exact lineage, update times, and limitations.
The UI must treat absent groups as unavailable, not as zero. Every production response should describe the time basis of each source independently.
@@ -61,11 +62,13 @@ The circulation transaction baseline follows the established BLUEcloud contract:
Current holdings come from current item records. Item creation transactions must not be labeled as holdings. Lifetime item checkout/renewal counters must not be plotted as historical monthly transactions.
-An active patron is a current user whose last-activity date falls inside the selected activity window. The response must state the window. Age groups are derived only from usable birth dates, and unknown dates remain a visible category.
+`patrons.total` means **current patrons**, not every historical user row. A current patron has a privilege-expiration date on or after the snapshot date or an explicit `NEVER` expiration. Expired accounts and blank/unreadable expiration values are excluded. `patrons.records_total`, `patrons.expired`, and `patrons.expiration_unknown` reconcile the source population so exclusions remain visible and auditable. `patrons.never_expires` is a subset of current patrons. An active patron is a current patron whose last-activity date falls inside the selected activity window. New registrations, current loans, current holds, profile, and age measures also use current patrons. The response must state the window. Age groups are derived only from usable birth dates, and unknown dates remain a visible category.
+
+Patron geography is built from a separate privacy-safe address aggregate. Until that source carries privilege expiration, geography is explicitly labeled as an all-record source and must not be described as current-patron geography.
The circulation period and the collection/patron activity window are separate dimensions. Calendar-year and fiscal-year choices control checkouts, renewals, transaction trends, and demand rankings. Current collection measures remain point-in-time or lifetime measures, while recent collection use and patron activity use the explicit `scope.active_window_days` rolling window. The interface must display these time bases independently and must not claim that a named circulation period changes aggregates that the source snapshot cannot calculate for that exact period.
-Patron breakdowns must display their coverage against the scoped patron total. Returned groups can exclude missing, invalid, or privacy-suppressed values and the visual ranking can show only its highest-volume groups, so a chart must not imply that its visible bars represent every patron.
+Current-patron breakdowns must display their coverage against the scoped current-patron total. Geography coverage uses the all-record audit denominator. Returned groups can exclude missing, invalid, or privacy-suppressed values and the visual ranking can show only its highest-volume groups, so a chart must not imply that its visible bars represent every patron. Complete branch/system tables remain available below summary charts; patron values below the privacy threshold are labeled `Suppressed`, never rendered as zero.
## Live-data behavior
diff --git a/src/styles/app.css b/src/styles/app.css
index 0eb39cf3..02f8bab7 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -7,7 +7,7 @@
@import url("./table.css?v=8bc187669db70079");
@import url("./controls.css?v=30d36ccc65181c21");
@import url("./history.css?v=64598bfd565a29af");
-@import url("./dashboard.css?v=d79fa67fe87bb96e");
+@import url("./dashboard.css?v=7aed1832e6cabd24");
@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 892c589f..28073e26 100644
--- a/src/styles/dashboard.css
+++ b/src/styles/dashboard.css
@@ -441,6 +441,61 @@
line-height: 1.35;
}
+.kpi-card__method {
+ margin-top: 0.15rem;
+ color: var(--theme-text-muted);
+ font-size: 0.7rem;
+}
+
+.kpi-card__method summary,
+.kpi-breakdown-details summary {
+ width: fit-content;
+ color: var(--theme-accent);
+ cursor: pointer;
+ font-weight: 800;
+}
+
+.kpi-card__method div {
+ display: grid;
+ gap: 0.32rem;
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid var(--theme-border);
+}
+
+.kpi-card__method p {
+ margin: 0;
+ font-size: 0.69rem;
+ line-height: 1.4;
+}
+
+.kpi-patron-reconciliation {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.kpi-breakdown-details {
+ margin-top: 1rem;
+}
+
+.kpi-breakdown-details summary {
+ margin-bottom: 0.75rem;
+}
+
+.kpi-breakdown-table th,
+.kpi-breakdown-table td {
+ white-space: nowrap;
+ text-align: right;
+}
+
+.kpi-breakdown-table th:first-child,
+.kpi-breakdown-table th:nth-child(2),
+.kpi-breakdown-table td:first-child,
+.kpi-breakdown-table td:nth-child(2) {
+ text-align: left;
+}
+
.kpi-dashboard__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -900,6 +955,9 @@
}
@media (max-width: 760px) {
+ .kpi-patron-reconciliation {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
.kpi-dashboard-toolbar {
position: static;
grid-template-columns: 1fr 1fr;
diff --git a/src/ui/dashboard/libraryDashboardCoverage.js b/src/ui/dashboard/libraryDashboardCoverage.js
index 16e122ab..b4d6e718 100644
--- a/src/ui/dashboard/libraryDashboardCoverage.js
+++ b/src/ui/dashboard/libraryDashboardCoverage.js
@@ -1,6 +1,6 @@
const SERVICE_COVERAGE = [
{ id: 'circulation', label: 'Circulation', detail: 'Checkouts, renewals, trends, and demand rankings.' },
- { id: 'collection', label: 'Collection', detail: 'Current items, recorded use, age, holds, and price coverage.' },
+ { id: 'collection', label: 'Collection', detail: 'Distinct titles, current items, turnover, inventory, availability, location, use, age, holds, and price coverage.' },
{ id: 'patrons', label: 'Patrons', detail: 'Current, active, and newly registered patrons with privacy-safe demographics.' },
{ id: 'operations', label: 'Query operations', detail: 'Report reliability, turnaround, staff activity, and Hydration outcomes.' },
{ id: 'visits', label: 'Visits and door counts', detail: 'Requires a gate-counter or reporting-system source.' },
@@ -8,8 +8,10 @@ const SERVICE_COVERAGE = [
{ id: 'technology', label: 'Computers and Wi-Fi', detail: 'Requires session or network aggregates.' },
{ id: 'eresources', label: 'Electronic resources', detail: 'Requires COUNTER or vendor usage aggregates.' },
{ id: 'ill', label: 'Interlibrary loan', detail: 'Requires request, filled, and turnaround aggregates.' },
- { id: 'budget', label: 'Acquisitions and budget', detail: 'Requires approved financial or acquisitions aggregates.' },
- { id: 'fulfillment', label: 'Hold fulfillment', detail: 'Requires placed, filled, expired, and elapsed-time aggregates.' }
+ { id: 'budget', label: 'Acquisitions and budget', detail: 'Sirsi acquisitions commands are installed; a dedicated privacy-safe aggregate is not connected yet.' },
+ { id: 'fulfillment', label: 'Hold fulfillment', detail: 'Sirsi hold records are available; a dedicated lifecycle aggregate is not connected yet.' },
+ { id: 'transit', label: 'Transit workflow', detail: 'Sirsi current-transit records and transaction logs are available; historical throughput is not connected yet.' },
+ { id: 'billing', label: 'Billing', detail: 'Sirsi bill and payment selectors are installed; leadership-safe aggregates are not connected yet.' }
];
function activityWindowLabel(days = 365) {
diff --git a/src/ui/dashboard/libraryDashboardModel.js b/src/ui/dashboard/libraryDashboardModel.js
index 20fa7006..e702a7ab 100644
--- a/src/ui/dashboard/libraryDashboardModel.js
+++ b/src/ui/dashboard/libraryDashboardModel.js
@@ -38,10 +38,13 @@ function normalizeLibraryDashboard(payload = {}) {
collection: normalizeMetricGroup(data.collection),
patrons: normalizeMetricGroup(data.patrons),
circulationTrend: normalizeSeries(data.circulation_trend),
+ systemBreakdown: normalizeSeries(data.system_breakdown),
libraryBreakdown: normalizeSeries(data.library_breakdown),
itemTypeBreakdown: normalizeSeries(data.item_type_breakdown),
useBands: normalizeSeries(data.use_bands),
ageBands: normalizeSeries(data.age_bands),
+ homeLocationBreakdown: normalizeSeries(data.home_location_breakdown),
+ currentLocationBreakdown: normalizeSeries(data.current_location_breakdown),
patronLibraryBreakdown: normalizeSeries(data.patron_library_breakdown),
patronProfileBreakdown: normalizeSeries(data.patron_profile_breakdown),
patronAgeBands: normalizeSeries(data.patron_age_bands),
@@ -58,6 +61,7 @@ function normalizeLibraryDashboard(payload = {}) {
? data.filters.fiscal_periods_by_system : {}
},
sources: Array.isArray(data.sources) ? data.sources : [],
+ metricDefinitions: data.metric_definitions && typeof data.metric_definitions === 'object' ? data.metric_definitions : {},
notes: Array.isArray(data.notes) ? data.notes : [],
privacy: data.privacy || {},
serviceCoverage: Array.isArray(data.service_coverage) ? data.service_coverage : [],
diff --git a/src/ui/dashboard/libraryDashboardView.js b/src/ui/dashboard/libraryDashboardView.js
index 17d165de..c439bfde 100644
--- a/src/ui/dashboard/libraryDashboardView.js
+++ b/src/ui/dashboard/libraryDashboardView.js
@@ -76,14 +76,52 @@ function sourceFreshnessLines(data) {
return lines.join('');
}
-function metricCard(label, value, detail, tone = '') {
+function metricDefinition(definition) {
+ if (!definition || typeof definition !== 'object') return '';
+ const fields = Array.isArray(definition.source_fields) && definition.source_fields.length
+ ? `
Fields: ${escapeHtml(definition.source_fields.join(', '))}
` : '';
+ return `How calculated
+
${escapeHtml(definition.calculation || '')}
+
Source: ${escapeHtml(definition.source || 'Not specified')}
+ ${fields}
Grain: ${escapeHtml(definition.grain || 'Aggregate')}
+
Time basis: ${escapeHtml(definition.time_basis || 'Not specified')}
+
`;
+}
+
+function metricCard(label, value, detail, tone = '', definition = null) {
return `
${escapeHtml(label)}
${escapeHtml(value)}
${escapeHtml(detail)}
+ ${metricDefinition(definition)}
`;
}
+function breakdownTable(items, { patronColumns = true } = {}) {
+ if (!items.length) return 'No system or branch totals are available for this scope.
';
+ const headers = ['System', 'Branch', 'Titles', 'Items', 'Checkouts', 'Renewals', 'Turnover', 'Used recently', 'Never used', 'Open holds', 'Inventory coverage', 'Unavailable', 'Missing / lost', 'In transit'];
+ if (patronColumns) headers.push('Current patrons', 'Active patrons', 'Expired', 'Unknown expiry');
+ const patronValue = (item, key) => item.patron_suppressed ? 'Suppressed' : formatNumber(item[key]);
+ const row = item => {
+ const items = Number(item.items || 0);
+ const turnover = items ? (Number(item.checkouts || 0) + Number(item.renewals || 0)) / items : 0;
+ const inventoryCoverage = items ? Number(item.inventoried || 0) / items : 0;
+ const branchCount = Number(item.branches || 0);
+ return `| ${escapeHtml(item.system || item.label || 'Unassigned')} | ${escapeHtml(item.system ? item.label : `${formatNumber(branchCount)} ${branchCount === 1 ? 'branch' : 'branches'}`)} | ${Object.prototype.hasOwnProperty.call(item, 'titles') ? formatNumber(item.titles) : '—'} | ${formatNumber(item.items)} | ${formatNumber(item.checkouts)} | ${formatNumber(item.renewals)} | ${turnover.toFixed(2)} | ${formatNumber(item.used_recently)} | ${formatNumber(item.never_used)} | ${formatNumber(item.holds)} | ${formatPercent(inventoryCoverage)} | ${formatNumber(item.unavailable_items)} | ${formatNumber(item.missing_lost_items)} | ${formatNumber(item.in_transit_items)} | ${patronColumns ? `${patronValue(item, 'patrons')} | ${patronValue(item, 'active_patrons')} | ${patronValue(item, 'expired_patrons')} | ${patronValue(item, 'expiration_unknown')} | ` : ''}
`;
+ };
+ return `${headers.map(header => `| ${escapeHtml(header)} | `).join('')}
${items.map(row).join('')}
`;
+}
+
+function systemBranchBreakdown(data) {
+ const systems = data.systemBreakdown || [];
+ const branches = data.libraryBreakdown || [];
+ return `
+ System and branch totals
Collection totals use each item's owning branch, circulation uses the transaction branch, and patrons use their assigned branch. 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)}
+ `;
+}
+
function rankedBars(items, key, emptyText = 'No data is available for this breakdown.') {
if (!items.length) return `${escapeHtml(emptyText)}
`;
const ranked = [...items].sort((left, right) =>
@@ -175,6 +213,15 @@ function sourceNotes(data) {
return `Definitions, sources, and privacy
${data.sources.map(source => `
${escapeHtml(source.label || source.name || 'Source')}: ${escapeHtml(source.detail || '')}
`).join('')}${data.notes.map(note => `
${escapeHtml(note)}
`).join('')}${data.privacy?.suppression_threshold ? `
Patron groups smaller than ${formatNumber(data.privacy.suppression_threshold)} are suppressed.
` : ''}
`;
}
+function dimensionSemantics() {
+ return `What “library” means in each section
+
Collection: the owning branch stored on the current item record.
+
Circulation: the branch recorded as the transaction library when the checkout or renewal occurred.
+
Patrons: the patron account's assigned User Library. Item-type choices do not change patron counts.
+
Location: the home value is the item's normal shelving policy; the current value is its present workflow or shelving policy at snapshot time.
+
`;
+}
+
function renderOverview(data) {
const circ = data.circulation;
const collection = data.collection;
@@ -189,12 +236,12 @@ function renderOverview(data) {
: '';
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' : '')}
- ${metricCard('Renewals', hasCirculation ? formatNumber(circ.renewals) : '—', hasCirculation ? periodComparisonDetail(circ, 'renewals', data.scope?.comparison_mode) : 'Period transaction feed not available')}
- ${metricCard('Current items', hasCollection ? formatNumber(collection.items) : '—', hasCollection ? (collection.titles ? `${formatNumber(collection.titles)} titles represented` : 'Actual current item records') : 'Current item snapshot not available')}
- ${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' : '')}
- ${metricCard('Active patrons', hasPatrons ? formatNumber(patrons.active) : '—', hasPatrons ? `${activityWindow} · ${formatPercent(patrons.active_rate)} of current patrons${patronScopeNote}` : 'Patron aggregate not available')}
- ${metricCard('New patrons', hasPatrons ? formatNumber(patrons.new) : '—', hasPatrons ? `${patrons.new_period_label || 'Created in the selected period'}${patronScopeNote}` : 'Patron aggregate not available')}
+ ${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('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('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)}
Circulation trend
Transactions by period; checkout and renewal definitions match the Analytics circulation contract.
${stackedTrend(data.circulationTrend)}
@@ -202,8 +249,9 @@ function renderOverview(data) {
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')}
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)}
- ${serviceCoverageSection(data)}${sourceNotes(data)}`;
+ ${serviceCoverageSection(data)}${dimensionSemantics()}${sourceNotes(data)}`;
}
function renderCollection(data) {
@@ -215,20 +263,26 @@ function renderCollection(data) {
const activityWindow = activityWindowLabel(data.scope?.active_window_days);
return `${dashboardIntro(data, 'Collection performance', 'Actual current holdings, lifetime item use, recent use, demand, age, and collection-development opportunities—not item-creation transactions mislabeled as holdings.')}
- ${metricCard('Items', formatNumber(collection.items), titleDetail)}
+ ${metricCard('Titles', Object.prototype.hasOwnProperty.call(collection, 'titles') ? formatNumber(collection.titles) : '—', Object.prototype.hasOwnProperty.call(collection, 'titles') ? 'Exact distinct catalog records represented' : 'Exact total unavailable for a combined custom scope', '', data.metricDefinitions.titles)}
+ ${metricCard('Items', formatNumber(collection.items), titleDetail, '', data.metricDefinitions.items)}
+ ${metricCard('Period turnover', Number(circ.turnover || 0).toFixed(2), 'Checkouts + renewals per current item', 'success', data.metricDefinitions.turnover)}
${metricCard('Lifetime checkouts', formatNumber(collection.lifetime_checkouts), `${Number(collection.checkouts_per_item || 0).toFixed(1)} per current item`, 'success')}
- ${metricCard('Lifetime renewals', formatNumber(collection.lifetime_renewals), 'Stored on current item records')}
${metricCard('In-house uses', formatNumber(collection.in_house_uses), 'Recorded use without checkout')}
${metricCard('Never used', formatNumber(collection.never_used), `${formatPercent(collection.never_used_rate)} of current items`, collection.never_used_rate > 0.35 ? 'active' : '')}
- ${metricCard('Collection value', formatMoney(collection.total_value), `${formatPercent(collection.price_coverage)} of items have a usable price`)}
Use distribution
Lifetime checkout bands across current items.
${rankedBars(data.useBands, 'items')}
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 coverage
Current records with a usable recorded inventory date.
${metricCard('Inventoried', formatPercent(collection.inventory_coverage), `${formatNumber(collection.inventoried)} inventoried · ${formatNumber(collection.never_inventoried)} without a date`, '', data.metricDefinitions.inventory_coverage)}
+ Availability snapshot
Current-location workflow states at the last item refresh.
${metricCard('Unavailable', formatNumber(collection.unavailable_items), `${formatPercent(collection.unavailable_rate)} of items · ${formatNumber(collection.missing_lost_items)} missing/lost · ${formatNumber(collection.in_transit_items)} in transit`)}
+ Current locations
Where items are now, including circulation and workflow locations.
${rankedBars(data.currentLocationBreakdown, 'items')}
+ Home locations
Normal shelving or collection location stored on each item.
${rankedBars(data.homeLocationBreakdown, 'items')}
+ Collection value
Value is only as complete as item price data.
${metricCard('Recorded value', formatMoney(collection.total_value), `${formatPercent(collection.price_coverage)} of items have a usable price`)}
Collection-development queue
Open the underlying records to review, sort, or export them.
${opportunityTable(data.opportunities)}
- ${sourceNotes(data)}`;
+ ${systemBranchBreakdown(data)}
+ ${dimensionSemantics()}${sourceNotes(data)}`;
}
function renderPatrons(data) {
@@ -238,21 +292,30 @@ function renderPatrons(data) {
const activityWindow = activityWindowLabel(data.scope?.active_window_days);
return `${dashboardIntro(patronData, 'Patron reach and engagement', 'Understand who the libraries serve, where registered users are based, and how recently they have interacted—using aggregated, privacy-protected measures.')}
- ${metricCard('Current patrons', available ? formatNumber(patrons.total) : '—', available ? 'User records in the selected library scope' : 'Patron aggregate not available')}
- ${metricCard('Active patrons', available ? formatNumber(patrons.active) : '—', available ? `${activityWindow} · ${formatPercent(patrons.active_rate)} of current patrons` : 'Patron aggregate not available', available ? 'success' : '')}
- ${metricCard('New registrations', available ? formatNumber(patrons.new) : '—', available ? (patrons.new_period_label || 'Selected period') : 'Patron aggregate not available')}
- ${metricCard('Patrons with loans', available ? formatNumber(patrons.with_charges) : '—', available ? 'Currently have one or more charged items' : 'Patron aggregate not available')}
- ${metricCard('Patrons with holds', available ? formatNumber(patrons.with_holds) : '—', available ? 'Currently have one or more holds' : 'Patron aggregate not available')}
- ${metricCard('Expiring soon', available ? formatNumber(patrons.expiring_soon) : '—', available ? 'Privileges expire in the next 90 days' : 'Patron aggregate not available')}
+ ${metricCard('Current patrons', available ? formatNumber(patrons.total) : '—', available ? (patrons.eligibility_label || 'Unexpired or non-expiring accounts') : 'Patron aggregate not available', '', data.metricDefinitions.current_patrons)}
+ ${metricCard('Active patrons', available ? formatNumber(patrons.active) : '—', available ? `${activityWindow} · ${formatPercent(patrons.active_rate)} of current patrons` : 'Patron aggregate not available', available ? 'success' : '', data.metricDefinitions.active_patrons)}
+ ${metricCard('New registrations', available ? formatNumber(patrons.new) : '—', available ? (patrons.new_period_label || 'Selected period') : 'Patron aggregate not available', '', data.metricDefinitions.new_patrons)}
+ ${metricCard('Patrons with loans', available ? formatNumber(patrons.with_charges) : '—', available ? 'Current accounts with one or more charged items' : 'Patron aggregate not available', '', data.metricDefinitions.patrons_with_loans)}
+ ${metricCard('Patrons with holds', available ? formatNumber(patrons.with_holds) : '—', available ? 'Current accounts with one or more holds' : 'Patron aggregate not available', '', data.metricDefinitions.patrons_with_holds)}
+ ${metricCard('Expiring soon', available ? formatNumber(patrons.expiring_soon) : '—', available ? 'Current dated privileges expiring in the next 90 days' : 'Patron aggregate not available', '', data.metricDefinitions.expiring_soon)}
+ Account eligibility reconciliation
Every source patron row is assigned to current, expired, or unknown expiration. “NEVER” is included in current and also shown separately.
${formatPercent(patrons.eligibility_rate)} current
+
+ ${metricCard('All patron records', available ? formatNumber(patrons.records_total) : '—', 'Audit denominator before eligibility', '', data.metricDefinitions.patron_records)}
+ ${metricCard('Current', available ? formatNumber(patrons.total) : '—', `${formatNumber(patrons.never_expires)} marked NEVER`, 'success', data.metricDefinitions.current_patrons)}
+ ${metricCard('Expired', available ? formatNumber(patrons.expired) : '—', `${formatNumber(patrons.expired_with_charges)} still have charged items`, patrons.expired_with_charges > 0 ? 'active' : '')}
+ ${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')}
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; exact ZIP codes and addresses are never returned.
${escapeHtml(patronCoverageText(data.patronGeoBreakdown, patrons, data.privacy))}
${rankedBars(data.patronGeoBreakdown, 'patrons')}
- Cities served
Top city and state groups after privacy suppression.
${escapeHtml(patronCoverageText(data.patronCityBreakdown, patrons, data.privacy))}
${rankedBars(data.patronCityBreakdown, 'patrons')}
- States served
Registered patrons by state after privacy suppression.
${escapeHtml(patronCoverageText(data.patronStateBreakdown, patrons, data.privacy))}
${rankedBars(data.patronStateBreakdown, 'patrons')}
- ${sourceNotes(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)}
+ ${metricDefinition(data.metricDefinitions.patron_geography)}${sourceNotes(data)}`;
}
function renderLibraryDashboard(data, view = 'overview') {
diff --git a/tests/browser/browserSmoke.mjs b/tests/browser/browserSmoke.mjs
index 1a82b2f6..39fa6429 100644
--- a/tests/browser/browserSmoke.mjs
+++ b/tests/browser/browserSmoke.mjs
@@ -529,7 +529,7 @@ async function runSmokeTest() {
dashboardState.cardValues[0] !== '880,229'
|| dashboardState.cardValues[1] !== '487,605'
|| dashboardState.cardValues[2] !== '2,813,442'
- || dashboardState.chartCount !== 6
+ || dashboardState.chartCount !== 7
|| dashboardState.opportunityRows !== 3
|| dashboardState.librarySelection.length !== 0
|| dashboardState.librarySummary !== 'All library systems'
diff --git a/tests/unit/ui/libraryDashboardModelLogic.mjs b/tests/unit/ui/libraryDashboardModelLogic.mjs
index b6d9279c..40c0dfe3 100644
--- a/tests/unit/ui/libraryDashboardModelLogic.mjs
+++ b/tests/unit/ui/libraryDashboardModelLogic.mjs
@@ -10,7 +10,11 @@ test('library dashboard normalizes aggregate groups and filter metadata', () =>
patrons: { total: 32 },
source_status: { items: { status: 'reused', completed_at: '2026-08-25T07:49:57Z' } },
service_coverage: [{ id: 'visits', connected: true }],
+ metric_definitions: { current_patrons: { calculation: 'Unexpired accounts.' } },
+ system_breakdown: [{ label: 'MAIN', items: '120', patrons: '32' }],
library_breakdown: [{ label: 'Main', checkouts: '30' }],
+ home_location_breakdown: [{ label: 'STACKS', items: '80' }],
+ current_location_breakdown: [{ label: 'CHECKEDOUT', items: '25' }],
filters: {
systems: [{ value: 'system:MAIN', label: 'Main System' }],
libraries: [{ value: 'MAIN', label: 'Main' }],
@@ -23,6 +27,10 @@ test('library dashboard normalizes aggregate groups and filter metadata', () =>
assert.equal(dashboard.circulation.period_label, 'Recent 90 days');
assert.equal(dashboard.circulation.coverage_complete, true);
assert.equal(dashboard.libraryBreakdown[0].checkouts, 30);
+ assert.equal(dashboard.systemBreakdown[0].items, 120);
+ assert.equal(dashboard.homeLocationBreakdown[0].items, 80);
+ assert.equal(dashboard.currentLocationBreakdown[0].items, 25);
+ assert.equal(dashboard.metricDefinitions.current_patrons.calculation, 'Unexpired accounts.');
assert.equal(dashboard.filters.libraries[0].value, 'MAIN');
assert.equal(dashboard.filters.systems[0].value, 'system:MAIN');
assert.equal(dashboard.filters.calendarPeriods[0].value, 'cy:2026');
diff --git a/tests/unit/ui/libraryDashboardViewLogic.mjs b/tests/unit/ui/libraryDashboardViewLogic.mjs
index 27347d16..07d54c8b 100644
--- a/tests/unit/ui/libraryDashboardViewLogic.mjs
+++ b/tests/unit/ui/libraryDashboardViewLogic.mjs
@@ -138,6 +138,54 @@ test('patron breakdowns state coverage and dashboard source gaps remain visible'
const overviewHtml = renderLibraryDashboard(dashboard, 'overview');
assert.match(overviewHtml, /Library service coverage/);
- assert.match(overviewHtml, /4 connected · 7 need a source/);
+ assert.match(overviewHtml, /4 connected · 9 need a source/);
assert.match(overviewHtml, /Electronic resources/);
});
+
+test('collection view explains and renders inventory, location, availability, and turnover metrics', () => {
+ const dashboard = normalizeLibraryDashboard({
+ circulation: { checkouts: 100, renewals: 25, turnover: 1.25 },
+ collection: {
+ titles: 70, items: 100, inventoried: 80, never_inventoried: 20,
+ inventory_coverage: 0.8, unavailable_items: 12, unavailable_rate: 0.12,
+ missing_lost_items: 3, in_transit_items: 4
+ },
+ home_location_breakdown: [{ label: 'STACKS', items: 90 }],
+ current_location_breakdown: [{ label: 'CHECKEDOUT', items: 12 }]
+ });
+ const html = renderLibraryDashboard(dashboard, 'collection');
+ assert.match(html, /Exact distinct catalog records represented/);
+ assert.match(html, /Period turnover/);
+ assert.match(html, /Inventory coverage/);
+ assert.match(html, /Current locations/);
+ assert.match(html, /CHECKEDOUT/);
+ assert.match(html, /Home locations/);
+ assert.match(html, /STACKS/);
+ assert.match(html, /3 missing\/lost · 4 in transit/);
+ assert.match(html, /What “library” means in each section/);
+});
+
+test('patron view explains eligibility, reconciles source records, and protects small branch totals', () => {
+ const dashboard = normalizeLibraryDashboard({
+ patrons: {
+ total: 80, records_total: 100, expired: 15, expiration_unknown: 5,
+ never_expires: 10, expired_with_charges: 2, eligibility_rate: 0.8,
+ active: 20, active_rate: 0.25, new: 4,
+ eligibility_label: 'Expiration is today or later, or privileges never expire'
+ },
+ metric_definitions: {
+ current_patrons: { calculation: 'Unexpired or NEVER.', source: 'Patron snapshot', grain: 'Patron account', time_basis: 'Snapshot' }
+ },
+ system_breakdown: [{ label: 'MLP', branches: 1, patron_suppressed: true, items: 5 }],
+ library_breakdown: [{ label: 'TINY', system: 'MLP', patron_suppressed: true, items: 5 }]
+ });
+ const html = renderLibraryDashboard(dashboard, 'patrons');
+ assert.match(html, /Account eligibility reconciliation/);
+ assert.match(html, /All patron records/);
+ assert.match(html, />100);
+ assert.match(html, /Unknown expiration/);
+ assert.match(html, /How calculated/);
+ assert.match(html, /System and branch totals/);
+ assert.match(html, /Suppressed/);
+ assert.match(html, /not presented as current-patron geography/);
+});