refactor: simplify codebase and replace external dependencies with Bun built-ins
Backend changes: - Merge duplicate award logic (calculatePointsAwardProgress + getPointsAwardEntityBreakdown) - Simplify LoTW service (merge syncQSOs functions, simplify polling) - Remove job queue abstraction (hardcode LoTW sync, remove processor registry) - Consolidate config files (database.js, logger.js, jwt.js → single config.js) - Replace bcrypt with Bun.password.hash/verify - Replace Pino logger with console-based logger - Fix: export syncQSOs and getLastLoTWQSLDate for job queue imports - Fix: correct database path resolution using new URL() Frontend changes: - Simplify auth store (remove localStorage wrappers, reduce from 222→109 lines) - Consolidate API layer (remove verbose JSDoc, 180→80 lines) - Add shared UI components (Loading, ErrorDisplay, BackButton) Dependencies: - Remove bcrypt (replaced with Bun.password) - Remove pino and pino-pretty (replaced with console logger) Total: ~445 lines removed (net), 3 dependencies removed Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { db, logger } from '../config.js';
|
||||
import { qsos } from '../db/schema/index.js';
|
||||
import { eq, and, or, desc, sql } from 'drizzle-orm';
|
||||
import logger from '../config/logger.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -63,12 +62,20 @@ export async function getAllAwards() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize award rules to a consistent format
|
||||
* Calculate award progress for a user
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.includeDetails - Include detailed entity breakdown
|
||||
*/
|
||||
function normalizeAwardRules(rules) {
|
||||
export async function calculateAwardProgress(userId, award, options = {}) {
|
||||
const { includeDetails = false } = options;
|
||||
let { rules } = award;
|
||||
|
||||
// Normalize rules inline to handle different formats
|
||||
// Handle "filtered" type awards (like DXCC CW)
|
||||
if (rules.type === 'filtered' && rules.baseRule) {
|
||||
return {
|
||||
rules = {
|
||||
type: 'entity',
|
||||
entityType: rules.baseRule.entityType,
|
||||
target: rules.baseRule.target,
|
||||
@@ -76,11 +83,9 @@ function normalizeAwardRules(rules) {
|
||||
filters: rules.filters,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle "counter" type awards (like RS-44)
|
||||
// These count unique callsigns instead of entities
|
||||
if (rules.type === 'counter') {
|
||||
return {
|
||||
else if (rules.type === 'counter') {
|
||||
rules = {
|
||||
type: 'entity',
|
||||
entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign',
|
||||
target: rules.target,
|
||||
@@ -88,30 +93,13 @@ function normalizeAwardRules(rules) {
|
||||
filters: rules.filters,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle "points" type awards (station-specific point values)
|
||||
// Keep as-is but validate stations array exists
|
||||
if (rules.type === 'points') {
|
||||
// Validate "points" type awards
|
||||
else if (rules.type === 'points') {
|
||||
if (!rules.stations || !Array.isArray(rules.stations)) {
|
||||
logger.warn('Point-based award missing stations array');
|
||||
}
|
||||
return rules; // Return as-is for special handling
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate award progress for a user
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
*/
|
||||
export async function calculateAwardProgress(userId, award) {
|
||||
let { rules } = award;
|
||||
|
||||
// Normalize rules to handle different formats
|
||||
rules = normalizeAwardRules(rules);
|
||||
|
||||
logger.debug('Calculating award progress', {
|
||||
userId,
|
||||
awardId: award.id,
|
||||
@@ -122,7 +110,7 @@ export async function calculateAwardProgress(userId, award) {
|
||||
|
||||
// Handle point-based awards
|
||||
if (rules.type === 'points') {
|
||||
return calculatePointsAwardProgress(userId, rules);
|
||||
return calculatePointsAwardProgress(userId, award, { includeDetails });
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
@@ -174,8 +162,14 @@ export async function calculateAwardProgress(userId, award) {
|
||||
* - "perBandMode": each unique (callsign, band, mode) combination earns points
|
||||
* - "perStation": each unique station earns points once
|
||||
* - "perQso": every confirmed QSO earns points
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.includeDetails - Include detailed entity breakdown
|
||||
*/
|
||||
async function calculatePointsAwardProgress(userId, rules) {
|
||||
async function calculatePointsAwardProgress(userId, award, options = {}) {
|
||||
const { includeDetails = false } = options;
|
||||
const { rules } = award;
|
||||
const { stations, target, countMode = 'perStation' } = rules;
|
||||
|
||||
// Create a map of callsign -> points for quick lookup
|
||||
@@ -196,162 +190,12 @@ async function calculatePointsAwardProgress(userId, rules) {
|
||||
.from(qsos)
|
||||
.where(eq(qsos.userId, userId));
|
||||
|
||||
const workedStations = new Set(); // Unique callsigns worked
|
||||
const workedStations = new Set();
|
||||
let totalPoints = 0;
|
||||
const stationDetails = [];
|
||||
|
||||
if (countMode === 'perBandMode') {
|
||||
// Count unique (callsign, band, mode) combinations
|
||||
const workedCombinations = new Set();
|
||||
const confirmedCombinations = new Map();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
const callsign = qso.callsign?.toUpperCase();
|
||||
if (!callsign) continue;
|
||||
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
const band = qso.band || 'Unknown';
|
||||
const mode = qso.mode || 'Unknown';
|
||||
const combinationKey = `${callsign}/${band}/${mode}`;
|
||||
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (!workedCombinations.has(combinationKey)) {
|
||||
workedCombinations.add(combinationKey);
|
||||
stationDetails.push({
|
||||
callsign,
|
||||
band,
|
||||
mode,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y' && !confirmedCombinations.has(combinationKey)) {
|
||||
confirmedCombinations.set(combinationKey, points);
|
||||
const detail = stationDetails.find((c) =>
|
||||
c.callsign === callsign && c.band === band && c.mode === mode
|
||||
);
|
||||
if (detail) {
|
||||
detail.confirmed = true;
|
||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalPoints = Array.from(confirmedCombinations.values()).reduce((sum, p) => sum + p, 0);
|
||||
} else if (countMode === 'perStation') {
|
||||
// Count unique stations only
|
||||
const workedStationsMap = new Map();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
const callsign = qso.callsign?.toUpperCase();
|
||||
if (!callsign) continue;
|
||||
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (!workedStationsMap.has(callsign)) {
|
||||
workedStationsMap.set(callsign, {
|
||||
callsign,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
});
|
||||
}
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
const detail = workedStationsMap.get(callsign);
|
||||
if (detail && !detail.confirmed) {
|
||||
detail.confirmed = true;
|
||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalPoints = Array.from(workedStationsMap.values())
|
||||
.filter((s) => s.confirmed)
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
stationDetails.push(...workedStationsMap.values());
|
||||
} else if (countMode === 'perQso') {
|
||||
// Count every confirmed QSO
|
||||
const qsoCount = { worked: 0, confirmed: 0, points: 0 };
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
const callsign = qso.callsign?.toUpperCase();
|
||||
if (!callsign) continue;
|
||||
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
workedStations.add(callsign);
|
||||
qsoCount.worked++;
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
qsoCount.confirmed++;
|
||||
qsoCount.points += points;
|
||||
}
|
||||
}
|
||||
|
||||
totalPoints = qsoCount.points;
|
||||
}
|
||||
|
||||
logger.debug('Point-based award progress', {
|
||||
workedStations: workedStations.size,
|
||||
totalPoints,
|
||||
target,
|
||||
});
|
||||
|
||||
return {
|
||||
worked: workedStations.size,
|
||||
confirmed: stationDetails.filter((s) => s.confirmed).length,
|
||||
totalPoints,
|
||||
target: target || 0,
|
||||
percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0,
|
||||
workedEntities: Array.from(workedStations),
|
||||
confirmedEntities: stationDetails.filter((s) => s.confirmed).map((s) => s.callsign),
|
||||
stationDetails,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity breakdown for point-based awards
|
||||
* countMode determines what entities are shown:
|
||||
* - "perBandMode": shows each (callsign, band, mode) combination
|
||||
* - "perStation": shows each unique station
|
||||
* - "perQso": shows every QSO (not recommended for large datasets)
|
||||
*/
|
||||
async function getPointsAwardEntityBreakdown(userId, award) {
|
||||
const { rules } = award;
|
||||
const { stations, target, countMode = 'perStation' } = rules;
|
||||
|
||||
// Create a map of callsign -> points for quick lookup
|
||||
const stationPoints = new Map();
|
||||
for (const station of stations) {
|
||||
stationPoints.set(station.callsign.toUpperCase(), station.points);
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
.from(qsos)
|
||||
.where(eq(qsos.userId, userId));
|
||||
|
||||
let entities = [];
|
||||
let totalPoints = 0;
|
||||
|
||||
if (countMode === 'perBandMode') {
|
||||
// Show each (callsign, band, mode) combination
|
||||
const combinationMap = new Map();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
@@ -365,33 +209,35 @@ async function getPointsAwardEntityBreakdown(userId, award) {
|
||||
const mode = qso.mode || 'Unknown';
|
||||
const combinationKey = `${callsign}/${band}/${mode}`;
|
||||
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (!combinationMap.has(combinationKey)) {
|
||||
combinationMap.set(combinationKey, {
|
||||
entity: combinationKey,
|
||||
entityId: null,
|
||||
entityName: `${callsign} (${band}/${mode})`,
|
||||
callsign,
|
||||
band,
|
||||
mode,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: qso.lotwQslRstatus === 'Y',
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
callsign: qso.callsign,
|
||||
lotwQslRdate: qso.lotwQslRdate,
|
||||
lotwQslRdate: null,
|
||||
});
|
||||
} else {
|
||||
const data = combinationMap.get(combinationKey);
|
||||
if (!data.confirmed && qso.lotwQslRstatus === 'Y') {
|
||||
data.confirmed = true;
|
||||
data.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
const detail = combinationMap.get(combinationKey);
|
||||
if (!detail.confirmed) {
|
||||
detail.confirmed = true;
|
||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entities = Array.from(combinationMap.values());
|
||||
totalPoints = entities.filter((e) => e.confirmed).reduce((sum, e) => sum + e.points, 0);
|
||||
const details = Array.from(combinationMap.values());
|
||||
stationDetails.push(...details);
|
||||
totalPoints = details.filter((d) => d.confirmed).reduce((sum, d) => sum + d.points, 0);
|
||||
} else if (countMode === 'perStation') {
|
||||
// Show each unique station
|
||||
// Count unique stations only
|
||||
const stationMap = new Map();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
@@ -401,33 +247,35 @@ async function getPointsAwardEntityBreakdown(userId, award) {
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (!stationMap.has(callsign)) {
|
||||
stationMap.set(callsign, {
|
||||
entity: callsign,
|
||||
entityId: null,
|
||||
entityName: callsign,
|
||||
callsign,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: qso.lotwQslRstatus === 'Y',
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
callsign: qso.callsign,
|
||||
lotwQslRdate: qso.lotwQslRdate,
|
||||
lotwQslRdate: null,
|
||||
});
|
||||
} else {
|
||||
const data = stationMap.get(callsign);
|
||||
if (!data.confirmed && qso.lotwQslRstatus === 'Y') {
|
||||
data.confirmed = true;
|
||||
data.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
const detail = stationMap.get(callsign);
|
||||
if (!detail.confirmed) {
|
||||
detail.confirmed = true;
|
||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entities = Array.from(stationMap.values());
|
||||
totalPoints = entities.filter((e) => e.confirmed).reduce((sum, e) => sum + e.points, 0);
|
||||
const details = Array.from(stationMap.values());
|
||||
stationDetails.push(...details);
|
||||
totalPoints = details.filter((d) => d.confirmed).reduce((sum, d) => sum + d.points, 0);
|
||||
} else if (countMode === 'perQso') {
|
||||
// Show every QSO (use with caution)
|
||||
// Count every confirmed QSO
|
||||
for (const qso of allQSOs) {
|
||||
const callsign = qso.callsign?.toUpperCase();
|
||||
if (!callsign) continue;
|
||||
@@ -435,39 +283,105 @@ async function getPointsAwardEntityBreakdown(userId, award) {
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
entities.push({
|
||||
entity: `${callsign}-${qso.qsoDate}`,
|
||||
entityId: null,
|
||||
entityName: `${callsign} on ${qso.qsoDate}`,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: qso.lotwQslRstatus === 'Y',
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
callsign: qso.callsign,
|
||||
lotwQslRdate: qso.lotwQslRdate,
|
||||
});
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
totalPoints += points;
|
||||
stationDetails.push({
|
||||
callsign,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: true,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
lotwQslRdate: qso.lotwQslRdate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
award: {
|
||||
logger.debug('Point-based award progress', {
|
||||
workedStations: workedStations.size,
|
||||
totalPoints,
|
||||
target,
|
||||
});
|
||||
|
||||
// Base result
|
||||
const result = {
|
||||
worked: workedStations.size,
|
||||
confirmed: stationDetails.filter((s) => s.confirmed).length,
|
||||
totalPoints,
|
||||
target: target || 0,
|
||||
percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0,
|
||||
workedEntities: Array.from(workedStations),
|
||||
confirmedEntities: stationDetails.filter((s) => s.confirmed).map((s) => s.callsign),
|
||||
};
|
||||
|
||||
// Add details if requested
|
||||
if (includeDetails) {
|
||||
// Convert stationDetails to entity format for breakdown
|
||||
const entities = stationDetails.map((detail) => {
|
||||
if (countMode === 'perBandMode') {
|
||||
return {
|
||||
entity: `${detail.callsign}/${detail.band}/${detail.mode}`,
|
||||
entityId: null,
|
||||
entityName: `${detail.callsign} (${detail.band}/${detail.mode})`,
|
||||
points: detail.points,
|
||||
worked: detail.worked,
|
||||
confirmed: detail.confirmed,
|
||||
qsoDate: detail.qsoDate,
|
||||
band: detail.band,
|
||||
mode: detail.mode,
|
||||
callsign: detail.callsign,
|
||||
lotwQslRdate: detail.lotwQslRdate,
|
||||
};
|
||||
} else if (countMode === 'perStation') {
|
||||
return {
|
||||
entity: detail.callsign,
|
||||
entityId: null,
|
||||
entityName: detail.callsign,
|
||||
points: detail.points,
|
||||
worked: detail.worked,
|
||||
confirmed: detail.confirmed,
|
||||
qsoDate: detail.qsoDate,
|
||||
band: detail.band,
|
||||
mode: detail.mode,
|
||||
callsign: detail.callsign,
|
||||
lotwQslRdate: detail.lotwQslRdate,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
entity: `${detail.callsign}-${detail.qsoDate}`,
|
||||
entityId: null,
|
||||
entityName: `${detail.callsign} on ${detail.qsoDate}`,
|
||||
points: detail.points,
|
||||
worked: detail.worked,
|
||||
confirmed: detail.confirmed,
|
||||
qsoDate: detail.qsoDate,
|
||||
band: detail.band,
|
||||
mode: detail.mode,
|
||||
callsign: detail.callsign,
|
||||
lotwQslRdate: detail.lotwQslRdate,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
result.award = {
|
||||
id: award.id,
|
||||
name: award.name,
|
||||
description: award.description,
|
||||
caption: award.caption,
|
||||
target: award.rules?.target || 0,
|
||||
},
|
||||
entities,
|
||||
total: entities.length,
|
||||
confirmed: entities.filter((e) => e.confirmed).length,
|
||||
totalPoints,
|
||||
};
|
||||
};
|
||||
result.entities = entities;
|
||||
result.total = entities.length;
|
||||
result.confirmed = entities.filter((e) => e.confirmed).length;
|
||||
} else {
|
||||
result.stationDetails = stationDetails;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -577,12 +491,28 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
||||
|
||||
let { rules } = award;
|
||||
|
||||
// Normalize rules to handle different formats
|
||||
rules = normalizeAwardRules(rules);
|
||||
// Normalize rules inline
|
||||
if (rules.type === 'filtered' && rules.baseRule) {
|
||||
rules = {
|
||||
type: 'entity',
|
||||
entityType: rules.baseRule.entityType,
|
||||
target: rules.baseRule.target,
|
||||
displayField: rules.baseRule.displayField,
|
||||
filters: rules.filters,
|
||||
};
|
||||
} else if (rules.type === 'counter') {
|
||||
rules = {
|
||||
type: 'entity',
|
||||
entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign',
|
||||
target: rules.target,
|
||||
displayField: rules.displayField,
|
||||
filters: rules.filters,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle point-based awards
|
||||
// Handle point-based awards - use the unified function
|
||||
if (rules.type === 'points') {
|
||||
return getPointsAwardEntityBreakdown(userId, award);
|
||||
return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
@@ -604,17 +534,14 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
||||
|
||||
if (!entityMap.has(entity)) {
|
||||
// Determine what to display as the entity name
|
||||
// Use displayField from award rules, or fallback to entity/type
|
||||
let displayName = String(entity);
|
||||
if (rules.displayField) {
|
||||
let rawValue = qso[rules.displayField];
|
||||
// For grid-based awards, truncate to first 4 characters
|
||||
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
|
||||
rawValue = rawValue.substring(0, 4);
|
||||
}
|
||||
displayName = String(rawValue || entity);
|
||||
} else {
|
||||
// Fallback: try entity, state, grid, callsign in order
|
||||
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user