From 6e155703e1ea7f0e9bc2dc1b7bdd433a0a7d385e Mon Sep 17 00:00:00 2001 From: Brandon Temple Date: Tue, 1 Sep 2026 23:00:38 -0500 Subject: [PATCH] Load complete record details and clarify dashboard sources --- README.md | 2 +- cache-bust.json | 22 +++-- docs/PROJECT_HISTORY.md | 1 + docs/schemas/query-api.openapi.json | 27 ++++- docs/schemas/query-api.schema.json | 23 +++++ scripts/lib/queryCli.mjs | 19 +++- src/core/mockQueryBackend.js | 32 ++++++ src/features/table/contextMenu.js | 35 +++---- src/features/table/recordDetailsAction.js | 37 +++++++ src/features/table/recordDetailsApi.js | 98 +++++++++++++++++++ src/features/table/recordDetailsDialog.js | 29 +++++- src/features/table/recordDetailsModel.js | 53 +++++++++- src/styles/app.css | 4 +- src/styles/dashboard.css | 58 +++++++++++ src/styles/table.css | 45 +++++++++ src/ui/dashboard/libraryDashboardModel.js | 2 + src/ui/dashboard/libraryDashboardView.js | 79 +++++++++++---- tests/browser/browserSmoke.mjs | 2 +- .../scenarios/browserSmokeScenarios.mjs | 54 +++++++++- tests/browser/support/browserSmokeSupport.mjs | 18 ++++ tests/unit/core/mockQueryBackendLogic.mjs | 14 +++ tests/unit/features/queryCliLogic.mjs | 40 ++++++++ tests/unit/features/recordDetailsApiLogic.mjs | 52 ++++++++++ .../unit/features/recordDetailsModelLogic.mjs | 40 +++++++- tests/unit/ui/libraryDashboardModelLogic.mjs | 4 + tests/unit/ui/libraryDashboardViewLogic.mjs | 25 +++++ 26 files changed, 749 insertions(+), 66 deletions(-) create mode 100644 src/features/table/recordDetailsAction.js create mode 100644 src/features/table/recordDetailsApi.js create mode 100644 tests/unit/features/recordDetailsApiLogic.mjs 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 => ``).join('')}${items.map(row).join('')}
${escapeHtml(header)}
`; +} + +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