cloudlog/application/views/dxcluster/index.php
Peter Goodhall cee6cd2def Refactor DXCC checks; add CW RBN lock UI
Replace inline DB queries in Dxcluster with a new Logbook_model::check_if_dxcc_worked_in_logbook method to centralize DXCC existence checks (per-band and overall). Remove several debug log statements from Dxcluster. Add client-side logic in dxcluster and qso views to enforce a CW↔RBN rule: when mode is 'cw' the "hide RBN" option is forced off and the checkbox is disabled (and restored when leaving CW), and apply that lock on load and mode changes. Also disable track-band when the user manually changes band in the QSO cluster UI.
2026-03-29 13:37:16 +01:00

725 lines
No EOL
29 KiB
PHP

<div class="container">
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="mb-0"><?php echo $page_title; ?></h3>
<div>
<span id="connectionStatus" class="badge bg-secondary me-2">Connecting...</span>
<a href="<?php echo site_url('dxcluster/bandmap'); ?>" class="btn btn-primary btn-sm" target="_blank">
<i class="fas fa-chart-line"></i> Bandmap
</a>
</div>
</div>
<div class="card-body">
<!-- Filters and Radio Control Panel -->
<div class="card mb-3">
<div class="card-body py-2">
<div id="radio_status"></div>
<div class="d-flex align-items-center gap-3 flex-wrap">
<!-- Radio Selection -->
<div class="d-flex align-items-center">
<label for="radio" class="form-label mb-0 me-2" style="white-space: nowrap;">
<i class="fas fa-broadcast-tower"></i> Radio:
</label>
<select class="form-select form-select-sm radios" id="radio" name="radio" style="min-width: 150px;">
<option value="0" selected="selected"><?php echo lang('general_word_none'); ?></option>
<?php foreach ($radios->result() as $row) { ?>
<option value="<?php echo $row->id; ?>" <?php if ($this->session->userdata('radio') == $row->id) { echo "selected=\"selected\""; } ?>><?php echo $row->radio; ?></option>
<?php } ?>
</select>
</div>
<!-- Band Filter -->
<div class="d-flex align-items-center">
<label for="bandFilter" class="form-label mb-0 me-2" style="white-space: nowrap;">
<i class="fas fa-wave-square"></i> Band:
</label>
<select class="form-select form-select-sm" id="bandFilter" style="min-width: 120px;">
<option value="all">All Bands</option>
<option value="160m">160m</option>
<option value="80m">80m</option>
<option value="60m">60m</option>
<option value="40m">40m</option>
<option value="30m">30m</option>
<option value="20m">20m</option>
<option value="17m">17m</option>
<option value="15m">15m</option>
<option value="12m">12m</option>
<option value="10m">10m</option>
<option value="6m">6m</option>
<option value="4m">4m</option>
<option value="2m">2m</option>
<option value="70cm">70cm</option>
<option value="23cm">23cm</option>
<option value="ghz">GHz+</option>
</select>
</div>
<!-- Mode Filter -->
<div class="d-flex align-items-center">
<label for="modeFilter" class="form-label mb-0 me-2" style="white-space: nowrap;">
<i class="fas fa-sliders-h"></i> Mode:
</label>
<select class="form-select form-select-sm" id="modeFilter" style="min-width: 110px;">
<option value="all">All Modes</option>
<option value="cw">CW</option>
<option value="ssb">SSB</option>
<option value="digi">Digital</option>
</select>
</div>
<!-- RBN Filter -->
<div class="form-check">
<input class="form-check-input" type="checkbox" id="hideRbnSpots" checked>
<label class="form-check-label" for="hideRbnSpots">
<i class="fas fa-filter"></i> Hide RBN Spots
</label>
</div>
<!-- New DXCC Filter -->
<div class="d-flex align-items-center">
<label for="newDxccFilter" class="form-label mb-0 me-2" style="white-space: nowrap;">
<i class="fas fa-star"></i> New:
</label>
<select class="form-select form-select-sm" id="newDxccFilter" style="min-width: 150px;">
<option value="all">All Spots</option>
<option value="new_any">New DXCC or Band</option>
<option value="new_dxcc">New DXCC only</option>
<option value="new_band">New Band only</option>
</select>
</div>
<!-- Info -->
<small class="text-muted ms-auto">
<i class="fas fa-info-circle"></i> Filters active
</small>
</div>
</div>
</div>
<div class="table-responsive">
<table id="dxSpotsTable" class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Time</th>
<th>DX Call</th>
<th>Frequency</th>
<th>Spotter</th>
<th>Comment</th>
<th>Age</th>
</tr>
</thead>
<tbody id="spotsTableBody">
<!-- Spots will be populated here -->
</tbody>
</table>
</div>
<div class="mt-3">
<div class="alert alert-info mb-2">
<strong><i class="fas fa-info-circle"></i> Legend:</strong>
<div class="mt-2">
<span class="me-3"><i class="fas fa-check text-success"></i> = Worked on this band</span>
<span class="me-3"><i class="fas fa-check text-info"></i> = Worked on another band</span>
<span class="me-3"><i class="fas fa-times text-secondary"></i> = Not worked</span>
</div>
<div class="mt-1">
<span class="me-3"><span class="badge bg-danger">New Band</span> = New DXCC on this band</span>
<span class="me-3"><span class="badge bg-warning text-dark">New DXCC</span> = New DXCC entity (never worked)</span>
</div>
<div class="mt-2 pt-2 border-top">
<small><i class="fas fa-mouse-pointer"></i> <strong>Tip:</strong> Click any frequency to QSY your radio (if selected above)</small>
</div>
</div>
<small class="text-muted">
<i class="fas fa-info-circle"></i>
Real-time DX cluster spots. Data refreshes automatically via WebSocket connection.
</small>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.connection-status.connected {
background-color: #28a745 !important;
}
.connection-status.disconnected {
background-color: #dc3545 !important;
}
.connection-status.connecting {
background-color: #ffc107 !important;
color: #212529 !important;
}
.spot-age {
font-size: 0.8em;
}
.spot-fresh {
background-color: rgba(40, 167, 69, 0.1) !important;
}
.spot-old {
opacity: 0.7;
}
.frequency-link {
color: #0d6efd;
text-decoration: none;
font-weight: 500;
font-family: 'Courier New', 'Monaco', 'Menlo', monospace;
font-size: 0.95em;
}
.frequency-link:hover {
color: #0a58ca;
text-decoration: underline;
}
.frequency-cell {
font-family: 'Courier New', 'Monaco', 'Menlo', monospace;
font-size: 0.95em;
}
</style>
<script src="<?php echo base_url(); ?>assets/js/dxcluster-utils.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
let ws = null;
let spots = new Map();
let dataTable = null;
let hideRbnSpots = true; // Default to hiding RBN spots
let selectedBand = 'all'; // Default to all bands
let selectedMode = 'all'; // Default to all modes
let selectedNewDxcc = 'all'; // Default to all spots
let workedStatus = {}; // Cache for worked status
let checkWorkedTimeout = null;
const connectionStatus = document.getElementById('connectionStatus');
const spotsTableBody = document.getElementById('spotsTableBody');
const hideRbnCheckbox = document.getElementById('hideRbnSpots');
const bandFilterSelect = document.getElementById('bandFilter');
const modeFilterSelect = document.getElementById('modeFilter');
const newDxccFilterSelect = document.getElementById('newDxccFilter');
// Load RBN filter preference from localStorage
const savedRbnPreference = localStorage.getItem('cloudlog_hideRbnSpots');
if (savedRbnPreference !== null) {
hideRbnSpots = savedRbnPreference === 'true';
hideRbnCheckbox.checked = hideRbnSpots;
}
// Load band filter preference from localStorage
const savedBandPreference = localStorage.getItem('cloudlog_bandFilter');
if (savedBandPreference !== null) {
selectedBand = savedBandPreference;
bandFilterSelect.value = selectedBand;
}
// Load mode filter preference from localStorage
const savedModePreference = localStorage.getItem('cloudlog_modeFilter');
if (savedModePreference !== null) {
selectedMode = savedModePreference;
modeFilterSelect.value = selectedMode;
}
// Apply CW lock in case CW mode was saved from a previous session
applyCwRbnLock();
// Load new DXCC filter preference from localStorage
const savedNewDxccPreference = localStorage.getItem('cloudlog_newDxccFilter');
if (savedNewDxccPreference !== null) {
selectedNewDxcc = savedNewDxccPreference;
newDxccFilterSelect.value = selectedNewDxcc;
}
// Listen for checkbox changes
hideRbnCheckbox.addEventListener('change', function() {
hideRbnSpots = this.checked;
localStorage.setItem('cloudlog_hideRbnSpots', hideRbnSpots.toString());
updateTable();
});
// Listen for band filter changes
bandFilterSelect.addEventListener('change', function() {
selectedBand = this.value;
localStorage.setItem('cloudlog_bandFilter', selectedBand);
updateTable();
});
// Listen for mode filter changes
modeFilterSelect.addEventListener('change', function() {
selectedMode = this.value;
localStorage.setItem('cloudlog_modeFilter', selectedMode);
applyCwRbnLock();
updateTable();
});
// Listen for new DXCC filter changes
newDxccFilterSelect.addEventListener('change', function() {
selectedNewDxcc = this.value;
localStorage.setItem('cloudlog_newDxccFilter', selectedNewDxcc);
updateTable();
});
// When CW mode is active, Hide RBN must be off (most CW spots ARE RBN).
// Disables the checkbox so the user cannot re-hide RBN while on CW.
function applyCwRbnLock() {
if (selectedMode === 'cw') {
if (hideRbnSpots) {
hideRbnSpots = false;
hideRbnCheckbox.checked = false;
localStorage.setItem('cloudlog_hideRbnSpots', 'false');
}
hideRbnCheckbox.disabled = true;
hideRbnCheckbox.title = 'Cannot hide RBN while CW mode is selected';
} else {
hideRbnCheckbox.disabled = false;
hideRbnCheckbox.title = '';
}
}
// Shared utility aliases (implementations in assets/js/dxcluster-utils.js)
const isRbnSpot = DXCluster.isRbnSpot;
const getBandFromFrequency = DXCluster.getBandFromFrequency;
const detectMode = DXCluster.detectMode;
// Check if spot matches selected band filter
function matchesBandFilter(freqKhz) {
if (selectedBand === 'all') return true;
const spotBand = getBandFromFrequency(freqKhz);
return spotBand === selectedBand;
}
// Check if spot matches selected mode filter
function matchesModeFilter(spot) {
if (selectedMode === 'all') return true;
return detectMode(spot) === selectedMode;
}
// Check if spot matches the new DXCC / new band filter.
// Spots whose worked status hasn't been fetched yet are hidden until
// checkWorkedStatus() populates the data and re-renders the table.
function matchesNewDxccFilter(spot) {
if (selectedNewDxcc === 'all') return true;
const status = workedStatus[spot.dx];
if (!status) return false; // Hold back until status is known
if (!status.dxcc) return false; // No DXCC data available
if (selectedNewDxcc === 'new_any') {
// Either never worked this entity at all, or not yet on this band
return !status.dxcc_worked_overall || !status.dxcc_worked_on_band;
}
if (selectedNewDxcc === 'new_dxcc') {
// Never worked this DXCC entity on any band
return !status.dxcc_worked_overall;
}
if (selectedNewDxcc === 'new_band') {
// Worked before, but not on this specific band
return status.dxcc_worked_overall && !status.dxcc_worked_on_band;
}
return true;
}
// Initialize DataTable
dataTable = $('#dxSpotsTable').DataTable({
order: [[0, 'desc']], // Order by time (newest first)
pageLength: 25,
responsive: true,
language: {
url: getDataTablesLanguageUrl()
},
columnDefs: [
{
targets: 0, // Time column
width: '8%',
render: function(data, type, row) {
return data;
}
},
{
targets: 1, // DX Call column
width: '35%',
render: function(data, type, row) {
if (type === 'display') {
const callsign = data;
const status = workedStatus[callsign];
let html = `<span class="dx-callsign" data-callsign="${callsign}">${callsign}</span>`;
if (status) {
// Worked status icon (right after callsign)
if (status.worked_on_band) {
html += `<i class="fas fa-check text-success ms-2" title="Worked on ${status.band}"></i>`;
} else if (status.worked_overall) {
html += `<i class="fas fa-check text-info ms-2" title="Worked on another band"></i>`;
} else {
html += `<i class="fas fa-times text-secondary ms-2" title="Not worked yet"></i>`;
}
// Country name badge
if (status.country) {
html += `<span class="badge bg-secondary ms-2" title="Country: ${status.country}">${status.country}</span>`;
}
// New country badges — check truly new first, then new-band
if (status.dxcc && !status.dxcc_worked_overall) {
html += `<span class="badge bg-warning text-dark ms-2" title="New DXCC entity">New DXCC</span>`;
} else if (status.dxcc && !status.dxcc_worked_on_band) {
html += `<span class="badge bg-danger ms-2" title="New DXCC entity on this band">New Band</span>`;
}
}
return html;
}
return data;
}
},
{
targets: 2, // Frequency column
width: '12%',
className: 'frequency-cell',
render: function(data, type, row) {
if (type === 'display') {
return `<a href="#" class="frequency-link" onclick="handleFrequencyClick('${data}')">${data}</a>`;
}
return data;
}
},
{
targets: 3, // Spotter column
width: '10%'
},
{
targets: 4, // Comment column
width: '25%'
},
{
targets: 5, // Age column
width: '10%',
render: function(data, type, row) {
if (type === 'display') {
return `<span class="spot-age">${data}</span>`;
}
return data;
}
}
]
});
function connect() {
connectionStatus.textContent = 'Connecting...';
connectionStatus.className = 'badge bg-warning text-dark me-2 connecting';
// Connect to the same WebSocket server as the bandmap
const wsUrl = 'wss://dxc.cloudlog.org';
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Connected to DX Cluster WebSocket server');
connectionStatus.textContent = 'Connected';
connectionStatus.className = 'badge bg-success me-2 connected';
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'spot') {
addSpot(data);
}
};
ws.onclose = () => {
console.log('Disconnected from WebSocket server');
connectionStatus.textContent = 'Disconnected';
connectionStatus.className = 'badge bg-danger me-2 disconnected';
setTimeout(connect, 3000); // Reconnect after 3 seconds
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
connectionStatus.textContent = 'Error';
connectionStatus.className = 'badge bg-danger me-2 disconnected';
};
}
function addSpot(spot) {
const spotId = `${spot.dx}-${spot.frequency}-${Date.now()}`;
spot.receivedAt = Date.now();
spots.set(spotId, spot);
// Clean up old spots (older than 2 hours)
const cutoffTime = Date.now() - (2 * 60 * 60 * 1000);
for (let [id, existingSpot] of spots) {
if (existingSpot.receivedAt < cutoffTime) {
spots.delete(id);
}
}
updateTable();
}
// Check worked status for callsigns
async function checkWorkedStatus() {
// Get unique callsigns from visible spots
const callsignsToCheck = [];
const spotArray = Array.from(spots.values());
const seen = new Set();
spotArray.forEach(spot => {
// Skip if already checked or if filtered out
if (workedStatus[spot.dx]) return;
if (hideRbnSpots && isRbnSpot(spot.spotter)) return;
if (!matchesBandFilter(spot.frequency)) return;
if (!matchesModeFilter(spot)) return;
if (seen.has(spot.dx)) return; // Avoid duplicates
const band = getBandFromFrequency(spot.frequency);
callsignsToCheck.push({
callsign: spot.dx,
band: band
});
seen.add(spot.dx);
});
console.log('Checking worked status for:', callsignsToCheck.length, 'callsigns');
if (callsignsToCheck.length === 0) return;
// Limit to 30 callsigns per request to reduce load
const batch = callsignsToCheck.slice(0, 30);
try {
const response = await fetch('<?php echo site_url('dxcluster/check_worked'); ?>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ callsigns: batch })
});
const data = await response.json();
console.log('Worked status response:', data);
if (data.success) {
// Update worked status cache
Object.assign(workedStatus, data.results);
// Redraw table to show badges
updateTable();
}
} catch (error) {
console.error('Error checking worked status:', error);
}
}
// Update worked status badges in the table (legacy function - now handled in render)
function updateWorkedBadges() {
// No longer needed - badges are rendered directly in DataTable column render function
console.log('Badges updated via table redraw');
}
function updateTable() {
// Clear existing data
dataTable.clear();
// Convert spots to array and sort by received time (most recent first)
const spotArray = Array.from(spots.values()).sort((a, b) => {
return b.receivedAt - a.receivedAt;
});
// Filter and add spots to table
spotArray.forEach(spot => {
// Skip RBN spots if filter is enabled
if (hideRbnSpots && isRbnSpot(spot.spotter)) {
return;
}
// Skip spots that don't match band filter
if (!matchesBandFilter(spot.frequency)) {
return;
}
// Skip spots that don't match mode filter
if (!matchesModeFilter(spot)) {
return;
}
// Skip spots that don't match new DXCC / band filter
if (!matchesNewDxccFilter(spot)) {
return;
}
const age = calculateAge(spot.receivedAt);
const timeFormatted = formatTime(spot.time);
const frequencyFormatted = `${parseFloat(spot.frequency).toFixed(1)} kHz`;
const rowData = [
timeFormatted,
spot.dx || '',
frequencyFormatted,
spot.spotter || '',
spot.comment || '',
age
];
dataTable.row.add(rowData);
});
dataTable.draw();
// Apply age-based styling
setTimeout(() => {
applyAgeBasedStyling();
}, 100);
// Check worked status after a short delay (debounce)
clearTimeout(checkWorkedTimeout);
checkWorkedTimeout = setTimeout(() => {
checkWorkedStatus();
}, 500);
}
function formatTime(timeString) {
if (!timeString) return '';
// Just return the original time string with Z if it doesn't already have it
// This matches what the bandmap does: ${spot.time}Z
if (timeString.toString().trim() === '' || timeString.toString() === '0' || timeString.toString() === 'null' || timeString.toString() === 'undefined') {
return 'N/A';
}
const timeStr = timeString.toString();
if (timeStr.endsWith('Z')) {
return timeStr;
} else {
return timeStr + 'Z';
}
}
function calculateAge(receivedAt) {
const now = Date.now();
const ageMs = now - receivedAt;
const ageMinutes = Math.floor(ageMs / (1000 * 60));
if (ageMinutes < 1) {
return 'Just now';
} else if (ageMinutes < 60) {
return `${ageMinutes}m`;
} else {
const ageHours = Math.floor(ageMinutes / 60);
const remainingMinutes = ageMinutes % 60;
return `${ageHours}h ${remainingMinutes}m`;
}
}
function applyAgeBasedStyling() {
const rows = dataTable.rows().nodes();
const now = Date.now();
Array.from(spots.values()).forEach((spot, index) => {
if (index < rows.length) {
const row = rows[index];
const age = now - spot.receivedAt;
if (age < 5 * 60 * 1000) { // Less than 5 minutes
row.classList.add('spot-fresh');
} else if (age > 60 * 60 * 1000) { // More than 1 hour
row.classList.add('spot-old');
}
}
});
}
// Update ages every minute
setInterval(() => {
if (spots.size > 0) {
updateTable();
}
}, 60000);
// Start the WebSocket connection
connect();
});
function handleFrequencyClick(frequency) {
const radioId = document.getElementById('radio').value;
if (radioId === '0') {
// No radio selected - copy to clipboard
if (navigator.clipboard) {
const freqInMHz = (parseFloat(frequency) / 1000).toFixed(3);
navigator.clipboard.writeText(freqInMHz).then(() => {
showToast('Frequency ' + freqInMHz + ' MHz copied to clipboard', 'info');
});
}
return;
}
// QSY the radio
const freqInMHz = (parseFloat(frequency) / 1000).toFixed(3);
fetch('<?php echo site_url('dxcluster/qsy'); ?>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
radio_id: radioId,
frequency: freqInMHz
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('QSY command sent to radio: ' + freqInMHz + ' MHz', 'success');
} else {
showToast('Failed to QSY radio: ' + data.message, 'danger');
}
})
.catch(error => {
console.error('Error sending QSY command:', error);
showToast('Error sending QSY command', 'danger');
});
}
function showToast(message, type = 'primary') {
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0 position-fixed top-0 end-0 m-3`;
toast.setAttribute('role', 'alert');
toast.style.zIndex = '9999';
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
setTimeout(() => {
toast.remove();
}, 3000);
}
// Helper function for DataTables language URL (if it exists in Cloudlog)
function getDataTablesLanguageUrl() {
// This function should match the one used in other Cloudlog views
if (typeof lang_datatables_language !== 'undefined' && lang_datatables_language !== 'english') {
return "<?php echo base_url(); ?>assets/js/datatables/" + lang_datatables_language + ".json";
}
return "";
}
</script>