diff --git a/cache-bust.json b/cache-bust.json index 38f4d64..c77c43c 100644 --- a/cache-bust.json +++ b/cache-bust.json @@ -1,5 +1,5 @@ { - "version": "5be459fb48f09ce6", + "version": "512de215e15f2828", "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": "3b45645564fad841", + "src/core/mockQueryBackend.js": "a69b05dccb087495", "src/core/operatorSelectUtils.js": "4986a94dce50dcd9", "src/core/queryErrorDetails.js": "5c26531918b7a2c8", "src/core/queryExecution.js": "5fddde4c241c8699", @@ -236,11 +236,11 @@ "src/ui/controls/selectorListPasteInput.js": "4e7f16d036dfd795", "src/ui/controls/tableNameInput.js": "b6fc884c36961de3", "src/ui/controls/virtualList.js": "bd7a8d24c7c48493", - "src/ui/dashboard/kpiDashboard.js": "0d3f87a50b0747e8", + "src/ui/dashboard/kpiDashboard.js": "a30231379c3c6b6f", "src/ui/dashboard/kpiDashboardModel.js": "fa4c42b18c1245e5", "src/ui/dashboard/kpiDashboardView.js": "82508c550975ada6", "src/ui/dashboard/libraryDashboardExport.js": "b756c909cbadc7e9", - "src/ui/dashboard/libraryDashboardModel.js": "dece4ebdde8f32cf", + "src/ui/dashboard/libraryDashboardModel.js": "ff0861a1889a9b32", "src/ui/dashboard/libraryDashboardView.js": "f46f69224a55ebe3", "src/ui/field-picker/buildableFieldPreview.js": "8abd486bb0a87fe2", "src/ui/field-picker/fieldPicker.js": "526a1bf934f5dd74", diff --git a/src/core/mockQueryBackend.js b/src/core/mockQueryBackend.js index 7c37a65..b279f5d 100644 --- a/src/core/mockQueryBackend.js +++ b/src/core/mockQueryBackend.js @@ -58,6 +58,10 @@ function demoFiscalPeriods(system, startMonth, source, now = new Date()) { const shiftedDay = Math.min(Number(value.slice(6, 8)), new Date(Date.UTC(shiftedYear, shiftedMonth, 0)).getUTCDate()); return compact(shiftedYear, shiftedMonth, shiftedDay); }; + const displayDate = value => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${months[Number(value.slice(4, 6)) - 1]} ${Number(value.slice(6, 8))}, ${value.slice(0, 4)}`; + }; return [0, 1, 2].map(offset => { const fiscalYear = currentFiscalYear - offset; const startYear = fiscalYear - 1; @@ -66,12 +70,33 @@ function demoFiscalPeriods(system, startMonth, source, now = new Date()) { const current = offset === 0; const start = compact(startYear, startMonth, 1); const end = current ? compact(year, month, now.getUTCDate()) : compact(fiscalYear, endMonth, endDay); + const dateSpan = `${displayDate(start)}–${displayDate(end)}`; return { value: `fy:${system}:${fiscalYear}`, - label: current ? `FY ${fiscalYear} to date` : `FY ${fiscalYear}`, + label: current ? `FY ${fiscalYear} to date (${dateSpan})` : `FY ${fiscalYear} (${dateSpan})`, system, fiscal_year: fiscalYear, start, end, previous_start: shiftYear(start), previous_end: shiftYear(end), - current_to_date: current, start_month: startMonth, source + current_to_date: current, start_month: startMonth, + start_label: displayDate(start), end_label: displayDate(end), date_span: dateSpan, source + }; + }); +} + +function demoCalendarPeriods(now = new Date()) { + const currentYear = now.getUTCFullYear(); + const compact = (year, month, day) => `${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}`; + return [0, 1, 2].map(offset => { + const calendarYear = currentYear - offset; + const current = offset === 0; + return { + value: `cy:${calendarYear}`, + label: current ? `Calendar Year ${calendarYear} to date` : `Calendar Year ${calendarYear}`, + calendar_year: calendarYear, + start: compact(calendarYear, 1, 1), + end: current ? compact(currentYear, now.getUTCMonth() + 1, now.getUTCDate()) : compact(calendarYear, 12, 31), + previous_start: compact(calendarYear - 1, 1, 1), + previous_end: current ? compact(calendarYear - 1, now.getUTCMonth() + 1, now.getUTCDate()) : compact(calendarYear - 1, 12, 31), + current_to_date: current }; }); } @@ -107,7 +132,10 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { 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 calendarPeriods = demoCalendarPeriods(); const fiscalPeriod = Object.values(fiscalPeriodsBySystem).flat().find(period => period.value === reportingPeriod); + const calendarPeriod = calendarPeriods.find(period => period.value === reportingPeriod); + const namedPeriod = fiscalPeriod || calendarPeriod; const currentCheckouts = scaled(880229); const currentRenewals = scaled(487605); const previousCheckouts = scaled(842110); @@ -120,6 +148,7 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { { value: 'LILS', label: 'Lee-Itawamba Library System' } ], item_types: ['BOOK', 'EBOOK', 'DVD', 'AUDIOBOOK', 'KIT'], + calendar_periods: calendarPeriods, fiscal_periods_by_system: fiscalPeriodsBySystem }; return { @@ -140,9 +169,11 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { 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', + comparison_period_label: fiscalPeriod ? 'Previous fiscal year equivalent' : calendarPeriod ? 'Previous calendar 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', + period_label: namedPeriod?.label || 'Illustrative 12-month reporting period', + period_type: fiscalPeriod ? 'fiscal' : calendarPeriod ? 'calendar' : 'rolling', + calendar_year: calendarPeriod?.calendar_year, fiscal_year: fiscalPeriod?.fiscal_year, fiscal_system: fiscalPeriod?.system }, collection: { diff --git a/src/ui/dashboard/kpiDashboard.js b/src/ui/dashboard/kpiDashboard.js index 45df51f..d247c87 100644 --- a/src/ui/dashboard/kpiDashboard.js +++ b/src/ui/dashboard/kpiDashboard.js @@ -65,8 +65,19 @@ function syncPeriodOptions(elements) { ]; const fiscal = Array.isArray(libraryData.filters.fiscalPeriodsBySystem?.[system]) ? libraryData.filters.fiscalPeriodsBySystem[system] : []; - const options = [...rolling, ...fiscal.map(period => ({ value: period.value, label: period.label }))]; - elements.period.replaceChildren(...options.map(option => new Option(option.label, option.value))); + const calendar = Array.isArray(libraryData.filters.calendarPeriods) ? libraryData.filters.calendarPeriods : []; + const groups = [ + { label: 'Rolling periods', options: rolling }, + { label: 'Calendar years', options: calendar }, + { label: 'Fiscal years', options: fiscal } + ].filter(group => group.options.length > 0); + const options = groups.flatMap(group => group.options); + elements.period.replaceChildren(...groups.map(group => { + const element = document.createElement('optgroup'); + element.label = group.label; + element.append(...group.options.map(option => new Option(option.label, option.value))); + return element; + })); elements.period.value = options.some(option => option.value === selected) ? selected : '365'; } diff --git a/src/ui/dashboard/libraryDashboardModel.js b/src/ui/dashboard/libraryDashboardModel.js index 2cf5dd6..ffca4ae 100644 --- a/src/ui/dashboard/libraryDashboardModel.js +++ b/src/ui/dashboard/libraryDashboardModel.js @@ -51,6 +51,7 @@ function normalizeLibraryDashboard(payload = {}) { filters: { libraries: Array.isArray(data.filters?.libraries) ? data.filters.libraries : [], itemTypes: Array.isArray(data.filters?.item_types) ? data.filters.item_types : [], + calendarPeriods: Array.isArray(data.filters?.calendar_periods) ? data.filters.calendar_periods : [], fiscalPeriodsBySystem: data.filters?.fiscal_periods_by_system && typeof data.filters.fiscal_periods_by_system === 'object' ? data.filters.fiscal_periods_by_system : {} }, diff --git a/tests/browser/browserSmoke.mjs b/tests/browser/browserSmoke.mjs index 4330c7d..e68c7d7 100644 --- a/tests/browser/browserSmoke.mjs +++ b/tests/browser/browserSmoke.mjs @@ -548,10 +548,23 @@ async function runSmokeTest() { 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.'); } + const reportingPeriodGroups = await page.locator('#kpi-dashboard-window optgroup').evaluateAll(groups => groups.map(group => group.label)); + if (!reportingPeriodGroups.includes('Calendar years') || !reportingPeriodGroups.includes('Rolling periods')) { + throw new Error(`Dashboard should separate calendar-year and rolling choices: ${JSON.stringify(reportingPeriodGroups)}`); + } + await page.locator('#kpi-dashboard-window').selectOption('cy:2026'); + await page.waitForFunction(() => document.querySelector('#kpi-dashboard-content .kpi-card')?.textContent?.includes('Calendar Year 2026 to date')); 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')); + if (!await page.locator('#kpi-dashboard-window optgroup[label="Fiscal years"]').count()) { + throw new Error('Dashboard should expose fiscal years as a distinct reporting-period group after choosing a system.'); + } 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')); + const fiscalLabel = await page.locator('#kpi-dashboard-window option:checked').textContent(); + if (!/FY 2027.*Jul 1, 2026.*Aug 21, 2026/u.test(fiscalLabel || '')) { + throw new Error(`Fiscal-year choice should display its actual calendar-date span: ${fiscalLabel}`); + } 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 4bc01d4..85d7364 100644 --- a/tests/browser/support/browserSmokeSupport.mjs +++ b/tests/browser/support/browserSmokeSupport.mjs @@ -294,7 +294,7 @@ function buildDefaultQueryApiResponse(payload) { schema_version: 1, generated_at: new Date().toISOString(), 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 }, + 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 (Jul 1, 2026–Aug 21, 2026)' : payload.reporting_period === 'cy:2026' ? 'Calendar Year 2026 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 }], @@ -309,7 +309,7 @@ function buildDefaultQueryApiResponse(payload) { 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'], fiscal_periods_by_system: { MSU: [{ value: 'fy:MSU:2027', label: 'FY 2027 to date' }, { value: 'fy:MSU:2026', label: 'FY 2026' }] } }, + filters: { libraries: [{ value: 'MSU', label: 'Mississippi State University' }], item_types: ['BOOK', 'EBOOK'], calendar_periods: [{ value: 'cy:2026', label: 'Calendar Year 2026 to date' }, { value: 'cy:2025', label: 'Calendar Year 2025' }], fiscal_periods_by_system: { MSU: [{ value: 'fy:MSU:2027', label: 'FY 2027 to date (Jul 1, 2026–Aug 21, 2026)', date_span: 'Jul 1, 2026–Aug 21, 2026' }, { value: 'fy:MSU:2026', label: 'FY 2026 (Jul 1, 2025–Jun 30, 2026)', date_span: 'Jul 1, 2025–Jun 30, 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 3c399bd..e46119c 100644 --- a/tests/unit/core/mockQueryBackendLogic.mjs +++ b/tests/unit/core/mockQueryBackendLogic.mjs @@ -89,11 +89,24 @@ test('demo library dashboard demonstrates comparisons, fiscal periods, and priva assert.equal(payload.scope.reporting_period, reportingPeriod); assert.equal(payload.circulation.comparison_available, true); assert.equal(payload.circulation.fiscal_system, 'MSU'); + assert.match(payload.circulation.period_label, /^FY \d{4}.*\([A-Z][a-z]{2} \d{1,2}, \d{4}–[A-Z][a-z]{2} \d{1,2}, \d{4}\)$/u); + assert.match(initial.filters.fiscal_periods_by_system.MSU[0].date_span, /^[A-Z][a-z]{2} \d{1,2}, \d{4}–[A-Z][a-z]{2} \d{1,2}, \d{4}$/u); 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); + + const calendarPeriod = initial.filters.calendar_periods[0].value; + const calendarResponse = await handleDemoQueryRequest({ + body: JSON.stringify({ action: 'library_dashboard', library: 'all', reporting_period: calendarPeriod }), + headers: authHeaders + }); + const calendarPayload = await calendarResponse.json(); + assert.equal(calendarPayload.scope.reporting_period, calendarPeriod); + assert.equal(calendarPayload.circulation.period_type, 'calendar'); + assert.equal(calendarPayload.circulation.calendar_year, Number(calendarPeriod.split(':')[1])); + assert.match(calendarPayload.circulation.period_label, /^Calendar Year \d{4}/u); }); test('demo backend supports authenticated local bib lookup and WorldCat comparison', async () => { diff --git a/tests/unit/ui/libraryDashboardModelLogic.mjs b/tests/unit/ui/libraryDashboardModelLogic.mjs index 60983d4..5c5ee2d 100644 --- a/tests/unit/ui/libraryDashboardModelLogic.mjs +++ b/tests/unit/ui/libraryDashboardModelLogic.mjs @@ -9,7 +9,11 @@ test('library dashboard normalizes aggregate groups and filter metadata', () => circulation: { checkouts: '45', period_label: 'Recent 90 days', coverage_complete: true }, patrons: { total: 32 }, library_breakdown: [{ label: 'Main', checkouts: '30' }], - filters: { libraries: [{ value: 'MAIN', label: 'Main' }], item_types: ['BOOK'] } + filters: { + libraries: [{ value: 'MAIN', label: 'Main' }], + item_types: ['BOOK'], + calendar_periods: [{ value: 'cy:2026', label: 'Calendar Year 2026 to date' }] + } }); assert.equal(dashboard.collection.items, 120); assert.equal(dashboard.circulation.checkouts, 45); @@ -17,6 +21,7 @@ test('library dashboard normalizes aggregate groups and filter metadata', () => assert.equal(dashboard.circulation.coverage_complete, true); assert.equal(dashboard.libraryBreakdown[0].checkouts, 30); assert.equal(dashboard.filters.libraries[0].value, 'MAIN'); + assert.equal(dashboard.filters.calendarPeriods[0].value, 'cy:2026'); assert.deepEqual(dashboard.availability, { circulation: true, collection: true, patrons: true }); assert.equal(libraryDashboardHasData(dashboard), true); });