Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cache-bust.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "bfed9485df3ca864",
"version": "969cdb8dc567be69",
"generatedBy": "scripts/updateCacheBusting.mjs",
"assets": {
"assets/app-icon-16.png": "6645731d86da1071",
Expand Down Expand Up @@ -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",
Expand Down
73 changes: 66 additions & 7 deletions src/core/mockQueryBackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -71,14 +101,26 @@ 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' },
{ value: 'MMRLS', label: 'Mid-Mississippi Regional Library System' },
{ 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,
Expand All @@ -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),
Expand Down Expand Up @@ -128,16 +178,25 @@ 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,
privacy: { suppression_threshold: 10 },
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.']
};
Expand Down
19 changes: 19 additions & 0 deletions tests/browser/browserSmoke.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { chromium } from 'playwright';

Expand Down Expand Up @@ -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 (
Expand All @@ -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 });
Expand Down
10 changes: 6 additions & 4 deletions tests/browser/support/browserSmokeSupport.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand All @@ -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.' }]
}),
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/core/mockQueryBackendLogic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down