Refactor: Complete codebase reorganization and modernization

- Split server.js routes into modular files (server/routes/)
  - departures.js: Departure data endpoints
  - sites.js: Site search and nearby sites
  - config.js: Configuration endpoints

- Reorganized file structure following Node.js best practices:
  - Moved sites-config.json to config/sites.json
  - Moved API_RESPONSE_DOCUMENTATION.md to docs/
  - Moved raspberry-pi-setup.sh to scripts/
  - Archived legacy files to archive/ directory

- Updated all code references to new file locations
- Added archive/ to .gitignore to exclude legacy files from repo
- Updated README.md with new structure and organization
- All functionality tested and working correctly

Version: 1.2.0
This commit is contained in:
2026-01-01 10:51:58 +01:00
parent d15142f1c6
commit 1c44b8ccde
28 changed files with 3196 additions and 3294 deletions

198
server/routes/sites.js Normal file
View File

@@ -0,0 +1,198 @@
/**
* Sites route handler
* Handles site search and nearby sites queries
*/
const https = require('https');
/**
* Normalize site data from API response to consistent format
* @param {Object} site - Raw site data from API
* @returns {Object} - Normalized site object
*/
function normalizeSite(site) {
return {
id: String(site.id || site.siteId || site.SiteId || ''),
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
lat: site.lat || site.latitude || site.Lat || site.Latitude || null,
lon: site.lon || site.longitude || site.Lon || site.Longitude || null
};
}
/**
* Parse sites from API response (handles multiple response formats)
* @param {Object|Array} parsedData - Parsed JSON data from API
* @returns {Array<Object>} - Array of normalized sites
*/
function parseSitesFromResponse(parsedData) {
let sites = [];
if (Array.isArray(parsedData)) {
sites = parsedData.map(normalizeSite);
} else if (parsedData.sites && Array.isArray(parsedData.sites)) {
sites = parsedData.sites.map(normalizeSite);
} else if (parsedData.ResponseData && parsedData.ResponseData.Result) {
sites = parsedData.ResponseData.Result.map(normalizeSite);
}
return sites;
}
/**
* Handle site search endpoint
* @param {http.IncomingMessage} req - HTTP request object
* @param {http.ServerResponse} res - HTTP response object
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
*/
function handleSiteSearch(req, res, parsedUrl) {
const query = parsedUrl.query.q;
if (!query || query.length < 2) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
return;
}
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(query)}`;
console.log(`Searching sites: ${searchUrl}`);
https.get(searchUrl, (apiRes) => {
let data = '';
if (apiRes.statusCode < 200 || apiRes.statusCode >= 300) {
console.error(`API returned status code: ${apiRes.statusCode}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] }));
return;
}
apiRes.on('data', (chunk) => {
data += chunk;
});
apiRes.on('end', () => {
try {
console.log('Raw API response:', data.substring(0, 500));
const parsedData = JSON.parse(data);
console.log('Parsed data:', JSON.stringify(parsedData).substring(0, 500));
const sites = parseSitesFromResponse(parsedData);
if (sites.length > 0) {
console.log('Sample site structure:', JSON.stringify(sites[0], null, 2));
const sitesWithCoords = sites.filter(s => s.lat && s.lon);
console.log(`Found ${sites.length} sites, ${sitesWithCoords.length} with coordinates`);
} else {
console.log('No sites found');
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites }));
} catch (error) {
console.error('Error parsing site search response:', error);
console.error('Response data:', data.substring(0, 500));
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Error parsing search results', details: error.message, sites: [] }));
}
});
}).on('error', (error) => {
console.error('Error searching sites:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Error searching sites', details: error.message, sites: [] }));
});
}
/**
* Calculate distance between two coordinates (simple approximation)
* @param {number} lat1 - Latitude of point 1
* @param {number} lon1 - Longitude of point 1
* @param {number} lat2 - Latitude of point 2
* @param {number} lon2 - Longitude of point 2
* @returns {number} - Distance in meters
*/
function calculateDistance(lat1, lon1, lat2, lon2) {
return Math.sqrt(
Math.pow((lat1 - lat2) * 111000, 2) +
Math.pow((lon1 - lon2) * 111000 * Math.cos(lat1 * Math.PI / 180), 2)
);
}
/**
* Handle nearby sites endpoint
* @param {http.IncomingMessage} req - HTTP request object
* @param {http.ServerResponse} res - HTTP response object
* @param {url.UrlWithParsedQuery} parsedUrl - Parsed URL object
*/
function handleNearbySites(req, res, parsedUrl) {
const lat = parseFloat(parsedUrl.query.lat);
const lon = parseFloat(parsedUrl.query.lon);
const radius = parseInt(parsedUrl.query.radius) || 5000; // Default 5km radius
if (isNaN(lat) || isNaN(lon)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid latitude or longitude', sites: [] }));
return;
}
// Use a broader search to get sites, then filter by distance
const searchTerms = ['Stockholm', 'T-Centralen', 'Gamla Stan', 'Södermalm'];
const allSites = [];
let completedSearches = 0;
searchTerms.forEach(term => {
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(term)}`;
https.get(searchUrl, (apiRes) => {
let data = '';
apiRes.on('data', (chunk) => {
data += chunk;
});
apiRes.on('end', () => {
try {
const parsedData = JSON.parse(data);
const sites = parseSitesFromResponse(parsedData);
sites.forEach(site => {
if (site.lat && site.lon) {
const distance = calculateDistance(lat, lon, site.lat, site.lon);
if (distance <= radius) {
allSites.push(site);
}
}
});
completedSearches++;
if (completedSearches === searchTerms.length) {
// Remove duplicates
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
}
} catch (error) {
completedSearches++;
if (completedSearches === searchTerms.length) {
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
}
}
});
}).on('error', () => {
completedSearches++;
if (completedSearches === searchTerms.length) {
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sites: uniqueSites }));
}
});
});
}
module.exports = {
handleSiteSearch,
handleNearbySites,
normalizeSite,
parseSitesFromResponse
};