diff --git a/cache-bust.json b/cache-bust.json index 55384c7..637b191 100644 --- a/cache-bust.json +++ b/cache-bust.json @@ -1,5 +1,5 @@ { - "version": "bfed9485df3ca864", + "version": "969cdb8dc567be69", "generatedBy": "scripts/updateCacheBusting.mjs", "assets": { "assets/app-icon-16.png": "6645731d86da1071", @@ -55,7 +55,7 @@ "src/core/hydrationEta.js": "250ec4ee2633be0f", "src/core/icons.js": "51f534abf118b7e1", "src/core/mobileScrollLock.js": "d197a1dc4a683409", - "src/core/mockQueryBackend.js": "0d87aca7e54aef56", + "src/core/mockQueryBackend.js": "3b45645564fad841", "src/core/operatorSelectUtils.js": "4986a94dce50dcd9", "src/core/queryErrorDetails.js": "5c26531918b7a2c8", "src/core/queryExecution.js": "5fddde4c241c8699", diff --git a/src/core/mockQueryBackend.js b/src/core/mockQueryBackend.js index c2ed6da..7c37a65 100644 --- a/src/core/mockQueryBackend.js +++ b/src/core/mockQueryBackend.js @@ -47,12 +47,42 @@ function buildDemoDashboardQueries(now = Date.now()) { ]); } +function demoFiscalPeriods(system, startMonth, source, now = new Date()) { + const year = now.getUTCFullYear(); + const month = now.getUTCMonth() + 1; + const currentFiscalYear = month >= startMonth ? year + 1 : year; + const compact = (dateYear, dateMonth, dateDay) => `${dateYear}${String(dateMonth).padStart(2, '0')}${String(dateDay).padStart(2, '0')}`; + const shiftYear = value => { + const shiftedYear = Number(value.slice(0, 4)) - 1; + const shiftedMonth = Number(value.slice(4, 6)); + const shiftedDay = Math.min(Number(value.slice(6, 8)), new Date(Date.UTC(shiftedYear, shiftedMonth, 0)).getUTCDate()); + return compact(shiftedYear, shiftedMonth, shiftedDay); + }; + return [0, 1, 2].map(offset => { + const fiscalYear = currentFiscalYear - offset; + const startYear = fiscalYear - 1; + const endMonth = startMonth - 1; + const endDay = new Date(Date.UTC(fiscalYear, endMonth, 0)).getUTCDate(); + const current = offset === 0; + const start = compact(startYear, startMonth, 1); + const end = current ? compact(year, month, now.getUTCDate()) : compact(fiscalYear, endMonth, endDay); + return { + value: `fy:${system}:${fiscalYear}`, + label: current ? `FY ${fiscalYear} to date` : `FY ${fiscalYear}`, + system, fiscal_year: fiscalYear, start, end, + previous_start: shiftYear(start), previous_end: shiftYear(end), + current_to_date: current, start_month: startMonth, source + }; + }); +} + function buildDemoLibraryDashboard(payload = {}, data = {}) { const library = payload.library || 'all'; const itemType = payload.item_type || 'all'; const scopeFactor = library === 'all' ? 1 : 0.08; const typeFactor = itemType === 'all' ? 1 : 0.22; const factor = scopeFactor * typeFactor; + const reportingPeriod = String(payload.reporting_period || payload.active_window_days || 365); const scaled = value => Math.round(value * factor); const scaleRows = (rows, keys) => rows.map(row => ({ ...row, @@ -71,6 +101,17 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { ['Books', 544810, 290442, 1864300], ['DVD / Blu-ray', 123440, 36110, 184220], ['Ebooks', 94220, 73310, 371800], ['Audiobooks', 72510, 48330, 168440], ['Juvenile kits', 28940, 10082, 69220], ['Other', 16420, 8331, 155462] ].map(([label, checkouts, renewals, items]) => ({ label, checkouts, renewals, items })); + const fiscalPeriodsBySystem = { + MSU: demoFiscalPeriods('MSU', 7, 'https://www.osp.msstate.edu/faq'), + MMRLS: demoFiscalPeriods('MMRLS', 10, 'https://www.imls.gov/research-evaluation/surveys/public-libraries-survey-pls'), + FRL: demoFiscalPeriods('FRL', 10, 'https://www.imls.gov/research-evaluation/surveys/public-libraries-survey-pls'), + LILS: demoFiscalPeriods('LILS', 10, 'https://www.imls.gov/research-evaluation/surveys/public-libraries-survey-pls') + }; + const fiscalPeriod = Object.values(fiscalPeriodsBySystem).flat().find(period => period.value === reportingPeriod); + const currentCheckouts = scaled(880229); + const currentRenewals = scaled(487605); + const previousCheckouts = scaled(842110); + const previousRenewals = scaled(469220); const filters = { libraries: [ { value: 'MSU', label: 'Mississippi State University' }, @@ -78,7 +119,8 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { { value: 'FRL', label: 'First Regional Library' }, { value: 'LILS', label: 'Lee-Itawamba Library System' } ], - item_types: ['BOOK', 'EBOOK', 'DVD', 'AUDIOBOOK', 'KIT'] + item_types: ['BOOK', 'EBOOK', 'DVD', 'AUDIOBOOK', 'KIT'], + fiscal_periods_by_system: fiscalPeriodsBySystem }; return { schema_version: 1, @@ -89,11 +131,19 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { library_label: library === 'all' ? 'All MLP libraries' : (filters.libraries.find(entry => entry.value === library)?.label || library), item_type: itemType, item_type_label: itemType === 'all' ? 'All item types' : itemType, - active_window_days: Number(payload.active_window_days || 365) + active_window_days: Number(payload.active_window_days || 365), + reporting_period: reportingPeriod }, circulation: { - checkouts: scaled(880229), renewals: scaled(487605), in_house_uses: scaled(61240), holds: scaled(121843), - renewal_share: 0.356, holds_per_100_items: 4.3, period_label: 'Illustrative 12-month reporting period' + checkouts: currentCheckouts, renewals: currentRenewals, previous_checkouts: previousCheckouts, previous_renewals: previousRenewals, + checkout_change: currentCheckouts - previousCheckouts, renewal_change: currentRenewals - previousRenewals, + checkout_change_rate: previousCheckouts ? (currentCheckouts - previousCheckouts) / previousCheckouts : null, + renewal_change_rate: previousRenewals ? (currentRenewals - previousRenewals) / previousRenewals : null, + comparison_available: true, comparison_coverage_complete: true, + comparison_period_label: fiscalPeriod ? 'Previous fiscal year equivalent' : 'Previous equivalent period', + in_house_uses: scaled(61240), holds: scaled(121843), renewal_share: 0.356, holds_per_100_items: 4.3, + period_label: fiscalPeriod?.label || 'Illustrative 12-month reporting period', + fiscal_year: fiscalPeriod?.fiscal_year, fiscal_system: fiscalPeriod?.system }, collection: { items: scaled(2813442), titles: scaled(1601291), lifetime_checkouts: scaled(12844308), lifetime_renewals: scaled(5160244), @@ -128,8 +178,16 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { { label: '25–44', patrons: 157880 }, { label: '45–64', patrons: 131440 }, { label: '65+', patrons: 72920 }, { label: 'Unknown', patrons: 91620 } ], ['patrons']), patron_geo_breakdown: scaleRows([ - { label: 'Tupelo area', patrons: 49220 }, { label: 'DeSoto County', patrons: 43880 }, { label: 'Rankin County', patrons: 39210 }, - { label: 'Lowndes County', patrons: 34190 }, { label: 'Oktibbeha County', patrons: 31220 }, { label: 'Other / unknown', patrons: 420700 } + { label: '388xx', patrons: 49220 }, { label: '386xx', patrons: 43880 }, { label: '390xx', patrons: 39210 }, + { label: '397xx', patrons: 34190 }, { label: '395xx', patrons: 31220 }, { label: 'Other / unknown', patrons: 420700 } + ], ['patrons']), + patron_city_breakdown: scaleRows([ + { label: 'Tupelo, MS', patrons: 42910 }, { label: 'Columbus, MS', patrons: 31180 }, { label: 'Starkville, MS', patrons: 29440 }, + { label: 'Southaven, MS', patrons: 26320 }, { label: 'Oxford, MS', patrons: 24210 }, { label: 'Other / unknown', patrons: 464360 } + ], ['patrons']), + patron_state_breakdown: scaleRows([ + { label: 'Mississippi', patrons: 564880 }, { label: 'Alabama', patrons: 17420 }, { label: 'Tennessee', patrons: 15110 }, + { label: 'Other / unknown', patrons: 21010 } ], ['patrons']), opportunities: (data.dashboardOpportunities || []).map(entry => ({ ...entry, count: scaled(entry.baseCount) })), filters, @@ -137,7 +195,8 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { sources: [ { label: 'Circulation transactions', detail: 'Checkout and renewal counts follow the established BLUEcloud Analytics command definitions.' }, { label: 'Current item snapshot', detail: 'Query item fields provide actual holdings, lifetime use, last use, item age, holds, and price.' }, - { label: 'Patron snapshot', detail: 'Sirsi user data is aggregated before display; no names, IDs, addresses, or individual records are returned.' } + { label: 'Patron snapshot', detail: 'Sirsi user data is aggregated before display; no names, IDs, addresses, or individual records are returned.' }, + { label: 'Fiscal-year definitions', detail: 'Illustrative periods follow the same system-specific reporting calendars used by the production dashboard.' } ], notes: ['Sample values demonstrate the dashboard contract and are not production MLP totals. Lifetime item counters and reporting-period transaction counts are shown separately.'] }; diff --git a/tests/browser/browserSmoke.mjs b/tests/browser/browserSmoke.mjs index b136776..49cf7a1 100644 --- a/tests/browser/browserSmoke.mjs +++ b/tests/browser/browserSmoke.mjs @@ -1,4 +1,5 @@ import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { chromium } from 'playwright'; @@ -519,6 +520,8 @@ async function runSmokeTest() { chartCount: panel.querySelectorAll('.kpi-chart-card').length, opportunityRows: panel.querySelectorAll('.kpi-opportunity-table tbody tr').length, libraryOptions: Array.from(panel.querySelectorAll('#kpi-dashboard-library option')).map(option => option.value), + exportVisible: !panel.querySelector('#kpi-dashboard-export')?.classList.contains('hidden'), + comparisonText: panel.querySelector('.kpi-card')?.textContent || '', selectedTab: panel.querySelector('[data-kpi-view][aria-selected="true"]')?.dataset.kpiView || '' })); if ( @@ -528,11 +531,27 @@ async function runSmokeTest() { || dashboardState.chartCount !== 6 || dashboardState.opportunityRows !== 1 || !dashboardState.libraryOptions.includes('MSU') + || !dashboardState.exportVisible + || !/up 38,119/iu.test(dashboardState.comparisonText) || dashboardState.selectedTab !== 'overview' ) { throw new Error(`Dashboard should reconcile library metrics, charts, filters, and opportunities: ${JSON.stringify(dashboardState)}`); } await expectNoHorizontalOverflow(page, 'Desktop KPI dashboard'); + const exportDownloadPromise = page.waitForEvent('download'); + await page.locator('#kpi-dashboard-export').click(); + const exportDownload = await exportDownloadPromise; + if (!/^MLP-KPI-overview-all-\d{4}-\d{2}-\d{2}\.csv$/u.test(exportDownload.suggestedFilename())) { + throw new Error(`Dashboard export should use a scoped Excel-compatible filename: ${exportDownload.suggestedFilename()}`); + } + const exportText = await readFile(await exportDownload.path(), 'utf8'); + if (!exportText.startsWith('\uFEFFSection,Label,Metric,Value') || !exportText.includes('previous_checkouts')) { + throw new Error('Dashboard export should include the UTF-8 header and prior-period metrics.'); + } + await page.locator('#kpi-dashboard-library').selectOption('MSU'); + await page.waitForFunction(() => Array.from(document.querySelectorAll('#kpi-dashboard-window option')).some(option => option.value === 'fy:MSU:2027')); + await page.locator('#kpi-dashboard-window').selectOption('fy:MSU:2027'); + await page.waitForFunction(() => document.querySelector('#kpi-dashboard-content .kpi-card')?.textContent?.includes('FY 2027 to date')); if (process.env.QUERY_DASHBOARD_SCREENSHOT_PATH) { await page.setViewportSize({ width: 1440, height: 1200 }); await page.screenshot({ path: process.env.QUERY_DASHBOARD_SCREENSHOT_PATH, fullPage: false }); diff --git a/tests/browser/support/browserSmokeSupport.mjs b/tests/browser/support/browserSmokeSupport.mjs index d685766..4bc01d4 100644 --- a/tests/browser/support/browserSmokeSupport.mjs +++ b/tests/browser/support/browserSmokeSupport.mjs @@ -293,8 +293,8 @@ function buildDefaultQueryApiResponse(payload) { body: JSON.stringify({ schema_version: 1, generated_at: new Date().toISOString(), - scope: { library_label: 'All MLP libraries', item_type_label: 'All item types' }, - circulation: { checkouts: 880229, renewals: 487605, renewal_share: 0.356, holds: 121843, holds_per_100_items: 4.3 }, + scope: { library: payload.library || 'all', library_label: payload.library === 'MSU' ? 'Mississippi State University' : 'All MLP libraries', item_type_label: 'All item types', reporting_period: payload.reporting_period || '365' }, + circulation: { checkouts: 880229, renewals: 487605, previous_checkouts: 842110, previous_renewals: 469220, checkout_change: 38119, renewal_change: 18385, checkout_change_rate: 0.045, renewal_change_rate: 0.039, comparison_available: true, comparison_coverage_complete: true, period_label: payload.reporting_period === 'fy:MSU:2027' ? 'FY 2027 to date' : 'Recent 365 days', renewal_share: 0.356, holds: 121843, holds_per_100_items: 4.3 }, collection: { items: 2813442, titles: 1601291, lifetime_checkouts: 12844308, lifetime_renewals: 5160244, in_house_uses: 843108, used_recently: 947835, recent_use_rate: 0.337, never_used: 1023995, never_used_rate: 0.364, checkouts_per_item: 4.6, total_value: 42850300, price_coverage: 0.71 }, patrons: { total: 618420, active: 183804, active_rate: 0.297, new: 42640, with_charges: 74482, with_holds: 18814, expiring_soon: 29711 }, circulation_trend: [{ label: 'Jul', checkouts: 66892, renewals: 35911 }, { label: 'Aug', checkouts: 74822, renewals: 39844 }], @@ -305,9 +305,11 @@ function buildDefaultQueryApiResponse(payload) { patron_library_breakdown: [{ label: 'MMRLS', patrons: 146820 }, { label: 'MSU', patrons: 31220 }], patron_profile_breakdown: [{ label: 'Adult', patrons: 359140 }, { label: 'Juvenile', patrons: 143880 }], patron_age_bands: [{ label: '18–24', patrons: 62330 }, { label: '25–44', patrons: 157880 }], - patron_geo_breakdown: [{ label: 'Tupelo area', patrons: 49220 }, { label: 'Other / unknown', patrons: 420700 }], + patron_geo_breakdown: [{ label: '388xx', patrons: 49220 }, { label: 'Other / unknown', patrons: 420700 }], + patron_city_breakdown: [{ label: 'Tupelo, MS', patrons: 49220 }, { label: 'Other / unknown', patrons: 420700 }], + patron_state_breakdown: [{ label: 'Mississippi', patrons: 582400 }, { label: 'Other / unknown', patrons: 36020 }], opportunities: [{ label: 'Older items with no recorded use', count: 618220, detail: 'Created more than five years ago with zero lifetime checkouts.' }], - filters: { libraries: [{ value: 'MSU', label: 'Mississippi State University' }], item_types: ['BOOK', 'EBOOK'] }, + filters: { libraries: [{ value: 'MSU', label: 'Mississippi State University' }], item_types: ['BOOK', 'EBOOK'], fiscal_periods_by_system: { MSU: [{ value: 'fy:MSU:2027', label: 'FY 2027 to date' }, { value: 'fy:MSU:2026', label: 'FY 2026' }] } }, privacy: { suppression_threshold: 10 }, sources: [{ label: 'Current item snapshot', detail: 'Aggregated test data.' }] }), diff --git a/tests/unit/core/mockQueryBackendLogic.mjs b/tests/unit/core/mockQueryBackendLogic.mjs index 3200d3c..3c399bd 100644 --- a/tests/unit/core/mockQueryBackendLogic.mjs +++ b/tests/unit/core/mockQueryBackendLogic.mjs @@ -73,6 +73,29 @@ test('demo dashboard status includes clearly marked sample operational activity' assert.ok(Object.values(payload.queries).some(run => run.status === 'failed')); }); +test('demo library dashboard demonstrates comparisons, fiscal periods, and privacy-safe geography', async () => { + const initialResponse = await handleDemoQueryRequest({ + body: JSON.stringify({ action: 'library_dashboard', library: 'MSU' }), + headers: authHeaders + }); + const initial = await initialResponse.json(); + const reportingPeriod = initial.filters.fiscal_periods_by_system.MSU[0].value; + const response = await handleDemoQueryRequest({ + body: JSON.stringify({ action: 'library_dashboard', library: 'MSU', reporting_period: reportingPeriod }), + headers: authHeaders + }); + const payload = await response.json(); + assert.equal(payload.sample_data, true); + assert.equal(payload.scope.reporting_period, reportingPeriod); + assert.equal(payload.circulation.comparison_available, true); + assert.equal(payload.circulation.fiscal_system, 'MSU'); + assert.ok(payload.filters.fiscal_periods_by_system.MSU.length >= 3); + assert.ok(payload.patron_geo_breakdown.every(row => /xx$|unknown$/u.test(row.label))); + assert.ok(payload.patron_city_breakdown.length > 0); + assert.ok(payload.patron_state_breakdown.length > 0); + assert.equal(JSON.stringify(payload).includes('street_address'), false); +}); + test('demo backend supports authenticated local bib lookup and WorldCat comparison', async () => { const searchResponse = await handleDemoQueryRequest({ body: JSON.stringify({