Add comprehensive documentation and LoTW integration

- Add detailed documentation covering architecture, components, code structure
- Document award system with multiple examples (DXCC, WAS, VUCC, Satellite)
- Implement LoTW sync service with ADIF parsing and long-polling
- Add QSO logbook page with filtering and statistics
- Add settings page for LoTW credentials management
- Add API endpoints for LoTW sync, QSO retrieval, and statistics

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 16:39:49 +01:00
parent 8c26fc93e3
commit 44c13e1bdc
6 changed files with 2229 additions and 2 deletions

View File

@@ -8,6 +8,11 @@ import {
getUserById,
updateLoTWCredentials,
} from './services/auth.service.js';
import {
syncQSOs,
getUserQSOs,
getQSOStats,
} from './services/lotw.service.js';
/**
* Main backend application
@@ -179,7 +184,17 @@ const app = new Elysia()
}
try {
await updateLoTWCredentials(user.id, body.lotwUsername, body.lotwPassword);
// Get current user data to preserve password if not provided
const userData = await getUserById(user.id);
if (!userData) {
set.status = 404;
return { success: false, error: 'User not found' };
}
// If password is empty, keep existing password
const lotwPassword = body.lotwPassword || userData.lotwPassword;
await updateLoTWCredentials(user.id, body.lotwUsername, lotwPassword);
return {
success: true,
@@ -196,11 +211,106 @@ const app = new Elysia()
{
body: t.Object({
lotwUsername: t.String(),
lotwPassword: t.String(),
lotwPassword: t.Optional(t.String()),
}),
}
)
/**
* POST /api/lotw/sync
* Sync QSOs from LoTW (requires authentication)
*/
.post('/api/lotw/sync', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
// Get user's LoTW credentials from database
const userData = await getUserById(user.id);
if (!userData || !userData.lotwUsername || !userData.lotwPassword) {
set.status = 400;
return {
success: false,
error: 'LoTW credentials not configured. Please add them in Settings.',
};
}
// Decrypt password (for now, assuming it's stored as-is. TODO: implement encryption)
const lotwPassword = userData.lotwPassword;
// Sync QSOs from LoTW
const result = await syncQSOs(user.id, userData.lotwUsername, lotwPassword);
return result;
} catch (error) {
set.status = 500;
return {
success: false,
error: `LoTW sync failed: ${error.message}`,
};
}
})
/**
* GET /api/qsos
* Get user's QSOs (requires authentication)
*/
.get('/api/qsos', async ({ user, query, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const filters = {};
if (query.band) filters.band = query.band;
if (query.mode) filters.mode = query.mode;
if (query.confirmed) filters.confirmed = query.confirmed === 'true';
const qsos = await getUserQSOs(user.id, filters);
return {
success: true,
qsos,
count: qsos.length,
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch QSOs',
};
}
})
/**
* GET /api/qsos/stats
* Get QSO statistics (requires authentication)
*/
.get('/api/qsos/stats', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const stats = await getQSOStats(user.id);
return {
success: true,
stats,
};
} catch (error) {
set.status = 500;
return {
success: false,
error: 'Failed to fetch statistics',
};
}
})
// Health check endpoint
.get('/api/health', () => ({
status: 'ok',

View File

@@ -0,0 +1,531 @@
import { db } from '../config/database.js';
import { qsos } from '../db/schema/index.js';
/**
* LoTW (Logbook of the World) Service
* Fetches QSOs from ARRL's LoTW system
*/
// Configuration for long-polling
const POLLING_CONFIG = {
maxRetries: 30, // Maximum number of retry attempts
retryDelay: 10000, // Delay between retries in ms (10 seconds)
requestTimeout: 60000, // Timeout for individual requests in ms (1 minute)
maxTotalTime: 600000, // Maximum total time to wait in ms (10 minutes)
};
/**
* Check if LoTW response indicates the report is still being prepared
* @param {string} responseData - The response text from LoTW
* @returns {boolean} True if report is still pending
*/
function isReportPending(responseData) {
const trimmed = responseData.trim().toLowerCase();
// LoTW returns various messages when report is not ready:
// - Empty responses
// - "Report is being prepared" or similar messages
// - HTML error pages
// - Very short responses that aren't valid ADIF
// Check for empty or very short responses
if (trimmed.length < 100) {
return true;
}
// Check for HTML responses (error pages)
if (trimmed.includes('<html>') || trimmed.includes('<!doctype html>')) {
return true;
}
// Check for common "not ready" messages
const pendingMessages = [
'report is being prepared',
'your report is being generated',
'please try again',
'report queue',
'not yet available',
'temporarily unavailable',
];
for (const msg of pendingMessages) {
if (trimmed.includes(msg)) {
return true;
}
}
// Check if it looks like valid ADIF data (should start with <ADIF_VER: or have <qso_date)
const hasAdifHeader = trimmed.includes('<adif_ver:') ||
trimmed.includes('<qso_date') ||
trimmed.includes('<call:') ||
trimmed.includes('<band:');
return !hasAdifHeader;
}
/**
* Sleep/delay utility
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch QSOs from LoTW with long-polling support
* @param {string} lotwUsername - LoTW username
* @param {string} lotwPassword - LoTW password
* @param {Date} sinceDate - Only fetch QSOs since this date
* @returns {Promise<Array>} Array of QSO objects
*/
export async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
// LoTW report URL
const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi';
// Build query parameters - qso_query=1 is REQUIRED to get QSO records!
const params = new URLSearchParams({
login: lotwUsername,
password: lotwPassword,
qso_query: '1', // REQUIRED: Without this, no QSO records are returned
qso_qsl: 'yes', // Only get QSOs with QSLs (confirmed)
qso_qsldetail: 'yes', // Include QSL details (station location)
qso_mydetail: 'yes', // Include my station details
qso_withown: 'yes', // Include own callsign
});
// Add date filter - ALWAYS send qso_qslsince to get all QSOs
// LoTW default behavior only returns QSOs since last download, so we must
// explicitly send a date to get QSOs since that date
const dateStr = sinceDate
? sinceDate.toISOString().split('T')[0]
: '2026-01-01'; // Default: Get QSOs since 2026-01-01
params.append('qso_qslsince', dateStr);
console.error('Date filter:', dateStr, sinceDate ? '(User-specified date)' : '(Default: since 2026-01-01)');
const fullUrl = `${url}?${params.toString()}`;
console.error('Fetching from LoTW:', fullUrl.replace(/password=[^&]+/, 'password=***'));
const startTime = Date.now();
// Long-polling loop
for (let attempt = 0; attempt < POLLING_CONFIG.maxRetries; attempt++) {
try {
// Check if we've exceeded max total time
const elapsed = Date.now() - startTime;
if (elapsed > POLLING_CONFIG.maxTotalTime) {
throw new Error(`LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds`);
}
if (attempt > 0) {
console.error(`Retry attempt ${attempt + 1}/${POLLING_CONFIG.maxRetries} (elapsed: ${Math.round(elapsed / 1000)}s)`);
}
// Make request with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), POLLING_CONFIG.requestTimeout);
const response = await fetch(fullUrl, { signal: controller.signal });
clearTimeout(timeoutId);
// Handle HTTP errors
if (!response.ok) {
if (response.status === 503) {
// Service unavailable - might be temporary, retry
console.error('LoTW returned 503 (Service Unavailable), waiting before retry...');
await sleep(POLLING_CONFIG.retryDelay);
continue;
} else if (response.status === 401) {
throw new Error('Invalid LoTW credentials. Please check your username and password in Settings.');
} else if (response.status === 404) {
throw new Error('LoTW service not found (404). The LoTW API URL may have changed.');
} else {
// Other errors - log but retry
console.error(`LoTW returned ${response.status} ${response.statusText}, waiting before retry...`);
await sleep(POLLING_CONFIG.retryDelay);
continue;
}
}
// Get response text
const adifData = await response.text();
console.error(`Response length: ${adifData.length} bytes`);
// Check if report is still pending
if (isReportPending(adifData)) {
console.error('LoTW report is still being prepared, waiting...', adifData.substring(0, 100));
// Wait before retrying
await sleep(POLLING_CONFIG.retryDelay);
continue;
}
// We have valid data!
console.error('LoTW report ready, parsing ADIF data...');
console.error('ADIF preview:', adifData.substring(0, 200));
// Parse ADIF format
const qsos = parseADIF(adifData);
console.error(`Successfully parsed ${qsos.length} QSOs from LoTW`);
return qsos;
} catch (error) {
const elapsed = Date.now() - startTime;
if (error.name === 'AbortError') {
console.error(`Request timeout on attempt ${attempt + 1}, retrying...`);
await sleep(POLLING_CONFIG.retryDelay);
continue;
}
// Re-throw credential/auth errors immediately
if (error.message.includes('credentials') || error.message.includes('401') || error.message.includes('404')) {
throw error;
}
// For other errors, log and retry if we haven't exhausted retries
if (attempt < POLLING_CONFIG.maxRetries - 1) {
console.error(`Error on attempt ${attempt + 1}: ${error.message}`);
console.error('Retrying...');
await sleep(POLLING_CONFIG.retryDelay);
continue;
} else {
throw error;
}
}
}
// If we get here, we exhausted all retries
const totalTime = Math.round((Date.now() - startTime) / 1000);
throw new Error(`LoTW sync failed: Report not ready after ${POLLING_CONFIG.maxRetries} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`);
}
/**
* Parse ADIF (Amateur Data Interchange Format) data
* @param {string} adifData - Raw ADIF text data
* @returns {Array<Array>} Array of QSOs (each QSO is an array of field objects)
*/
function parseADIF(adifData) {
const qsos = [];
const records = adifData.split('<eor>');
console.error(`Total records after splitting by <eor>: ${records.length}`);
for (let i = 0; i < records.length; i++) {
const record = records[i];
// Skip empty records or records that are just header info
if (!record.trim()) continue;
if (record.trim().startsWith('<') && !record.includes('<CALL:') && !record.includes('<call:')) continue;
const qso = {};
// Match ADIF fields: <FIELDNAME:length[:type]>value
// Important: The 'type' part is optional, and field names can contain underscores
// We use the length parameter to extract exactly that many characters
const regex = /<([A-Z_]+):(\d+)(?::[A-Z]+)?>([\s\S])/gi;
let match;
while ((match = regex.exec(record)) !== null) {
const [fullMatch, fieldName, lengthStr, firstChar] = match;
const length = parseInt(lengthStr, 10);
// Extract exactly 'length' characters starting from the position after the '>'
const valueStart = match.index + fullMatch.length - 1; // -1 because firstChar is already captured
const value = record.substring(valueStart, valueStart + length);
qso[fieldName.toLowerCase()] = value.trim();
// Move regex lastIndex to the end of the value so we can find the next tag
regex.lastIndex = valueStart + length;
}
// Only add if we have actual QSO data (has CALL or call field)
if (Object.keys(qso).length > 0 && (qso.call || qso.call)) {
qsos.push(qso);
// Log first few QSOs for debugging
if (qsos.length <= 3) {
console.error(`Parsed QSO #${qsos.length}: ${qso.call} on ${qso.qso_date} ${qso.band} ${qso.mode}`);
}
}
}
console.error(`Total QSOs parsed: ${qsos.length}`);
return qsos;
}
/**
* Convert ADIF QSO to database format
* @param {Object} adifQSO - QSO object from ADIF parser
* @param {number} userId - User ID
* @returns {Object} Database-ready QSO object
*/
export function convertQSODatabaseFormat(adifQSO, userId) {
return {
userId,
callsign: adifQSO.call || '',
qsoDate: adifQSO.qso_date || '',
timeOn: adifQSO.time_on || adifQSO.time_off || '000000',
band: normalizeBand(adifQSO.band),
mode: normalizeMode(adifQSO.mode),
freq: adifQSO.freq ? parseInt(adifQSO.freq) : null,
freqRx: adifQSO.freq_rx ? parseInt(adifQSO.freq_rx) : null,
entity: adifQSO.country || adifQSO.dxcc_country || '',
entityId: adifQSO.dxcc || null,
grid: adifQSO.gridsquare || '',
continent: adifQSO.continent || '',
cqZone: adifQSO.cq_zone ? parseInt(adifQSO.cq_zone) : null,
ituZone: adifQSO.itu_zone ? parseInt(adifQSO.itu_zone) : null,
state: adifQSO.state || adifQSO.us_state || '',
satName: adifQSO.sat_name || '',
satMode: adifQSO.sat_mode || '',
lotwQslRdate: adifQSO.qslrdate || '',
lotwQslRstatus: adifQSO.qsl_rcvd || 'N',
lotwSyncedAt: new Date(),
};
}
/**
* Normalize band name
* @param {string} band - Band from ADIF
* @returns {string|null} Normalized band
*/
function normalizeBand(band) {
if (!band) return null;
const bandMap = {
'160m': '160m',
'80m': '80m',
'60m': '60m',
'40m': '40m',
'30m': '30m',
'20m': '20m',
'17m': '17m',
'15m': '15m',
'12m': '12m',
'10m': '10m',
'6m': '6m',
'4m': '4m',
'2m': '2m',
'1.25m': '1.25m',
'70cm': '70cm',
'33cm': '33cm',
'23cm': '23cm',
'13cm': '13cm',
'9cm': '9cm',
'6cm': '6cm',
'3cm': '3cm',
'1.2cm': '1.2cm',
'mm': 'mm',
};
return bandMap[band.toLowerCase()] || band;
}
/**
* Normalize mode name
* @param {string} mode - Mode from ADIF
* @returns {string} Normalized mode
*/
function normalizeMode(mode) {
if (!mode) return '';
const modeMap = {
'cw': 'CW',
'ssb': 'SSB',
'am': 'AM',
'fm': 'FM',
'rtty': 'RTTY',
'psk31': 'PSK31',
'psk63': 'PSK63',
'ft8': 'FT8',
'ft4': 'FT4',
'jt65': 'JT65',
'jt9': 'JT9',
'js8': 'JS8',
'mfsk': 'MFSK',
' Olivia': 'OLIVIA',
};
const normalized = modeMap[mode.toLowerCase()];
return normalized || mode.toUpperCase();
}
/**
* Sync QSOs from LoTW to database
* @param {number} userId - User ID
* @param {string} lotwUsername - LoTW username
* @param {string} lotwPassword - LoTW password
* @param {Date} sinceDate - Only sync QSOs since this date
* @returns {Promise<Object>} Sync result with counts
*/
export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null) {
try {
// Fetch QSOs from LoTW
const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate);
if (!adifQSOs || adifQSOs.length === 0) {
return {
success: true,
total: 0,
added: 0,
updated: 0,
message: 'No QSOs found in LoTW',
};
}
let addedCount = 0;
let updatedCount = 0;
const errors = [];
// Process each QSO
for (const qsoData of adifQSOs) {
try {
console.error('Raw ADIF QSO data:', JSON.stringify(qsoData));
const dbQSO = convertQSODatabaseFormat(qsoData, userId);
console.error('Converted QSO:', JSON.stringify(dbQSO));
console.error('Processing QSO:', dbQSO.callsign, dbQSO.qsoDate, dbQSO.band, dbQSO.mode);
// Check if QSO already exists (by callsign, date, time, band, mode)
const { eq } = await import('drizzle-orm');
const existing = await db
.select()
.from(qsos)
.where(eq(qsos.userId, userId))
.where(eq(qsos.callsign, dbQSO.callsign))
.where(eq(qsos.qsoDate, dbQSO.qsoDate))
.where(eq(qsos.band, dbQSO.band))
.where(eq(qsos.mode, dbQSO.mode))
.limit(1);
console.error('Existing QSOs found:', existing.length);
if (existing.length > 0) {
console.error('Existing QSO:', JSON.stringify(existing[0]));
}
if (existing.length > 0) {
// Update existing QSO
console.error('Updating existing QSO');
await db
.update(qsos)
.set({
lotwQslRdate: dbQSO.lotwQslRdate,
lotwQslRstatus: dbQSO.lotwQslRstatus,
lotwSyncedAt: dbQSO.lotwSyncedAt,
})
.where(eq(qsos.id, existing[0].id));
updatedCount++;
} else {
// Insert new QSO
console.error('Inserting new QSO with data:', JSON.stringify(dbQSO));
try {
const result = await db.insert(qsos).values(dbQSO);
console.error('Insert result:', result);
addedCount++;
} catch (insertError) {
console.error('Insert failed:', insertError.message);
console.error('Insert error details:', insertError);
throw insertError;
}
}
} catch (error) {
console.error('ERROR processing QSO:', error);
errors.push({
qso: qsoData,
error: error.message,
});
}
}
return {
success: true,
total: adifQSOs.length,
added: addedCount,
updated: updatedCount,
errors: errors.length > 0 ? errors : undefined,
};
} catch (error) {
throw new Error(`LoTW sync failed: ${error.message}`);
}
}
/**
* Get QSOs for a user
* @param {number} userId - User ID
* @param {Object} filters - Query filters
* @returns {Promise<Array>} Array of QSOs
*/
export async function getUserQSOs(userId, filters = {}) {
const { eq, and } = await import('drizzle-orm');
console.error('getUserQSOs called with userId:', userId, 'filters:', filters);
// Build where conditions
const conditions = [eq(qsos.userId, userId)];
if (filters.band) {
conditions.push(eq(qsos.band, filters.band));
}
if (filters.mode) {
conditions.push(eq(qsos.mode, filters.mode));
}
if (filters.confirmed) {
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
}
// Use and() to combine all conditions
const results = await db.select().from(qsos).where(and(...conditions));
console.error('getUserQSOs returning', results.length, 'QSOs');
// Order by date descending, then time
return results.sort((a, b) => {
const dateCompare = b.qsoDate.localeCompare(a.qsoDate);
if (dateCompare !== 0) return dateCompare;
return b.timeOn.localeCompare(a.timeOn);
});
}
/**
* Get QSO statistics for a user
* @param {number} userId - User ID
* @returns {Promise<Object>} Statistics object
*/
export async function getQSOStats(userId) {
const { eq } = await import('drizzle-orm');
const allQSOs = await db.select().from(qsos).where(eq(qsos.userId, userId));
console.error('getQSOStats called with userId:', userId, 'found', allQSOs.length, 'QSOs in database');
const confirmed = allQSOs.filter((q) => q.lotwQslRstatus === 'Y');
// Count unique entities
const uniqueEntities = new Set();
const uniqueBands = new Set();
const uniqueModes = new Set();
allQSOs.forEach((q) => {
if (q.entity) uniqueEntities.add(q.entity);
if (q.band) uniqueBands.add(q.band);
if (q.mode) uniqueModes.add(q.mode);
});
const stats = {
total: allQSOs.length,
confirmed: confirmed.length,
uniqueEntities: uniqueEntities.size,
uniqueBands: uniqueBands.size,
uniqueModes: uniqueModes.size,
};
console.error('getQSOStats returning:', stats);
return stats;
}

