Add per-award configurable mode groups for filtering multiple modes together in the award detail view. Mode groups are displayed with visual separators in the mode filter dropdown. Backend changes: - Add modeGroups to getAllAwards() return mapping - Add getAwardById() function to fetch single award definition - Add GET /api/awards/:awardId endpoint Frontend changes: - Fetch award definition separately to get modeGroups - Update availableModes to include mode groups with separator - Update filteredEntities logic to handle mode groups - Update groupDataForTable() and applyFilter() for mode groups - Disable separator option in dropdown Award definitions: - DXCC: Add Digi-Modes, Classic Digi-Modes, Mixed-Mode w/o WSJT-Modes, Phone-Modes groups - DLD: Add same mode groups (adjusted for available modes) Co-Authored-By: Claude <noreply@anthropic.com>
841 lines
24 KiB
JavaScript
841 lines
24 KiB
JavaScript
import { db, logger } from '../config.js';
|
|
import { qsos } from '../db/schema/index.js';
|
|
import { eq, and, or, desc, sql } from 'drizzle-orm';
|
|
import { readFileSync, readdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.js';
|
|
|
|
/**
|
|
* Awards Service
|
|
* Calculates award progress based on QSO data
|
|
*/
|
|
|
|
// Load award definitions from files
|
|
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
|
|
|
|
// In-memory cache for award definitions (static, never changes at runtime)
|
|
let cachedAwardDefinitions = null;
|
|
|
|
/**
|
|
* Load all award definitions (cached in memory)
|
|
*/
|
|
function loadAwardDefinitions() {
|
|
// Return cached definitions if available
|
|
if (cachedAwardDefinitions) {
|
|
return cachedAwardDefinitions;
|
|
}
|
|
|
|
const definitions = [];
|
|
|
|
try {
|
|
// Auto-discover all JSON files in the award-definitions directory
|
|
const files = readdirSync(AWARD_DEFINITIONS_DIR)
|
|
.filter(f => f.endsWith('.json'))
|
|
.sort();
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const filePath = join(AWARD_DEFINITIONS_DIR, file);
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const definition = JSON.parse(content);
|
|
definitions.push(definition);
|
|
} catch (error) {
|
|
logger.warn('Failed to load award definition', { file, error: error.message });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error loading award definitions', { error: error.message });
|
|
}
|
|
|
|
// Cache the definitions for future calls
|
|
cachedAwardDefinitions = definitions;
|
|
|
|
return definitions;
|
|
}
|
|
|
|
/**
|
|
* Get all available awards
|
|
*/
|
|
export async function getAllAwards() {
|
|
const definitions = loadAwardDefinitions();
|
|
|
|
return definitions.map((def) => ({
|
|
id: def.id,
|
|
name: def.name,
|
|
description: def.description,
|
|
caption: def.caption,
|
|
category: def.category,
|
|
rules: def.rules,
|
|
modeGroups: def.modeGroups || null,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get a single award by ID
|
|
* @param {string} awardId - Award ID
|
|
* @returns {Object|null} Award definition or null if not found
|
|
*/
|
|
export function getAwardById(awardId) {
|
|
const definitions = loadAwardDefinitions();
|
|
const award = definitions.find((def) => def.id === awardId);
|
|
|
|
if (!award) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: award.id,
|
|
name: award.name,
|
|
description: award.description,
|
|
caption: award.caption,
|
|
category: award.category,
|
|
rules: award.rules,
|
|
modeGroups: award.modeGroups || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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) {
|
|
rules = {
|
|
type: 'entity',
|
|
entityType: rules.baseRule.entityType,
|
|
target: rules.baseRule.target,
|
|
displayField: rules.baseRule.displayField,
|
|
filters: rules.filters,
|
|
};
|
|
}
|
|
// Handle "counter" type awards (like RS-44)
|
|
else if (rules.type === 'counter') {
|
|
rules = {
|
|
type: 'entity',
|
|
entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign',
|
|
target: rules.target,
|
|
displayField: rules.displayField,
|
|
filters: rules.filters,
|
|
};
|
|
}
|
|
// Validate "points" type awards
|
|
else if (rules.type === 'points') {
|
|
if (!rules.stations || !Array.isArray(rules.stations)) {
|
|
logger.warn('Point-based award missing stations array');
|
|
}
|
|
}
|
|
|
|
logger.debug('Calculating award progress', {
|
|
userId,
|
|
awardId: award.id,
|
|
awardType: rules.type,
|
|
entityType: rules.entityType,
|
|
hasFilters: !!rules.filters,
|
|
});
|
|
|
|
// Handle DOK-based awards (DLD)
|
|
if (rules.type === 'dok') {
|
|
return calculateDOKAwardProgress(userId, award, { includeDetails });
|
|
}
|
|
|
|
// Handle point-based awards
|
|
if (rules.type === 'points') {
|
|
return calculatePointsAwardProgress(userId, award, { includeDetails });
|
|
}
|
|
|
|
// Get all QSOs for user
|
|
const allQSOs = await db
|
|
.select()
|
|
.from(qsos)
|
|
.where(eq(qsos.userId, userId));
|
|
|
|
logger.debug('Total QSOs for user', { count: allQSOs.length });
|
|
|
|
// Apply filters if defined
|
|
let filteredQSOs = allQSOs;
|
|
if (rules.filters) {
|
|
filteredQSOs = applyFilters(allQSOs, rules.filters);
|
|
logger.debug('QSOs after filters', { count: filteredQSOs.length });
|
|
}
|
|
|
|
// Apply allowed_bands filter if present
|
|
let finalQSOs = filteredQSOs;
|
|
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
|
|
finalQSOs = filteredQSOs.filter(qso => {
|
|
const band = qso.band;
|
|
return rules.allowed_bands.includes(band);
|
|
});
|
|
logger.debug('QSOs after allowed_bands filter', { count: finalQSOs.length });
|
|
}
|
|
|
|
// Apply satellite_only filter if present
|
|
if (rules.satellite_only) {
|
|
finalQSOs = finalQSOs.filter(qso => qso.satName);
|
|
logger.debug('QSOs after satellite_only filter', { count: finalQSOs.length });
|
|
}
|
|
|
|
// Calculate worked and confirmed entities
|
|
const workedEntities = new Set();
|
|
const confirmedEntities = new Set();
|
|
|
|
for (const qso of finalQSOs) {
|
|
const entity = getEntityValue(qso, rules.entityType);
|
|
|
|
if (entity) {
|
|
// Worked: QSO exists (any LoTW status)
|
|
workedEntities.add(entity);
|
|
|
|
// Confirmed: LoTW QSL received
|
|
if (qso.lotwQslRstatus === 'Y') {
|
|
confirmedEntities.add(entity);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
worked: workedEntities.size,
|
|
confirmed: confirmedEntities.size,
|
|
target: rules.target || 0,
|
|
percentage: rules.target ? Math.round((confirmedEntities.size / rules.target) * 100) : 0,
|
|
workedEntities: Array.from(workedEntities),
|
|
confirmedEntities: Array.from(confirmedEntities),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate progress for DOK-based awards (DLD)
|
|
* Counts unique (DOK, band, mode) combinations with DCL confirmation
|
|
* @param {number} userId - User ID
|
|
* @param {Object} award - Award definition
|
|
* @param {Object} options - Options
|
|
* @param {boolean} options.includeDetails - Include detailed entity breakdown
|
|
*/
|
|
async function calculateDOKAwardProgress(userId, award, options = {}) {
|
|
const { includeDetails = false } = options;
|
|
const { rules } = award;
|
|
const { target, displayField, filters } = rules;
|
|
|
|
logger.debug('Calculating DOK-based award progress', { userId, awardId: award.id, target, hasFilters: !!filters });
|
|
|
|
// Get all QSOs for user
|
|
const allQSOs = await db
|
|
.select()
|
|
.from(qsos)
|
|
.where(eq(qsos.userId, userId));
|
|
|
|
logger.debug('Total QSOs for user', { count: allQSOs.length });
|
|
|
|
// Apply filters if defined
|
|
let filteredQSOs = allQSOs;
|
|
if (filters) {
|
|
filteredQSOs = applyFilters(allQSOs, filters);
|
|
logger.debug('QSOs after DOK award filters', { count: filteredQSOs.length });
|
|
}
|
|
|
|
// Track unique (DOK, band, mode) combinations
|
|
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object with qsos array
|
|
|
|
for (const qso of filteredQSOs) {
|
|
const dok = qso.darcDok;
|
|
if (!dok) continue; // Skip QSOs without DOK
|
|
|
|
const band = qso.band || 'Unknown';
|
|
const mode = qso.mode || 'Unknown';
|
|
const combinationKey = `${dok}/${band}/${mode}`;
|
|
|
|
// Initialize combination if not exists
|
|
if (!dokCombinations.has(combinationKey)) {
|
|
dokCombinations.set(combinationKey, {
|
|
entity: dok,
|
|
entityId: null,
|
|
entityName: dok,
|
|
band,
|
|
mode,
|
|
worked: false,
|
|
confirmed: false,
|
|
qsos: [], // Array of confirmed QSOs for this slot
|
|
});
|
|
}
|
|
|
|
const detail = dokCombinations.get(combinationKey);
|
|
detail.worked = true;
|
|
|
|
// Check for DCL confirmation and add to qsos array
|
|
if (qso.dclQslRstatus === 'Y') {
|
|
if (!detail.confirmed) {
|
|
detail.confirmed = true;
|
|
}
|
|
// Add this confirmed QSO to the qsos array
|
|
detail.qsos.push({
|
|
qsoId: qso.id,
|
|
callsign: qso.callsign,
|
|
mode: qso.mode,
|
|
qsoDate: qso.qsoDate,
|
|
timeOn: qso.timeOn,
|
|
band: qso.band,
|
|
satName: qso.satName,
|
|
confirmed: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
const workedDOKs = new Set();
|
|
const confirmedDOKs = new Set();
|
|
|
|
for (const [key, detail] of dokCombinations) {
|
|
const dok = detail.entity;
|
|
workedDOKs.add(dok);
|
|
if (detail.confirmed) {
|
|
confirmedDOKs.add(dok);
|
|
}
|
|
}
|
|
|
|
logger.debug('DOK award progress', {
|
|
workedDOKs: workedDOKs.size,
|
|
confirmedDOKs: confirmedDOKs.size,
|
|
target,
|
|
});
|
|
|
|
// Base result
|
|
const result = {
|
|
worked: workedDOKs.size,
|
|
confirmed: confirmedDOKs.size,
|
|
target: target || 0,
|
|
percentage: target ? Math.round((confirmedDOKs.size / target) * 100) : 0,
|
|
workedEntities: Array.from(workedDOKs),
|
|
confirmedEntities: Array.from(confirmedDOKs),
|
|
};
|
|
|
|
// Add details if requested
|
|
if (includeDetails) {
|
|
result.award = {
|
|
id: award.id,
|
|
name: award.name,
|
|
description: award.description,
|
|
caption: award.caption,
|
|
target: target || 0,
|
|
};
|
|
result.entities = Array.from(dokCombinations.values());
|
|
result.total = result.entities.length;
|
|
result.confirmed = result.entities.filter((e) => e.confirmed).length;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Calculate progress for point-based awards
|
|
* countMode determines how points are counted:
|
|
* - "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, award, options = {}) {
|
|
const { includeDetails = false } = options;
|
|
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);
|
|
}
|
|
|
|
logger.debug('Point-based award stations', {
|
|
totalStations: stations.length,
|
|
countMode,
|
|
maxPoints: stations.reduce((sum, s) => sum + s.points, 0),
|
|
});
|
|
|
|
// Get all QSOs for user
|
|
const allQSOs = await db
|
|
.select()
|
|
.from(qsos)
|
|
.where(eq(qsos.userId, userId));
|
|
|
|
const workedStations = new Set();
|
|
let totalPoints = 0;
|
|
const stationDetails = [];
|
|
|
|
if (countMode === 'perBandMode') {
|
|
// Count unique (callsign, band, mode) combinations
|
|
const combinationMap = 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 (!combinationMap.has(combinationKey)) {
|
|
combinationMap.set(combinationKey, {
|
|
callsign,
|
|
band,
|
|
mode,
|
|
points,
|
|
worked: true,
|
|
confirmed: false,
|
|
qsos: [], // Array of confirmed QSOs for this slot
|
|
});
|
|
}
|
|
|
|
if (qso.lotwQslRstatus === 'Y') {
|
|
const detail = combinationMap.get(combinationKey);
|
|
if (!detail.confirmed) {
|
|
detail.confirmed = true;
|
|
}
|
|
// Add this confirmed QSO to the qsos array
|
|
detail.qsos.push({
|
|
qsoId: qso.id,
|
|
callsign: qso.callsign,
|
|
mode: qso.mode,
|
|
qsoDate: qso.qsoDate,
|
|
timeOn: qso.timeOn,
|
|
band: qso.band,
|
|
satName: qso.satName,
|
|
confirmed: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
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') {
|
|
// Count unique stations only
|
|
const stationMap = 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 (!stationMap.has(callsign)) {
|
|
stationMap.set(callsign, {
|
|
callsign,
|
|
points,
|
|
worked: true,
|
|
confirmed: false,
|
|
qsos: [], // Array of confirmed QSOs for this station
|
|
});
|
|
}
|
|
|
|
if (qso.lotwQslRstatus === 'Y') {
|
|
const detail = stationMap.get(callsign);
|
|
if (!detail.confirmed) {
|
|
detail.confirmed = true;
|
|
}
|
|
// Add this confirmed QSO to the qsos array
|
|
detail.qsos.push({
|
|
qsoId: qso.id,
|
|
callsign: qso.callsign,
|
|
mode: qso.mode,
|
|
qsoDate: qso.qsoDate,
|
|
timeOn: qso.timeOn,
|
|
band: qso.band,
|
|
satName: qso.satName,
|
|
confirmed: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
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') {
|
|
// Count every confirmed QSO
|
|
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 (qso.lotwQslRstatus === 'Y') {
|
|
totalPoints += points;
|
|
// For perQso mode, each QSO is its own slot with a qsos array containing just itself
|
|
stationDetails.push({
|
|
qsoId: qso.id,
|
|
callsign,
|
|
points,
|
|
worked: true,
|
|
confirmed: true,
|
|
qsoDate: qso.qsoDate,
|
|
band: qso.band,
|
|
mode: qso.mode,
|
|
qsos: [{
|
|
qsoId: qso.id,
|
|
callsign: qso.callsign,
|
|
mode: qso.mode,
|
|
qsoDate: qso.qsoDate,
|
|
timeOn: qso.timeOn,
|
|
band: qso.band,
|
|
satName: qso.satName,
|
|
confirmed: true,
|
|
}],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
qsoId: detail.qsoId,
|
|
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,
|
|
qsos: detail.qsos || [], // All confirmed QSOs for this slot
|
|
};
|
|
} else if (countMode === 'perStation') {
|
|
return {
|
|
qsoId: detail.qsoId,
|
|
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,
|
|
qsos: detail.qsos || [], // All confirmed QSOs for this station
|
|
};
|
|
} else {
|
|
return {
|
|
qsoId: detail.qsoId,
|
|
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,
|
|
qsos: detail.qsos || [], // All confirmed QSOs for this slot (just this one QSO)
|
|
};
|
|
}
|
|
});
|
|
|
|
result.award = {
|
|
id: award.id,
|
|
name: award.name,
|
|
description: award.description,
|
|
caption: award.caption,
|
|
target: award.rules?.target || 0,
|
|
};
|
|
result.entities = entities;
|
|
result.total = entities.length;
|
|
result.confirmed = entities.filter((e) => e.confirmed).length;
|
|
} else {
|
|
result.stationDetails = stationDetails;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get entity value from QSO based on entity type
|
|
*/
|
|
function getEntityValue(qso, entityType) {
|
|
switch (entityType) {
|
|
case 'dxcc':
|
|
return qso.entityId;
|
|
case 'state':
|
|
return qso.state;
|
|
case 'grid':
|
|
// For VUCC, use first 4 characters of grid
|
|
return qso.grid ? qso.grid.substring(0, 4) : null;
|
|
case 'callsign':
|
|
return qso.callsign;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply filters to QSOs based on award rules
|
|
*/
|
|
function applyFilters(qsos, filters) {
|
|
if (!filters || !filters.filters) {
|
|
return qsos;
|
|
}
|
|
|
|
return qsos.filter((qso) => {
|
|
if (filters.operator === 'AND') {
|
|
return filters.filters.every((filter) => matchesFilter(qso, filter));
|
|
} else if (filters.operator === 'OR') {
|
|
return filters.filters.some((filter) => matchesFilter(qso, filter));
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if a QSO matches a filter
|
|
*/
|
|
function matchesFilter(qso, filter) {
|
|
let value;
|
|
|
|
// Special handling for satellite field
|
|
if (filter.field === 'satellite') {
|
|
// Check if it's a satellite QSO (has satName)
|
|
value = qso.satName && qso.satName.length > 0;
|
|
} else {
|
|
value = qso[filter.field];
|
|
}
|
|
|
|
switch (filter.operator) {
|
|
case 'eq':
|
|
return value === filter.value;
|
|
case 'ne':
|
|
return value !== filter.value;
|
|
case 'in':
|
|
return Array.isArray(filter.value) && filter.value.includes(value);
|
|
case 'nin':
|
|
return Array.isArray(filter.value) && !filter.value.includes(value);
|
|
case 'contains':
|
|
return value && typeof value === 'string' && value.toLowerCase().includes(filter.value.toLowerCase());
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get award progress with QSO details
|
|
*/
|
|
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
|
|
const definitions = loadAwardDefinitions();
|
|
const award = definitions.find((def) => def.id === awardId);
|
|
|
|
if (!award) {
|
|
return null;
|
|
}
|
|
|
|
// Calculate progress
|
|
const progress = await calculateAwardProgress(userId, award);
|
|
|
|
const result = {
|
|
award: {
|
|
id: award.id,
|
|
name: award.name,
|
|
description: award.description,
|
|
caption: award.caption,
|
|
category: award.category,
|
|
},
|
|
...progress,
|
|
};
|
|
|
|
// Store in cache
|
|
setCachedAwardProgress(userId, awardId, result);
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get detailed entity breakdown for an award
|
|
*/
|
|
export async function getAwardEntityBreakdown(userId, awardId) {
|
|
const definitions = loadAwardDefinitions();
|
|
const award = definitions.find((def) => def.id === awardId);
|
|
|
|
if (!award) {
|
|
return null;
|
|
}
|
|
|
|
let { rules } = award;
|
|
|
|
// 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 DOK-based awards - use the dedicated function
|
|
if (rules.type === 'dok') {
|
|
return await calculateDOKAwardProgress(userId, award, { includeDetails: true });
|
|
}
|
|
|
|
// Handle point-based awards - use the unified function
|
|
if (rules.type === 'points') {
|
|
return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
|
|
}
|
|
|
|
// Get all QSOs for user
|
|
const allQSOs = await db
|
|
.select()
|
|
.from(qsos)
|
|
.where(eq(qsos.userId, userId));
|
|
|
|
// Apply filters
|
|
const filteredQSOs = applyFilters(allQSOs, rules.filters);
|
|
|
|
// Apply allowed_bands filter if present
|
|
let finalQSOs = filteredQSOs;
|
|
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
|
|
finalQSOs = filteredQSOs.filter(qso => {
|
|
const band = qso.band;
|
|
return rules.allowed_bands.includes(band);
|
|
});
|
|
}
|
|
|
|
// Apply satellite_only filter if present
|
|
if (rules.satellite_only) {
|
|
finalQSOs = finalQSOs.filter(qso => qso.satName);
|
|
}
|
|
|
|
// Group by (entity, band, mode) slot for entity awards
|
|
// This allows showing multiple QSOs per entity on different bands/modes
|
|
const slotMap = new Map(); // Key: "entity/band/mode" -> slot object
|
|
|
|
for (const qso of finalQSOs) {
|
|
const entity = getEntityValue(qso, rules.entityType);
|
|
|
|
if (!entity) continue;
|
|
|
|
const band = qso.band || 'Unknown';
|
|
const mode = qso.mode || 'Unknown';
|
|
const slotKey = `${entity}/${band}/${mode}`;
|
|
|
|
// Determine what to display as the entity name (only on first create)
|
|
let displayName = String(entity);
|
|
if (rules.displayField) {
|
|
let rawValue = qso[rules.displayField];
|
|
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
|
|
rawValue = rawValue.substring(0, 4);
|
|
}
|
|
displayName = String(rawValue || entity);
|
|
} else {
|
|
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
|
|
}
|
|
|
|
if (!slotMap.has(slotKey)) {
|
|
slotMap.set(slotKey, {
|
|
entity,
|
|
entityId: qso.entityId,
|
|
entityName: displayName,
|
|
band,
|
|
mode,
|
|
worked: false,
|
|
confirmed: false,
|
|
qsos: [], // Array of confirmed QSOs for this slot
|
|
});
|
|
}
|
|
|
|
const slotData = slotMap.get(slotKey);
|
|
slotData.worked = true;
|
|
|
|
// Check for LoTW confirmation and add to qsos array
|
|
if (qso.lotwQslRstatus === 'Y') {
|
|
if (!slotData.confirmed) {
|
|
slotData.confirmed = true;
|
|
}
|
|
// Add this confirmed QSO to the qsos array
|
|
slotData.qsos.push({
|
|
qsoId: qso.id,
|
|
callsign: qso.callsign,
|
|
mode: qso.mode,
|
|
qsoDate: qso.qsoDate,
|
|
timeOn: qso.timeOn,
|
|
band: qso.band,
|
|
satName: qso.satName,
|
|
confirmed: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
award: {
|
|
id: award.id,
|
|
name: award.name,
|
|
description: award.description,
|
|
caption: award.caption,
|
|
target: rules.target || 0,
|
|
},
|
|
entities: Array.from(slotMap.values()),
|
|
total: slotMap.size,
|
|
confirmed: Array.from(slotMap.values()).filter((e) => e.confirmed).length,
|
|
};
|
|
}
|