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 ( +