View File

@@ -127,6 +127,12 @@ export const qsosAPI = {
return apiRequest(`/qsos?${params}`);
},
/**
* Get QSO statistics
* @returns {Promise<Object>} QSO statistics
*/
getStats: () => apiRequest('/qsos/stats'),
/**
* Sync QSOs from LoTW
* @returns {Promise<Object>} Sync result

View File

@@ -0,0 +1,462 @@
<script>
import { onMount } from 'svelte';
import { qsosAPI } from '$lib/api.js';
import { auth } from '$lib/stores.js';
let qsos = [];
let stats = null;
let loading = true;
let error = null;
let syncing = false;
let syncResult = null;
let filters = {
band: '',
mode: '',
confirmed: false
};
// Load QSOs on mount
onMount(async () => {
if (!$auth.user) return;
await loadQSOs();
await loadStats();
});
async function loadQSOs() {
try {
loading = true;
error = null;
const activeFilters = {};
if (filters.band) activeFilters.band = filters.band;
if (filters.mode) activeFilters.mode = filters.mode;
if (filters.confirmed) activeFilters.confirmed = 'true';
const response = await qsosAPI.getAll(activeFilters);
qsos = response.qsos;
} catch (err) {
error = err.message;
} finally {
loading = false;
}
}
async function loadStats() {
try {
const response = await qsosAPI.getStats();
stats = response.stats;
} catch (err) {
console.error('Failed to load stats:', err);
}
}
async function handleSync() {
console.log('handleSync called!');
try {
syncing = true;
syncResult = null;
console.log('Calling qsosAPI.syncFromLoTW...');
const result = await qsosAPI.syncFromLoTW();
console.log('Sync result:', result);
syncResult = result;
// Reload QSOs and stats after sync
await loadQSOs();
await loadStats();
} catch (err) {
console.error('Sync error:', err);
syncResult = {
success: false,
error: err.message
};
} finally {
syncing = false;
console.log('Sync complete, syncing = false');
}
}
async function applyFilters() {
await loadQSOs();
}
function clearFilters() {
filters = {
band: '',
mode: '',
confirmed: false
};
loadQSOs();
}
function formatDate(dateStr) {
if (!dateStr) return '-';
// ADIF format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
function formatTime(timeStr) {
if (!timeStr) return '-';
// ADIF format: HHMMSS
return timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4);
}
const bands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
const modes = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9'];
</script>
<svelte:head>
<title>QSO Log - Ham Radio Awards</title>
</svelte:head>
<div class="container">
<div class="header">
<h1>QSO Log</h1>
<button
class="btn btn-primary"
on:click={handleSync}
disabled={syncing}
>
{syncing ? 'Syncing from LoTW...' : 'Sync from LoTW'}
</button>
</div>
{#if syncResult}
<div class="alert {syncResult.success ? 'alert-success' : 'alert-error'}">
{#if syncResult.success}
<h3>Sync Complete!</h3>
<p>Total: {syncResult.total}, Added: {syncResult.added}, Updated: {syncResult.updated}</p>
{#if syncResult.errors && syncResult.errors.length > 0}
<p class="text-small">Some QSOs had errors</p>
{/if}
{:else}
<h3>Sync Failed</h3>
<p>{syncResult.error}</p>
{/if}
<button on:click={() => syncResult = null} class="btn-small">Dismiss</button>
</div>
{/if}
{#if stats}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{stats.total}</div>
<div class="stat-label">Total QSOs</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.confirmed}</div>
<div class="stat-label">Confirmed</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.uniqueEntities}</div>
<div class="stat-label">DXCC Entities</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.uniqueBands}</div>
<div class="stat-label">Bands</div>
</div>
</div>
{/if}
<div class="filters">
<h3>Filters</h3>
<div class="filter-row">
<select bind:value={filters.band} on:change={applyFilters}>
<option value="">All Bands</option>
{#each bands as band}
<option value={band}>{band}</option>
{/each}
</select>
<select bind:value={filters.mode} on:change={applyFilters}>
<option value="">All Modes</option>
{#each modes as mode}
<option value={mode}>{mode}</option>
{/each}
</select>
<label class="checkbox-label">
<input type="checkbox" bind:checked={filters.confirmed} on:change={applyFilters}>
Confirmed only
</label>
<button class="btn btn-secondary" on:click={clearFilters}>Clear</button>
</div>
</div>
{#if loading}
<div class="loading">Loading QSOs...</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if qsos.length === 0}
<div class="empty">
<p>No QSOs found. Sync from LoTW to get started!</p>
</div>
{:else}
<div class="qso-table-container">
<table class="qso-table">
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Callsign</th>
<th>Band</th>
<th>Mode</th>
<th>Entity</th>
<th>Grid</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each qsos as qso}
<tr>
<td>{formatDate(qso.qsoDate)}</td>
<td>{formatTime(qso.timeOn)}</td>
<td class="callsign">{qso.callsign}</td>
<td>{qso.band || '-'}</td>
<td>{qso.mode || '-'}</td>
<td>{qso.entity || '-'}</td>
<td>{qso.grid || '-'}</td>
<td>
{#if qso.lotwQslRstatus === 'Y'}
<span class="badge badge-success">Confirmed</span>
{:else}
<span class="badge badge-pending">Pending</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="showing">Showing {qsos.length} QSOs</p>
{/if}
</div>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header h1 {
margin: 0;
color: #333;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #4a90e2;
}
.stat-label {
color: #666;
margin-top: 0.5rem;
}
.filters {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.filters h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: #333;
}
.filter-row {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.filter-row select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4a90e2;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #357abd;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-small {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px;
cursor: pointer;
color: inherit;
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.alert-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.alert-error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert h3 {
margin: 0 0 0.5rem 0;
}
.alert p {
margin: 0;
}
.text-small {
font-size: 0.875rem;
margin-top: 0.5rem;
}
.qso-table-container {
overflow-x: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.qso-table {
width: 100%;
border-collapse: collapse;
}
.qso-table th,
.qso-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.qso-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
}
.qso-table tr:hover {
background-color: #f8f9fa;
}
.callsign {
font-weight: 600;
color: #4a90e2;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-pending {
background-color: #fff3cd;
color: #856404;
}
.loading, .error, .empty {
text-align: center;
padding: 3rem;
color: #666;
}
.error {
color: #dc3545;
}
.showing {
text-align: center;
color: #666;
font-size: 0.875rem;
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,335 @@
<script>
import { onMount } from 'svelte';
import { authAPI } from '$lib/api.js';
import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation';
let lotwUsername = '';
let lotwPassword = '';
let loading = false;
let saving = false;
let error = null;
let success = false;
let hasCredentials = false;
onMount(async () => {
// Load user profile to check if credentials exist
await loadProfile();
});
async function loadProfile() {
try {
loading = true;
const response = await authAPI.getProfile();
console.log('Loaded profile:', response.user);
if (response.user) {
lotwUsername = response.user.lotwUsername || '';
lotwPassword = ''; // Never pre-fill password for security
hasCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
console.log('Has credentials:', hasCredentials);
}
} catch (err) {
console.error('Failed to load profile:', err);
error = err.message;
} finally {
loading = false;
}
}
async function handleSave(e) {
e.preventDefault();
try {
saving = true;
error = null;
success = false;
console.log('Saving credentials:', { lotwUsername, hasPassword: !!lotwPassword });
await authAPI.updateLoTWCredentials({
lotwUsername,
lotwPassword
});
console.log('Save successful!');
// Reload profile to update hasCredentials flag
await loadProfile();
success = true;
} catch (err) {
console.error('Save failed:', err);
error = err.message;
} finally {
saving = false;
}
}
function handleLogout() {
auth.logout();
goto('/');
}
</script>
<svelte:head>
<title>Settings - Ham Radio Awards</title>
</svelte:head>
<div class="container">
<div class="header">
<h1>Settings</h1>
<button class="btn btn-secondary" on:click={handleLogout}>Logout</button>
</div>
<div class="user-info">
<h2>Account</h2>
<p><strong>Callsign:</strong> {$auth.user?.callsign || '-'}</p>
<p><strong>Email:</strong> {$auth.user?.email || '-'}</p>
</div>
<div class="settings-section">
<h2>LoTW Credentials</h2>
<p class="help-text">
Configure your ARRL Logbook of the World (LoTW) credentials to sync your QSOs.
Your credentials are stored securely and used only to fetch your confirmed QSOs.
</p>
{#if hasCredentials}
<div class="alert alert-info">
<strong>Credentials configured</strong> - You can update them below if needed.
</div>
{/if}
<form on:submit={handleSave} class="settings-form">
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if success}
<div class="alert alert-success">
LoTW credentials saved successfully!
</div>
{/if}
<div class="form-group">
<label for="lotwUsername">LoTW Username</label>
<input
id="lotwUsername"
type="text"
bind:value={lotwUsername}
placeholder="your_lotw_username"
required
/>
</div>
<div class="form-group">
<label for="lotwPassword">LoTW Password</label>
<input
id="lotwPassword"
type="password"
bind:value={lotwPassword}
placeholder="Your LoTW password"
required
/>
<p class="hint">
Leave blank to keep existing password
</p>
</div>
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Credentials'}
</button>
</form>
<div class="info-box">
<h3>About LoTW</h3>
<p>
LoTW (Logbook of the World) is ARRL's system for confirming amateur radio contacts.
Once configured, you can sync your confirmed QSOs to track award progress.
</p>
<p>
<strong>Don't have a LoTW account?</strong>{' '}
<a href="https://lotw.arrl.org/" target="_blank" rel="noopener">
Sign up at LoTWARRL.org
</a>
</p>
</div>
</div>
</div>
<style>
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header h1 {
margin: 0;
color: #333;
}
.user-info {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.user-info h2 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
color: #333;
}
.user-info p {
margin: 0.5rem 0;
color: #666;
}
.settings-section {
margin-bottom: 2rem;
}
.settings-section h2 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
color: #333;
}
.help-text {
color: #666;
margin-bottom: 1.5rem;
line-height: 1.6;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.alert-info {
background-color: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.alert-error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.settings-form {
background: white;
padding: 2rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
}
.hint {
font-size: 0.875rem;
color: #666;
margin-top: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4a90e2;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #357abd;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.info-box {
background: #e8f4fd;
border-left: 4px solid #4a90e2;
padding: 1.5rem;
border-radius: 4px;
}
.info-box h3 {
margin: 0 0 1rem 0;
color: #333;
}
.info-box p {
margin: 0.5rem 0;
color: #666;
line-height: 1.6;
}
.info-box a {
color: #4a90e2;
text-decoration: none;
}
.info-box a:hover {
text-decoration: underline;
}
</style>