diff --git a/CHANGELOG.md b/CHANGELOG.md index 95050a7..f592913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Planned - Satellite tracking with pass predictions - SOTA API integration -- Contest calendar - WebSocket DX cluster connection - Azimuthal equidistant projection option -## [3.0.0] - 2024-01-30 +## [3.3.0] - 2026-01-30 + +### Added +- **Contest Calendar** - Shows upcoming and active ham radio contests + - Integrates with WA7BNM Contest Calendar API + - Fallback calculation for major recurring contests (CQ WW, ARRL, etc.) + - Weekly mini-contests (CWT, SST, NCCC Sprint) + - Active contest highlighting with blinking indicator +- **Classic Layout** - New layout option inspired by original HamClock + - Side panels for DE/DX info, DX cluster, contests + - Large centered map + - Compact data-dense design +- **Theme System** - Three visual themes + - 🌙 Dark (default) - Modern dark theme with amber/cyan accents + - ☀️ Light - Bright theme for daytime use + - 📟 Legacy - Classic green-on-black CRT style +- **Quick Stats Panel** - Overview of active contests, POTA activators, DX spots +- **4-column modern layout** - Improved data organization +- **Settings persistence** - Theme and layout saved to localStorage + +### Changed +- Modern layout now uses 4-column grid for better information density +- Improved DX cluster API with multiple fallback sources +- Settings panel now includes theme and layout selection + +## [3.2.0] - 2026-01-30 + +### Added +- Theme support (dark, light, legacy) +- Layout selection in settings +- Real-time theme preview in settings + +## [3.1.0] - 2026-01-30 + +### Added +- User settings panel with callsign and location configuration +- Grid square entry with automatic lat/lon conversion +- Browser geolocation support ("Use My Current Location") +- Settings saved to localStorage + +### Fixed +- DX cluster now uses server proxy only (no CORS errors) +- Improved DX cluster API reliability with multiple sources + +## [3.0.0] - 2026-01-30 ### Added - **Real map tiles** via Leaflet.js - no more approximated shapes! @@ -83,7 +126,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | Version | Date | Highlights | |---------|------|------------| -| 3.0.0 | 2024-01-30 | Real maps, Electron, Docker, Railway | +| 3.3.0 | 2026-01-30 | Contest calendar, classic layout, themes | +| 3.2.0 | 2026-01-30 | Theme system (dark/light/legacy) | +| 3.1.0 | 2026-01-30 | User settings, DX cluster fixes | +| 3.0.0 | 2026-01-30 | Real maps, Electron, Docker, Railway | | 2.0.0 | 2024-01-29 | Live APIs, improved map | | 1.0.0 | 2024-01-29 | Initial release | diff --git a/package.json b/package.json index abf916f..708aa3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openhamclock", - "version": "3.2.0", + "version": "3.3.0", "description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map", "main": "server.js", "scripts": { diff --git a/public/index.html b/public/index.html index 049c241..8329532 100644 --- a/public/index.html +++ b/public/index.html @@ -605,6 +605,143 @@ return { data, loading }; }; + // ============================================ + // CONTEST CALENDAR HOOK + // ============================================ + const useContests = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchContests = async () => { + try { + // Try our proxy endpoint first + const response = await fetch('/api/contests'); + + if (response.ok) { + const contests = await response.json(); + if (contests && contests.length > 0) { + setData(contests.slice(0, 10)); + } else { + // No contests from API, use calculated upcoming contests + setData(getUpcomingContests()); + } + } else { + setData(getUpcomingContests()); + } + } catch (err) { + console.error('Contest fetch error:', err); + setData(getUpcomingContests()); + } finally { + setLoading(false); + } + }; + + fetchContests(); + const interval = setInterval(fetchContests, 3600000); // Refresh every hour + return () => clearInterval(interval); + }, []); + + return { data, loading }; + }; + + // Calculate upcoming major contests based on typical schedule + const getUpcomingContests = () => { + const now = new Date(); + const contests = []; + + // Major contest schedule (approximate - these follow patterns) + const majorContests = [ + { name: 'CQ WW DX CW', month: 10, weekend: 4, duration: 48 }, // Last full weekend Nov + { name: 'CQ WW DX SSB', month: 9, weekend: 4, duration: 48 }, // Last full weekend Oct + { name: 'ARRL DX CW', month: 1, weekend: 3, duration: 48 }, // 3rd full weekend Feb + { name: 'ARRL DX SSB', month: 2, weekend: 1, duration: 48 }, // 1st full weekend Mar + { name: 'CQ WPX SSB', month: 2, weekend: 4, duration: 48 }, // Last full weekend Mar + { name: 'CQ WPX CW', month: 4, weekend: 4, duration: 48 }, // Last full weekend May + { name: 'IARU HF Championship', month: 6, weekend: 2, duration: 24 }, // 2nd full weekend Jul + { name: 'ARRL Field Day', month: 5, weekend: 4, duration: 24 }, // 4th full weekend Jun + { name: 'ARRL Sweepstakes CW', month: 10, weekend: 1, duration: 24 }, // 1st full weekend Nov + { name: 'ARRL Sweepstakes SSB', month: 10, weekend: 3, duration: 24 }, // 3rd full weekend Nov + { name: 'ARRL 10m Contest', month: 11, weekend: 2, duration: 48 }, // 2nd full weekend Dec + { name: 'ARRL RTTY Roundup', month: 0, weekend: 1, duration: 24 }, // 1st full weekend Jan + { name: 'NA QSO Party CW', month: 0, weekend: 2, duration: 12 }, // 2nd full weekend Jan + { name: 'NA QSO Party SSB', month: 0, weekend: 3, duration: 12 }, // 3rd full weekend Jan + { name: 'CQ 160m CW', month: 0, weekend: 4, duration: 48 }, // Last full weekend Jan + { name: 'Winter Field Day', month: 0, weekend: 4, duration: 24 }, // Last full weekend Jan + ]; + + // Weekly/recurring contests + const weeklyContests = [ + { name: 'CWT Mini-Test', day: 3, time: '13:00z', duration: 1 }, // Wednesday + { name: 'CWT Mini-Test', day: 3, time: '19:00z', duration: 1 }, // Wednesday + { name: 'CWT Mini-Test', day: 3, time: '03:00z', duration: 1 }, // Thursday morning UTC + { name: 'NCCC Sprint', day: 5, time: '03:30z', duration: 0.5 }, // Friday + { name: 'K1USN SST', day: 1, time: '00:00z', duration: 1 }, // Monday + { name: 'ICWC MST', day: 1, time: '15:00z', duration: 1 }, // Monday + ]; + + // Get next occurrence of weekly contests + weeklyContests.forEach(contest => { + const next = new Date(now); + const daysUntil = (contest.day - now.getUTCDay() + 7) % 7; + next.setUTCDate(next.getUTCDate() + (daysUntil === 0 ? 7 : daysUntil)); + const [hours, mins] = contest.time.replace('z', '').split(':'); + next.setUTCHours(parseInt(hours), parseInt(mins), 0, 0); + + if (next > now) { + contests.push({ + name: contest.name, + start: next.toISOString(), + end: new Date(next.getTime() + contest.duration * 3600000).toISOString(), + mode: contest.name.includes('CW') ? 'CW' : 'Mixed', + status: 'upcoming' + }); + } + }); + + // Calculate next major contest dates + majorContests.forEach(contest => { + const year = now.getFullYear(); + for (let y = year; y <= year + 1; y++) { + const date = getNthWeekendOfMonth(y, contest.month, contest.weekend); + const endDate = new Date(date.getTime() + contest.duration * 3600000); + + if (endDate > now) { + const status = now >= date && now <= endDate ? 'active' : 'upcoming'; + contests.push({ + name: contest.name, + start: date.toISOString(), + end: endDate.toISOString(), + mode: contest.name.includes('CW') ? 'CW' : contest.name.includes('SSB') ? 'SSB' : contest.name.includes('RTTY') ? 'RTTY' : 'Mixed', + status: status + }); + break; + } + } + }); + + // Sort by start date and return top 10 + return contests + .sort((a, b) => new Date(a.start) - new Date(b.start)) + .slice(0, 10); + }; + + // Helper to get nth weekend of a month + const getNthWeekendOfMonth = (year, month, n) => { + const date = new Date(Date.UTC(year, month, 1, 0, 0, 0)); + let weekendCount = 0; + + while (weekendCount < n) { + if (date.getUTCDay() === 6) { // Saturday + weekendCount++; + if (weekendCount === n) break; + } + date.setUTCDate(date.getUTCDate() + 1); + } + + return date; + }; + // ============================================ // LEAFLET MAP COMPONENT // ============================================ @@ -994,6 +1131,330 @@ ); + // ============================================ + // CONTEST CALENDAR PANEL + // ============================================ + const ContestPanel = ({ contests, loading }) => { + const formatContestTime = (isoString) => { + const date = new Date(isoString); + const now = new Date(); + const diffMs = date - now; + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffHours / 24); + + if (diffMs < 0) { + // Contest is active or past + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } else if (diffHours < 24) { + return `In ${diffHours}h`; + } else if (diffDays < 7) { + return `In ${diffDays}d`; + } else { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + }; + + const getStatusColor = (contest) => { + if (contest.status === 'active') return 'var(--accent-green)'; + const start = new Date(contest.start); + const now = new Date(); + const hoursUntil = (start - now) / 3600000; + if (hoursUntil < 24) return 'var(--accent-amber)'; + return 'var(--text-secondary)'; + }; + + const getModeColor = (mode) => { + switch(mode) { + case 'CW': return 'var(--accent-cyan)'; + case 'SSB': return 'var(--accent-amber)'; + case 'RTTY': return 'var(--accent-purple)'; + default: return 'var(--text-secondary)'; + } + }; + + return ( +
+
+ 🏆 CONTESTS +
+ {loading &&
} +
+
+
+ {contests.length > 0 ? contests.map((c, i) => ( +
+
+ + {c.name} + {c.status === 'active' && } + + + {c.mode} + +
+
+ {c.status === 'active' ? 'NOW' : formatContestTime(c.start)} +
+
+ )) : ( +
+ No upcoming contests +
+ )} +
+
+ ); + }; + + // ============================================ + // LEGACY LAYOUT (Classic HamClock Style) + // ============================================ + const LegacyLayout = ({ + config, currentTime, utcTime, utcDate, localTime, localDate, + deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange, + spaceWeather, bandConditions, potaSpots, dxCluster, contests, + onSettingsClick + }) => { + const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon); + const distance = calculateDistance(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon); + + const panelStyle = { + background: 'var(--bg-panel)', + border: '1px solid var(--border-color)', + padding: '8px 12px', + fontFamily: 'JetBrains Mono, monospace' + }; + + const labelStyle = { + fontSize: '10px', + color: 'var(--accent-green)', + fontWeight: '700', + letterSpacing: '1px' + }; + + const valueStyle = { + fontSize: '14px', + color: 'var(--text-primary)', + fontWeight: '600' + }; + + const bigValueStyle = { + fontSize: '32px', + color: 'var(--accent-green)', + fontWeight: '700', + fontFamily: 'Orbitron, JetBrains Mono, monospace', + textShadow: '0 0 10px var(--accent-green-dim)' + }; + + return ( +
+ {/* TOP LEFT - Callsign & Time */} +
+
+ {config.callsign} +
+
+ SFI {spaceWeather.data?.solarFlux || '--'} • K{spaceWeather.data?.kIndex || '-'} • SSN {spaceWeather.data?.sunspotNumber || '--'} +
+
+ + {/* TOP CENTER - Large Clock */} +
+
+
{utcTime}
+
{utcDate} UTC
+
+
+
+
{localTime}
+
{localDate} Local
+
+
+ + {/* TOP RIGHT - Space Weather */} +
+
☀ SOLAR CONDITIONS
+
+
+
SFI
+
{spaceWeather.data?.solarFlux || '--'}
+
+
+
K-Index
+
4 ? 'var(--accent-red)' : 'var(--accent-green)', fontWeight: '700' }}>{spaceWeather.data?.kIndex || '-'}
+
+
+
SSN
+
{spaceWeather.data?.sunspotNumber || '--'}
+
+
+
Conditions
+
{spaceWeather.data?.conditions || '--'}
+
+
+
+ + {/* LEFT SIDEBAR - DE/DX Info */} +
+ {/* DE Section */} +
+
DE:
+
{deGrid}
+
+ {config.location.lat.toFixed(2)}°{config.location.lat >= 0 ? 'N' : 'S'}, {config.location.lon.toFixed(2)}°{config.location.lon >= 0 ? 'E' : 'W'} +
+
+ ☀↑ {deSunTimes.sunrise}z ☀↓ {deSunTimes.sunset}z +
+
+ + {/* DX Section */} +
+
DX:
+
{dxGrid}
+
+ {dxLocation.lat.toFixed(2)}°{dxLocation.lat >= 0 ? 'N' : 'S'}, {dxLocation.lon.toFixed(2)}°{dxLocation.lon >= 0 ? 'E' : 'W'} +
+
+ ☀↑ {dxSunTimes.sunrise}z ☀↓ {dxSunTimes.sunset}z +
+
+ + {/* Path Info */} +
+
+ SP: + {bearing.toFixed(0)}° +
+
+ LP: + {((bearing + 180) % 360).toFixed(0)}° +
+
+ Dist: + {Math.round(distance).toLocaleString()} km +
+
+ + {/* Band Conditions - Compact */} +
+
📡 BANDS
+
+ {bandConditions.data.slice(0, 9).map((b, i) => { + const color = b.condition === 'GOOD' ? 'var(--accent-green)' : b.condition === 'FAIR' ? 'var(--accent-amber)' : 'var(--accent-red)'; + return ( +
+
{b.band}
+
+ ); + })} +
+
+
+ + {/* CENTER - Map */} +
+ +
+ + {/* RIGHT SIDEBAR - DX Cluster & Contests */} +
+ {/* DX Cluster */} +
+
🌐 DX CLUSTER ● LIVE
+
+ {dxCluster.data.slice(0, 8).map((s, i) => ( +
+
+ {s.freq} + {s.call} +
+
{s.comment}
+
+ ))} +
+
+ + {/* Contests */} +
+
🏆 CONTESTS
+
+ {contests.data.slice(0, 5).map((c, i) => ( +
+
+ {c.name} {c.status === 'active' && } +
+
+ {c.mode} • {new Date(c.start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +
+
+ ))} +
+
+ + {/* POTA */} +
+
🏕 POTA
+
+ {potaSpots.data.slice(0, 4).map((a, i) => ( +
+ {a.call} + {a.ref} + {a.freq} +
+ ))} +
+
+
+ + {/* BOTTOM - Footer */} +
+ + OpenHamClock v3.3.0 • In memory of Elwood Downey WB0OEW + + + Click map to set DX • 73 de {config.callsign} + + +
+
+ ); + }; + // ============================================ // SETTINGS PANEL COMPONENT // ============================================ @@ -1327,6 +1788,7 @@ const bandConditions = useBandConditions(); const potaSpots = usePOTASpots(); const dxCluster = useDXCluster(); + const contests = useContests(); const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); @@ -1354,36 +1816,99 @@ const utcDate = currentTime.toISOString().substr(0, 10); const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + // Use Legacy Layout if selected + if (config.layout === 'legacy') { + return ( + <> + setShowSettings(true)} + /> + setShowSettings(false)} + config={config} + onSave={handleSaveConfig} + /> + + ); + } + + // Modern Layout (default) return (
-
setShowSettings(true)} /> +
setShowSettings(true)} /> -
+
{/* Row 1 */} + - {/* Row 2: Map */} -
+ {/* Row 2: Map + Side Panel */} +
Click anywhere on map to set DX location • Use buttons to change map style
- +
{/* Row 3 */} +
+
+ 📊 QUICK STATS +
+
+
+ Active Contests + {contests.data.filter(c => c.status === 'active').length} +
+
+ POTA Activators + {potaSpots.data.length} +
+
+ DX Spots + {dxCluster.data.length} +
+
+ Solar Flux + {spaceWeather.data?.solarFlux || '--'} +
+
+ Uptime + {uptime} +
+
+
{/* Settings Panel */} diff --git a/server.js b/server.js index 3c7f5cc..951311e 100644 --- a/server.js +++ b/server.js @@ -270,6 +270,194 @@ app.get('/api/qrz/lookup/:callsign', async (req, res) => { }); }); +// ============================================ +// CONTEST CALENDAR API +// ============================================ + +app.get('/api/contests', async (req, res) => { + console.log('[Contests] Fetching contest calendar...'); + + // Try WA7BNM Contest Calendar API + try { + const response = await fetch('https://www.contestcalendar.com/contestcal.json', { + headers: { + 'User-Agent': 'OpenHamClock/3.3', + 'Accept': 'application/json' + } + }); + + if (response.ok) { + const data = await response.json(); + console.log('[Contests] WA7BNM returned', data.length, 'contests'); + + const now = new Date(); + const contests = data + .filter(c => new Date(c.end) > now) // Only future/active + .slice(0, 20) + .map(c => { + const startDate = new Date(c.start); + const endDate = new Date(c.end); + let status = 'upcoming'; + if (now >= startDate && now <= endDate) { + status = 'active'; + } + + return { + name: c.name || c.contest, + start: startDate.toISOString(), + end: endDate.toISOString(), + mode: c.mode || 'Mixed', + status: status, + url: c.url || null + }; + }); + + return res.json(contests); + } + } catch (error) { + console.error('[Contests] WA7BNM error:', error.message); + } + + // Fallback: Calculate known recurring contests + try { + const contests = calculateUpcomingContests(); + console.log('[Contests] Using calculated contests:', contests.length); + return res.json(contests); + } catch (error) { + console.error('[Contests] Calculation error:', error.message); + } + + res.json([]); +}); + +// Helper function to calculate upcoming contests +function calculateUpcomingContests() { + const now = new Date(); + const contests = []; + + // Major contest definitions with typical schedules + const majorContests = [ + { name: 'CQ WW DX CW', month: 10, weekend: -1, duration: 48, mode: 'CW' }, // Last full weekend Nov + { name: 'CQ WW DX SSB', month: 9, weekend: -1, duration: 48, mode: 'SSB' }, // Last full weekend Oct + { name: 'ARRL DX CW', month: 1, weekend: 3, duration: 48, mode: 'CW' }, // 3rd full weekend Feb + { name: 'ARRL DX SSB', month: 2, weekend: 1, duration: 48, mode: 'SSB' }, // 1st full weekend Mar + { name: 'CQ WPX SSB', month: 2, weekend: -1, duration: 48, mode: 'SSB' }, // Last full weekend Mar + { name: 'CQ WPX CW', month: 4, weekend: -1, duration: 48, mode: 'CW' }, // Last full weekend May + { name: 'IARU HF Championship', month: 6, weekend: 2, duration: 24, mode: 'Mixed' }, // 2nd full weekend Jul + { name: 'ARRL Field Day', month: 5, weekend: 4, duration: 27, mode: 'Mixed' }, // 4th full weekend Jun + { name: 'ARRL Sweepstakes CW', month: 10, weekend: 1, duration: 24, mode: 'CW' }, // 1st full weekend Nov + { name: 'ARRL Sweepstakes SSB', month: 10, weekend: 3, duration: 24, mode: 'SSB' }, // 3rd full weekend Nov + { name: 'ARRL 10m Contest', month: 11, weekend: 2, duration: 48, mode: 'Mixed' }, // 2nd full weekend Dec + { name: 'ARRL RTTY Roundup', month: 0, weekend: 1, duration: 24, mode: 'RTTY' }, // 1st full weekend Jan + { name: 'NA QSO Party CW', month: 0, weekend: 2, duration: 12, mode: 'CW' }, + { name: 'NA QSO Party SSB', month: 0, weekend: 3, duration: 12, mode: 'SSB' }, + { name: 'CQ 160m CW', month: 0, weekend: -1, duration: 42, mode: 'CW' }, + { name: 'CQ WW RTTY', month: 8, weekend: -1, duration: 48, mode: 'RTTY' }, + { name: 'JIDX CW', month: 3, weekend: 2, duration: 48, mode: 'CW' }, + { name: 'JIDX SSB', month: 10, weekend: 2, duration: 48, mode: 'SSB' }, + ]; + + // Weekly mini-contests (CWT, SST, etc.) + const weeklyContests = [ + { name: 'CWT 1300z', dayOfWeek: 3, hour: 13, duration: 1, mode: 'CW' }, + { name: 'CWT 1900z', dayOfWeek: 3, hour: 19, duration: 1, mode: 'CW' }, + { name: 'CWT 0300z', dayOfWeek: 4, hour: 3, duration: 1, mode: 'CW' }, + { name: 'NCCC Sprint', dayOfWeek: 5, hour: 3, minute: 30, duration: 0.5, mode: 'CW' }, + { name: 'K1USN SST', dayOfWeek: 0, hour: 0, duration: 1, mode: 'CW' }, + { name: 'ICWC MST', dayOfWeek: 1, hour: 13, duration: 1, mode: 'CW' }, + ]; + + // Calculate next occurrences of weekly contests + weeklyContests.forEach(contest => { + const next = new Date(now); + const currentDay = now.getUTCDay(); + let daysUntil = contest.dayOfWeek - currentDay; + if (daysUntil < 0) daysUntil += 7; + if (daysUntil === 0) { + // Check if it's today but already passed + const todayStart = new Date(now); + todayStart.setUTCHours(contest.hour, contest.minute || 0, 0, 0); + if (now > todayStart) daysUntil = 7; + } + + next.setUTCDate(now.getUTCDate() + daysUntil); + next.setUTCHours(contest.hour, contest.minute || 0, 0, 0); + + const endTime = new Date(next.getTime() + contest.duration * 3600000); + + contests.push({ + name: contest.name, + start: next.toISOString(), + end: endTime.toISOString(), + mode: contest.mode, + status: (now >= next && now <= endTime) ? 'active' : 'upcoming' + }); + }); + + // Calculate next occurrences of major contests + const year = now.getFullYear(); + majorContests.forEach(contest => { + for (let y = year; y <= year + 1; y++) { + let startDate; + + if (contest.weekend === -1) { + // Last weekend of month + startDate = getLastWeekendOfMonth(y, contest.month); + } else { + // Nth weekend of month + startDate = getNthWeekendOfMonth(y, contest.month, contest.weekend); + } + + // Most contests start at 00:00 UTC Saturday + startDate.setUTCHours(0, 0, 0, 0); + const endDate = new Date(startDate.getTime() + contest.duration * 3600000); + + if (endDate > now) { + const status = (now >= startDate && now <= endDate) ? 'active' : 'upcoming'; + contests.push({ + name: contest.name, + start: startDate.toISOString(), + end: endDate.toISOString(), + mode: contest.mode, + status: status + }); + break; // Only add next occurrence + } + } + }); + + // Sort by start date + contests.sort((a, b) => new Date(a.start) - new Date(b.start)); + + return contests.slice(0, 15); +} + +function getNthWeekendOfMonth(year, month, n) { + const date = new Date(Date.UTC(year, month, 1, 0, 0, 0)); + let weekendCount = 0; + + while (date.getUTCMonth() === month) { + if (date.getUTCDay() === 6) { // Saturday + weekendCount++; + if (weekendCount === n) return new Date(date); + } + date.setUTCDate(date.getUTCDate() + 1); + } + + return date; +} + +function getLastWeekendOfMonth(year, month) { + // Start from last day of month and work backwards + const date = new Date(Date.UTC(year, month + 1, 0)); // Last day of month + + while (date.getUTCDay() !== 6) { // Find last Saturday + date.setUTCDate(date.getUTCDate() - 1); + } + + return date; +} + // ============================================ // HEALTH CHECK // ============================================ @@ -277,7 +465,7 @@ app.get('/api/qrz/lookup/:callsign', async (req, res) => { app.get('/api/health', (req, res) => { res.json({ status: 'ok', - version: '3.0.0', + version: '3.3.0', uptime: process.uptime(), timestamp: new Date().toISOString() });