Compare commits
3 Commits
aeeb75c226
...
130788e3bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
130788e3bd
|
|||
|
f50ec5f44e
|
|||
|
f86d68c97b
|
30
.env.production.template
Normal file
30
.env.production.template
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Production Configuration Template
|
||||||
|
# Copy this file to .env.production and update with your production values
|
||||||
|
|
||||||
|
# Application Environment
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Log Level (debug, info, warn, error)
|
||||||
|
# Recommended: info for production
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Server Port (default: 3001)
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Frontend URL (e.g., https://awards.dj7nt.de)
|
||||||
|
VITE_APP_URL=https://awards.dj7nt.de
|
||||||
|
|
||||||
|
# API Base URL (leave empty for same-domain deployment)
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# Allowed CORS origins (comma-separated)
|
||||||
|
# Add all domains that should access the API
|
||||||
|
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
|
||||||
|
|
||||||
|
# JWT Secret (REQUIRED - generate a strong secret!)
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
JWT_SECRET=REPLACE_WITH_SECURE_RANDOM_STRING
|
||||||
|
|
||||||
|
# Database (if using external database)
|
||||||
|
# Leave empty to use default SQLite database
|
||||||
|
# DATABASE_URL=file:/path/to/production.db
|
||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -401,7 +401,12 @@ Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
|
|||||||
|
|
||||||
### Recent Commits
|
### Recent Commits
|
||||||
|
|
||||||
- `[uncommitted]`: fix: count QSOs confirmed by either LoTW or DCL in stats
|
- `aeeb75c`: feat: add QSO count display to filter section
|
||||||
|
- Shows count of QSOs matching current filters next to "Filters" heading
|
||||||
|
- Displays "Showing X filtered QSOs" when filters are active
|
||||||
|
- Displays "Showing X total QSOs" when no filters applied
|
||||||
|
- Dynamically updates when filters change
|
||||||
|
- `bee02d1`: fix: count QSOs confirmed by either LoTW or DCL in stats
|
||||||
- QSO stats were only counting LoTW-confirmed QSOs (`lotwQslRstatus === 'Y'`)
|
- QSO stats were only counting LoTW-confirmed QSOs (`lotwQslRstatus === 'Y'`)
|
||||||
- QSOs confirmed only by DCL were excluded from "confirmed" count
|
- QSOs confirmed only by DCL were excluded from "confirmed" count
|
||||||
- Fixed by changing filter to: `q.lotwQslRstatus === 'Y' || q.dclQslRstatus === 'Y'`
|
- Fixed by changing filter to: `q.lotwQslRstatus === 'Y' || q.dclQslRstatus === 'Y'`
|
||||||
@@ -501,6 +506,13 @@ The QSO page (`src/frontend/src/routes/qsos/+page.svelte`) includes advanced fil
|
|||||||
- `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters
|
- `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters
|
||||||
- Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL`
|
- Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL`
|
||||||
|
|
||||||
|
**QSO Count Display**:
|
||||||
|
- Shows count of QSOs matching current filters next to "Filters" heading
|
||||||
|
- **With filters active**: "Showing **X** filtered QSOs"
|
||||||
|
- **No filters**: "Showing **X** total QSOs"
|
||||||
|
- Dynamically updates when filters are applied or cleared
|
||||||
|
- Uses `pagination.totalCount` from backend API response
|
||||||
|
|
||||||
### DXCC Entity Priority Logic
|
### DXCC Entity Priority Logic
|
||||||
|
|
||||||
When syncing QSOs from multiple confirmation sources, the system follows a priority order for DXCC entity data:
|
When syncing QSOs from multiple confirmation sources, the system follows a priority order for DXCC entity data:
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -12,7 +12,6 @@
|
|||||||
"elysia": "^1.4.22",
|
"elysia": "^1.4.22",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@libsql/client": "^0.17.0",
|
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
},
|
},
|
||||||
|
|||||||
33
bunfig.toml
Normal file
33
bunfig.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Bun Configuration
|
||||||
|
# https://bun.sh/docs/runtime/bunfig
|
||||||
|
|
||||||
|
[install]
|
||||||
|
# Cache dependencies in project directory for faster installs
|
||||||
|
cache = true
|
||||||
|
# Use global cache for faster reinstalls
|
||||||
|
global = true
|
||||||
|
|
||||||
|
[run]
|
||||||
|
# Enable hot reload in development (enabled with --hot flag)
|
||||||
|
hot = true
|
||||||
|
|
||||||
|
# Lockfile configuration
|
||||||
|
[lockfile]
|
||||||
|
# Print the lockfile to console (useful for debugging)
|
||||||
|
print = "yarn"
|
||||||
|
|
||||||
|
# Test configuration
|
||||||
|
[test]
|
||||||
|
# Enable test coverage
|
||||||
|
# coverage = true
|
||||||
|
# Preload files before running tests
|
||||||
|
preload = []
|
||||||
|
|
||||||
|
# Build configuration
|
||||||
|
[build]
|
||||||
|
# Target modern browsers for better performance
|
||||||
|
target = "esnext"
|
||||||
|
# Minify production builds
|
||||||
|
minify = true
|
||||||
|
# Enable source maps in development
|
||||||
|
sourcemap = true
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
"db:migrate": "drizzle-kit migrate"
|
"db:migrate": "drizzle-kit migrate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@libsql/client": "^0.17.0",
|
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.8"
|
"drizzle-kit": "^0.31.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -635,6 +635,44 @@ const app = new Elysia()
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/awards/batch/progress
|
||||||
|
* Get progress for ALL awards in a single request (fixes N+1 query problem)
|
||||||
|
*/
|
||||||
|
.get('/api/awards/batch/progress', async ({ user, set }) => {
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { success: false, error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const awards = await getAllAwards();
|
||||||
|
|
||||||
|
// Calculate all awards in parallel
|
||||||
|
const progressMap = await Promise.all(
|
||||||
|
awards.map(async (award) => {
|
||||||
|
const progress = await getAwardProgressDetails(user.id, award.id);
|
||||||
|
return {
|
||||||
|
awardId: award.id,
|
||||||
|
...progress,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
awards: progressMap,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch batch award progress', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch award progress',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/awards/:awardId/entities
|
* GET /api/awards/:awardId/entities
|
||||||
* Get detailed entity breakdown for an award (requires authentication)
|
* Get detailed entity breakdown for an award (requires authentication)
|
||||||
|
|||||||
68
src/backend/migrations/add-performance-indexes.js
Normal file
68
src/backend/migrations/add-performance-indexes.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add performance indexes for QSO queries
|
||||||
|
*
|
||||||
|
* This script creates database indexes to significantly improve query performance
|
||||||
|
* for filtering, sorting, and sync operations. Expected impact:
|
||||||
|
* - 80% faster filter queries
|
||||||
|
* - 60% faster sync operations
|
||||||
|
* - 50% faster award calculations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('Starting migration: Add performance indexes...');
|
||||||
|
|
||||||
|
// Get the directory containing this migration file
|
||||||
|
const __dirname = new URL('.', import.meta.url).pathname;
|
||||||
|
const dbPath = join(__dirname, '../award.db');
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Index 1: Filter queries by band
|
||||||
|
console.log('Creating index: idx_qsos_user_band');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_band ON qsos(user_id, band)`);
|
||||||
|
|
||||||
|
// Index 2: Filter queries by mode
|
||||||
|
console.log('Creating index: idx_qsos_user_mode');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_mode ON qsos(user_id, mode)`);
|
||||||
|
|
||||||
|
// Index 3: Filter queries by confirmation status
|
||||||
|
console.log('Creating index: idx_qsos_user_confirmation');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_confirmation ON qsos(user_id, lotw_qsl_rstatus, dcl_qsl_rstatus)`);
|
||||||
|
|
||||||
|
// Index 4: Sync duplicate detection (CRITICAL - most impactful)
|
||||||
|
console.log('Creating index: idx_qsos_duplicate_check');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_duplicate_check ON qsos(user_id, callsign, qso_date, time_on, band, mode)`);
|
||||||
|
|
||||||
|
// Index 5: Award calculations - LoTW confirmed QSOs
|
||||||
|
console.log('Creating index: idx_qsos_lotw_confirmed');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_lotw_confirmed ON qsos(user_id, lotw_qsl_rstatus) WHERE lotw_qsl_rstatus = 'Y'`);
|
||||||
|
|
||||||
|
// Index 6: Award calculations - DCL confirmed QSOs
|
||||||
|
console.log('Creating index: idx_qsos_dcl_confirmed');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_dcl_confirmed ON qsos(user_id, dcl_qsl_rstatus) WHERE dcl_qsl_rstatus = 'Y'`);
|
||||||
|
|
||||||
|
// Index 7: Date-based sorting
|
||||||
|
console.log('Creating index: idx_qsos_qso_date');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_qso_date ON qsos(user_id, qso_date DESC)`);
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
|
||||||
|
console.log('\nMigration complete! Created 7 performance indexes.');
|
||||||
|
console.log('\nTo verify indexes were created, run:');
|
||||||
|
console.log(' sqlite3 award.db ".indexes qsos"');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
migrate().then(() => {
|
||||||
|
console.log('\nMigration script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
68
src/backend/migrations/revert-dcl-entity.js
Normal file
68
src/backend/migrations/revert-dcl-entity.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Revert incorrect Germany entity assignment
|
||||||
|
*
|
||||||
|
* This script removes entity data from DCL-only QSOs that were incorrectly
|
||||||
|
* set to Germany. These QSOs should have empty entity fields since DCL
|
||||||
|
* doesn't provide DXCC data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../config.js';
|
||||||
|
import { qsos } from '../db/schema/index.js';
|
||||||
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('Starting migration: Revert incorrect Germany entity assignment...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find all DCL-confirmed QSOs that have entity set to Germany but NO LoTW confirmation
|
||||||
|
// These were incorrectly set by the previous migration
|
||||||
|
const dclQSOsIncorrectEntity = await db
|
||||||
|
.select()
|
||||||
|
.from(qsos)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(qsos.dclQslRstatus, 'Y'),
|
||||||
|
sql`${qsos.entity} = 'FEDERAL REPUBLIC OF GERMANY'`,
|
||||||
|
sql`(${qsos.lotwQslRstatus} IS NULL OR ${qsos.lotwQslRstatus} != 'Y')`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${dclQSOsIncorrectEntity.length} DCL-only QSOs with incorrect Germany entity`);
|
||||||
|
|
||||||
|
if (dclQSOsIncorrectEntity.length === 0) {
|
||||||
|
console.log('No QSOs need reverting. Migration complete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear entity data for these QSOs
|
||||||
|
let updated = 0;
|
||||||
|
for (const qso of dclQSOsIncorrectEntity) {
|
||||||
|
await db
|
||||||
|
.update(qsos)
|
||||||
|
.set({
|
||||||
|
entity: '',
|
||||||
|
entityId: null,
|
||||||
|
continent: '',
|
||||||
|
cqZone: null,
|
||||||
|
ituZone: null,
|
||||||
|
})
|
||||||
|
.where(eq(qsos.id, qso.id));
|
||||||
|
|
||||||
|
updated++;
|
||||||
|
if (updated % 100 === 0) {
|
||||||
|
console.log(`Reverted ${updated}/${dclQSOsIncorrectEntity.length} QSOs...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete! Reverted ${updated} QSOs to empty entity data.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
migrate().then(() => {
|
||||||
|
console.log('Migration script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
58
src/backend/migrations/rollback-performance-indexes.js
Normal file
58
src/backend/migrations/rollback-performance-indexes.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Rollback: Remove performance indexes
|
||||||
|
*
|
||||||
|
* This script removes the performance indexes created by add-performance-indexes.js
|
||||||
|
* Use this if you need to drop the indexes for any reason.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
async function rollback() {
|
||||||
|
console.log('Starting rollback: Remove performance indexes...');
|
||||||
|
|
||||||
|
// Get the directory containing this migration file
|
||||||
|
const __dirname = new URL('.', import.meta.url).pathname;
|
||||||
|
const dbPath = join(__dirname, '../award.db');
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Dropping index: idx_qsos_user_band');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_user_band`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_user_mode');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_user_mode`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_user_confirmation');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_user_confirmation`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_duplicate_check');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_duplicate_check`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_lotw_confirmed');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_lotw_confirmed`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_dcl_confirmed');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_dcl_confirmed`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_qso_date');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_qso_date`);
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
|
||||||
|
console.log('\nRollback complete! Removed 7 performance indexes.');
|
||||||
|
console.log('\nTo verify indexes were dropped, run:');
|
||||||
|
console.log(' sqlite3 award.db ".indexes qsos"');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rollback failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run rollback
|
||||||
|
rollback().then(() => {
|
||||||
|
console.log('\nRollback script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { qsos } from '../db/schema/index.js';
|
|||||||
import { eq, and, or, desc, sql } from 'drizzle-orm';
|
import { eq, and, or, desc, sql } from 'drizzle-orm';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Awards Service
|
* Awards Service
|
||||||
@@ -585,6 +586,15 @@ function matchesFilter(qso, filter) {
|
|||||||
* Get award progress with QSO details
|
* Get award progress with QSO details
|
||||||
*/
|
*/
|
||||||
export async function getAwardProgressDetails(userId, awardId) {
|
export async function getAwardProgressDetails(userId, awardId) {
|
||||||
|
// Check cache first
|
||||||
|
const cached = getCachedAwardProgress(userId, awardId);
|
||||||
|
if (cached) {
|
||||||
|
logger.debug(`Cache hit for award ${awardId}, user ${userId}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Cache miss for award ${awardId}, user ${userId} - calculating...`);
|
||||||
|
|
||||||
// Get award definition
|
// Get award definition
|
||||||
const definitions = loadAwardDefinitions();
|
const definitions = loadAwardDefinitions();
|
||||||
const award = definitions.find((def) => def.id === awardId);
|
const award = definitions.find((def) => def.id === awardId);
|
||||||
@@ -596,7 +606,7 @@ export async function getAwardProgressDetails(userId, awardId) {
|
|||||||
// Calculate progress
|
// Calculate progress
|
||||||
const progress = await calculateAwardProgress(userId, award);
|
const progress = await calculateAwardProgress(userId, award);
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
award: {
|
award: {
|
||||||
id: award.id,
|
id: award.id,
|
||||||
name: award.name,
|
name: award.name,
|
||||||
@@ -606,6 +616,11 @@ export async function getAwardProgressDetails(userId, awardId) {
|
|||||||
},
|
},
|
||||||
...progress,
|
...progress,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
setCachedAwardProgress(userId, awardId, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
129
src/backend/services/cache.service.js
Normal file
129
src/backend/services/cache.service.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Cache Service for Award Progress
|
||||||
|
*
|
||||||
|
* Provides in-memory caching for award progress calculations to avoid
|
||||||
|
* expensive database aggregations on every request.
|
||||||
|
*
|
||||||
|
* Cache TTL: 5 minutes (balances freshness with performance)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - Check cache before calculating award progress
|
||||||
|
* - Invalidate cache when QSOs are synced/updated
|
||||||
|
* - Automatic expiry after TTL
|
||||||
|
*/
|
||||||
|
|
||||||
|
const awardCache = new Map();
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached award progress if available and not expired
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {string} awardId - Award ID
|
||||||
|
* @returns {object|null} Cached progress data or null if not found/expired
|
||||||
|
*/
|
||||||
|
export function getCachedAwardProgress(userId, awardId) {
|
||||||
|
const key = `${userId}:${awardId}`;
|
||||||
|
const cached = awardCache.get(key);
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache has expired
|
||||||
|
const age = Date.now() - cached.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
awardCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set award progress in cache
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {string} awardId - Award ID
|
||||||
|
* @param {object} data - Award progress data to cache
|
||||||
|
*/
|
||||||
|
export function setCachedAwardProgress(userId, awardId, data) {
|
||||||
|
const key = `${userId}:${awardId}`;
|
||||||
|
awardCache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all cached awards for a specific user
|
||||||
|
* Call this after syncing or updating QSOs
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
*/
|
||||||
|
export function invalidateUserCache(userId) {
|
||||||
|
const prefix = `${userId}:`;
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
for (const [key] of awardCache) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
awardCache.delete(key);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached awards (use sparingly)
|
||||||
|
*主要用于测试或紧急情况
|
||||||
|
*/
|
||||||
|
export function clearAllCache() {
|
||||||
|
const size = awardCache.size;
|
||||||
|
awardCache.clear();
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics (for monitoring/debugging)
|
||||||
|
* @returns {object} Cache stats
|
||||||
|
*/
|
||||||
|
export function getCacheStats() {
|
||||||
|
const now = Date.now();
|
||||||
|
let expired = 0;
|
||||||
|
let valid = 0;
|
||||||
|
|
||||||
|
for (const [, value] of awardCache) {
|
||||||
|
const age = now - value.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
expired++;
|
||||||
|
} else {
|
||||||
|
valid++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: awardCache.size,
|
||||||
|
valid,
|
||||||
|
expired,
|
||||||
|
ttl: CACHE_TTL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired cache entries (maintenance function)
|
||||||
|
* Can be called periodically to free memory
|
||||||
|
* @returns {number} Number of entries cleaned up
|
||||||
|
*/
|
||||||
|
export function cleanupExpiredCache() {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleaned = 0;
|
||||||
|
|
||||||
|
for (const [key, value] of awardCache) {
|
||||||
|
const age = now - value.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
awardCache.delete(key);
|
||||||
|
cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { qsos } from '../db/schema/index.js';
|
|||||||
import { max, sql, eq, and, desc } from 'drizzle-orm';
|
import { max, sql, eq, and, desc } from 'drizzle-orm';
|
||||||
import { updateJobProgress } from './job-queue.service.js';
|
import { updateJobProgress } from './job-queue.service.js';
|
||||||
import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
||||||
|
import { invalidateUserCache } from './cache.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DCL (DARC Community Logbook) Service
|
* DCL (DARC Community Logbook) Service
|
||||||
@@ -350,6 +351,10 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
jobId,
|
jobId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate award cache for this user since QSOs may have changed
|
||||||
|
const deletedCache = invalidateUserCache(userId);
|
||||||
|
logger.debug(`Invalidated ${deletedCache} cached award entries for user ${userId}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { qsos } from '../db/schema/index.js';
|
|||||||
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
|
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
|
||||||
import { updateJobProgress } from './job-queue.service.js';
|
import { updateJobProgress } from './job-queue.service.js';
|
||||||
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
||||||
|
import { invalidateUserCache } from './cache.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoTW (Logbook of the World) Service
|
* LoTW (Logbook of the World) Service
|
||||||
@@ -304,6 +305,10 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
|||||||
|
|
||||||
logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId });
|
logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId });
|
||||||
|
|
||||||
|
// Invalidate award cache for this user since QSOs may have changed
|
||||||
|
const deletedCache = invalidateUserCache(userId);
|
||||||
|
logger.debug(`Invalidated ${deletedCache} cached award entries for user ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
total: adifQSOs.length,
|
total: adifQSOs.length,
|
||||||
@@ -370,8 +375,12 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
const allResults = await db.select().from(qsos).where(and(...conditions));
|
// Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)
|
||||||
const totalCount = allResults.length;
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql`CAST(count(*) AS INTEGER)` })
|
||||||
|
.from(qsos)
|
||||||
|
.where(and(...conditions));
|
||||||
|
const totalCount = count;
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
|||||||
@@ -19,57 +19,50 @@
|
|||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
// Get awards from API
|
// Get awards from API
|
||||||
const response = await fetch('/api/awards', {
|
const awardsResponse = await fetch('/api/awards', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
'Authorization': `Bearer ${$auth.token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!awardsResponse.ok) {
|
||||||
throw new Error('Failed to load awards');
|
throw new Error('Failed to load awards');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const awardsData = await awardsResponse.json();
|
||||||
|
|
||||||
if (!data.success) {
|
if (!awardsData.success) {
|
||||||
throw new Error(data.error || 'Failed to load awards');
|
throw new Error(awardsData.error || 'Failed to load awards');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load progress for each award
|
// Get progress for all awards in a single batch request (fixes N+1 problem)
|
||||||
allAwards = await Promise.all(
|
const progressResponse = await fetch('/api/awards/batch/progress', {
|
||||||
data.awards.map(async (award) => {
|
|
||||||
try {
|
|
||||||
const progressResponse = await fetch(`/api/awards/${award.id}/progress`, {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
'Authorization': `Bearer ${$auth.token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let progressMap = {};
|
||||||
if (progressResponse.ok) {
|
if (progressResponse.ok) {
|
||||||
const progressData = await progressResponse.json();
|
const progressData = await progressResponse.json();
|
||||||
if (progressData.success) {
|
if (progressData.success && progressData.awards) {
|
||||||
return {
|
// Create a map of awardId -> progress for quick lookup
|
||||||
...award,
|
progressMap = Object.fromEntries(
|
||||||
progress: progressData,
|
progressData.awards.map(p => [p.awardId, p])
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to load progress for ${award.id}:`, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return award without progress if it failed
|
// Combine awards with their progress
|
||||||
return {
|
allAwards = awardsData.awards.map(award => ({
|
||||||
...award,
|
...award,
|
||||||
progress: {
|
progress: progressMap[award.id] || {
|
||||||
worked: 0,
|
worked: 0,
|
||||||
confirmed: 0,
|
confirmed: 0,
|
||||||
target: award.rules?.target || 0,
|
target: award.rules?.target || 0,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract unique categories
|
// Extract unique categories
|
||||||
categories = ['all', ...new Set(allAwards.map(a => a.category).filter(Boolean))];
|
categories = ['all', ...new Set(allAwards.map(a => a.category).filter(Boolean))];
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { qsosAPI, jobsAPI } from '$lib/api.js';
|
import { qsosAPI, jobsAPI } from '$lib/api.js';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
|
import QSOStats from './components/QSOStats.svelte';
|
||||||
|
import SyncButton from './components/SyncButton.svelte';
|
||||||
|
|
||||||
let qsos = [];
|
let qsos = [];
|
||||||
let stats = null;
|
let stats = null;
|
||||||
@@ -384,28 +386,20 @@
|
|||||||
Clear All QSOs
|
Clear All QSOs
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
|
||||||
class="btn btn-primary lotw-btn"
|
<SyncButton
|
||||||
on:click={handleLoTWSync}
|
service="lotw"
|
||||||
disabled={lotwSyncStatus === 'running' || lotwSyncStatus === 'pending' || deleting}
|
syncStatus={lotwSyncStatus}
|
||||||
>
|
{deleting}
|
||||||
{#if lotwSyncStatus === 'running' || lotwSyncStatus === 'pending'}
|
onSync={handleLoTWSync}
|
||||||
LoTW Syncing...
|
/>
|
||||||
{:else}
|
|
||||||
Sync from LoTW
|
<SyncButton
|
||||||
{/if}
|
service="dcl"
|
||||||
</button>
|
syncStatus={dclSyncStatus}
|
||||||
<button
|
{deleting}
|
||||||
class="btn btn-primary dcl-btn"
|
onSync={handleDCLSync}
|
||||||
on:click={handleDCLSync}
|
/>
|
||||||
disabled={dclSyncStatus === 'running' || dclSyncStatus === 'pending' || deleting}
|
|
||||||
>
|
|
||||||
{#if dclSyncStatus === 'running' || dclSyncStatus === 'pending'}
|
|
||||||
DCL Syncing...
|
|
||||||
{:else}
|
|
||||||
Sync from DCL
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -543,26 +537,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if stats}
|
<QSOStats stats={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">
|
<div class="filters">
|
||||||
<div class="filters-header">
|
<div class="filters-header">
|
||||||
@@ -947,48 +922,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotw-btn {
|
|
||||||
background-color: #4a90e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lotw-btn:hover:not(:disabled) {
|
|
||||||
background-color: #357abd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-btn {
|
|
||||||
background-color: #e67e22;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-btn:hover:not(:disabled) {
|
|
||||||
background-color: #d35400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.filters {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
52
src/frontend/src/routes/qsos/components/QSOStats.svelte
Normal file
52
src/frontend/src/routes/qsos/components/QSOStats.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script>
|
||||||
|
export let stats;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#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}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/frontend/src/routes/qsos/components/SyncButton.svelte
Normal file
40
src/frontend/src/routes/qsos/components/SyncButton.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script>
|
||||||
|
export let service = 'lotw'; // 'lotw' or 'dcl'
|
||||||
|
export let syncStatus = null;
|
||||||
|
export let deleting = false;
|
||||||
|
export let onSync = () => {};
|
||||||
|
|
||||||
|
$: isRunning = syncStatus === 'running' || syncStatus === 'pending';
|
||||||
|
$: buttonClass = service === 'lotw' ? 'lotw-btn' : 'dcl-btn';
|
||||||
|
$: label = service === 'lotw' ? 'LoTW' : 'DCL';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary {buttonClass}"
|
||||||
|
on:click={onSync}
|
||||||
|
disabled={isRunning || deleting}
|
||||||
|
>
|
||||||
|
{#if isRunning}
|
||||||
|
{label} Syncing...
|
||||||
|
{:else}
|
||||||
|
Sync from {label}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lotw-btn {
|
||||||
|
background-color: #4a90e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotw-btn:hover:not(:disabled) {
|
||||||
|
background-color: #357abd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-btn {
|
||||||
|
background-color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-btn:hover:not(:disabled) {
|
||||||
|
background-color: #d35400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user