diff --git a/README.md b/README.md
index 9062154a..9b571a75 100644
--- a/README.md
+++ b/README.md
@@ -172,7 +172,7 @@ Hydration's browser interface is open source and intended for adoption by other
The comparer reports edition evidence from title/responsibility, ISBN, edition statement, publication year, and physical description before describing a record as hydration-ready. When multiple WorldCat records match, their selector remains available after comparison begins so staff can switch candidates without repeating the local-record search. MARC 521 and 526 counts are shown separately because a general audience note is not automatically Lexile data. The Symphony record and the selected OCLC or Library of Congress record can be downloaded from their summary cards as binary MARC (`.mrc`), MARCXML (`.xml`), readable MARC text (`.mrk`), or JSON. The workspace is read-only and does not execute catalog hydration.
-The table also supports sorting, expand/collapse layout, manual column resizing with live row/header alignment, a draggable scrollbar thumb, and post filters that only affect the loaded result set. Text post filters include candidate finders for numeric or spelled-out number markers, Roman numeral markers, and alphabetic series markers such as `A is for ...`. Post filters are intentionally client-side and are cleared between query runs.
+The table also supports sorting, expand/collapse layout, manual column resizing with live row/header alignment, a draggable scrollbar thumb, and post filters that only affect the loaded result set. A row context menu can load a read-only complete record view by exact item, call-number, or catalog identifier; this fetches every concrete field the signed-in user is authorized to see, including fields that were not selected as table columns. Text post filters include candidate finders for numeric or spelled-out number markers, Roman numeral markers, and alphabetic series markers such as `A is for ...`. Post filters are intentionally client-side and are cleared between query runs.
The virtual table is also exposed as a reusable ES module component in `src/components/`: hosts can either mount the DOM-backed table with its packaged virtual scroller or use the headless projection/render-plan API in their own framework. Column drag/drop, XLSX generation, the custom date picker, and the tooltip system have public component entrypoints too.
diff --git a/cache-bust.json b/cache-bust.json
index 98deed30..97047a66 100644
--- a/cache-bust.json
+++ b/cache-bust.json
@@ -1,5 +1,5 @@
{
- "version": "a84285368b00ee95",
+ "version": "234a55185339ec0e",
"generatedBy": "scripts/updateCacheBusting.mjs",
"assets": {
"assets/app-icon-16.png": "6645731d86da1071",
@@ -56,7 +56,7 @@
"src/core/icons.js": "51f534abf118b7e1",
"src/core/libraryScopes.js": "88a7459ae976c5d3",
"src/core/mobileScrollLock.js": "d197a1dc4a683409",
- "src/core/mockQueryBackend.js": "4ebaa2df67601d45",
+ "src/core/mockQueryBackend.js": "faf5097dfe4a2c9e",
"src/core/operatorSelectUtils.js": "4986a94dce50dcd9",
"src/core/queryErrorDetails.js": "5c26531918b7a2c8",
"src/core/queryExecution.js": "5fddde4c241c8699",
@@ -116,7 +116,7 @@
"src/features/history/view/queryHistoryRows.js": "de474b69a780b15a",
"src/features/history/view/queryHistoryTooltips.js": "e8823ba1d844643d",
"src/features/history/view/queryHistoryViewHelpers.js": "9424ea75f3961c2d",
- "src/features/table/contextMenu.js": "3e2ab9ce6f4778d7",
+ "src/features/table/contextMenu.js": "42beaabdbcc4b0e5",
"src/features/table/contextMenuPreview.js": "647d0446a7ab46b2",
"src/features/table/drag-drop/columnManager.js": "3cfe04dd5c964186",
"src/features/table/drag-drop/columnResizeController.js": "f9623a02aad91c76",
@@ -140,8 +140,10 @@
"src/features/table/post-filters/postFilterSnapshotMutations.js": "d8512577fdfb74e2",
"src/features/table/post-filters/postFilterStreamedEqualsSelector.js": "6bb29619142f11bf",
"src/features/table/post-filters/postFilterValueFormatting.js": "25adc8ebe8649689",
- "src/features/table/recordDetailsDialog.js": "b74ceaf6ad092e09",
- "src/features/table/recordDetailsModel.js": "454d562f1513a67e",
+ "src/features/table/recordDetailsAction.js": "ed45a3c7c9e0f25d",
+ "src/features/table/recordDetailsApi.js": "42f4b6c0cbb42c8f",
+ "src/features/table/recordDetailsDialog.js": "89772c9c9c66f1ad",
+ "src/features/table/recordDetailsModel.js": "11147dd84d739a5a",
"src/features/table/tableQueryState.js": "8e4a58757d3b753d",
"src/features/table/tableServices.js": "c653c3bc07f80f6d",
"src/features/table/tableToast.js": "2b8bc648f99e7a29",
@@ -192,18 +194,18 @@
"src/lib/workbook-export/workbookXmlUtils.js": "2cc0fcdda2a1d411",
"src/lib/workbook-export/xlsxZipWriter.js": "5f4c1f6e98fbcb06",
"src/styles/api-settings.css": "c5f76b60fb8684bb",
- "src/styles/app.css": "9db55e7080cccda4",
+ "src/styles/app.css": "e6b1b3adffb9e823",
"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",
"src/styles/history.css": "64598bfd565a29af",
"src/styles/interaction.css": "57124a9332ac9605",
"src/styles/site-update.css": "b16b51ef4c7bd942",
- "src/styles/table.css": "f5753ada512e978a",
+ "src/styles/table.css": "9a49bacfc430ce95",
"src/styles/templates.css": "1c0a1594bedc65b9",
"src/styles/theme.css": "1a78e7eb470d0cf6",
"src/styles/tokens.css": "d20a2992e5150f85",
@@ -244,8 +246,8 @@
"src/ui/dashboard/kpiDashboardView.js": "82508c550975ada6",
"src/ui/dashboard/libraryDashboardCoverage.js": "d2da9d84bf50899f",
"src/ui/dashboard/libraryDashboardExport.js": "ae9057c966126624",
- "src/ui/dashboard/libraryDashboardModel.js": "34c866b3a028dbe2",
- "src/ui/dashboard/libraryDashboardView.js": "c6debdb8b5e1ae1f",
+ "src/ui/dashboard/libraryDashboardModel.js": "1c036eaacc95d14e",
+ "src/ui/dashboard/libraryDashboardView.js": "63676db31c2a28ad",
"src/ui/dashboard/reportingPeriodPicker.js": "334aea95b73dee72",
"src/ui/field-picker/buildableFieldPreview.js": "8abd486bb0a87fe2",
"src/ui/field-picker/fieldPicker.js": "526a1bf934f5dd74",
diff --git a/docs/PROJECT_HISTORY.md b/docs/PROJECT_HISTORY.md
index dddb3b8b..8112dca3 100644
--- a/docs/PROJECT_HISTORY.md
+++ b/docs/PROJECT_HISTORY.md
@@ -56,6 +56,7 @@ This is a non-redundant summary of the work represented by the repository commit
## Results Table
+- Added an on-demand complete record-details view for result rows. It resolves an exact item, call-number, or catalog identifier and retrieves every concrete field authorized for that record scope, including blank and non-displayed fields, without bloating ordinary query results.
- Added virtualized result rendering so large result sets render only the visible rows plus an overscan buffer.
- Added native and custom table scrolling behavior, including a draggable scrollbar thumb.
- Fixed scroll boundaries so the table cannot scroll above headers or below the result range.
diff --git a/docs/schemas/query-api.openapi.json b/docs/schemas/query-api.openapi.json
index fca810ec..684ec11c 100644
--- a/docs/schemas/query-api.openapi.json
+++ b/docs/schemas/query-api.openapi.json
@@ -24,6 +24,7 @@
"schema": {
"oneOf": [
{ "$ref": "#/components/schemas/GetFieldsRequest" },
+ { "$ref": "#/components/schemas/RecordDetailsRequest" },
{ "$ref": "#/components/schemas/RunRequest" },
{ "$ref": "#/components/schemas/StatusRequest" },
{ "$ref": "#/components/schemas/CancelRequest" },
@@ -65,8 +66,9 @@
"application/json": {
"schema": {
"oneOf": [
- { "$ref": "#/components/schemas/GetFieldsResponse" },
- { "$ref": "#/components/schemas/StatusResponse" },
+ { "$ref": "#/components/schemas/GetFieldsResponse" },
+ { "$ref": "#/components/schemas/RecordDetailsResponse" },
+ { "$ref": "#/components/schemas/StatusResponse" },
{ "$ref": "#/components/schemas/TemplatesResponse" },
{ "$ref": "#/components/schemas/GenericJsonResponse" }
]
@@ -253,6 +255,27 @@
},
"additionalProperties": true
},
+ "RecordDetailsRequest": {
+ "type": "object",
+ "required": ["action", "lookup_type", "lookup_value"],
+ "properties": {
+ "action": { "const": "record_details" },
+ "lookup_type": { "type": "string", "enum": ["item_id", "item_key", "call_number_key", "catalog_key"] },
+ "lookup_value": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "RecordDetailsResponse": {
+ "type": "object",
+ "required": ["kind", "lookup", "fields", "source_row_count"],
+ "properties": {
+ "kind": { "type": "object", "additionalProperties": true },
+ "lookup": { "type": "object", "additionalProperties": true },
+ "fields": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
+ "source_row_count": { "type": "integer", "minimum": 1 }
+ },
+ "additionalProperties": true
+ },
"RunRequest": {
"type": "object",
"required": ["action", "result_format", "display_fields", "filters"],
diff --git a/docs/schemas/query-api.schema.json b/docs/schemas/query-api.schema.json
index 73bb56d5..a8741706 100644
--- a/docs/schemas/query-api.schema.json
+++ b/docs/schemas/query-api.schema.json
@@ -310,6 +310,27 @@
}
]
},
+ "recordDetailsRequest": {
+ "type": "object",
+ "required": ["action", "lookup_type", "lookup_value"],
+ "properties": {
+ "action": { "const": "record_details" },
+ "lookup_type": { "type": "string", "enum": ["item_id", "item_key", "call_number_key", "catalog_key"] },
+ "lookup_value": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": false
+ },
+ "recordDetailsResponse": {
+ "type": "object",
+ "required": ["kind", "lookup", "fields", "source_row_count"],
+ "properties": {
+ "kind": { "type": "object", "required": ["key", "label"], "properties": { "key": { "type": "string" }, "label": { "type": "string" } }, "additionalProperties": true },
+ "lookup": { "type": "object", "required": ["type", "field", "value"], "properties": { "type": { "type": "string" }, "field": { "type": "string" }, "value": { "type": "string" } }, "additionalProperties": true },
+ "fields": { "type": "array", "items": { "type": "object", "required": ["name", "values"], "properties": { "name": { "type": "string" }, "category": { "type": "string" }, "description": { "type": "string" }, "values": { "type": "array", "items": { "$ref": "#/$defs/scalar" } } }, "additionalProperties": true } },
+ "source_row_count": { "type": "integer", "minimum": 1 }
+ },
+ "additionalProperties": true
+ },
"runRequest": {
"type": "object",
"required": ["action", "display_fields", "filters"],
@@ -659,6 +680,8 @@
"anyOf": [
{ "$ref": "#/$defs/getFieldsRequest" },
{ "$ref": "#/$defs/getFieldsResponse" },
+ { "$ref": "#/$defs/recordDetailsRequest" },
+ { "$ref": "#/$defs/recordDetailsResponse" },
{ "$ref": "#/$defs/runRequest" },
{ "$ref": "#/$defs/queryResult" },
{ "$ref": "#/$defs/jsonlEvent" },
diff --git a/scripts/lib/queryCli.mjs b/scripts/lib/queryCli.mjs
index 3f5ffc5c..3ced1136 100644
--- a/scripts/lib/queryCli.mjs
+++ b/scripts/lib/queryCli.mjs
@@ -898,7 +898,22 @@ async function buildApiPayload(options = {}) {
async function runApiCommand(options = {}) {
const apiUrl = getApiUrl({}, options);
const payload = await buildApiPayload(options);
- const headers = await getCliAuthorizationHeaders(apiUrl, options);
+ const authApiUrl = String(options['auth-api-url'] || options.authApiUrl || apiUrl).trim();
+ let requestOrigin;
+ let authOrigin;
+ try {
+ requestOrigin = new URL(apiUrl).origin;
+ authOrigin = new URL(authApiUrl).origin;
+ } catch (_error) {
+ throw new Error('API and authentication URLs must be valid HTTPS URLs.');
+ }
+ if (!apiUrl.startsWith('https://') || !authApiUrl.startsWith('https://')) {
+ throw new Error('API and authentication URLs must use HTTPS.');
+ }
+ if (requestOrigin !== authOrigin) {
+ throw new Error('Refusing to send a Query CLI session to a different origin.');
+ }
+ const headers = await getCliAuthorizationHeaders(authApiUrl, options);
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
@@ -925,7 +940,7 @@ async function runApiCommand(options = {}) {
process.stdout.write(output);
if (output.length && output[output.length - 1] !== 10) process.stdout.write('\n');
}
- return { action: payload.action, apiUrl, bytes: output.length, contentType, outputPath };
+ return { action: payload.action, apiUrl, authApiUrl, bytes: output.length, contentType, outputPath };
}
async function runDashboardCommand(options = {}) {
diff --git a/src/core/mockQueryBackend.js b/src/core/mockQueryBackend.js
index 4ef60b42..6c4ea4dc 100644
--- a/src/core/mockQueryBackend.js
+++ b/src/core/mockQueryBackend.js
@@ -333,6 +333,32 @@ function runQuery(payload, data) {
});
}
+function loadDemoRecordDetails(payload, data) {
+ const fields = data.fields || [];
+ const lookupDefinition = fields.find(field => field.recordLookupType === payload.lookup_type);
+ if (!lookupDefinition) return null;
+ const lookupIndex = fields.indexOf(lookupDefinition);
+ if (lookupIndex < 0) return null;
+ const row = (data.rows || []).find(values => String(values[lookupIndex] ?? '') === String(payload.lookup_value ?? ''));
+ if (!row) return null;
+ const kind = payload.lookup_type.startsWith('item_')
+ ? { key: 'item', label: 'Item record' }
+ : payload.lookup_type === 'call_number_key'
+ ? { key: 'call_number', label: 'Call number record' }
+ : { key: 'bibliographic', label: 'Bibliographic record' };
+ return {
+ kind,
+ lookup: { type: payload.lookup_type, field: lookupDefinition.name, value: payload.lookup_value },
+ fields: fields.map((field, index) => ({
+ name: field.name,
+ category: field.category || 'Other',
+ description: field.desc || '',
+ values: Array.isArray(row[index]) ? row[index] : [String(row[index] ?? '')]
+ })),
+ source_row_count: 1
+ };
+}
+
function searchDemoBibs(payload, data) {
const query = String(payload.query || '').trim().toLocaleLowerCase();
const lookupType = payload.lookup_type || 'title';
@@ -617,6 +643,12 @@ async function handleDemoQueryRequest(options = {}) {
case 'change_password': return json({ error: 'The shared demo password cannot be changed.' }, 403);
case 'get_fields': return json({ fields: data.fields || [] });
case 'run': return runQuery(payload, data);
+ case 'record_details': {
+ const details = loadDemoRecordDetails(payload, data);
+ return details
+ ? json(details)
+ : json({ error: 'The selected record was not found.' }, 404);
+ }
case 'query_plan': return json({
ok: true,
data: {
diff --git a/src/features/table/contextMenu.js b/src/features/table/contextMenu.js
index 3ce37f75..fd2d2cb7 100644
--- a/src/features/table/contextMenu.js
+++ b/src/features/table/contextMenu.js
@@ -15,8 +15,8 @@ import { createTableContextPreview } from './contextMenuPreview.js';
import { openCollapsedRowsViewer } from './virtual-table/collapsedRowsViewer.js';
import { OclcBibCompare } from '../../ui/bib-compare/oclcBibCompare.js';
import { resolveBibCompareLookup } from '../../ui/bib-compare/bibCompareLaunch.js';
-import { buildRecordDetailsModel } from './recordDetailsModel.js';
-import { openRecordDetails } from './recordDetailsDialog.js';
+import { buildRecordDetailsModel, resolveRecordDetailsLookup } from './recordDetailsModel.js';
+import { createRecordDetailsAction } from './recordDetailsAction.js';
(() => {
let menuEl = null;
@@ -166,8 +166,6 @@ import { openRecordDetails } from './recordDetailsDialog.js';
`;
- const RECORD_DETAILS_ICON = ` `;
-
const {
previewCell,
previewColumn,
@@ -468,7 +466,10 @@ import { openRecordDetails } from './recordDetailsDialog.js';
? resolveBibCompareLookup(fields, getRowValues(rowIndex))
: null;
const virtualTableData = getVT();
- const recordDetails = hasRow ? buildRecordDetailsModel({ headers: virtualTableData?.headers || [], row: virtualTableData?.rows?.[rowIndex] || [], displayedFields: fields }) : null;
+ const recordHeaders = virtualTableData?.headers || [];
+ const recordRow = virtualTableData?.rows?.[rowIndex] || [];
+ const recordDetails = hasRow ? buildRecordDetailsModel({ headers: recordHeaders, row: recordRow, displayedFields: fields }) : null;
+ const recordLookup = hasRow ? resolveRecordDetailsLookup(recordHeaders, recordRow) : null;
const collapsedRowGroup = hasRow ? getCollapsedRowGroup(rowIndex) : null;
const hasCollapsedRowGroup = Boolean(collapsedRowGroup);
const isHeaderTarget = Boolean(headerCell && !bodyCell);
@@ -541,22 +542,14 @@ import { openRecordDetails } from './recordDetailsDialog.js';
});
}
}] : []),
- ...(recordDetails?.fields?.length ? [{
- icon: RECORD_DETAILS_ICON,
- label: 'View Record Details',
- hint: recordDetails.kind.label,
- preview() {
- return hasRow ? previewRow(tr) : null;
- },
- run() {
- openRecordDetails({
- record: recordDetails,
- trigger: bodyCell,
- bibLookup: bibCompareLookup,
- onOpenBib: lookup => OclcBibCompare.openForLookup(lookup)
- });
- }
- }] : []),
+ ...[createRecordDetailsAction({
+ bodyCell,
+ fields,
+ recordDetails,
+ recordLookup,
+ bibCompareLookup,
+ preview: () => hasRow ? previewRow(tr) : null
+ })].filter(Boolean),
{
icon: CELL_ICON,
label: 'Copy Cell',
diff --git a/src/features/table/recordDetailsAction.js b/src/features/table/recordDetailsAction.js
new file mode 100644
index 00000000..7b04409c
--- /dev/null
+++ b/src/features/table/recordDetailsAction.js
@@ -0,0 +1,37 @@
+import { getClientErrorMessage } from '../../core/clientErrorMessages.js';
+import { OclcBibCompare } from '../../ui/bib-compare/oclcBibCompare.js';
+import { showToastMessage } from './tableToast.js';
+import { fetchCompleteRecordDetails } from './recordDetailsApi.js';
+import { closeActiveRecordDetails, openRecordDetails, openRecordDetailsLoading } from './recordDetailsDialog.js';
+import { buildRecordDetailsModelFromResponse } from './recordDetailsModel.js';
+
+function createRecordDetailsAction({ bodyCell, fields, recordDetails, recordLookup, bibCompareLookup, preview }) {
+ if (!recordDetails?.fields?.length) return null;
+ return {
+ icon: ' ',
+ label: 'View Record Details',
+ hint: recordDetails.kind.label,
+ preview,
+ async run() {
+ if (!recordLookup) {
+ showToastMessage('This row does not include an item, call number, or catalog identifier needed to load complete details.', 'warning');
+ return;
+ }
+ openRecordDetailsLoading({ trigger: bodyCell });
+ try {
+ const payload = await fetchCompleteRecordDetails(recordLookup);
+ openRecordDetails({
+ record: buildRecordDetailsModelFromResponse(payload, fields),
+ trigger: bodyCell,
+ bibLookup: bibCompareLookup,
+ onOpenBib: lookup => OclcBibCompare.openForLookup(lookup)
+ });
+ } catch (error) {
+ closeActiveRecordDetails();
+ showToastMessage(getClientErrorMessage(error, { fallback: 'The complete record details could not be loaded. Try again.' }), 'error');
+ }
+ }
+ };
+}
+
+export { createRecordDetailsAction };
diff --git a/src/features/table/recordDetailsApi.js b/src/features/table/recordDetailsApi.js
new file mode 100644
index 00000000..5382765c
--- /dev/null
+++ b/src/features/table/recordDetailsApi.js
@@ -0,0 +1,98 @@
+import { BackendApi } from '../../core/backendApi.js';
+import { fieldDefsArray } from '../../core/fieldDefs.js';
+import { assertQueryRunStreamResponse } from '../../core/queryRunResponse.js';
+import { readStreamedQueryResult } from '../../core/queryStream.js';
+
+function getCompleteRecordFieldDefinitions(definitions = fieldDefsArray) {
+ return (Array.isArray(definitions) ? definitions : [])
+ .filter(definition => definition?.name && !definition.builder)
+ .filter(definition => definition.recordDetailsAvailable !== false && definition.recordDetailsAvailable !== 0);
+}
+
+function getLookupDefinition(lookup, definitions = fieldDefsArray) {
+ return (Array.isArray(definitions) ? definitions : [])
+ .find(definition => definition?.recordLookupType === lookup?.lookupType);
+}
+
+function buildRecordDetailsQueryPayload(lookup, definitions = fieldDefsArray) {
+ const lookupDefinition = getLookupDefinition(lookup, definitions);
+ if (!lookupDefinition?.name || !lookup?.lookupValue) {
+ throw new Error('This row does not include a record identifier that can load complete details.');
+ }
+ const fields = getCompleteRecordFieldDefinitions(definitions);
+ if (!fields.length) throw new Error('The complete field list is not available yet. Try again after fields finish loading.');
+ return {
+ action: 'run',
+ name: 'Record details',
+ result_format: 'jsonl',
+ display_fields: fields.map(field => field.name),
+ filters: [{ field: lookupDefinition.name, operator: '=', value: String(lookup.lookupValue) }],
+ max_rows: 2
+ };
+}
+
+function buildRecordDetailsResponseFromQuery(streamed, lookup, definitions = fieldDefsArray) {
+ const columns = Array.isArray(streamed?.jsonPayload?.columns) ? streamed.jsonPayload.columns : [];
+ const rows = Array.isArray(streamed?.jsonPayload?.rows) ? streamed.jsonPayload.rows : [];
+ if (!columns.length || !rows.length) throw new Error('No matching record was found.');
+ const definitionsByName = new Map(getCompleteRecordFieldDefinitions(definitions)
+ .map(definition => [definition.name, definition]));
+ const lookupDefinition = getLookupDefinition(lookup, definitions) || {};
+ const firstRow = Array.isArray(rows[0]) ? rows[0] : [];
+ return {
+ kind: { key: lookup.lookupType.startsWith('item_') ? 'item' : 'bibliographic', label: lookup.lookupType.startsWith('item_') ? 'Item record' : 'Bibliographic record' },
+ lookup: { type: lookup.lookupType, field: lookupDefinition.name || '', value: String(lookup.lookupValue) },
+ fields: columns.map((name, index) => {
+ const definition = definitionsByName.get(name) || {};
+ const value = firstRow[index];
+ return {
+ name,
+ category: definition.category || 'Other',
+ description: definition.desc || '',
+ values: Array.isArray(value) ? value : [value ?? '']
+ };
+ }),
+ source_row_count: rows.length
+ };
+}
+
+async function fetchRecordDetailsThroughQuery(lookup, options = {}) {
+ const payload = buildRecordDetailsQueryPayload(lookup, options.definitions || fieldDefsArray);
+ const response = await BackendApi.request(payload, { timeoutMs: Number(options.timeoutMs) || 60000 });
+ await assertQueryRunStreamResponse(response, BackendApi);
+ const streamed = await readStreamedQueryResult(response);
+ if (streamed.partial || streamed.streamError) throw streamed.streamError || new Error('The complete record lookup ended early. Try again.');
+ return buildRecordDetailsResponseFromQuery(streamed, lookup, options.definitions || fieldDefsArray);
+}
+
+async function fetchCompleteRecordDetails(lookup, options = {}) {
+ if (!lookup?.lookupType || !lookup?.lookupValue) {
+ throw new Error('This row does not include a record identifier that can load complete details.');
+ }
+ let data;
+ try {
+ ({ data } = await BackendApi.postJson({
+ action: 'record_details',
+ lookup_type: lookup.lookupType,
+ lookup_value: lookup.lookupValue
+ }, {
+ timeoutMs: Number(options.timeoutMs) || 60000
+ }));
+ } catch (error) {
+ const unsupported = error?.status === 400
+ && /unsupported action|incomplete or invalid|invalid query request/iu.test(`${error?.message || ''} ${error?.payload?.error || ''}`);
+ if (!unsupported) throw error;
+ data = await fetchRecordDetailsThroughQuery(lookup, options);
+ }
+ if (!Array.isArray(data?.fields) || !data.fields.length) {
+ throw new Error('The backend did not return any record fields.');
+ }
+ return data;
+}
+
+export {
+ buildRecordDetailsQueryPayload,
+ buildRecordDetailsResponseFromQuery,
+ fetchCompleteRecordDetails,
+ getCompleteRecordFieldDefinitions
+};
diff --git a/src/features/table/recordDetailsDialog.js b/src/features/table/recordDetailsDialog.js
index a2e0c98a..eafb459e 100644
--- a/src/features/table/recordDetailsDialog.js
+++ b/src/features/table/recordDetailsDialog.js
@@ -37,8 +37,11 @@ function buildFieldRow(field) {
row.dataset.searchText = `${field.name} ${field.values.join(' ')}`.toLocaleLowerCase();
row.dataset.blank = field.isEmpty ? 'true' : 'false';
if (field.isIdentifier) row.dataset.identifier = 'true';
- const term = element('dt', 'record-details-field__name', field.name);
+ const term = element('dt', 'record-details-field__name');
+ term.appendChild(element('strong', '', field.name));
+ if (field.category) term.appendChild(element('small', 'record-details-field__category', field.category));
if (field.isDisplayed) term.appendChild(element('span', 'record-details-field__badge', 'In table'));
+ if (field.description) term.title = field.description;
const description = element('dd', 'record-details-field__value');
if (field.isEmpty) description.appendChild(element('em', 'record-details-field__blank', 'Blank'));
else field.values.forEach(value => description.appendChild(element('span', 'record-details-value', value)));
@@ -66,7 +69,7 @@ function openRecordDetails({ record, trigger = null, bibLookup = null, onOpenBib
const summary = element('section', 'record-details-summary');
summary.append(
buildIdentifierStrip(record),
- element('p', 'record-details-scope', `Showing all ${record.totalCount.toLocaleString()} fields returned for this row; ${record.nonEmptyCount.toLocaleString()} contain data.`)
+ element('p', 'record-details-scope', record.scopeText || `Showing ${record.totalCount.toLocaleString()} loaded fields; ${record.nonEmptyCount.toLocaleString()} contain data.`)
);
const toolbar = element('div', 'record-details-toolbar');
const searchLabel = element('label', 'record-details-search');
@@ -135,4 +138,24 @@ function openRecordDetails({ record, trigger = null, bibLookup = null, onOpenBib
return true;
}
-export { closeActiveRecordDetails, openRecordDetails };
+function openRecordDetailsLoading({ trigger = null } = {}) {
+ closeActiveRecordDetails();
+ const dialog = element('dialog', 'record-details-dialog record-details-dialog--loading');
+ dialog.setAttribute('aria-label', 'Loading complete record details');
+ const shell = element('div', 'record-details-shell record-details-loading');
+ const spinner = element('span', 'record-details-loading__spinner');
+ spinner.setAttribute('aria-hidden', 'true');
+ shell.append(spinner, element('strong', '', 'Loading complete record details…'), element('p', '', 'Retrieving every field available for this record.'));
+ dialog.appendChild(shell);
+ document.body.appendChild(dialog);
+ activeDialog = dialog;
+ dialog.addEventListener('close', () => {
+ if (activeDialog === dialog) activeDialog = null;
+ dialog.remove();
+ trigger?.focus?.({ preventScroll: true });
+ }, { once: true });
+ dialog.showModal();
+ return true;
+}
+
+export { closeActiveRecordDetails, openRecordDetails, openRecordDetailsLoading };
diff --git a/src/features/table/recordDetailsModel.js b/src/features/table/recordDetailsModel.js
index 2ba9b961..78ddb54a 100644
--- a/src/features/table/recordDetailsModel.js
+++ b/src/features/table/recordDetailsModel.js
@@ -6,6 +6,13 @@ const IDENTIFIER_FIELDS = new Map([
['isbn', 'ISBN'], ['issn', 'ISSN']
]);
+const RECORD_LOOKUP_FIELDS = Object.freeze([
+ { lookupType: 'item_key', aliases: ['itemkey'] },
+ { lookupType: 'item_id', aliases: ['itemid', 'itembarcode', 'barcode'] },
+ { lookupType: 'call_number_key', aliases: ['callnumberkey', 'callkey'] },
+ { lookupType: 'catalog_key', aliases: ['catalogkey', 'catalogid', 'bibkey'] }
+]);
+
function normalizeRecordFieldName(value) {
return String(value || '').toLocaleLowerCase().replace(/[^a-z0-9]+/gu, '');
}
@@ -41,6 +48,43 @@ function firstFieldValue(fields, aliases) {
return match?.values?.[0] || '';
}
+function resolveRecordDetailsLookup(headers = [], row = []) {
+ const values = new Map();
+ (Array.isArray(headers) ? headers : []).forEach((header, index) => {
+ const normalized = normalizeRecordFieldName(header);
+ const value = flattenRecordValue(Array.isArray(row) ? row[index] : '')
+ .map(candidate => candidate.trim())
+ .find(Boolean);
+ if (normalized && value && !values.has(normalized)) values.set(normalized, value);
+ });
+ for (const definition of RECORD_LOOKUP_FIELDS) {
+ const alias = definition.aliases.find(candidate => values.has(candidate));
+ if (alias) return { lookupType: definition.lookupType, lookupValue: values.get(alias) };
+ }
+ return null;
+}
+
+function buildRecordDetailsModelFromResponse(payload = {}, displayedFields = []) {
+ const responseFields = Array.isArray(payload.fields) ? payload.fields : [];
+ const headers = responseFields.map(field => field?.name || '');
+ const row = responseFields.map(field => Array.isArray(field?.values) ? field.values : [field?.value ?? '']);
+ const model = buildRecordDetailsModel({ headers, row, displayedFields });
+ const responseKind = payload.kind && typeof payload.kind === 'object' ? payload.kind : null;
+ return {
+ ...model,
+ fields: model.fields.map((field, index) => ({
+ ...field,
+ category: String(responseFields[index]?.category || 'Other'),
+ description: String(responseFields[index]?.description || '')
+ })),
+ kind: responseKind?.key && responseKind?.label
+ ? { key: String(responseKind.key), label: String(responseKind.label) }
+ : model.kind,
+ sourceRowCount: Math.max(1, Number(payload.source_row_count) || 1),
+ scopeText: `Loaded all ${model.totalCount.toLocaleString()} fields available for this ${String(responseKind?.label || model.kind.label).toLocaleLowerCase()}; ${model.nonEmptyCount.toLocaleString()} contain data.`
+ };
+}
+
function buildRecordDetailsModel({ headers = [], row = [], displayedFields = [] } = {}) {
const safeHeaders = Array.isArray(headers) ? headers : [];
const safeRow = Array.isArray(row) ? row : [];
@@ -77,4 +121,11 @@ function buildRecordDetailsModel({ headers = [], row = [], displayedFields = []
};
}
-export { buildRecordDetailsModel, flattenRecordValue, inferRecordKind, normalizeRecordFieldName };
+export {
+ buildRecordDetailsModel,
+ buildRecordDetailsModelFromResponse,
+ flattenRecordValue,
+ inferRecordKind,
+ normalizeRecordFieldName,
+ resolveRecordDetailsLookup
+};
diff --git a/src/styles/app.css b/src/styles/app.css
index 9a3e97fe..b5925c7e 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -4,10 +4,10 @@
@import url("./help.css?v=1e75cc7af857e66a");
@import url("./filters.css?v=9e7297e65dd68a3c");
@import url("./form-mode.css?v=7de8927db30d30f6");
-@import url("./table.css?v=f5753ada512e978a");
+@import url("./table.css?v=9a49bacfc430ce95");
@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/styles/table.css b/src/styles/table.css
index 92addbb8..39165207 100644
--- a/src/styles/table.css
+++ b/src/styles/table.css
@@ -39,6 +39,37 @@
min-height: 0;
}
+.record-details-dialog--loading {
+ width: min(28rem, calc(100vw - 2rem));
+}
+
+.record-details-loading {
+ display: grid;
+ justify-items: center;
+ gap: 0.65rem;
+ padding: 2rem;
+ text-align: center;
+}
+
+.record-details-loading p {
+ margin: 0;
+ color: var(--theme-text-muted, #64748b);
+ font-size: 0.82rem;
+}
+
+.record-details-loading__spinner {
+ width: 2rem;
+ height: 2rem;
+ border: 3px solid color-mix(in srgb, var(--theme-accent, #2563eb) 20%, transparent);
+ border-top-color: var(--theme-accent, #2563eb);
+ border-radius: 50%;
+ animation: record-details-spin 0.8s linear infinite;
+}
+
+@keyframes record-details-spin {
+ to { transform: rotate(360deg); }
+}
+
.record-details-header,
.record-details-footer {
display: flex;
@@ -210,6 +241,20 @@
overflow-wrap: anywhere;
}
+.record-details-field__name strong {
+ display: block;
+}
+
+.record-details-field__category {
+ display: inline-block;
+ margin-top: 0.2rem;
+ color: var(--theme-text-muted, #94a3b8);
+ font-size: 0.66rem;
+ font-weight: 650;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
.record-details-field__badge {
display: inline-block;
margin-left: 0.4rem;
diff --git a/src/ui/dashboard/libraryDashboardModel.js b/src/ui/dashboard/libraryDashboardModel.js
index 20fa7006..ffba208e 100644
--- a/src/ui/dashboard/libraryDashboardModel.js
+++ b/src/ui/dashboard/libraryDashboardModel.js
@@ -38,6 +38,7 @@ 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),
@@ -58,6 +59,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..27f5bbf3 100644
--- a/src/ui/dashboard/libraryDashboardView.js
+++ b/src/ui/dashboard/libraryDashboardView.js
@@ -76,14 +76,46 @@ 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', 'Items', 'Checkouts', 'Renewals'];
+ 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 => `${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')} ` : ''} `;
+ 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 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)}
+ `;
+}
+
function rankedBars(items, key, emptyText = 'No data is available for this breakdown.') {
if (!items.length) return `${escapeHtml(emptyText)}
`;
const ranked = [...items].sort((left, right) =>
@@ -189,12 +221,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,6 +234,7 @@ 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)}`;
}
@@ -215,7 +248,7 @@ 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('Items', formatNumber(collection.items), titleDetail, '', data.metricDefinitions.items)}
${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')}
@@ -228,6 +261,7 @@ function renderCollection(data) {
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`)}
Collection-development queue Open the underlying records to review, sort, or export them.
${opportunityTable(data.opportunities)}
+ ${systemBranchBreakdown(data)}
${sourceNotes(data)}`;
}
@@ -238,21 +272,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/browser/scenarios/browserSmokeScenarios.mjs b/tests/browser/scenarios/browserSmokeScenarios.mjs
index ea0656a6..69ec3b98 100644
--- a/tests/browser/scenarios/browserSmokeScenarios.mjs
+++ b/tests/browser/scenarios/browserSmokeScenarios.mjs
@@ -55,7 +55,7 @@ const ZIP_LOCAL_FILE_HEADER = 0x04034b50;
const ZIP_CENTRAL_FILE_HEADER = 0x02014b50;
const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50;
-function decodeEntities(value = '') {
+function parseXmlEntities(value = '') {
return String(value).replace(/&([^;]+);/gu, (match, entity) => XML_ENTITIES.get(entity) || match);
}
@@ -122,7 +122,7 @@ function extractZipEntryText(workbookEntries, path) {
function getWorkbookSheetId(workbookEntries, sheetName) {
const workbookXml = extractZipEntryText(workbookEntries, 'xl/workbook.xml');
for (const match of workbookXml.matchAll(/]*name="([^"]+)"[^>]*sheetId="(\d+)"/gu)) {
- if (decodeEntities(match[1]) === sheetName) {
+ if (parseXmlEntities(match[1]) === sheetName) {
return Number(match[2]);
}
}
@@ -131,7 +131,7 @@ function getWorkbookSheetId(workbookEntries, sheetName) {
function getTableColumns(tableXml) {
return [...tableXml.matchAll(/]*name="([^"]*)"/gu)]
- .map(match => decodeEntities(match[1]));
+ .map(match => parseXmlEntities(match[1]));
}
function parseSheetRows(sheetXml) {
@@ -140,7 +140,7 @@ function parseSheetRows(sheetXml) {
.map(rowMatch => [...rowMatch[1].matchAll(/]*)>([\s\S]*?)<\/c>/gu)].map(cellMatch => {
const cellBody = cellMatch[2];
const textMatch = cellBody.match(/]*)?>([\s\S]*?)<\/t>/u);
- if (textMatch) return decodeEntities(textMatch[1]);
+ if (textMatch) return parseXmlEntities(textMatch[1]);
const valueMatch = cellBody.match(/([\s\S]*?)<\/v>/u);
if (!valueMatch) return '';
const numericValue = Number(valueMatch[1]);
@@ -2941,6 +2941,52 @@ async function exerciseTableBuildableDisplayField(page) {
async function exerciseDesktopResultsWorkflow(page, queryApiStub) {
await exerciseProjectedDuplicateCollapse(page);
+ await page.evaluate(async () => {
+ const { appServices } = await import('./src/core/appServices.js');
+ const { QueryChangeManager } = await import('./src/core/queryState.js');
+ const { QueryTableView } = await import('./src/ui/queryTableView.js');
+ const headers = ['Item Id', 'Title'];
+ const rows = [['100001', 'Smoke Test Title']];
+ QueryChangeManager.replaceDisplayedFields(headers, { source: 'BrowserSmoke.recordDetailsContextMenu' });
+ QueryChangeManager.setLifecycleState(
+ { hasLoadedResultSet: true, queryRunning: false },
+ { source: 'BrowserSmoke.recordDetailsContextMenu', silent: true }
+ );
+ appServices.setVirtualTableData({
+ headers,
+ rows,
+ columnMap: new Map(headers.map((field, index) => [field, index]))
+ });
+ await QueryTableView.showExampleTable(headers, { syncQueryState: false });
+ appServices.renderVirtualTable();
+ });
+ await openDesktopTableContextMenu(
+ page,
+ '#example-table tbody tr[data-row-index="0"] td[data-col-index="1"]',
+ 'Desktop item row'
+ );
+ const detailsAction = page.locator('.tcm.tcm--visible .tcm-item', { hasText: 'View Record Details' });
+ await detailsAction.waitFor({ state: 'visible', timeout: 5000 });
+ await detailsAction.click();
+ await page.locator('.record-details-dialog[open]:not(.record-details-dialog--loading)').waitFor({ state: 'visible', timeout: 5000 });
+ const recordDetailsMetrics = await page.locator('.record-details-dialog[open]').evaluate(dialog => ({
+ fieldCount: dialog.querySelectorAll('[data-record-details-field]').length,
+ hasNonTableField: [...dialog.querySelectorAll('.record-details-field__name strong')]
+ .some(field => field.textContent?.trim() === 'Current Location'),
+ scope: dialog.querySelector('.record-details-scope')?.textContent?.trim() || '',
+ status: dialog.querySelector('.record-details-visible-status')?.textContent?.trim() || ''
+ }));
+ if (
+ recordDetailsMetrics.fieldCount !== 7
+ || !recordDetailsMetrics.hasNonTableField
+ || !recordDetailsMetrics.scope.includes('Loaded all 7 fields')
+ || recordDetailsMetrics.status !== '7 of 7 fields'
+ ) {
+ throw new Error(`Record details should load complete backend fields beyond the table columns: ${JSON.stringify(recordDetailsMetrics)}`);
+ }
+ await page.locator('.record-details-close').click();
+ await page.locator('.record-details-dialog').waitFor({ state: 'detached', timeout: 5000 });
+
const demoBibData = JSON.parse(await readFile(
new URL('../../../assets/demo/oclc-bib-data.json', import.meta.url),
'utf8'
diff --git a/tests/browser/support/browserSmokeSupport.mjs b/tests/browser/support/browserSmokeSupport.mjs
index 364a85f0..fb979970 100644
--- a/tests/browser/support/browserSmokeSupport.mjs
+++ b/tests/browser/support/browserSmokeSupport.mjs
@@ -347,6 +347,24 @@ function buildDefaultQueryApiResponse(payload) {
body: JSON.stringify(smokeTemplateResponse),
contentType: 'application/json; charset=utf-8'
};
+ case 'record_details':
+ return {
+ body: JSON.stringify({
+ kind: { key: 'item', label: 'Item record' },
+ lookup: { type: payload.lookup_type, field: 'Item Id', value: payload.lookup_value },
+ source_row_count: 1,
+ fields: [
+ { name: 'Item Id', category: 'Item', description: 'Unique item identifier.', values: [String(payload.lookup_value || '100001')] },
+ { name: 'Title', category: 'Catalog', description: 'Title statement.', values: ['Smoke Test Title'] },
+ { name: 'Author', category: 'Catalog', description: 'Primary creator.', values: ['Smoke Author'] },
+ { name: 'Library', category: 'Item', description: 'Owning branch.', values: ['MAIN'] },
+ { name: 'Status', category: 'Circulation', description: 'Current circulation status.', values: ['AVAILABLE'] },
+ { name: 'Current Location', category: 'Item', description: 'Current shelving location.', values: ['STACKS'] },
+ { name: 'Public Note', category: 'Notes', description: 'Public note.', values: ['Complete-record smoke value'] }
+ ]
+ }),
+ contentType: 'application/json; charset=utf-8'
+ };
case 'library_dashboard':
return {
body: JSON.stringify({
diff --git a/tests/unit/core/mockQueryBackendLogic.mjs b/tests/unit/core/mockQueryBackendLogic.mjs
index 61cfb80d..034f68de 100644
--- a/tests/unit/core/mockQueryBackendLogic.mjs
+++ b/tests/unit/core/mockQueryBackendLogic.mjs
@@ -56,6 +56,20 @@ test('demo backend exposes sample fields and filtered JSONL rows after sign-in',
assert.equal(events.at(-1).type, 'done');
});
+test('demo backend loads complete record details beyond displayed query columns', async () => {
+ const response = await handleDemoQueryRequest({
+ body: JSON.stringify({ action: 'record_details', lookup_type: 'item_id', lookup_value: '100001' }),
+ headers: authHeaders
+ });
+ const payload = await response.json();
+ assert.equal(response.status, 200);
+ assert.equal(payload.kind.key, 'item');
+ assert.equal(payload.lookup.value, '100001');
+ assert.equal(payload.fields.length, 9);
+ assert.equal(payload.fields.find(field => field.name === 'Current Location').values[0], 'DISPLAY');
+ assert.deepEqual(payload.fields.find(field => field.name === 'Public Note').values, ['Local history display', 'Ask at the service desk']);
+});
+
test('demo endpoint detection is limited to the explicit path', () => {
assert.equal(isDemoApiUrl('https://bt1142msstate.github.io/Query/demo-api'), true);
assert.equal(isDemoApiUrl('https://example.org/query-api'), false);
diff --git a/tests/unit/features/queryCliLogic.mjs b/tests/unit/features/queryCliLogic.mjs
index 919a19bf..3276dedb 100644
--- a/tests/unit/features/queryCliLogic.mjs
+++ b/tests/unit/features/queryCliLogic.mjs
@@ -305,6 +305,46 @@ test('generic API command reaches newer backend actions with JSON payloads and a
}
});
+test('generic API command can reuse the Query session for a same-origin protected app', async () => {
+ const originalFetch = globalThis.fetch;
+ let requestedUrl;
+ let storedSessionUrl;
+ let request;
+ globalThis.fetch = async (apiUrl, init = {}) => {
+ requestedUrl = apiUrl;
+ request = init;
+ return Response.json({ status: 'idle' });
+ };
+ try {
+ const result = await runApiCommand({
+ action: 'ssn_status',
+ 'api-url': 'https://mlp.sirsi.net/uhtbin/ssn_api.pl',
+ 'auth-api-url': 'https://mlp.sirsi.net/uhtbin/query_api.pl',
+ sessionStore: {
+ read: async apiUrl => {
+ storedSessionUrl = apiUrl;
+ return { token: 'test-session-token' };
+ }
+ }
+ });
+ assert.equal(requestedUrl, 'https://mlp.sirsi.net/uhtbin/ssn_api.pl');
+ assert.equal(storedSessionUrl, 'https://mlp.sirsi.net/uhtbin/query_api.pl');
+ assert.equal(request.headers['X-Query-Session'], 'test-session-token');
+ assert.equal(result.authApiUrl, 'https://mlp.sirsi.net/uhtbin/query_api.pl');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('generic API command refuses to reuse a session across origins', async () => {
+ await assert.rejects(() => runApiCommand({
+ action: 'ssn_status',
+ 'api-url': 'https://other.example/uhtbin/ssn_api.pl',
+ 'auth-api-url': 'https://mlp.sirsi.net/uhtbin/query_api.pl',
+ sessionStore: { read: async () => ({ token: 'must-not-be-read' }) }
+ }), /different origin/u);
+});
+
test('dashboard CLI requests the same scoped aggregate used by the interface', async () => {
const originalFetch = globalThis.fetch;
let payload;
diff --git a/tests/unit/features/recordDetailsApiLogic.mjs b/tests/unit/features/recordDetailsApiLogic.mjs
new file mode 100644
index 00000000..a69131f9
--- /dev/null
+++ b/tests/unit/features/recordDetailsApiLogic.mjs
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ buildRecordDetailsQueryPayload,
+ buildRecordDetailsResponseFromQuery,
+ getCompleteRecordFieldDefinitions
+} from '../../../src/features/table/recordDetailsApi.js';
+
+const definitions = [
+ { name: 'Title', category: 'Catalog', desc: 'Title statement' },
+ { name: 'Item Identifier', category: 'Item', desc: 'Barcode', recordLookupType: 'item_id' },
+ { name: 'Staff Note', category: 'Item', desc: 'Staff-only note' },
+ { name: 'MARC Field', category: 'MARC', builder: { inputs: [] } },
+ { name: 'Unavailable A', category: 'Item', recordDetailsAvailable: false },
+ { name: 'Unavailable B', category: 'Item', recordDetailsAvailable: 0 }
+];
+
+test('record detail fallback requests every concrete valid field for an exact item', () => {
+ assert.deepEqual(getCompleteRecordFieldDefinitions(definitions).map(field => field.name), [
+ 'Title', 'Item Identifier', 'Staff Note'
+ ]);
+ assert.deepEqual(buildRecordDetailsQueryPayload({
+ lookupType: 'item_id',
+ lookupValue: '33222109838913'
+ }, definitions), {
+ action: 'run',
+ name: 'Record details',
+ result_format: 'jsonl',
+ display_fields: ['Title', 'Item Identifier', 'Staff Note'],
+ filters: [{ field: 'Item Identifier', operator: '=', value: '33222109838913' }],
+ max_rows: 2
+ });
+});
+
+test('record detail fallback converts a streamed row into the complete dialog response', () => {
+ const response = buildRecordDetailsResponseFromQuery({
+ jsonPayload: {
+ columns: ['Title', 'Item Identifier', 'Staff Note'],
+ rows: [['A title', '33222109838913', ['First', 'Second']]]
+ }
+ }, { lookupType: 'item_id', lookupValue: '33222109838913' }, definitions);
+
+ assert.equal(response.kind.key, 'item');
+ assert.equal(response.source_row_count, 1);
+ assert.deepEqual(response.fields[2], {
+ name: 'Staff Note',
+ category: 'Item',
+ description: 'Staff-only note',
+ values: ['First', 'Second']
+ });
+});
diff --git a/tests/unit/features/recordDetailsModelLogic.mjs b/tests/unit/features/recordDetailsModelLogic.mjs
index 1d977bda..44fb3ba1 100644
--- a/tests/unit/features/recordDetailsModelLogic.mjs
+++ b/tests/unit/features/recordDetailsModelLogic.mjs
@@ -3,9 +3,11 @@ import test from 'node:test';
import {
buildRecordDetailsModel,
+ buildRecordDetailsModelFromResponse,
flattenRecordValue,
inferRecordKind,
- normalizeRecordFieldName
+ normalizeRecordFieldName,
+ resolveRecordDetailsLookup
} from '../../../src/features/table/recordDetailsModel.js';
test('record details classifies item rows and preserves all returned fields', () => {
@@ -41,3 +43,39 @@ test('record details normalizes aliases and safely flattens multi-value data', (
assert.deepEqual(flattenRecordValue(['One', ['Two', 3], null]), ['One', 'Two', '3', '']);
assert.deepEqual(flattenRecordValue({ status: 'available' }), ['{"status":"available"}']);
});
+
+test('record details selects the strongest exact identifier for an on-demand lookup', () => {
+ assert.deepEqual(
+ resolveRecordDetailsLookup(
+ ['Catalog Key', 'Item ID', 'Item Key'],
+ ['923278', '32276003001044', '448812']
+ ),
+ { lookupType: 'item_key', lookupValue: '448812' }
+ );
+ assert.deepEqual(
+ resolveRecordDetailsLookup(['Title', 'Catalog Key'], ['A title', '923278']),
+ { lookupType: 'catalog_key', lookupValue: '923278' }
+ );
+ assert.equal(resolveRecordDetailsLookup(['Title'], ['A title']), null);
+});
+
+test('record details builds a complete model from the backend field response', () => {
+ const model = buildRecordDetailsModelFromResponse({
+ kind: { key: 'item', label: 'Item record' },
+ source_row_count: 1,
+ fields: [
+ { name: 'Title', category: 'Catalog', description: 'Title statement', values: ['A title'] },
+ { name: 'Item Id', category: 'Item', description: 'Barcode', values: ['32276003001044'] },
+ { name: 'Staff Note', category: 'Item', description: 'Private note', values: [''] }
+ ]
+ }, ['Title']);
+
+ assert.equal(model.kind.key, 'item');
+ assert.equal(model.totalCount, 3);
+ assert.equal(model.nonEmptyCount, 2);
+ assert.equal(model.fields[0].category, 'Catalog');
+ assert.equal(model.fields[0].description, 'Title statement');
+ assert.equal(model.fields[0].isDisplayed, true);
+ assert.equal(model.fields[2].isEmpty, true);
+ assert.match(model.scopeText, /Loaded all 3 fields available/u);
+});
diff --git a/tests/unit/ui/libraryDashboardModelLogic.mjs b/tests/unit/ui/libraryDashboardModelLogic.mjs
index b6d9279c..10249986 100644
--- a/tests/unit/ui/libraryDashboardModelLogic.mjs
+++ b/tests/unit/ui/libraryDashboardModelLogic.mjs
@@ -10,6 +10,8 @@ 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' }],
filters: {
systems: [{ value: 'system:MAIN', label: 'Main System' }],
@@ -23,6 +25,8 @@ 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.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..703a427f 100644
--- a/tests/unit/ui/libraryDashboardViewLogic.mjs
+++ b/tests/unit/ui/libraryDashboardViewLogic.mjs
@@ -141,3 +141,28 @@ test('patron breakdowns state coverage and dashboard source gaps remain visible'
assert.match(overviewHtml, /4 connected · 7 need a source/);
assert.match(overviewHtml, /Electronic resources/);
});
+
+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/);
+});