Compare commits
2 Commits
0020f0318d
...
8d47e6e4ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
8d47e6e4ad
|
|||
|
b422c20463
|
79
CLAUDE.md
79
CLAUDE.md
@@ -467,3 +467,82 @@ Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
|
||||
- LoTW never sends DOK data; only DCL provides DOK fields
|
||||
|
||||
**Important**: DCL sync only updates DOK/grid fields when DCL provides non-empty values. This prevents accidentally clearing DOK data that was manually entered or imported from other sources.
|
||||
|
||||
### QSO Page Filters
|
||||
|
||||
The QSO page (`src/frontend/src/routes/qsos/+page.svelte`) includes advanced filtering capabilities:
|
||||
|
||||
**Available Filters**:
|
||||
- **Search Box**: Full-text search across callsign, entity (DXCC country), and grid square fields
|
||||
- Press Enter to apply search
|
||||
- Case-insensitive partial matching
|
||||
- **Band Filter**: Dropdown to filter by amateur band (160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm)
|
||||
- **Mode Filter**: Dropdown to filter by mode (CW, SSB, AM, FM, RTTY, PSK31, FT8, FT4, JT65, JT9)
|
||||
- **Confirmation Type Filter**: Filter by confirmation status
|
||||
- "All QSOs": Shows all QSOs (no filter)
|
||||
- "LoTW Only": Shows QSOs confirmed by LoTW but NOT DCL
|
||||
- "DCL Only": Shows QSOs confirmed by DCL but NOT LoTW
|
||||
- "Both Confirmed": Shows QSOs confirmed by BOTH LoTW AND DCL
|
||||
- "Not Confirmed": Shows QSOs confirmed by NEITHER LoTW nor DCL
|
||||
- **Clear Button**: Resets all filters and reloads all QSOs
|
||||
|
||||
**Backend Implementation** (`src/backend/services/lotw.service.js`):
|
||||
- `getUserQSOs(userId, filters, options)`: Main filtering function
|
||||
- Supports pagination with `page` and `limit` options
|
||||
- Filter logic uses Drizzle ORM query builders for safe SQL generation
|
||||
- Debug logging when `LOG_LEVEL=debug` shows applied filters
|
||||
|
||||
**Frontend API** (`src/frontend/src/lib/api.js`):
|
||||
- `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters
|
||||
- Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL`
|
||||
|
||||
### DXCC Entity Priority Logic
|
||||
|
||||
When syncing QSOs from multiple confirmation sources, the system follows a priority order for DXCC entity data:
|
||||
|
||||
**Priority Order**: LoTW > DCL
|
||||
|
||||
**Implementation** (`src/backend/services/dcl.service.js`):
|
||||
```javascript
|
||||
// DXCC priority: LoTW > DCL
|
||||
// Only update entity fields from DCL if:
|
||||
// 1. QSO is NOT LoTW confirmed, AND
|
||||
// 2. DCL actually sent entity data, AND
|
||||
// 3. Current entity is missing
|
||||
const hasLoTWConfirmation = existingQSO.lotwQslRstatus === 'Y';
|
||||
const hasDCLData = dbQSO.entity || dbQSO.entityId;
|
||||
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
|
||||
|
||||
if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
|
||||
// Fill in entity data from DCL (only if DCL provides it)
|
||||
updateData.entity = dbQSO.entity;
|
||||
updateData.entityId = dbQSO.entityId;
|
||||
// ... other entity fields
|
||||
}
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
1. **LoTW-confirmed QSOs**: Always use LoTW's DXCC data (most reliable)
|
||||
2. **DCL-only QSOs**: Use DCL's DXCC data IF available in ADIF payload
|
||||
3. **Empty entity fields**: If DCL doesn't send DXCC data, entity remains empty
|
||||
4. **Never overwrite**: Once LoTW confirms with entity data, DCL sync won't change it
|
||||
|
||||
**Important Note**: DCL API currently doesn't send DXCC/entity fields in their ADIF export. This is a limitation of the DCL API, not the application. If DCL adds these fields in the future, the system will automatically use them for DCL-only QSOs.
|
||||
|
||||
### Recent Development Work (January 2025)
|
||||
|
||||
**QSO Page Enhancements**:
|
||||
- Added confirmation type filter with exclusive logic (LoTW Only, DCL Only, Both Confirmed, Not Confirmed)
|
||||
- Added search box for filtering by callsign, entity, or grid square
|
||||
- Renamed "All Confirmation" to "All QSOs" for clarity
|
||||
- Fixed filter logic to properly handle exclusive confirmation types
|
||||
|
||||
**Bug Fixes**:
|
||||
- Fixed confirmation filter showing wrong QSOs (e.g., "LoTW Only" was also showing DCL QSOs)
|
||||
- Implemented proper SQL conditions for exclusive filters using separate condition pushes
|
||||
- Added debug logging to track filter application
|
||||
|
||||
**DXCC Entity Handling**:
|
||||
- Clarified that DCL API doesn't send DXCC fields (current limitation)
|
||||
- Implemented priority logic: LoTW entity data takes precedence over DCL
|
||||
- System ready to auto-use DCL DXCC data if they add it in future API updates
|
||||
|
||||
@@ -450,6 +450,7 @@ const app = new Elysia()
|
||||
* GET /api/qsos
|
||||
* Get user's QSOs (requires authentication)
|
||||
* Supports pagination: ?page=1&limit=100
|
||||
* Supports filters: band, mode, confirmed, confirmationType (all, lotw, dcl, both, none), search
|
||||
*/
|
||||
.get('/api/qsos', async ({ user, query, set }) => {
|
||||
if (!user) {
|
||||
@@ -462,6 +463,10 @@ const app = new Elysia()
|
||||
if (query.band) filters.band = query.band;
|
||||
if (query.mode) filters.mode = query.mode;
|
||||
if (query.confirmed) filters.confirmed = query.confirmed === 'true';
|
||||
if (query.confirmationType && query.confirmationType !== 'all') {
|
||||
filters.confirmationType = query.confirmationType;
|
||||
}
|
||||
if (query.search) filters.search = query.search;
|
||||
|
||||
// Pagination options
|
||||
const options = {
|
||||
|
||||
@@ -148,6 +148,7 @@ function convertQSODatabaseFormat(adifQSO, userId) {
|
||||
mode: normalizeMode(adifQSO.mode),
|
||||
freq: adifQSO.freq ? parseInt(adifQSO.freq) : null,
|
||||
freqRx: adifQSO.freq_rx ? parseInt(adifQSO.freq_rx) : null,
|
||||
// DCL may or may not include DXCC fields - use them if available
|
||||
entity: adifQSO.country || adifQSO.dxcc_country || '',
|
||||
entityId: adifQSO.dxcc ? parseInt(adifQSO.dxcc) : null,
|
||||
grid: adifQSO.gridsquare || '',
|
||||
@@ -252,7 +253,6 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
||||
|
||||
if (dataChanged) {
|
||||
// Update existing QSO with changed DCL confirmation and DOK data
|
||||
// Only update DOK/grid fields if DCL actually sent values (non-empty)
|
||||
const updateData = {
|
||||
dclQslRdate: dbQSO.dclQslRdate,
|
||||
dclQslRstatus: dbQSO.dclQslRstatus,
|
||||
@@ -268,6 +268,24 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
||||
updateData.gridSource = dbQSO.gridSource;
|
||||
}
|
||||
|
||||
// DXCC priority: LoTW > DCL
|
||||
// Only update entity fields from DCL if:
|
||||
// 1. QSO is NOT LoTW confirmed, AND
|
||||
// 2. DCL actually sent entity data, AND
|
||||
// 3. Current entity is missing
|
||||
const hasLoTWConfirmation = existingQSO.lotwQslRstatus === 'Y';
|
||||
const hasDCLData = dbQSO.entity || dbQSO.entityId;
|
||||
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
|
||||
|
||||
if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
|
||||
// Fill in entity data from DCL (only if DCL provides it)
|
||||
if (dbQSO.entity) updateData.entity = dbQSO.entity;
|
||||
if (dbQSO.entityId) updateData.entityId = dbQSO.entityId;
|
||||
if (dbQSO.continent) updateData.continent = dbQSO.continent;
|
||||
if (dbQSO.cqZone) updateData.cqZone = dbQSO.cqZone;
|
||||
if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(qsos)
|
||||
.set(updateData)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db, logger } from '../config.js';
|
||||
import { qsos } from '../db/schema/index.js';
|
||||
import { max, sql, eq, and, desc } from 'drizzle-orm';
|
||||
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
|
||||
import { updateJobProgress } from './job-queue.service.js';
|
||||
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
||||
|
||||
@@ -322,12 +322,54 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
||||
export async function getUserQSOs(userId, filters = {}, options = {}) {
|
||||
const { page = 1, limit = 100 } = options;
|
||||
|
||||
logger.debug('getUserQSOs called', { userId, filters, options });
|
||||
|
||||
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'));
|
||||
|
||||
// Confirmation type filter: lotw, dcl, both, none
|
||||
if (filters.confirmationType) {
|
||||
logger.debug('Applying confirmation type filter', { confirmationType: filters.confirmationType });
|
||||
if (filters.confirmationType === 'lotw') {
|
||||
// LoTW only: Confirmed by LoTW but NOT by DCL
|
||||
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
||||
conditions.push(
|
||||
sql`(${qsos.dclQslRstatus} IS NULL OR ${qsos.dclQslRstatus} != 'Y')`
|
||||
);
|
||||
} else if (filters.confirmationType === 'dcl') {
|
||||
// DCL only: Confirmed by DCL but NOT by LoTW
|
||||
conditions.push(eq(qsos.dclQslRstatus, 'Y'));
|
||||
conditions.push(
|
||||
sql`(${qsos.lotwQslRstatus} IS NULL OR ${qsos.lotwQslRstatus} != 'Y')`
|
||||
);
|
||||
} else if (filters.confirmationType === 'both') {
|
||||
// Both confirmed: Confirmed by LoTW AND DCL
|
||||
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
||||
conditions.push(eq(qsos.dclQslRstatus, 'Y'));
|
||||
} else if (filters.confirmationType === 'none') {
|
||||
// Not confirmed: Not confirmed by LoTW AND not confirmed by DCL
|
||||
conditions.push(
|
||||
sql`(${qsos.lotwQslRstatus} IS NULL OR ${qsos.lotwQslRstatus} != 'Y')`
|
||||
);
|
||||
conditions.push(
|
||||
sql`(${qsos.dclQslRstatus} IS NULL OR ${qsos.dclQslRstatus} != 'Y')`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter: callsign, entity, or grid
|
||||
if (filters.search) {
|
||||
const searchTerm = `%${filters.search}%`;
|
||||
conditions.push(or(
|
||||
like(qsos.callsign, searchTerm),
|
||||
like(qsos.entity, searchTerm),
|
||||
like(qsos.grid, searchTerm)
|
||||
));
|
||||
}
|
||||
|
||||
const allResults = await db.select().from(qsos).where(and(...conditions));
|
||||
const totalCount = allResults.length;
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
|
||||
let filters = {
|
||||
band: '',
|
||||
mode: ''
|
||||
mode: '',
|
||||
confirmationType: 'all',
|
||||
search: ''
|
||||
};
|
||||
|
||||
// Load QSOs on mount
|
||||
@@ -65,6 +67,10 @@
|
||||
const activeFilters = {};
|
||||
if (filters.band) activeFilters.band = filters.band;
|
||||
if (filters.mode) activeFilters.mode = filters.mode;
|
||||
if (filters.confirmationType && filters.confirmationType !== 'all') {
|
||||
activeFilters.confirmationType = filters.confirmationType;
|
||||
}
|
||||
if (filters.search) activeFilters.search = filters.search;
|
||||
|
||||
activeFilters.page = currentPage;
|
||||
activeFilters.limit = pageSize;
|
||||
@@ -261,7 +267,9 @@
|
||||
currentPage = 1;
|
||||
filters = {
|
||||
band: '',
|
||||
mode: ''
|
||||
mode: '',
|
||||
confirmationType: 'all',
|
||||
search: ''
|
||||
};
|
||||
loadQSOs();
|
||||
}
|
||||
@@ -534,6 +542,14 @@
|
||||
<div class="filters">
|
||||
<h3>Filters</h3>
|
||||
<div class="filter-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={filters.search}
|
||||
placeholder="Search callsign, entity, grid..."
|
||||
class="search-input"
|
||||
on:keydown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
/>
|
||||
|
||||
<select bind:value={filters.band} on:change={applyFilters}>
|
||||
<option value="">All Bands</option>
|
||||
{#each bands as band}
|
||||
@@ -548,6 +564,14 @@
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select bind:value={filters.confirmationType} on:change={applyFilters} class="confirmation-filter">
|
||||
<option value="all">All QSOs</option>
|
||||
<option value="lotw">LoTW Only</option>
|
||||
<option value="dcl">DCL Only</option>
|
||||
<option value="both">Both Confirmed</option>
|
||||
<option value="none">Not Confirmed</option>
|
||||
</select>
|
||||
|
||||
<button class="btn btn-secondary" on:click={clearFilters}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -778,6 +802,21 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user