Files
FlexDXClusterGui/static/index.html
2025-10-12 15:56:19 +05:30

2000 lines
102 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlexDXCluster Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body, html {
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh;
width: 100vw;
}
.animate-spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
.full-height { height: 100vh; display: flex; flex-direction: column; }
.scrollable { overflow-y: auto; flex: 1; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: rgb(15 23 42); }
::-webkit-scrollbar-thumb { background: rgb(51 65 85); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgb(71 85 105); }
input[type="text"] {
font-family: inherit;
}
input[type="text"]:focus {
outline: none;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.status-connected {
background: rgba(34, 197, 94, 0.2);
color: rgb(34, 197, 94);
}
.status-disconnected {
background: rgba(239, 68, 68, 0.2);
color: rgb(239, 68, 68);
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
z-index: 1000;
animation: slideInBottom 0.3s ease-out;
}
@keyframes slideInBottom {
from { transform: translateY(400px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideOutBottom {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(400px); opacity: 0; }
}
.toast.hiding {
animation: slideOutBottom 0.3s ease-out forwards;
}
.toast-success { background: rgb(34 197 94); color: white; }
.toast-error { background: rgb(239 68 68); color: white; }
.toast-warning { background: rgb(251 146 60); color: white; }
.toast-info { background: rgb(59 130 246); color: white; }
.error-banner {
background: linear-gradient(to right, rgb(239 68 68), rgb(220 38 38));
color: white;
padding: 12px;
text-align: center;
font-size: 14px;
border-bottom: 2px solid rgb(185 28 28);
}
.dx-callsign {
cursor: pointer;
transition: all 0.2s ease;
}
.dx-callsign:hover {
text-shadow: 0 0 8px rgba(59, 130, 246, 0.8);
transform: scale(1.05);
}
.toggle-checkbox {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
background-color: rgb(51 65 85);
border-radius: 24px;
transition: background-color 0.3s ease;
flex-shrink: 0;
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
transition: transform 0.3s ease;
}
.toggle-checkbox:checked + .toggle-label .toggle-switch {
background-color: rgb(59 130 246);
}
.toggle-checkbox:checked + .toggle-label .toggle-switch::after {
transform: translateX(20px);
}
.toggle-label:hover .toggle-switch {
background-color: rgb(71 85 105);
}
.toggle-checkbox:checked + .toggle-label:hover .toggle-switch {
background-color: rgb(37 99 235);
}
.ws-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.ws-connected {
background: rgba(34, 197, 94, 0.2);
color: rgb(34, 197, 94);
}
.ws-disconnected {
background: rgba(239, 68, 68, 0.2);
color: rgb(239, 68, 68);
}
.ws-connecting {
background: rgba(251, 146, 60, 0.2);
color: rgb(251, 146, 60);
}
.band-bar {
transition: all 0.5s ease;
cursor: pointer;
}
.band-bar:hover {
opacity: 0.8;
transform: scaleY(1.05);
}
.tab-button {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 8px 8px 0 0;
cursor: pointer;
transition: all 0.2s ease;
background: transparent;
color: rgb(148, 163, 184);
border: none;
}
.tab-button:hover {
background: rgba(51, 65, 85, 0.5);
color: rgb(203, 213, 225);
}
.tab-button.active {
background: rgba(59, 130, 246, 0.2);
color: rgb(96, 165, 250);
border-bottom: 2px solid rgb(59, 130, 246);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
height: 100%;
}
</style>
</head>
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
<div id="error-banner"></div>
<div id="toast-container"></div>
<div id="app" class="full-height p-4"></div>
<script>
const API_BASE_URL = 'http://localhost:8080/api';
const WS_URL = 'ws://localhost:8080/api/ws';
const BANDS = ['160M', '80M', '60M', '40M', '30M', '20M', '17M', '15M', '12M', '10M', '6M'];
const BAND_COLORS = {
'160M': '#ef4444',
'80M': '#f97316',
'60M': '#f59e0b',
'40M': '#eab308',
'30M': '#84cc16',
'20M': '#22c55e',
'17M': '#10b981',
'15M': '#14b8a6',
'12M': '#06b6d4',
'10M': '#0ea5e9',
'6M': '#3b82f6'
};
let ws = null;
let wsReconnectTimer = null;
let wsReconnectAttempts = 0;
let maxReconnectAttempts = 10;
let state = {
spots: [],
filteredSpots: [],
watchlistScrollTop: 0,
recentQSOs: [],
logStats: { today: 0, thisWeek: 0, thisMonth: 0, total: 0 },
dxccProgress: { worked: 0, total: 340 },
solarData: {
sfi: 'N/A',
sunspots: 'N/A',
aIndex: 'N/A',
kIndex: 'N/A'
},
stats: {
totalSpots: 0,
activeSpotters: 0,
newDXCC: 0,
connectedClients: 0,
totalContacts: 0,
clusterStatus: 'disconnected',
flexStatus: 'disconnected',
myCallsign: '',
filters: { skimmer: false, ft8: false, ft4: false }
},
topSpotters: [],
loading: true,
wsStatus: 'disconnected',
activeTab: 'stats',
watchlist: [],
spotFilters: {
showAll: true,
showNewDXCC: false,
showNewBand: false,
showNewMode: false,
showNewBandMode: false,
showNewSlot: false,
showWorked: false,
showWatchlist: false,
showDigital: false,
showSSB: false,
showCW: false,
band160M: false,
band80M: false,
band60M: false,
band40M: false,
band30M: false,
band20M: false,
band17M: false,
band15M: false,
band12M: false,
band10M: false,
band6M: false
}
};
// WebSocket Connection Management
function connectWebSocket() {
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
return;
}
state.wsStatus = 'connecting';
render();
try {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('WebSocket connected');
state.wsStatus = 'connected';
state.loading = false;
wsReconnectAttempts = 0;
hideErrorBanner();
showToast('Connected to server', 'success');
render();
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
state.wsStatus = 'disconnected';
};
ws.onclose = () => {
console.log('WebSocket closed');
state.wsStatus = 'disconnected';
render();
if (wsReconnectAttempts < maxReconnectAttempts) {
wsReconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, wsReconnectAttempts), 30000);
showErrorBanner(`Connection lost. Reconnecting in ${delay/1000}s... (attempt ${wsReconnectAttempts}/${maxReconnectAttempts})`);
wsReconnectTimer = setTimeout(() => {
connectWebSocket();
}, delay);
} else {
showErrorBanner('Unable to connect to server. Please refresh the page.');
}
};
} catch (error) {
console.error('Error creating WebSocket:', error);
state.wsStatus = 'disconnected';
render();
}
}
function handleWebSocketMessage(message) {
switch (message.type) {
case 'stats':
state.stats = message.data;
updateStatsOnly();
break;
case 'spots':
state.spots = message.data || [];
applySpotFilters();
updateSpotsTable();
updateSpotCounts();
if (state.activeTab === 'stats') {
updateBandPropagation();
}
break;
case 'spotters':
state.topSpotters = message.data || [];
if (state.activeTab === 'stats') {
updateTopSpotters();
}
break;
case 'watchlist':
state.watchlist = message.data || [];
if (state.activeTab === 'watchlist') {
updateWatchlistItems();
updateWatchlistCounters();
}
break;
case 'log':
state.recentQSOs = message.data || [];
if (state.activeTab === 'log') {
updateLogTable();
}
break;
case 'logStats':
state.logStats = message.data || {};
if (state.activeTab === 'log') {
updateLogStats();
}
break;
}
}
// Mettre à jour uniquement les stats
function updateStatsOnly() {
// Total Spots
const totalSpotsEl = document.querySelector('[data-stat="totalSpots"]');
if (totalSpotsEl) totalSpotsEl.textContent = state.stats.totalSpots;
// New DXCC
const newDxccEl = document.querySelector('[data-stat="newDXCC"]');
if (newDxccEl) newDxccEl.textContent = state.stats.newDXCC;
// Active Spotters
const spottersEl = document.querySelector('[data-stat="activeSpotters"]');
if (spottersEl) spottersEl.textContent = state.stats.activeSpotters;
// Connected Clients
const clientsEl = document.querySelector('[data-stat="connectedClients"]');
if (clientsEl) clientsEl.textContent = state.stats.connectedClients;
const totalContactsEl = document.querySelector('[data-stat="totalContacts"]');
if (totalContactsEl) totalContactsEl.textContent = state.stats.totalContacts;
// Status indicators
updateStatusIndicators();
updateFilterToggles();
}
function updateBandPropagation() {
const container = document.querySelector('[data-section="band-propagation"]');
if (!container) return;
const bandStats = getBandStats();
const maxSpots = Math.max(...Object.values(bandStats), 1);
container.innerHTML = BANDS.map(band => {
const count = bandStats[band];
const percentage = maxSpots > 0 ? (count / maxSpots) * 100 : 0;
const color = BAND_COLORS[band];
return `
<div class="band-bar" onclick="toggleSpotFilter('band${band}')">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold" style="color: ${color}">${band}</span>
<span class="text-xs text-slate-400">${count} spots</span>
</div>
<div class="w-full bg-slate-700/30 rounded-full h-2 overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
style="width: ${percentage}%; background-color: ${color}">
</div>
</div>
</div>
`;
}).join('');
}
function updateFilterButtons() {
// Mise à jour du bouton "All"
const allBtn = document.querySelector('[data-filter="showAll"]');
if (allBtn) {
if (state.spotFilters.showAll) {
allBtn.className = 'px-2 py-0.5 text-xs rounded transition-colors bg-blue-600 text-white';
} else {
allBtn.className = 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700';
}
}
// Mise à jour des filtres de type
const typeFilters = [
{ key: 'showWatchlist', color: 'pink' },
{ key: 'showNewDXCC', color: 'green' },
{ key: 'showNewBandMode', color: 'purple' },
{ key: 'showNewBand', color: 'yellow' },
{ key: 'showNewMode', color: 'orange' },
{ key: 'showNewSlot', color: 'sky' },
{ key: 'showWorked', color: 'cyan' }
];
typeFilters.forEach(filter => {
const btn = document.querySelector(`[data-filter="${filter.key}"]`);
if (btn) {
if (state.spotFilters[filter.key]) {
btn.className = `px-2 py-0.5 text-xs rounded transition-colors bg-${filter.color}-600 text-white`;
} else {
btn.className = 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700';
}
}
});
// Mise à jour des filtres de mode
const modeFilters = [
{ key: 'showDigital', color: 'teal' },
{ key: 'showSSB', color: 'amber' },
{ key: 'showCW', color: 'rose' }
];
modeFilters.forEach(filter => {
const btn = document.querySelector(`[data-filter="${filter.key}"]`);
if (btn) {
if (state.spotFilters[filter.key]) {
btn.className = `px-2 py-0.5 text-xs rounded transition-colors bg-${filter.color}-600 text-white`;
} else {
btn.className = 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700';
}
}
});
// Mise à jour des filtres de bande
const bands = ['160M', '80M', '60M', '40M', '30M', '20M', '17M', '15M', '12M', '10M', '6M'];
bands.forEach(band => {
const key = `band${band}`;
const btn = document.querySelector(`[data-filter="${key}"]`);
if (btn) {
if (state.spotFilters[key]) {
btn.className = 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white';
} else {
btn.className = 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700';
}
}
});
}
function updateStatusIndicators() {
// Cluster status
const clusterStatus = document.querySelector('[data-status="cluster"]');
if (clusterStatus) {
const isConnected = state.stats.clusterStatus === 'connected';
clusterStatus.className = `status-indicator ${isConnected ? 'status-connected' : 'status-disconnected'}`;
clusterStatus.innerHTML = `
<span class="w-2 h-2 ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'} rounded-full"></span>
Cluster
`;
}
// Flex status
const flexStatus = document.querySelector('[data-status="flex"]');
if (flexStatus) {
const isConnected = state.stats.flexStatus === 'connected';
flexStatus.className = `status-indicator ${isConnected ? 'status-connected' : 'status-disconnected'}`;
flexStatus.innerHTML = `
<span class="w-2 h-2 ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'} rounded-full"></span>
Flex
`;
}
}
function updateSpotCounts() {
// Vider le cache
countCache = {};
// Mise à jour manuelle de chaque bouton par son data-filter
const filters = [
{ selector: '[data-filter="showAll"]', count: state.spots.length },
{ selector: '[data-filter="showWatchlist"]', count: countWatchlistSpots() },
{ selector: '[data-filter="showNewDXCC"]', count: countSpotsByType('newDXCC') },
{ selector: '[data-filter="showNewBandMode"]', count: countSpotsByType('newBandMode') },
{ selector: '[data-filter="showNewBand"]', count: countSpotsByType('newBand') },
{ selector: '[data-filter="showNewMode"]', count: countSpotsByType('newMode') },
{ selector: '[data-filter="showNewSlot"]', count: countSpotsByType('newSlot') },
{ selector: '[data-filter="showWorked"]', count: countSpotsByType('worked') },
{ selector: '[data-filter="showDigital"]', count: countSpotsByMode('digital') },
{ selector: '[data-filter="showSSB"]', count: countSpotsByMode('ssb') },
{ selector: '[data-filter="showCW"]', count: countSpotsByMode('cw') },
{ selector: '[data-filter="band160M"]', count: countSpotsByType('160M') },
{ selector: '[data-filter="band80M"]', count: countSpotsByType('80M') },
{ selector: '[data-filter="band60M"]', count: countSpotsByType('60M') },
{ selector: '[data-filter="band40M"]', count: countSpotsByType('40M') },
{ selector: '[data-filter="band30M"]', count: countSpotsByType('30M') },
{ selector: '[data-filter="band20M"]', count: countSpotsByType('20M') },
{ selector: '[data-filter="band17M"]', count: countSpotsByType('17M') },
{ selector: '[data-filter="band15M"]', count: countSpotsByType('15M') },
{ selector: '[data-filter="band12M"]', count: countSpotsByType('12M') },
{ selector: '[data-filter="band10M"]', count: countSpotsByType('10M') },
{ selector: '[data-filter="band6M"]', count: countSpotsByType('6M') }
];
filters.forEach(filter => {
const btn = document.querySelector(filter.selector);
if (btn) {
// Remplacer le nombre entre parenthèses
btn.textContent = btn.textContent.replace(/\(\d+\)/, `(${filter.count})`);
}
});
}
function updateTopSpotters() {
const container = document.querySelector('[data-section="top-spotters"]');
if (!container) return;
container.innerHTML = state.topSpotters.slice(0, 3).map((spotter, index) => `
<div class="flex items-center justify-between mb-2 p-2 bg-slate-900/30 rounded hover:bg-slate-700/30 transition-colors">
<div class="flex items-center gap-2">
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center text-xs font-bold">
${index + 1}
</div>
<span class="text-sm font-semibold">${spotter.Spotter}</span>
</div>
<span class="text-slate-400 font-mono text-xs">${spotter.NumberofSpots}</span>
</div>
`).join('');
}
function updateLogTable() {
const container = document.querySelector('[data-section="recent-qsos"]');
if (!container) return;
if (state.recentQSOs.length === 0) {
container.innerHTML = `
<div class="text-center py-8 text-slate-400">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm">No QSOs in log</p>
</div>
`;
return;
}
container.innerHTML = `
<table class="w-full text-xs">
<thead class="bg-slate-900/50 sticky top-0">
<tr class="text-left text-xs text-slate-400">
<th class="p-2 font-semibold">Date</th>
<th class="p-2 font-semibold">Time</th>
<th class="p-2 font-semibold">Callsign</th>
<th class="p-2 font-semibold">Band</th>
<th class="p-2 font-semibold">Mode</th>
<th class="p-2 font-semibold">RST S/R</th>
<th class="p-2 font-semibold">Country</th>
</tr>
</thead>
<tbody>
${state.recentQSOs.map(qso => {
let qsoDate = 'N/A';
let qsoTime = 'N/A';
if (qso.date) {
const d = new Date(qso.date.replace(' ', 'T')); // Assure la compatibilité
qsoDate = d.toISOString().split('T')[0];
qsoTime = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
return `
<tr class="border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors">
<td class="p-2 text-slate-300">${qsoDate}</td>
<td class="p-2 text-slate-300">${qsoTime}</td>
<td class="p-2">
<span class="font-bold text-blue-400">${qso.callsign}</span>
</td>
<td class="p-2">
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">${qso.band || 'N/A'}</span>
</td>
<td class="p-2">
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">${qso.mode || 'N/A'}</span>
</td>
<td class="p-2 font-mono text-xs text-slate-400">${qso.rstSent || '---'} / ${qso.rstRcvd || '---'}</td>
<td class="p-2 text-slate-400">${qso.country || 'N/A'}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
function updateLogStats() {
// Mettre à jour les cartes de stats
const todayEl = document.querySelector('[data-log-stat="today"]');
if (todayEl) todayEl.textContent = state.logStats.today || 0;
const weekEl = document.querySelector('[data-log-stat="thisWeek"]');
if (weekEl) weekEl.textContent = state.logStats.thisWeek || 0;
const monthEl = document.querySelector('[data-log-stat="thisMonth"]');
if (monthEl) monthEl.textContent = state.logStats.thisMonth || 0;
const totalEl = document.querySelector('[data-log-stat="total"]');
if (totalEl) totalEl.textContent = state.logStats.total || 0;
// Mettre à jour la barre DXCC
const dxccWorkedEl = document.querySelector('[data-dxcc="worked"]');
if (dxccWorkedEl) dxccWorkedEl.textContent = state.dxccProgress.worked || 0;
const dxccBarEl = document.querySelector('[data-dxcc="bar"]');
if (dxccBarEl) {
dxccBarEl.style.width = `${state.dxccProgress.percentage || 0}%`;
}
const dxccPercentEl = document.querySelector('[data-dxcc="percentage"]');
if (dxccPercentEl) {
dxccPercentEl.textContent = `${(state.dxccProgress.percentage || 0).toFixed(1)}% Complete`;
}
}
// Toast notifications
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<div class="flex items-center gap-2">
${type === 'success' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ''}
${type === 'error' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>' : ''}
${type === 'warning' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>' : ''}
<span>${message}</span>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
function showErrorBanner(message) {
const banner = document.getElementById('error-banner');
banner.innerHTML = `
<div class="flex items-center justify-between max-w-7xl mx-auto">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span>${message}</span>
</div>
<button onclick="hideErrorBanner()" class="hover:bg-red-700 px-3 py-1 rounded">✕</button>
</div>
`;
banner.className = 'error-banner';
}
function hideErrorBanner() {
document.getElementById('error-banner').className = '';
document.getElementById('error-banner').innerHTML = '';
}
async function fetchWithRetry(url, options = {}) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
} catch (error) {
throw error;
}
}
function switchTab(tabName) {
state.activeTab = tabName;
render();
}
async function sendCallsignToLog4OM(callsign, frequency, mode) {
try {
const response = await fetchWithRetry(`${API_BASE_URL}/send-callsign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
callsign: callsign,
frequency: frequency,
mode: mode
})
});
const data = await response.json();
if (data.success) {
showToast(`${callsign} Sent - Radio tuned on ${frequency} in ${mode}`, 'success');
} else {
showToast('Échec de l\'envoi', 'error');
}
} catch (error) {
console.error('Erreur lors de l\'envoi:', error);
showToast(`Erreur: ${error.message}`, 'error');
}
}
let filterCache = new Map();
async function fetchPropagationData() {
try {
const response = await fetch(`${API_BASE_URL}/solar`);
const json = await response.json();
if (!json.success) {
throw new Error(json.error || 'Failed to fetch solar data');
}
return {
sfi: json.data.sfi || 'N/A',
sunspots: json.data.sunspots || 'N/A',
aIndex: json.data.aIndex || 'N/A',
kIndex: json.data.kIndex || 'N/A'
};
} catch (error) {
console.error('Error fetching propagation data:', error);
return {
sfi: 'N/A',
sunspots: 'N/A',
aIndex: 'N/A',
kIndex: 'N/A'
};
}
}
async function fetchLogData() {
try {
// Fetch recent QSOs
const qsosResponse = await fetch(`${API_BASE_URL}/log/recent?limit=10`);
const qsosJson = await qsosResponse.json();
if (qsosJson.success) {
state.recentQSOs = qsosJson.data || [];
}
// Fetch stats
const statsResponse = await fetch(`${API_BASE_URL}/log/stats`);
const statsJson = await statsResponse.json();
if (statsJson.success) {
state.logStats = statsJson.data || { today: 0, thisWeek: 0, thisMonth: 0, total: 0 };
}
// Fetch DXCC progress
const dxccResponse = await fetch(`${API_BASE_URL}/log/dxcc-progress`);
const dxccJson = await dxccResponse.json();
if (dxccJson.success) {
state.dxccProgress = dxccJson.data || { worked: 0, total: 340, percentage: 0 };
}
if (state.activeTab === 'log') {
updateLogTable();
updateLogStats();
}
} catch (error) {
console.error('Error fetching log data:', error);
}
}
async function fetchWatchlistSpotsWithStatus() {
try {
const response = await fetch(`${API_BASE_URL}/watchlist/spots`);
const json = await response.json();
if (json.success) {
return json.data || [];
}
return [];
} catch (error) {
console.error('Error fetching watchlist spots:', error);
return [];
}
}
function updateSolarData() {
const sfiEl = document.querySelector('[data-solar="sfi"]');
if (sfiEl) {
sfiEl.className = getSFIColor(state.solarData.sfi);
sfiEl.textContent = state.solarData.sfi;
}
const ssnEl = document.querySelector('[data-solar="ssn"]');
if (ssnEl) {
ssnEl.className = getSunspotsColor(state.solarData.sunspots);
ssnEl.textContent = state.solarData.sunspots;
}
const aIndexEl = document.querySelector('[data-solar="aIndex"]');
if (aIndexEl) {
aIndexEl.className = getAIndexColor(state.solarData.aIndex);
aIndexEl.textContent = state.solarData.aIndex;
}
const kIndexEl = document.querySelector('[data-solar="kIndex"]');
if (kIndexEl) {
kIndexEl.className = getKIndexColor(state.solarData.kIndex);
kIndexEl.textContent = state.solarData.kIndex;
}
}
function applySpotFilters() {
if (state.spotFilters.showAll) {
state.filteredSpots = state.spots;
return;
}
const bandFiltersActive = state.spotFilters.band160M || state.spotFilters.band80M ||
state.spotFilters.band60M || state.spotFilters.band40M || state.spotFilters.band30M ||
state.spotFilters.band20M || state.spotFilters.band17M || state.spotFilters.band15M ||
state.spotFilters.band12M || state.spotFilters.band10M || state.spotFilters.band6M;
const typeFiltersActive = state.spotFilters.showNewDXCC || state.spotFilters.showNewBand ||
state.spotFilters.showNewMode || state.spotFilters.showNewBandMode ||
state.spotFilters.showNewSlot || state.spotFilters.showWorked || state.spotFilters.showWatchlist;
const modeFiltersActive = state.spotFilters.showDigital || state.spotFilters.showSSB || state.spotFilters.showCW;
state.filteredSpots = state.spots.filter(spot => {
let matchesBand = false;
let matchesType = false;
let matchesMode = false;
// Filtres de bande
if (bandFiltersActive) {
matchesBand = (
(state.spotFilters.band160M && spot.Band === '160M') ||
(state.spotFilters.band80M && spot.Band === '80M') ||
(state.spotFilters.band60M && spot.Band === '60M') ||
(state.spotFilters.band40M && spot.Band === '40M') ||
(state.spotFilters.band30M && spot.Band === '30M') ||
(state.spotFilters.band20M && spot.Band === '20M') ||
(state.spotFilters.band17M && spot.Band === '17M') ||
(state.spotFilters.band15M && spot.Band === '15M') ||
(state.spotFilters.band12M && spot.Band === '12M') ||
(state.spotFilters.band10M && spot.Band === '10M') ||
(state.spotFilters.band6M && spot.Band === '6M')
);
}
// Filtres de type
if (typeFiltersActive) {
if (state.spotFilters.showWatchlist) {
const inWatchlist = state.watchlist.some(pattern => {
return spot.DX === pattern || spot.DX.startsWith(pattern);
});
if (inWatchlist) {
matchesType = true;
}
}
if (state.spotFilters.showNewDXCC && spot.NewDXCC) {
matchesType = true;
} else if (state.spotFilters.showNewBandMode && spot.NewBand && spot.NewMode && !spot.NewDXCC) {
matchesType = true;
} else if (state.spotFilters.showNewBand && spot.NewBand && !spot.NewMode && !spot.NewDXCC) {
matchesType = true;
} else if (state.spotFilters.showNewMode && spot.NewMode && !spot.NewBand && !spot.NewDXCC) {
matchesType = true;
} else if (state.spotFilters.showNewSlot && spot.NewSlot && !spot.NewDXCC && !spot.NewBand && !spot.NewMode) {
matchesType = true;
} else if (state.spotFilters.showWorked && spot.Worked) {
matchesType = true;
}
}
// Filtres de mode
if (modeFiltersActive) {
const mode = spot.Mode || '';
if (state.spotFilters.showDigital) {
if (mode === 'FT8' || mode === 'FT4' || mode === 'RTTY') {
matchesMode = true;
}
}
if (state.spotFilters.showSSB) {
if (mode === 'SSB' || mode === 'USB' || mode === 'LSB') {
matchesMode = true;
}
}
if (state.spotFilters.showCW) {
if (mode === 'CW') {
matchesMode = true;
}
}
}
// Logique de combinaison des filtres
const numActiveFilterTypes = [bandFiltersActive, typeFiltersActive, modeFiltersActive].filter(Boolean).length;
if (numActiveFilterTypes === 0) return false;
if (numActiveFilterTypes === 1) {
if (bandFiltersActive) return matchesBand;
if (typeFiltersActive) return matchesType;
if (modeFiltersActive) return matchesMode;
}
if (numActiveFilterTypes === 2) {
if (bandFiltersActive && typeFiltersActive) return matchesBand && matchesType;
if (bandFiltersActive && modeFiltersActive) return matchesBand && matchesMode;
if (typeFiltersActive && modeFiltersActive) return matchesType && matchesMode;
}
if (numActiveFilterTypes === 3) {
return matchesBand && matchesType && matchesMode;
}
return false;
});
}
function toggleSpotFilter(filterName) {
if (filterName === 'showAll') {
state.spotFilters = {
showAll: true,
showNewDXCC: false,
showNewBand: false,
showNewMode: false,
showNewBandMode: false,
showNewSlot: false,
showWorked: false,
showWatchlist: false,
showDigital: false,
showSSB: false,
showCW: false,
band160M: false,
band80M: false,
band60M: false,
band40M: false,
band30M: false,
band20M: false,
band17M: false,
band15M: false,
band12M: false,
band10M: false,
band6M: false
};
} else {
state.spotFilters.showAll = false;
state.spotFilters[filterName] = !state.spotFilters[filterName];
const anyActive = Object.keys(state.spotFilters).some(key =>
key !== 'showAll' && state.spotFilters[key]
);
if (!anyActive) {
state.spotFilters.showAll = true;
}
}
applySpotFilters();
updateSpotsTable();
updateSpotCounts();
updateFilterButtons();
}
async function updateFilter(filterName, value) {
try {
const response = await fetchWithRetry(`${API_BASE_URL}/filters`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [filterName]: value })
});
const data = await response.json();
if (data.success) {
state.stats.filters[filterName] = value;
showToast(`Filtre ${filterName} mis à jour`, 'success');
}
} catch (error) {
console.error('Erreur lors de la mise à jour du filtre:', error);
showToast(`Erreur de mise à jour: ${error.message}`, 'error');
}
}
async function shutdownApp() {
try {
const response = await fetchWithRetry(`${API_BASE_URL}/shutdown`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
showToast('FlexDXCluster s\'arrête...', 'info');
if (ws) ws.close();
setTimeout(() => {
document.body.innerHTML = '<div class="min-h-screen flex items-center justify-center bg-slate-900 text-white"><div class="text-center"><h1 class="text-4xl font-bold mb-4">FlexDXCluster Arrêté</h1><p class="text-slate-400">L\'application a été arrêtée avec succès.</p></div></div>';
}, 1000);
}
} catch (error) {
console.error('Erreur lors de l\'arrêt:', error);
showToast(`Impossible d\'arrêter l\'application: ${error.message}`, 'error');
}
}
function getPriorityColor(spot) {
const inWatchlist = state.watchlist.some(pattern => {
return spot.DX === pattern || spot.DX.startsWith(pattern);
});
if (inWatchlist) return 'bg-pink-500/20 text-pink-400 border-pink-500/50';
if (spot.DX === state.stats.myCallsign) return 'bg-red-500/20 text-red-400 border-red-500/50';
if (spot.NewDXCC) return 'bg-green-500/20 text-green-400 border-green-500/50';
if (spot.NewBand && spot.NewMode) return 'bg-purple-500/20 text-purple-400 border-purple-500/50';
if (spot.NewBand) return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50';
if (spot.NewMode) return 'bg-orange-500/20 text-orange-400 border-orange-500/50';
if (spot.Worked) return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/50';
return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
}
function getStatusLabel(spot) {
const inWatchlist = state.watchlist.some(pattern => {
return spot.DX === pattern || spot.DX.startsWith(pattern);
});
if (inWatchlist) return 'Watchlist';
if (spot.DX === state.stats.myCallsign) return 'My Call';
if (spot.NewDXCC) return 'New DXCC';
if (spot.NewBand && spot.NewMode) return 'New B&M';
if (spot.NewBand) return 'New Band';
if (spot.NewMode) return 'New Mode';
if (spot.NewSlot) return 'New Slot';
if (spot.Worked) return 'Worked';
return '';
}
let countCache = {};
function countSpotsByType(type) {
if (countCache[type] !== undefined) return countCache[type];
let count;
switch(type) {
case 'newDXCC':
count = state.spots.filter(s => s.NewDXCC).length;
break;
case 'newBandMode':
count = state.spots.filter(s => s.NewBand && s.NewMode && !s.NewDXCC).length;
break;
case 'newBand':
count = state.spots.filter(s => s.NewBand && !s.NewMode && !s.NewDXCC).length;
break;
case 'newMode':
count = state.spots.filter(s => s.NewMode && !s.NewBand && !s.NewDXCC).length;
break;
case 'newSlot':
count = state.spots.filter(s => s.NewSlot && !s.NewDXCC && !s.NewBand && !s.NewMode).length;
break;
case 'worked':
count = state.spots.filter(s => s.Worked).length;
break;
case '160M': count = state.spots.filter(s => s.Band === '160M').length; break;
case '80M': count = state.spots.filter(s => s.Band === '80M').length; break;
case '60M': count = state.spots.filter(s => s.Band === '60M').length; break;
case '40M': count = state.spots.filter(s => s.Band === '40M').length; break;
case '30M': count = state.spots.filter(s => s.Band === '30M').length; break;
case '20M': count = state.spots.filter(s => s.Band === '20M').length; break;
case '17M': count = state.spots.filter(s => s.Band === '17M').length; break;
case '15M': count = state.spots.filter(s => s.Band === '15M').length; break;
case '12M': count = state.spots.filter(s => s.Band === '12M').length; break;
case '10M': count = state.spots.filter(s => s.Band === '10M').length; break;
case '6M': count = state.spots.filter(s => s.Band === '6M').length; break;
default: count = state.spots.length;
}
countCache[type] = count;
return count;
}
function countSpotsByMode(mode) {
if (countCache[mode] !== undefined) return countCache[mode];
let count;
switch(mode) {
case 'digital':
count = state.spots.filter(s => s.Mode === 'FT8' || s.Mode === 'FT4' || s.Mode === 'RTTY').length;
break;
case 'ssb':
count = state.spots.filter(s => s.Mode === 'SSB' || s.Mode === 'USB' || s.Mode === 'LSB').length;
break;
case 'cw':
count = state.spots.filter(s => s.Mode === 'CW').length;
break;
default:
count = 0;
}
countCache[mode] = count;
return count;
}
function countWatchlistSpots() {
return state.spots.filter(spot => {
return state.watchlist.some(pattern => {
return spot.DX === pattern || spot.DX.startsWith(pattern);
});
}).length;
}
function getBandStats() {
const stats = {};
BANDS.forEach(band => {
stats[band] = state.spots.filter(s => s.Band === band).length;
});
return stats;
}
function getSunspotsColor(sunspots) {
const value = parseInt(sunspots);
if (isNaN(value)) return 'text-slate-500';
if (value >= 100) return 'text-green-400 font-bold';
if (value >= 50) return 'text-yellow-400';
return 'text-orange-400';
}
function getSFIColor(sfi) {
const value = parseInt(sfi);
if (isNaN(value)) return 'text-slate-500';
if (value >= 150) return 'text-green-400 font-bold';
if (value >= 100) return 'text-yellow-400';
return 'text-red-400';
}
function getAIndexColor(aIndex) {
const value = parseInt(aIndex);
if (isNaN(value)) return 'text-slate-500';
if (value <= 7) return 'text-green-400 font-bold';
if (value <= 15) return 'text-yellow-400';
return 'text-red-400';
}
function getKIndexColor(kIndex) {
const value = parseInt(kIndex);
if (isNaN(value)) return 'text-slate-500';
if (value <= 2) return 'text-green-400 font-bold';
if (value <= 4) return 'text-yellow-400';
return 'text-red-400';
}
async function addToWatchlist() {
const input = document.getElementById('watchlist-input');
const callsign = input.value.trim().toUpperCase();
if (!callsign) {
showToast('Please enter a callsign', 'warning');
return;
}
try {
const response = await fetchWithRetry(`${API_BASE_URL}/watchlist/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callsign })
});
const data = await response.json();
if (data.success) {
input.value = '';
showToast(`${callsign} added to watchlist`, 'success');
} else {
showToast('Failed to add callsign', 'error');
}
} catch (error) {
console.error('Error adding to watchlist:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
async function removeFromWatchlist(callsign) {
try {
const response = await fetchWithRetry(`${API_BASE_URL}/watchlist/remove`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callsign })
});
const data = await response.json();
if (data.success) {
showToast(`${callsign} removed from watchlist`, 'success');
} else {
showToast('Failed to remove callsign', 'error');
}
} catch (error) {
console.error('Error removing from watchlist:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
function countWatchlistSpots() {
return state.spots.filter(spot => {
return state.watchlist.some(pattern => {
return spot.DX === pattern || spot.DX.startsWith(pattern);
});
}).length;
}
function getWebSocketStatusHTML() {
const statuses = {
'connected': '<span class="ws-indicator ws-connected"><span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>WebSocket</span>',
'connecting': '<span class="ws-indicator ws-connecting"><span class="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></span>Connecting...</span>',
'disconnected': '<span class="ws-indicator ws-disconnected"><span class="w-2 h-2 bg-red-500 rounded-full"></span>Disconnected</span>'
};
return statuses[state.wsStatus] || statuses['disconnected'];
}
function updateWatchlistCounters() {
// Mettre à jour le compteur en haut
const counterElement = document.querySelector('.watchlist-counter');
if (counterElement) {
counterElement.textContent = `${countWatchlistSpots()} matching spots`;
}
// Mettre à jour les compteurs de chaque callsign
state.watchlist.forEach(callsign => {
const matchingSpots = state.spots.filter(s =>
s.DX === callsign || s.DX.startsWith(callsign)
).length;
const counterEl = document.querySelector(`[data-watchlist="${callsign}"] .spot-counter`);
if (counterEl) {
counterEl.textContent = `${matchingSpots} active spot${matchingSpots !== 1 ? 's' : ''}`;
}
});
}
function render() {
countCache = {};
const app = document.getElementById('app');
if (state.loading) {
app.innerHTML = `
<div class="h-full flex items-center justify-center">
<div class="text-center">
<div class="w-12 h-12 border-4 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-slate-400">Connecting to server...</p>
</div>
</div>
`;
return;
}
// Vérifier si on est en train de taper dans l'input watchlist
const watchlistInput = document.getElementById('watchlist-input');
const isTypingInWatchlist = state.activeTab === 'watchlist' &&
watchlistInput &&
document.activeElement === watchlistInput;
// Si on tape dans la watchlist, faire seulement un update partiel
if (isTypingInWatchlist) {
updateWatchlistCounters();
updateSpotsTable(); // Mettre à jour seulement le tableau des spots
return;
}
const spotsContainer = document.querySelector('.scrollable');
const scrollTop = spotsContainer ? spotsContainer.scrollTop : 0;
// Sauvegarder la position de scroll de la watchlist
const watchlistScroll = document.querySelector('#watchlist-items-container')?.parentElement;
if (watchlistScroll && state.activeTab === 'watchlist') {
state.watchlistScrollTop = watchlistScroll.scrollTop;
}
app.innerHTML = `
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<svg class="w-8 h-8 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
<div>
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">FlexDXCluster</h1>
<div class="flex items-center gap-3 text-xs text-slate-400">
<span>F4BPO • <span data-stat="totalContacts">${state.stats.totalContacts}</span> Contacts</span>
<span class="text-slate-600">|</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-amber-400">SFI:</span>
<span data-solar="sfi" class="${getSFIColor(state.solarData.sfi)}">${state.solarData.sfi}</span>
</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-yellow-400">SSN:</span>
<span data-solar="ssn" class="${getSunspotsColor(state.solarData.sunspots)}">${state.solarData.sunspots}</span>
</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-red-400">A:</span>
<span data-solar="aIndex" class="${getAIndexColor(state.solarData.aIndex)}">${state.solarData.aIndex}</span>
</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-purple-400">K:</span>
<span data-solar="kIndex" class="${getKIndexColor(state.solarData.kIndex)}">${state.solarData.kIndex}</span>
</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
${getWebSocketStatusHTML()}
<span class="status-indicator ${state.stats.clusterStatus === 'connected' ? 'status-connected' : 'status-disconnected'}" data-status="cluster">
<span class="w-2 h-2 ${state.stats.clusterStatus === 'connected' ? 'bg-green-500' : 'bg-red-500'} rounded-full ${state.stats.clusterStatus === 'connected' ? 'animate-pulse' : ''}"></span>
Cluster
</span>
<span class="status-indicator ${state.stats.flexStatus === 'connected' ? 'status-connected' : 'status-disconnected'}" data-status="flex">
<span class="w-2 h-2 ${state.stats.flexStatus === 'connected' ? 'bg-green-500' : 'bg-red-500'} rounded-full ${state.stats.flexStatus === 'connected' ? 'animate-pulse' : ''}"></span>
Flex
</span>
<button onclick="shutdownApp()" class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Shutdown
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-3 mb-3">
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div class="text-xl font-bold text-blue-400" data-stat="totalSpots">${state.stats.totalSpots}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Total Spots</p>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xl font-bold text-green-400" data-stat="newDXCC">${state.stats.newDXCC}</div>
</div>
<p class="text-xs text-slate-400 mt-1">New DXCC</p>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div class="text-xl font-bold text-purple-400" data-stat="activeSpotters">${state.stats.activeSpotters}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Spotters</p>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
<div class="text-xl font-bold text-orange-400" data-stat="connectedClients">${state.stats.connectedClients}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Clients</p>
</div>
<div class="col-span-3 bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-center gap-6 h-full">
<input type="checkbox" id="skimmer" class="toggle-checkbox" ${state.stats.filters.skimmer ? 'checked' : ''}
onchange="updateFilter('skimmer', this.checked)" />
<label for="skimmer" class="toggle-label">
<div class="toggle-switch"></div>
<span class="text-sm font-medium">CW</span>
</label>
<input type="checkbox" id="ft8" class="toggle-checkbox" ${state.stats.filters.ft8 ? 'checked' : ''}
onchange="updateFilter('ft8', this.checked)" />
<label for="ft8" class="toggle-label">
<div class="toggle-switch"></div>
<span class="text-sm font-medium">FT8</span>
</label>
<input type="checkbox" id="ft4" class="toggle-checkbox" ${state.stats.filters.ft4 ? 'checked' : ''}
onchange="updateFilter('ft4', this.checked)" />
<label for="ft4" class="toggle-label">
<div class="toggle-switch"></div>
<span class="text-sm font-medium">FT4</span>
</label>
</div>
</div>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-2 border border-slate-700/50 mb-3">
<div class="flex items-center gap-1 flex-wrap">
<span class="text-xs font-bold text-slate-400 mr-2">TYPE:</span>
<button onclick="toggleSpotFilter('showAll')" data-filter="showAll"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showAll ? 'bg-blue-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
All (${state.spots.length})
</button>
<button onclick="toggleSpotFilter('showWatchlist')" data-filter="showWatchlist"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showWatchlist ? 'bg-pink-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Watch (${countWatchlistSpots()})
</button>
<button onclick="toggleSpotFilter('showNewDXCC')" data-filter="showNewDXCC"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showNewDXCC ? 'bg-green-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
DXCC (${countSpotsByType('newDXCC')})
</button>
<button onclick="toggleSpotFilter('showNewBandMode')" data-filter="showNewBandMode"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showNewBandMode ? 'bg-purple-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
B&M (${countSpotsByType('newBandMode')})
</button>
<button onclick="toggleSpotFilter('showNewBand')" data-filter="showNewBand"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showNewBand ? 'bg-yellow-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Band (${countSpotsByType('newBand')})
</button>
<button onclick="toggleSpotFilter('showNewMode')" data-filter="showNewMode"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showNewMode ? 'bg-orange-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Mode (${countSpotsByType('newMode')})
</button>
<button onclick="toggleSpotFilter('showNewSlot')" data-filter="showNewSlot"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showNewSlot ? 'bg-sky-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Slot (${countSpotsByType('newSlot')})
</button>
<button onclick="toggleSpotFilter('showWorked')" data-filter="showWorked"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showWorked ? 'bg-cyan-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Wkd (${countSpotsByType('worked')})
</button>
<span class="text-slate-600 mx-1">|</span>
<span class="text-xs font-bold text-slate-400 mr-2">MODE:</span>
<button onclick="toggleSpotFilter('showDigital')" data-filter="showDigital"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showDigital ? 'bg-teal-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Digi (${countSpotsByMode('digital')})
</button>
<button onclick="toggleSpotFilter('showSSB')" data-filter="showSSB"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showSSB ? 'bg-amber-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
SSB (${countSpotsByMode('ssb')})
</button>
<button onclick="toggleSpotFilter('showCW')" data-filter="showCW"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.showCW ? 'bg-rose-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
CW (${countSpotsByMode('cw')})
</button>
<span class="text-slate-600 mx-1">|</span>
<span class="text-xs font-bold text-slate-400 mr-2">BAND:</span>
<button onclick="toggleSpotFilter('band160M')" data-filter="band160M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band160M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
160 (${countSpotsByType('160M')})
</button>
<button onclick="toggleSpotFilter('band80M')" data-filter="band80M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band80M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
80 (${countSpotsByType('80M')})
</button>
<button onclick="toggleSpotFilter('band60M')" data-filter="band60M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band60M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
60 (${countSpotsByType('60M')})
</button>
<button onclick="toggleSpotFilter('band40M')" data-filter="band40M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band40M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
40 (${countSpotsByType('40M')})
</button>
<button onclick="toggleSpotFilter('band30M')" data-filter="band30M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band30M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
30 (${countSpotsByType('30M')})
</button>
<button onclick="toggleSpotFilter('band20M')" data-filter="band20M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band20M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
20 (${countSpotsByType('20M')})
</button>
<button onclick="toggleSpotFilter('band17M')" data-filter="band17M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band17M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
17 (${countSpotsByType('17M')})
</button>
<button onclick="toggleSpotFilter('band15M')" data-filter="band15M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band15M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
15 (${countSpotsByType('15M')})
</button>
<button onclick="toggleSpotFilter('band12M')" data-filter="band12M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band12M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
12 (${countSpotsByType('12M')})
</button>
<button onclick="toggleSpotFilter('band10M')" data-filter="band10M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band10M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
10 (${countSpotsByType('10M')})
</button>
<button onclick="toggleSpotFilter('band6M')" data-filter="band6M"
class="px-2 py-0.5 text-xs rounded transition-colors ${state.spotFilters.band6M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
6 (${countSpotsByType('6M')})
</button>
</div>
</div>
<div class="grid grid-cols-4 gap-3" style="height: calc(100vh - 360px);">
<div class="col-span-3 bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden">
<div class="p-3 border-b border-slate-700/50 flex items-center justify-between">
<h2 class="text-lg font-bold">Recent Spots (<span id="spots-count">${state.filteredSpots.length}</span>)</h2>
</div>
<div class="scrollable" id="spots-table-container">
<!-- Table will be injected by updateSpotsTable() -->
</div>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden">
<!-- Tabs Header -->
<div class="flex border-b border-slate-700/50 bg-slate-900/30">
<button class="tab-button ${state.activeTab === 'stats' ? 'active' : ''}"
onclick="switchTab('stats')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Stats
</button>
<button class="tab-button ${state.activeTab === 'watchlist' ? 'active' : ''}"
onclick="switchTab('watchlist')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Watchlist
</button>
<button class="tab-button ${state.activeTab === 'log' ? 'active' : ''}"
onclick="switchTab('log')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Log
</button>
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-hidden">
<!-- Stats Tab -->
<div class="tab-content ${state.activeTab === 'stats' ? 'active' : ''} h-full overflow-y-auto">
<div class="p-3 border-b border-slate-700/50">
<h2 class="text-lg font-bold">Top 3 Spotters</h2>
</div>
<div class="p-3" data-section="top-spotters">
${state.topSpotters.slice(0, 3).map((spotter, index) => `
<div class="flex items-center justify-between mb-2 p-2 bg-slate-900/30 rounded hover:bg-slate-700/30 transition-colors">
<div class="flex items-center gap-2">
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center text-xs font-bold">
${index + 1}
</div>
<span class="text-sm font-semibold">${spotter.Spotter}</span>
</div>
<span class="text-slate-400 font-mono text-xs">${spotter.NumberofSpots}</span>
</div>
`).join('')}
</div>
<div class="p-3 border-t border-slate-700/50 flex-shrink-0">
<h2 class="text-lg font-bold mb-3">Band Propagation</h2>
</div>
<div class="px-3 pb-3 space-y-2" data-section="band-propagation">
<!-- Le contenu sera injecté par updateBandPropagation() -->
</div>
</div>
<!-- Watchlist Tab -->
<div class="tab-content ${state.activeTab === 'watchlist' ? 'active' : ''}" style="height: 100%; display: ${state.activeTab === 'watchlist' ? 'flex' : 'none'}; flex-direction: column; overflow: hidden;">
<div class="p-3 border-b border-slate-700/50 flex-shrink-0">
<h2 class="text-lg font-bold mb-2">Watchlist</h2>
<p class="text-xs text-slate-400 mb-3 watchlist-counter">${countWatchlistSpots()} matching spots</p>
<div class="flex gap-2">
<input
type="text"
id="watchlist-input"
placeholder="Enter callsign or prefix (e.g., VK9)"
class="flex-1 px-3 py-2 bg-slate-700/50 border border-slate-600 rounded text-sm text-white placeholder-slate-400 focus:outline-none focus:border-blue-500"
onkeypress="if(event.key === 'Enter') { event.preventDefault(); addToWatchlist(); }"
/>
<button
onclick="addToWatchlist()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium transition-colors">
Add
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto" style="min-height: 0;">
<div class="p-3" id="watchlist-items-container">
<!-- Watchlist items will be injected by updateWatchlistItems() -->
</div>
</div>
</div>
<!-- Log Tab -->
<div class="tab-content ${state.activeTab === 'log' ? 'active' : ''} h-full overflow-hidden flex flex-col">
<!-- Stats cards en haut -->
<div class="p-3 border-b border-slate-700/50 flex-shrink-0">
<h2 class="text-lg font-bold mb-3">Station Log</h2>
<div class="grid grid-cols-4 gap-2 mb-3">
<div class="bg-slate-900/50 rounded p-2">
<div class="text-xs text-slate-400">Today</div>
<div class="text-xl font-bold text-blue-400" data-log-stat="today">${state.logStats.today || 0}</div>
</div>
<div class="bg-slate-900/50 rounded p-2">
<div class="text-xs text-slate-400">This Week</div>
<div class="text-xl font-bold text-green-400">${state.logStats.thisWeek || 0}</div>
</div>
<div class="bg-slate-900/50 rounded p-2">
<div class="text-xs text-slate-400">This Month</div>
<div class="text-xl font-bold text-purple-400">${state.logStats.thisMonth || 0}</div>
</div>
<div class="bg-slate-900/50 rounded p-2">
<div class="text-xs text-slate-400">Total</div>
<div class="text-xl font-bold text-orange-400">${state.logStats.total || 0}</div>
</div>
</div>
<!-- DXCC Progress Bar -->
<div class="bg-slate-900/50 rounded p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-slate-300">DXCC Progress</span>
<span class="text-sm font-bold text-green-400">${state.dxccProgress.worked || 0} / ${state.dxccProgress.total || 340}</span>
</div>
<div class="w-full bg-slate-700/30 rounded-full h-3 overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-green-500 to-emerald-500 transition-all duration-500"
data-dxcc="bar" style="width: ${state.dxccProgress.percentage || 0}%">
</div>
</div>
<div class="text-xs text-slate-400 text-right mt-1">${(state.dxccProgress.percentage || 0).toFixed(1)}% Complete</div>
</div>
</div>
<!-- Tableau QSOs en bas scrollable -->
<div class="flex-1 overflow-y-auto" style="min-height: 0;">
<div class="p-3">
<h3 class="text-sm font-bold text-slate-400 mb-2">Recent QSOs</h3>
${state.recentQSOs.length === 0 ? `
<div class="text-center py-8 text-slate-400">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm">No QSOs in log</p>
</div>
` : `
<table class="w-full text-xs">
<thead class="bg-slate-900/50 sticky top-0">
<tr class="text-left text-xs text-slate-400">
<th class="p-2 font-semibold">Date</th>
<th class="p-2 font-semibold">Time</th>
<th class="p-2 font-semibold">Callsign</th>
<th class="p-2 font-semibold">Band</th>
<th class="p-2 font-semibold">Mode</th>
<th class="p-2 font-semibold">RST S/R</th>
<th class="p-2 font-semibold">Country</th>
</tr>
</thead>
<tbody>
${state.recentQSOs.map(qso => {
let qsoDate = 'N/A';
let qsoTime = 'N/A';
if (qso.date) {
const d = new Date(qso.date.replace(' ', 'T')); // Assure la compatibilité
qsoDate = d.toISOString().split('T')[0];
qsoTime = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
return `
<tr class="border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors">
<td class="p-2 text-slate-300">${qsoDate}</td>
<td class="p-2 text-slate-300">${qsoTime}</td>
<td class="p-2">
<span class="font-bold text-blue-400">${qso.callsign}</span>
</td>
<td class="p-2">
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">${qso.band || 'N/A'}</span>
</td>
<td class="p-2">
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">${qso.mode || 'N/A'}</span>
</td>
<td class="p-2 font-mono text-xs text-slate-400">${qso.rstSent || '---'} / ${qso.rstRcvd || '---'}</td>
<td class="p-2 text-slate-400">${qso.country || 'N/A'}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`}
</div>
</div>
</div>
</div>
</div>
`;
// Injecter initialement les tables
updateSpotsTable();
updateWatchlistItems();
if (state.activeTab === 'stats') {
updateBandPropagation();
updateTopSpotters(); // Au cas où
}
// Restaurer le scroll de la table des spots
const newSpotsContainer = document.querySelector('.scrollable');
if (newSpotsContainer && scrollTop > 0) {
newSpotsContainer.scrollTop = scrollTop;
}
// Restaurer le scroll de la watchlist
const newWatchlistScroll = document.querySelector('#watchlist-items-container')?.parentElement;
if (newWatchlistScroll && state.activeTab === 'watchlist' && state.watchlistScrollTop > 0) {
newWatchlistScroll.scrollTop = state.watchlistScrollTop;
}
}
function updateFilterToggles() {
const skimmerCheckbox = document.getElementById('skimmer');
if (skimmerCheckbox) {
skimmerCheckbox.checked = state.stats.filters.skimmer;
}
const ft8Checkbox = document.getElementById('ft8');
if (ft8Checkbox) {
ft8Checkbox.checked = state.stats.filters.ft8;
}
const ft4Checkbox = document.getElementById('ft4');
if (ft4Checkbox) {
ft4Checkbox.checked = state.stats.filters.ft4;
}
}
function updateSpotsTable() {
const container = document.getElementById('spots-table-container');
if (!container) return;
const countElement = document.getElementById('spots-count');
if (countElement) {
countElement.textContent = state.filteredSpots.length;
}
container.innerHTML = `
<table class="w-full text-sm">
<thead class="bg-slate-900/50 sticky top-0">
<tr class="text-left text-xs text-slate-400">
<th class="p-2 font-semibold">DX</th>
<th class="p-2 font-semibold">Freq</th>
<th class="p-2 font-semibold">Band</th>
<th class="p-2 font-semibold">Mode</th>
<th class="p-2 font-semibold">Spotter</th>
<th class="p-2 font-semibold">Time</th>
<th class="p-2 font-semibold">Country</th>
<th class="p-2 font-semibold">Status</th>
</tr>
</thead>
<tbody>
${state.filteredSpots.map(spot => `
<tr class="border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors text-sm">
<td class="p-2">
<span class="font-bold text-blue-400 dx-callsign"
onclick="sendCallsignToLog4OM('${spot.DX}', '${spot.FrequencyMhz}', '${spot.Mode}')"
title="Click to send to Log4OM and tune radio">
${spot.DX}
</span>
</td>
<td class="p-2 font-mono text-xs">${spot.FrequencyMhz}</td>
<td class="p-2"><span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">${spot.Band}</span></td>
<td class="p-2"><span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">${spot.Mode}</span></td>
<td class="p-2 text-slate-300 text-xs">${spot.SpotterCallsign}</td>
<td class="p-2 text-slate-400 text-xs">${spot.UTCTime}</td>
<td class="p-2 text-slate-400 text-xs">${spot.CountryName || 'N/A'}</td>
<td class="p-2">
${getStatusLabel(spot) ? `<span class="px-1.5 py-0.5 rounded text-xs font-semibold border ${getPriorityColor(spot)}">${getStatusLabel(spot)}</span>` : ''}
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function updateWatchlistItems() {
const container = document.getElementById('watchlist-items-container');
if (!container) return;
if (state.watchlist.length === 0) {
container.innerHTML = `
<div class="text-center py-8 text-slate-400">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<p class="text-sm">No callsigns in watchlist</p>
<p class="text-xs mt-1">Add callsigns or prefixes to monitor</p>
</div>
`;
return;
}
// Récupérer les spots enrichis avec le statut "worked"
const watchlistSpots = await fetchWatchlistSpotsWithStatus();
// Grouper les spots par callsign/prefix
const spotsByCallsign = {};
watchlistSpots.forEach(spot => {
// Trouver le pattern correspondant
let matchedPattern = '';
for (const pattern of state.watchlist) {
if (spot.dx === pattern || spot.dx.startsWith(pattern)) {
matchedPattern = pattern;
break;
}
}
if (!spotsByCallsign[matchedPattern]) {
spotsByCallsign[matchedPattern] = [];
}
spotsByCallsign[matchedPattern].push(spot);
});
container.innerHTML = state.watchlist.map(callsign => {
const spots = spotsByCallsign[callsign] || [];
const matchingCount = spots.length;
// Trier les spots : Needed en premier, puis Worked
spots.sort((a, b) => {
if (!a.workedBandMode && b.workedBandMode) return -1;
if (a.workedBandMode && !b.workedBandMode) return 1;
return 0;
});
// Afficher les spots actifs pour ce callsign
const spotsHtml = spots.length > 0 ? `
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto">
${spots.map(spot => {
const workedIcon = spot.workedBandMode
? '<svg class="w-4 h-4 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>'
: '<svg class="w-4 h-4 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>';
const statusBadge = spot.workedBandMode
? '<span class="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded text-xs font-semibold">Worked</span>'
: '<span class="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded text-xs font-semibold">Needed!</span>';
return `
<div class="flex items-center justify-between p-2 bg-slate-800/50 rounded text-xs cursor-pointer hover:bg-slate-700/50 transition-colors ${!spot.workedBandMode ? 'border-l-2 border-orange-500' : ''}"
onclick="sendCallsignToLog4OM('${spot.dx}', '${spot.frequencyMhz}', '${spot.mode}')"
title="Click to send to Log4OM and tune radio">
<div class="flex items-center gap-2 flex-1 min-w-0">
${workedIcon}
<span class="font-bold text-blue-400">${spot.dx}</span>
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded flex-shrink-0">${spot.band}</span>
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded flex-shrink-0">${spot.mode}</span>
<span class="text-slate-400 font-mono truncate">${spot.frequencyMhz}</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
${statusBadge}
<span class="text-slate-500">${spot.utcTime}</span>
</div>
</div>
`;
}).join('')}
</div>
` : '<div class="mt-2 text-xs text-slate-500 text-center py-2 bg-slate-800/30 rounded">No active spots</div>';
const neededCount = spots.filter(s => !s.workedBandMode).length;
let neededBadge = '';
if (matchingCount > 0) {
neededBadge = neededCount > 0
? `<span class="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded text-xs font-semibold">${neededCount} needed</span>`
: '<span class="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded text-xs font-semibold">All worked</span>';
}
return `
<div class="mb-3 p-3 bg-slate-900/30 rounded hover:bg-slate-700/30 transition-colors border ${neededCount > 0 ? 'border-orange-500/30' : 'border-slate-700/50'}" data-watchlist="${callsign}">
<div class="flex items-center justify-between mb-2">
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<div class="font-bold text-pink-400 text-lg">${callsign}</div>
<span class="text-xs text-slate-400">${matchingCount} active spot${matchingCount !== 1 ? 's' : ''}</span>
${neededBadge}
</div>
</div>
<button
onclick="event.stopPropagation(); removeFromWatchlist('${callsign}')"
class="px-2 py-1 text-xs bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded transition-colors">
Remove
</button>
</div>
${spotsHtml}
</div>
`;
}).join('');
}
function updateWatchlistCounters() {
// Mettre à jour le compteur en haut
const counterElement = document.querySelector('.watchlist-counter');
if (counterElement) {
counterElement.textContent = `${countWatchlistSpots()} matching spots`;
}
}
async function initPropagation() {
state.solarData = await fetchPropagationData();
// Mettre à jour toutes les 15 minutes
setInterval(async () => {
state.solarData = await fetchPropagationData();
updateSolarData();
}, 15 * 60 * 1000);
}
// Initialize
async function init() {
await initPropagation();
await fetchLogData();
connectWebSocket();
}
init();
setInterval(async () => {
if (state.activeTab === 'log') {
await fetchLogData();
}
}, 5000);
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (ws) {
ws.close();
}
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
}
});
</script>
</body>
</html>