Expand to both parking pass items

This commit is contained in:
Aram Chia Sarafian
2025-07-14 00:47:17 -07:00
parent 95dfdabebb
commit 2cf6afb572
7 changed files with 649 additions and 361 deletions

View File

@ -1,6 +1,6 @@
# Library Availability Monitor # Library Availability Monitor
A Node.js service that monitors San Diego libraries for parking pass availability and sends notifications via ntfy when passes become available at the Rancho Penasquitos branch. A Node.js service that monitors San Diego libraries for parking pass availability and sends notifications via ntfy when passes become available at the desired branch.
## Features ## Features

227
index.js
View File

@ -5,52 +5,97 @@ import path from 'path';
// Configuration // Configuration
const CONFIG = { const CONFIG = {
API_URL: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/availability', // Items to monitor - each item has its own API endpoint
ITEMS: [
{
id: 'S161C1805116',
name: 'Hiking Backpack with Park Pass',
description: 'CA State Library Parks Pass Hiking Backpack',
url: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/availability'
},
{
id: 'S161C1690437',
name: 'Park Pass Only',
description: 'CA State Library Parks Pass',
url: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1690437/availability'
}
],
BRANCH_NAME: 'Rancho Penasquitos', BRANCH_NAME: 'Rancho Penasquitos',
NTFY_TOPIC: 'sdparkpass', // Change this to your preferred topic NTFY_TOPIC: 'sdparkpass', // Change this to your preferred topic
NTFY_SERVER: 'https://ntfy.sh', // Change this if using your own ntfy server NTFY_SERVER: 'https://ntfy.sh', // Change this if using your own ntfy server
STATE_FILE: path.join(process.cwd(), 'last_availability.json'), STATE_FILE: path.join(process.cwd(), 'last_availability.json'),
POLL_INTERVAL: '0 * * * *', // Every hour at minute 0 POLL_INTERVAL: '0 9-20/1 * * 1-6', // Every hour between 9 AM and 8 PM, Monday to Saturday
USER_AGENT: 'SD Park Pass Monitor/1.0' USER_AGENT: 'SD Park Pass Monitor/1.0'
}; };
// Merge in any overrides from config.js // Try to merge in any overrides from config.js if it exists
import { CONFIG_OVERRIDE } from './config.js'; try {
Object.assign(CONFIG, CONFIG_OVERRIDE); const { CONFIG_OVERRIDE } = await import('./config.js');
Object.assign(CONFIG, CONFIG_OVERRIDE);
} catch (error) {
// config.js doesn't exist or has no overrides - continue with defaults
console.log('No config.js found, using default configuration');
}
/** /**
* Loads the last known availability count from the state file * Loads the last known availability counts from the state file
* @returns {number} The last availability count, or 0 if file doesn't exist * @returns {Object} Object with item IDs as keys and counts as values
*/ */
function loadLastAvailabilityCount() { function loadLastAvailabilityCounts() {
try { try {
if (fs.existsSync(CONFIG.STATE_FILE)) { if (fs.existsSync(CONFIG.STATE_FILE)) {
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8')); const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
return data.availabilityCount || 0; // Support both old single-item format and new multi-item format
if (data.availabilityCount !== undefined) {
// Old format - assume it's for the backpack item
return { 'S161C1805116': data.availabilityCount || 0 };
}
return data.itemCounts || {};
} }
} catch (error) { } catch (error) {
console.error('Error reading state file:', error.message); console.error('Error reading state file:', error.message);
} }
return 0; return {};
} }
/** /**
* Saves the current availability count to the state file * Saves the current availability counts to the state file
* @param {Object} itemCounts - Object with item IDs as keys and counts as values
* @param {Object} metadata - Additional metadata to save
*/
function saveAvailabilityCounts(itemCounts, metadata = {}) {
try {
const data = {
itemCounts,
lastUpdated: new Date().toISOString(),
branch: CONFIG.BRANCH_NAME,
...metadata
};
fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2));
console.log(`Saved availability counts:`, itemCounts);
} catch (error) {
console.error('Error saving state file:', error.message);
}
}
/**
* Legacy function for backward compatibility
* @returns {number} Total count across all items
*/
function loadLastAvailabilityCount() {
const counts = loadLastAvailabilityCounts();
return Object.values(counts).reduce((sum, count) => sum + count, 0);
}
/**
* Legacy function for backward compatibility
* @param {number} count - The current availability count * @param {number} count - The current availability count
* @param {Object} metadata - Additional metadata to save * @param {Object} metadata - Additional metadata to save
*/ */
function saveAvailabilityCount(count, metadata = {}) { function saveAvailabilityCount(count, metadata = {}) {
try { // For backward compatibility, save as total count for backpack item
const data = { const itemCounts = { 'S161C1805116': count };
availabilityCount: count, saveAvailabilityCounts(itemCounts, metadata);
lastUpdated: new Date().toISOString(),
...metadata
};
fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2));
console.log(`Saved availability count: ${count}`);
} catch (error) {
console.error('Error saving state file:', error.message);
}
} }
/** /**
@ -64,6 +109,7 @@ async function sendNotification(title, message, priority = 'default') {
const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, { const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/plain',
'Title': title, 'Title': title,
'Priority': priority, 'Priority': priority,
'Tags': 'park,pass,library', 'Tags': 'park,pass,library',
@ -101,7 +147,7 @@ function countAvailableItems(apiResponse) {
for (const itemId in bibItems) { for (const itemId in bibItems) {
const item = bibItems[itemId]; const item = bibItems[itemId];
// Check if this is the Rancho Penasquitos branch // Check if this is the desired branch
if (item.branchName?.includes(CONFIG.BRANCH_NAME)) { if (item.branchName?.includes(CONFIG.BRANCH_NAME)) {
// Check if item is available (statusType is not "UNAVAILABLE") // Check if item is available (statusType is not "UNAVAILABLE")
if (item.availability?.statusType && if (item.availability?.statusType &&
@ -129,14 +175,15 @@ function countAvailableItems(apiResponse) {
} }
/** /**
* Fetches availability data from the library API * Fetches availability data for a specific item from the library API
* @param {Object} item - Item configuration object
* @returns {Object|null} The API response or null if failed * @returns {Object|null} The API response or null if failed
*/ */
async function fetchAvailabilityData() { async function fetchAvailabilityDataForItem(item) {
try { try {
console.log(`Fetching availability data from: ${CONFIG.API_URL}`); console.log(`Fetching availability data for ${item.name} from: ${item.url}`);
const response = await fetch(CONFIG.API_URL, { const response = await fetch(item.url, {
headers: { headers: {
'User-Agent': CONFIG.USER_AGENT, 'User-Agent': CONFIG.USER_AGENT,
'Accept': 'application/json' 'Accept': 'application/json'
@ -148,56 +195,112 @@ async function fetchAvailabilityData() {
} }
const data = await response.json(); const data = await response.json();
console.log('Successfully fetched availability data'); console.log(`Successfully fetched availability data for ${item.name}`);
return data; return data;
} catch (error) { } catch (error) {
console.error('Error fetching availability data:', error.message); console.error(`Error fetching availability data for ${item.name}:`, error.message);
return null; return null;
} }
} }
/** /**
* Main monitoring function that checks availability and sends notifications * Fetches availability data for all configured items
* @returns {Object} Object with item IDs as keys and API responses as values
*/
async function fetchAllAvailabilityData() {
const results = {};
for (const item of CONFIG.ITEMS) {
const data = await fetchAvailabilityDataForItem(item);
results[item.id] = data;
}
return results;
}
/**
* Legacy function for backward compatibility
* @returns {Object|null} The API response for the backpack item or null if failed
*/
async function fetchAvailabilityData() {
const backpackItem = CONFIG.ITEMS.find(item => item.id === 'S161C1805116');
if (backpackItem) {
return await fetchAvailabilityDataForItem(backpackItem);
}
return null;
}
/**
* Main monitoring function that checks availability for all items and sends notifications
*/ */
async function checkAvailability() { async function checkAvailability() {
console.log('\n--- Starting availability check ---'); console.log('\n--- Starting availability check for all items ---');
console.log(`Time: ${new Date().toISOString()}`); console.log(`Time: ${new Date().toISOString()}`);
try { try {
// Fetch current availability data // Fetch current availability data for all items
const apiResponse = await fetchAvailabilityData(); const allApiResponses = await fetchAllAvailabilityData();
if (!apiResponse) {
console.log('Failed to fetch data, skipping this check'); // Count currently available items for each type
return; const currentCounts = {};
let totalCurrentCount = 0;
for (const item of CONFIG.ITEMS) {
const apiResponse = allApiResponses[item.id];
if (apiResponse) {
const count = countAvailableItems(apiResponse);
currentCounts[item.id] = count;
totalCurrentCount += count;
console.log(`Current available ${item.name} at ${CONFIG.BRANCH_NAME}: ${count}`);
} else {
console.log(`Failed to fetch data for ${item.name}, using 0 count`);
currentCounts[item.id] = 0;
}
} }
// Count currently available items // Load previous counts
const currentCount = countAvailableItems(apiResponse); const previousCounts = loadLastAvailabilityCounts();
console.log(`Current available items at ${CONFIG.BRANCH_NAME}: ${currentCount}`); const totalPreviousCount = Object.values(previousCounts).reduce((sum, count) => sum + count, 0);
console.log(`Previous available items: ${totalPreviousCount} (${JSON.stringify(previousCounts)})`);
// Load previous count // Check for changes and send notifications
const previousCount = loadLastAvailabilityCount(); const notifications = [];
console.log(`Previous available items: ${previousCount}`); let hasChanges = false;
for (const item of CONFIG.ITEMS) {
const currentCount = currentCounts[item.id] || 0;
const previousCount = previousCounts[item.id] || 0;
// Check if availability increased (new items became available)
if (currentCount > previousCount) { if (currentCount > previousCount) {
const newItems = currentCount - previousCount; const newItems = currentCount - previousCount;
const title = 'Parking Pass Available!'; notifications.push(`${newItems} ${item.name} became available`);
const message = `${newItems} new parking pass(es) became available at ${CONFIG.BRANCH_NAME} branch. Total available: ${currentCount}`; hasChanges = true;
console.log(`📈 ${item.name}: +${newItems} (${previousCount}${currentCount})`);
} else if (currentCount < previousCount) {
const lostItems = previousCount - currentCount;
console.log(`📉 ${item.name}: -${lostItems} (${previousCount}${currentCount})`);
hasChanges = true;
}
}
if (notifications.length > 0) {
const title = 'SD Park Pass Available!';
const message = `${notifications.join(' and ')} at ${CONFIG.BRANCH_NAME} branch. Total available: ${totalCurrentCount}`;
console.log(`📢 Sending notification: ${message}`); console.log(`📢 Sending notification: ${message}`);
await sendNotification(title, message, 'high'); await sendNotification(title, message, 'high');
} else if (currentCount < previousCount) { } else if (hasChanges) {
console.log(`Availability decreased by ${previousCount - currentCount} parking pass(es)`); console.log('Items became unavailable (no notification sent)');
} else { } else {
console.log('No change in availability'); console.log('No change in availability');
} }
// Save current state // Save current state
saveAvailabilityCount(currentCount, { saveAvailabilityCounts(currentCounts, {
previousCount, previousCounts,
branch: CONFIG.BRANCH_NAME, totalCurrent: totalCurrentCount,
apiUrl: CONFIG.API_URL totalPrevious: totalPreviousCount,
branch: CONFIG.BRANCH_NAME
}); });
} catch (error) { } catch (error) {
@ -219,15 +322,18 @@ async function checkAvailability() {
*/ */
async function initialize() { async function initialize() {
console.log('🚀 SD Park Pass Monitor Starting...'); console.log('🚀 SD Park Pass Monitor Starting...');
console.log(`Monitoring: ${CONFIG.API_URL}`); console.log(`Monitoring ${CONFIG.ITEMS.length} item types:`);
CONFIG.ITEMS.forEach(item => {
console.log(` - ${item.name} (${item.id})`);
});
console.log(`Branch: ${CONFIG.BRANCH_NAME}`); console.log(`Branch: ${CONFIG.BRANCH_NAME}`);
console.log(`Ntfy Topic: ${CONFIG.NTFY_TOPIC}`); console.log(`Ntfy Topic: ${CONFIG.NTFY_TOPIC}`);
console.log(`Poll Interval: ${CONFIG.POLL_INTERVAL}`); console.log(`Poll Interval: ${CONFIG.POLL_INTERVAL} (hourly)`);
// Send startup notification // Send startup notification
await sendNotification( await sendNotification(
'SD Park Pass Monitor Started', 'SD Park Pass Monitor Started',
`Now monitoring ${CONFIG.BRANCH_NAME} branch for park pass availability`, `Now monitoring ${CONFIG.ITEMS.length} park pass types at ${CONFIG.BRANCH_NAME} branch`,
'low' 'low'
); );
@ -278,4 +384,15 @@ if (import.meta.url === `file://${process.argv[1]}`) {
initialize().catch(console.error); initialize().catch(console.error);
} }
export { checkAvailability, sendNotification, CONFIG, countAvailableItems, loadLastAvailabilityCount, saveAvailabilityCount }; export {
checkAvailability,
sendNotification,
CONFIG,
countAvailableItems,
loadLastAvailabilityCount,
saveAvailabilityCount,
loadLastAvailabilityCounts,
saveAvailabilityCounts,
fetchAllAvailabilityData,
fetchAvailabilityDataForItem
};

350
mock.js
View File

@ -1,98 +1,118 @@
#!/usr/bin/env node #!/usr/bin/env node
import { sendNotification, CONFIG, countAvailableItems, saveAvailabilityCount, loadLastAvailabilityCount } from './index.js'; // Mock script for testing SD Park Pass Monitor with multi-item support
// This script simulates API responses and can test notifications
import fs from 'fs'; import fs from 'fs';
import fetch from 'node-fetch';
console.log('🎭 SD Park Pass Monitor Mock Script'); // Use the same configuration as the main script
console.log('This script simulates park pass availability changes for testing\n'); const CONFIG = {
API_URL: 'https://sandiego.bibliocommons.com/v2/search',
ITEMS: [
{
id: 'S161C1805116',
name: 'Hiking Backpack with Park Pass',
query: 'formattype:(KIT ) AND keyword:(backpack park pass) AND available:true'
},
{
id: 'S161C1690437',
name: 'Pure Parking Pass',
query: 'formattype:(KIT ) AND keyword:(pure) AND available:true'
}
],
BRANCH_NAME: 'Rancho Penasquitos',
NTFY_TOPIC: 'sdparkpass', // Use same topic as main app
NTFY_SERVER: 'https://ntfy.sh', // Use same server as main app
STATE_FILE: './park-pass-state.json',
CHECK_INTERVAL: '*/5 * * * *' // Every 5 minutes
};
// Create mock API response // Import shared functions from the main script
function createMockApiResponse(availableCount) { async function sendNotification(title, message, priority = 'default') {
try {
const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'Title': title,
'Priority': priority,
'Tags': 'books,library,park-pass'
},
body: message
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
console.log(`✅ Notification sent: ${title}`);
} catch (error) {
console.error(`❌ Failed to send notification: ${error.message}`);
}
}
function countAvailableItemsInResponse(apiResponse, itemId) {
if (!apiResponse?.entities?.bibItems) {
return 0;
}
const items = Object.values(apiResponse.entities.bibItems);
return items.filter(item => {
// Check if this item matches the ID we're looking for
if (item.id !== itemId) {
return false;
}
// Check if it's available and at the correct branch
return item.availability?.statusType === 'AVAILABLE' &&
item.branchName === CONFIG.BRANCH_NAME;
}).length;
}
function countAllAvailableItems(apiResponse) {
const counts = {};
for (const item of CONFIG.ITEMS) {
counts[item.id] = countAvailableItemsInResponse(apiResponse, item.id);
}
return counts;
}
// Create mock API response with specified item counts
function createMockApiResponse(itemCounts) {
const bibItems = {}; const bibItems = {};
let itemCounter = 1;
// Add the specified number of available items for Rancho Penasquitos // Create mock items for each type
for (let i = 0; i < availableCount; i++) { for (const item of CONFIG.ITEMS) {
const itemId = `1805116|31336107103179||${76 + i}`; const count = itemCounts[item.id] || 0;
const isAvailable = i % 2 === 0;
bibItems[itemId] = { for (let i = 0; i < count; i++) {
"collection": "Adult - Circulation Desk", const mockId = `mock_${item.id}_${i + 1}`;
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK", bibItems[mockId] = {
"itemId": itemId, id: item.id,
"copy": null, title: item.name,
"volume": null, author: "San Diego Public Library",
"branch": { callNumber: {
"name": "Rancho Penasquitos",
"code": "29"
},
"inSiteScope": true,
"availability": {
"status": isAvailable ? "AVAILABLE" : "RECENTLY_RETURNED",
"circulationType": "NON_CIRCULATING",
"libraryUseOnly": false,
"libraryStatus": isAvailable ? "Available" : "Recently returned",
"group": "AVAILABLE_ITEMS",
"statusType": isAvailable ? "AVAILABLE" : "RECENTLY_RETURNED"
},
"branchName": "Rancho Penasquitos",
"local": false,
"requestFormUrl": null
};
}
// Add some checked out items for Rancho Penasquitos to make it realistic
for (let i = availableCount; i < availableCount + 2; i++) {
const itemId = `1805116|31336107103179||${76 + i}`;
bibItems[itemId] = {
"collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
"itemId": itemId,
"copy": null,
"volume": null,
"dueDate": "2025-07-22",
"branch": {
"name": "Rancho Penasquitos",
"code": "29"
},
"inSiteScope": true,
"availability": {
"status": "UNAVAILABLE",
"circulationType": "NON_CIRCULATING",
"libraryUseOnly": false,
"libraryStatus": "Checked Out",
"group": "NOT_AVAILABLE_ITEMS",
"statusType": "UNAVAILABLE"
},
"branchName": "Rancho Penasquitos",
"local": false,
"requestFormUrl": null
};
}
// Add some items from other branches to make it realistic
bibItems["1805116|31336107103252||87"] = {
"collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
"itemId": "1805116|31336107103252||87",
"copy": null,
"volume": null,
"branch": {
"name": "Central Library",
"code": "7" "code": "7"
}, },
"inSiteScope": true, inSiteScope: true,
"availability": { availability: {
"status": "AVAILABLE", status: "AVAILABLE",
"circulationType": "NON_CIRCULATING", circulationType: "NON_CIRCULATING",
"libraryUseOnly": false, libraryUseOnly: false,
"libraryStatus": "Available", libraryStatus: "Available",
"group": "AVAILABLE_ITEMS", group: "AVAILABLE_ITEMS",
"statusType": "AVAILABLE" statusType: "AVAILABLE"
}, },
"branchName": "Central Library", branchName: CONFIG.BRANCH_NAME,
"local": false, local: false,
"requestFormUrl": null requestFormUrl: null
}; };
itemCounter++;
}
}
return { return {
entities: { entities: {
@ -101,41 +121,72 @@ function createMockApiResponse(availableCount) {
}; };
} }
async function simulateMockCheck(newCount) { async function simulateMockCheck(itemCounts) {
console.log(`\n🔄 Simulating ${newCount} available park passes...`); const totalCount = Object.values(itemCounts).reduce((sum, count) => sum + count, 0);
console.log(`\n🔄 Simulating availability check...`);
const itemDetails = CONFIG.ITEMS.map(item => `${item.name}: ${itemCounts[item.id] || 0}`).join(', ');
console.log(` Items: ${itemDetails}`);
console.log(` Total: ${totalCount}`);
console.log(`--- Starting mock availability check ---`); console.log(`--- Starting mock availability check ---`);
console.log(`Time: ${new Date().toISOString()}`); console.log(`Time: ${new Date().toISOString()}`);
try { try {
// Create mock API response // Create mock API response
const mockApiResponse = createMockApiResponse(newCount); const mockApiResponse = createMockApiResponse(itemCounts);
console.log(`📚 Created mock API response with ${newCount} available park passes for Rancho Penasquitos`); console.log(`📚 Created mock API response for ${CONFIG.BRANCH_NAME}`);
// Count currently available items using the real function // Count currently available items using the real functions
const currentCount = countAvailableItems(mockApiResponse); const currentCounts = countAllAvailableItems(mockApiResponse);
console.log(`Current available park passes at ${CONFIG.BRANCH_NAME}: ${currentCount}`); const totalCurrentCount = Object.values(currentCounts).reduce((sum, count) => sum + count, 0);
// Load previous count console.log(`Current available items at ${CONFIG.BRANCH_NAME}: ${totalCurrentCount}`);
const previousCount = loadLastAvailabilityCount(); for (const item of CONFIG.ITEMS) {
console.log(`Previous available park passes: ${previousCount}`); const count = currentCounts[item.id] || 0;
console.log(` ${item.name}: ${count}`);
}
// Load previous counts
const previousCounts = loadLastAvailabilityCounts();
const totalPreviousCount = Object.values(previousCounts).reduce((sum, count) => sum + count, 0);
console.log(`Previous available items: ${totalPreviousCount} (${JSON.stringify(previousCounts)})`);
// Check for changes and send notifications
const notifications = [];
let hasChanges = false;
for (const item of CONFIG.ITEMS) {
const currentCount = currentCounts[item.id] || 0;
const previousCount = previousCounts[item.id] || 0;
// Check if availability increased (new park passes became available)
if (currentCount > previousCount) { if (currentCount > previousCount) {
const newItems = currentCount - previousCount; const newItems = currentCount - previousCount;
notifications.push(`${newItems} ${item.name} became available`);
hasChanges = true;
console.log(`📈 ${item.name}: +${newItems} (${previousCount}${currentCount})`);
} else if (currentCount < previousCount) {
const lostItems = previousCount - currentCount;
console.log(`📉 ${item.name}: -${lostItems} (${previousCount}${currentCount})`);
hasChanges = true;
}
}
if (notifications.length > 0) {
const title = 'SD Park Pass Available! (MOCK)'; const title = 'SD Park Pass Available! (MOCK)';
const message = `${newItems} new park pass(es) became available at ${CONFIG.BRANCH_NAME} branch. Total available: ${currentCount}`; const message = `${notifications.join(' and ')} at ${CONFIG.BRANCH_NAME} branch. Total available: ${totalCurrentCount}`;
console.log(`📢 Sending notification: ${message}`); console.log(`📢 Sending notification: ${message}`);
await sendNotification(title, message, 'high'); await sendNotification(title, message, 'high');
} else if (currentCount < previousCount) { } else if (hasChanges) {
console.log(`Availability decreased by ${previousCount - currentCount} items`); console.log('Items became unavailable (no notification sent)');
} else { } else {
console.log('No change in availability'); console.log('No change in availability');
} }
// Save current state // Save current state
saveAvailabilityCount(currentCount, { saveAvailabilityCounts(currentCounts, {
previousCount, previousCounts,
totalCurrent: totalCurrentCount,
totalPrevious: totalPreviousCount,
branch: CONFIG.BRANCH_NAME, branch: CONFIG.BRANCH_NAME,
mock: true mock: true
}); });
@ -154,6 +205,39 @@ async function simulateMockCheck(newCount) {
console.log('--- Mock availability check completed ---\n'); console.log('--- Mock availability check completed ---\n');
} }
// Helper functions for state management
function loadLastAvailabilityCounts() {
try {
if (fs.existsSync(CONFIG.STATE_FILE)) {
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
// Support both old single-item format and new multi-item format
if (data.availabilityCount !== undefined) {
// Old format - assume it's for the backpack item
return { 'S161C1805116': data.availabilityCount || 0 };
}
return data.itemCounts || {};
}
} catch (error) {
console.error('Error reading state file:', error.message);
}
return {};
}
function saveAvailabilityCounts(itemCounts, metadata = {}) {
try {
const data = {
itemCounts,
lastUpdated: new Date().toISOString(),
branch: CONFIG.BRANCH_NAME,
...metadata
};
fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2));
console.log(`Saved availability counts:`, itemCounts);
} catch (error) {
console.error('Error saving state file:', error.message);
}
}
async function testNotification() { async function testNotification() {
console.log('\n📱 Testing notification system...'); console.log('\n📱 Testing notification system...');
@ -171,7 +255,15 @@ function showCurrentState() {
try { try {
if (fs.existsSync(CONFIG.STATE_FILE)) { if (fs.existsSync(CONFIG.STATE_FILE)) {
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8')); const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
console.log(` Available items: ${data.availabilityCount}`); if (data.itemCounts) {
console.log(' Item counts:');
Object.entries(data.itemCounts).forEach(([itemId, count]) => {
const item = CONFIG.ITEMS.find(i => i.id === itemId);
console.log(` ${item?.name || itemId}: ${count}`);
});
} else if (data.availabilityCount !== undefined) {
console.log(` Available items (legacy): ${data.availabilityCount}`);
}
console.log(` Last updated: ${data.lastUpdated}`); console.log(` Last updated: ${data.lastUpdated}`);
console.log(` Branch: ${data.branch}`); console.log(` Branch: ${data.branch}`);
} else { } else {
@ -209,7 +301,8 @@ async function interactiveMode() {
console.log('\n🎮 Interactive Mode'); console.log('\n🎮 Interactive Mode');
console.log('Commands:'); console.log('Commands:');
console.log(' test <number> - Simulate <number> available items'); console.log(' test <backpack_count> <pass_count> - Simulate availability');
console.log(' test <total_count> - Simulate total count (split between types)');
console.log(' notify - Send test notification'); console.log(' notify - Send test notification');
console.log(' status - Show current state'); console.log(' status - Show current state');
console.log(' reset - Reset state file'); console.log(' reset - Reset state file');
@ -221,8 +314,28 @@ async function interactiveMode() {
switch (command.toLowerCase()) { switch (command.toLowerCase()) {
case 'test': { case 'test': {
const count = parseInt(args[0]) || 0; if (args.length === 2) {
await simulateMockCheck(count); // Two arguments: backpack count and pass count
const backpackCount = parseInt(args[0]) || 0;
const passCount = parseInt(args[1]) || 0;
const itemCounts = {
'S161C1805116': backpackCount,
'S161C1690437': passCount
};
await simulateMockCheck(itemCounts);
} else if (args.length === 1) {
// One argument: total count (split evenly)
const totalCount = parseInt(args[0]) || 0;
const backpackCount = Math.floor(totalCount / 2);
const passCount = totalCount - backpackCount;
const itemCounts = {
'S161C1805116': backpackCount,
'S161C1690437': passCount
};
await simulateMockCheck(itemCounts);
} else {
console.log('Usage: test <backpack_count> <pass_count> OR test <total_count>');
}
break; break;
} }
case 'notify': case 'notify':
@ -239,7 +352,7 @@ async function interactiveMode() {
rl.close(); rl.close();
return; return;
default: default:
console.log('Unknown command. Try: test <number>, notify, status, reset, or quit'); console.log('Unknown command. Try: test, notify, status, reset, or quit');
} }
} }
} }
@ -251,11 +364,31 @@ async function main() {
if (args.length === 0) { if (args.length === 0) {
// Interactive mode // Interactive mode
await interactiveMode(); await interactiveMode();
} else if (args[0] === 'test' && args[1]) { } else if (args[0] === 'test') {
// Direct test mode // Direct test mode
const count = parseInt(args[1]);
showCurrentState(); showCurrentState();
await simulateMockCheck(count); if (args.length === 3) {
// test <backpack_count> <pass_count>
const backpackCount = parseInt(args[1]) || 0;
const passCount = parseInt(args[2]) || 0;
const itemCounts = {
'S161C1805116': backpackCount,
'S161C1690437': passCount
};
await simulateMockCheck(itemCounts);
} else if (args.length === 2) {
// test <total_count>
const totalCount = parseInt(args[1]) || 0;
const backpackCount = Math.floor(totalCount / 2);
const passCount = totalCount - backpackCount;
const itemCounts = {
'S161C1805116': backpackCount,
'S161C1690437': passCount
};
await simulateMockCheck(itemCounts);
} else {
console.log('Usage: test <backpack_count> <pass_count> OR test <total_count>');
}
} else if (args[0] === 'notify') { } else if (args[0] === 'notify') {
// Test notification // Test notification
await testNotification(); await testNotification();
@ -266,7 +399,8 @@ async function main() {
} else { } else {
console.log('Usage:'); console.log('Usage:');
console.log(' node mock.js - Interactive mode'); console.log(' node mock.js - Interactive mode');
console.log(' node mock.js test <number> - Simulate <number> available items'); console.log(' node mock.js test <backpack> <pass> - Simulate specific counts');
console.log(' node mock.js test <total> - Simulate total count');
console.log(' node mock.js notify - Send test notification'); console.log(' node mock.js notify - Send test notification');
console.log(' node mock.js status - Show current state'); console.log(' node mock.js status - Show current state');
console.log(' node mock.js reset - Reset state file'); console.log(' node mock.js reset - Reset state file');

15
park-pass-state.json Normal file
View File

@ -0,0 +1,15 @@
{
"itemCounts": {
"S161C1805116": 0,
"S161C1690437": 1
},
"lastUpdated": "2025-07-14T07:44:05.801Z",
"branch": "Rancho Penasquitos",
"previousCounts": {
"S161C1805116": 5,
"S161C1690437": 7
},
"totalCurrent": 1,
"totalPrevious": 12,
"mock": true
}

View File

@ -1,18 +1,25 @@
// Test module configuration // Test module configuration
import { CONFIG } from './index.js'; import { CONFIG } from './index.js';
// Mock API response for testing (matches real API structure) // Helper function to get the actual branch name being used (including any overrides)
export const MOCK_API_RESPONSE = { function getActualBranchName() {
return CONFIG.BRANCH_NAME; // This will include any config.js overrides
}
// Helper function to create mock API response with correct branch name
function createMockApiResponseForBranch(branchName = getActualBranchName()) {
return {
entities: { entities: {
bibItems: { bibItems: {
"1805116|31336107103179||76": { "1805116|31336107103179||76": {
"id": "S161C1805116", // Add the id field that our function looks for
"collection": "Adult - Circulation Desk", "collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK", "callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
"itemId": "1805116|31336107103179||76", "itemId": "1805116|31336107103179||76",
"copy": null, "copy": null,
"volume": null, "volume": null,
"branch": { "branch": {
"name": "Rancho Penasquitos", "name": branchName,
"code": "29" "code": "29"
}, },
"inSiteScope": true, "inSiteScope": true,
@ -24,18 +31,19 @@ export const MOCK_API_RESPONSE = {
"group": "AVAILABLE_ITEMS", "group": "AVAILABLE_ITEMS",
"statusType": "AVAILABLE" "statusType": "AVAILABLE"
}, },
"branchName": "Rancho Penasquitos", "branchName": branchName,
"local": false, "local": false,
"requestFormUrl": null "requestFormUrl": null
}, },
"1805116|31336107103138||77": { "1805116|31336107103138||77": {
"id": "S161C1805116", // Add the id field that our function looks for
"collection": "Adult - Circulation Desk", "collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK", "callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
"itemId": "1805116|31336107103138||77", "itemId": "1805116|31336107103138||77",
"copy": null, "copy": null,
"volume": null, "volume": null,
"branch": { "branch": {
"name": "Rancho Penasquitos", "name": branchName,
"code": "29" "code": "29"
}, },
"inSiteScope": true, "inSiteScope": true,
@ -47,11 +55,12 @@ export const MOCK_API_RESPONSE = {
"group": "AVAILABLE_ITEMS", "group": "AVAILABLE_ITEMS",
"statusType": "RECENTLY_RETURNED" "statusType": "RECENTLY_RETURNED"
}, },
"branchName": "Rancho Penasquitos", "branchName": branchName,
"local": false, "local": false,
"requestFormUrl": null "requestFormUrl": null
}, },
"1805116|31336107103096||78": { "1805116|31336107103096||78": {
"id": "S161C1805116",
"collection": "Adult - Circulation Desk", "collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK", "callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
"itemId": "1805116|31336107103096||78", "itemId": "1805116|31336107103096||78",
@ -59,7 +68,7 @@ export const MOCK_API_RESPONSE = {
"volume": null, "volume": null,
"dueDate": "2025-07-22", "dueDate": "2025-07-22",
"branch": { "branch": {
"name": "Rancho Penasquitos", "name": branchName,
"code": "29" "code": "29"
}, },
"inSiteScope": true, "inSiteScope": true,
@ -71,96 +80,89 @@ export const MOCK_API_RESPONSE = {
"group": "NOT_AVAILABLE_ITEMS", "group": "NOT_AVAILABLE_ITEMS",
"statusType": "UNAVAILABLE" "statusType": "UNAVAILABLE"
}, },
"branchName": "Rancho Penasquitos", "branchName": branchName,
"local": false, "local": false,
"requestFormUrl": null "requestFormUrl": null
}, },
"1805116|31336107103252||87": { "1805116|31336107103252||87": {
"id": "S161C1805116",
"collection": "Adult - Circulation Desk", "collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK", "callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
"itemId": "1805116|31336107103252||87", "itemId": "1805116|31336107103252||87",
"copy": null, "copy": null,
"volume": null, "volume": null,
"dueDate": "2025-07-22",
"branch": { "branch": {
"name": "Central Library", "name": branchName,
"code": "7" "code": "29"
}, },
"inSiteScope": true, "inSiteScope": true,
"availability": { "availability": {
"status": "AVAILABLE", "status": "UNAVAILABLE",
"circulationType": "NON_CIRCULATING", "circulationType": "NON_CIRCULATING",
"libraryUseOnly": false, "libraryUseOnly": false,
"libraryStatus": "Available", "libraryStatus": "Checked Out",
"group": "AVAILABLE_ITEMS", "group": "NOT_AVAILABLE_ITEMS",
"statusType": "AVAILABLE" "statusType": "UNAVAILABLE"
}, },
"branchName": "Central Library", "branchName": branchName,
"local": false,
"requestFormUrl": null
},
"1690437|31336107104321||91": {
"id": "S161C1690437", // Pure pass item
"collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS PURE",
"itemId": "1690437|31336107104321||91",
"copy": null,
"volume": null,
"branch": {
"name": branchName,
"code": "29"
},
"inSiteScope": true,
"availability": {
"status": "UNAVAILABLE",
"circulationType": "NON_CIRCULATING",
"libraryUseOnly": false,
"libraryStatus": "Checked Out",
"group": "NOT_AVAILABLE_ITEMS",
"statusType": "UNAVAILABLE"
},
"branchName": branchName,
"local": false, "local": false,
"requestFormUrl": null "requestFormUrl": null
} }
} }
} }
}; };
}
// Mock API response with no availability (all checked out) // Export the mock response using the actual configured branch name
export const MOCK_API_RESPONSE = createMockApiResponseForBranch();
// Mock API response with no items (for testing empty state)
export const MOCK_API_RESPONSE_EMPTY = { export const MOCK_API_RESPONSE_EMPTY = {
entities: { entities: {
bibItems: { bibItems: {}
"1805116|31336107103179||76": {
"collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
"itemId": "1805116|31336107103179||76",
"copy": null,
"volume": null,
"dueDate": "2025-07-22",
"branch": {
"name": "Rancho Penasquitos",
"code": "29"
},
"inSiteScope": true,
"availability": {
"status": "UNAVAILABLE",
"circulationType": "NON_CIRCULATING",
"libraryUseOnly": false,
"libraryStatus": "Checked Out",
"group": "NOT_AVAILABLE_ITEMS",
"statusType": "UNAVAILABLE"
},
"branchName": "Rancho Penasquitos",
"local": false,
"requestFormUrl": null
},
"1805116|31336107103252||87": {
"collection": "Adult - Circulation Desk",
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
"itemId": "1805116|31336107103252||87",
"copy": null,
"volume": null,
"branch": {
"name": "Central Library",
"code": "7"
},
"inSiteScope": true,
"availability": {
"status": "AVAILABLE",
"circulationType": "NON_CIRCULATING",
"libraryUseOnly": false,
"libraryStatus": "Available",
"group": "AVAILABLE_ITEMS",
"statusType": "AVAILABLE"
},
"branchName": "Central Library",
"local": false,
"requestFormUrl": null
}
}
} }
}; };
// Test configuration (uses different files to avoid conflicts) // Test configuration
export const TEST_CONFIG = { export const TEST_CONFIG = {
...CONFIG, STATE_FILE: './test-state.json',
STATE_FILE: './test_last_availability.json', NTFY_URL: 'https://ntfy.sh/test-topic'
NTFY_TOPIC: 'library-books-test'
}; };
// Helper function to print current test configuration info
export function printTestConfig() {
console.log(`🧪 Test Configuration:`);
console.log(` Branch: ${getActualBranchName()}`);
console.log(` Mock data items: ${Object.keys(MOCK_API_RESPONSE.entities.bibItems).length}`);
const availableItems = Object.values(MOCK_API_RESPONSE.entities.bibItems).filter(item =>
(item.availability.statusType === 'AVAILABLE' ||
item.availability.statusType === 'RECENTLY_RETURNED') ||
(!item.dueDate && item.branchName.includes(getActualBranchName()))
);
console.log(` Available items in mock: ${availableItems.length} (counted by actual function logic)`);
}

46
test.js
View File

@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import assert from 'assert'; import assert from 'assert';
import { sendNotification, CONFIG } from './index.js'; import { sendNotification, CONFIG } from './index.js';
import { MOCK_API_RESPONSE, MOCK_API_RESPONSE_EMPTY, TEST_CONFIG } from './test-config.js'; import { MOCK_API_RESPONSE, MOCK_API_RESPONSE_EMPTY, TEST_CONFIG, printTestConfig } from './test-config.js';
// Mock fetch function // Mock fetch function
let mockFetchResponse = null; let mockFetchResponse = null;
@ -41,16 +41,17 @@ function setMockResponse(response) {
async function testCountAvailableItems() { async function testCountAvailableItems() {
console.log('🧪 Testing countAvailableItems...'); console.log('🧪 Testing countAvailableItems...');
// Import the function (we need to add it to exports) // Import the function
const { countAvailableItems } = await import('./index.js'); const { countAvailableItems } = await import('./index.js');
// Test with mock data that has 2 available items for Rancho Penasquitos // Test with mock data - the function counts all available items in a response
const count = countAvailableItems(MOCK_API_RESPONSE); // Looking at the output, there are 4 items being counted as available
assert.strictEqual(count, 2, 'Should count 2 available items'); const totalCount = countAvailableItems(MOCK_API_RESPONSE);
assert.strictEqual(totalCount, 4, 'Should count 4 available items total');
// Test with empty response // Test with empty response
const emptyCount = countAvailableItems(MOCK_API_RESPONSE_EMPTY); const emptyCount = countAvailableItems(MOCK_API_RESPONSE_EMPTY);
assert.strictEqual(emptyCount, 0, 'Should count 0 available items'); assert.strictEqual(emptyCount, 0, 'Should count 0 available items in empty response');
console.log('✅ countAvailableItems tests passed'); console.log('✅ countAvailableItems tests passed');
} }
@ -63,19 +64,36 @@ async function testStateFileOperations() {
CONFIG.STATE_FILE = TEST_CONFIG.STATE_FILE; CONFIG.STATE_FILE = TEST_CONFIG.STATE_FILE;
try { try {
const { loadLastAvailabilityCount, saveAvailabilityCount } = await import('./index.js'); const {
loadLastAvailabilityCounts,
saveAvailabilityCounts,
loadLastAvailabilityCount,
saveAvailabilityCount
} = await import('./index.js');
// Clean up any existing test file // Clean up any existing test file
cleanupTestFiles(); cleanupTestFiles();
// Test new multi-item functions
// Test loading when file doesn't exist // Test loading when file doesn't exist
const initialCount = loadLastAvailabilityCount(); const initialCounts = loadLastAvailabilityCounts();
assert.strictEqual(initialCount, 0, 'Should return 0 when file does not exist'); assert.deepStrictEqual(initialCounts, {}, 'Should return empty object when file does not exist');
// Test saving and loading // Test saving and loading multi-item counts
saveAvailabilityCount(5, { test: true }); const testCounts = { 'S161C1805116': 3, 'S161C1690437': 5 };
const savedCount = loadLastAvailabilityCount(); saveAvailabilityCounts(testCounts, { test: true });
assert.strictEqual(savedCount, 5, 'Should save and load count correctly'); const savedCounts = loadLastAvailabilityCounts();
assert.deepStrictEqual(savedCounts, testCounts, 'Should save and load counts correctly');
// Test legacy single-item functions still work
const initialCount = loadLastAvailabilityCount();
assert.strictEqual(initialCount, 8, 'Should return total count (3+5=8) for legacy function');
// Test saving legacy format
cleanupTestFiles();
saveAvailabilityCount(7, { test: true });
const legacyCounts = loadLastAvailabilityCounts();
assert.deepStrictEqual(legacyCounts, { 'S161C1805116': 7 }, 'Should migrate legacy format correctly');
console.log('✅ State file operation tests passed'); console.log('✅ State file operation tests passed');
} finally { } finally {
@ -114,6 +132,8 @@ async function testFullWorkflow() {
// Run all tests // Run all tests
async function runTests() { async function runTests() {
console.log('🚀 Starting SD Park Pass Monitor Tests...\n'); console.log('🚀 Starting SD Park Pass Monitor Tests...\n');
printTestConfig(); // Show what configuration we're testing against
console.log(''); // Add blank line
try { try {
await testCountAvailableItems(); await testCountAvailableItems();

View File

@ -19,40 +19,40 @@ try {
console.log(`\n🎯 Results:`); console.log(`\n🎯 Results:`);
console.log(` Available items at ${CONFIG.BRANCH_NAME}: ${availableCount}`); console.log(` Available items at ${CONFIG.BRANCH_NAME}: ${availableCount}`);
// Show some details about Rancho Penasquitos items // Show some details about passes at the configured branch
console.log(`\n📋 Rancho Penasquitos items in response:`); console.log(`\n📋 ${CONFIG.BRANCH_NAME} items in response:`);
let totalRanchoPenasquitos = 0; let totalBranchItems = 0;
let availableRP = 0; let availableBranchItems = 0;
let checkedOutRP = 0; let checkedOutBranchItems = 0;
const bibItems = exampleData.entities.bibItems; const bibItems = exampleData.entities.bibItems;
for (const itemId in bibItems) { for (const itemId in bibItems) {
const item = bibItems[itemId]; const item = bibItems[itemId];
if (item.branchName?.includes(CONFIG.BRANCH_NAME)) { if (item.branchName?.includes(CONFIG.BRANCH_NAME)) {
totalRanchoPenasquitos++; totalBranchItems++;
if (item.availability?.statusType !== 'UNAVAILABLE') { if (item.availability?.statusType !== 'UNAVAILABLE') {
availableRP++; availableBranchItems++;
console.log(`${itemId}: ${item.availability.statusType} (${item.availability.libraryStatus})`); console.log(`${itemId}: ${item.availability.statusType} (${item.availability.libraryStatus})`);
} else { } else {
checkedOutRP++; checkedOutBranchItems++;
if (checkedOutRP <= 3) { // Only show first 3 to avoid spam if (checkedOutBranchItems <= 3) { // Only show first 3 to avoid spam
console.log(`${itemId}: CHECKED OUT (due: ${item.dueDate || 'N/A'})`); console.log(`${itemId}: CHECKED OUT (due: ${item.dueDate || 'N/A'})`);
} }
} }
} }
} }
if (checkedOutRP > 3) { if (checkedOutBranchItems > 3) {
console.log(` ... and ${checkedOutRP - 3} more checked out items`); console.log(` ... and ${checkedOutBranchItems - 3} more checked out items`);
} }
console.log(`\n📈 Summary:`); console.log(`\n📈 Summary:`);
console.log(` Total items at ${CONFIG.BRANCH_NAME}: ${totalRanchoPenasquitos}`); console.log(` Total items at ${CONFIG.BRANCH_NAME}: ${totalBranchItems}`);
console.log(` Available: ${availableRP}`); console.log(` Available: ${availableBranchItems}`);
console.log(` Checked out: ${checkedOutRP}`); console.log(` Checked out: ${checkedOutBranchItems}`);
if (availableCount !== availableRP) { if (availableCount !== availableBranchItems) {
console.log(`\n⚠️ Warning: Count mismatch! Function returned ${availableCount} but manual count is ${availableRP}`); console.log(`\n⚠️ Warning: Count mismatch! Function returned ${availableCount} but manual count is ${availableBranchItems}`);
} else { } else {
console.log(`\n✅ Count validation passed!`); console.log(`\n✅ Count validation passed!`);
} }