diff --git a/award-definitions/dld.json b/award-definitions/dld.json new file mode 100644 index 0000000..91770f6 --- /dev/null +++ b/award-definitions/dld.json @@ -0,0 +1,13 @@ +{ + "id": "dld", + "name": "DLD", + "description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes", + "caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on different band/mode combinations. Each unique DOK on a unique band/mode counts as one point. Only DCL-confirmed QSOs with valid DOK information count toward this award.", + "category": "darc", + "rules": { + "type": "dok", + "target": 100, + "confirmationType": "dcl", + "displayField": "darcDok" + } +} diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index 02ea817..0423bcd 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -26,6 +26,7 @@ function loadAwardDefinitions() { 'vucc-sat.json', 'sat-rs44.json', 'special-stations.json', + 'dld.json', ]; for (const file of files) { @@ -108,6 +109,11 @@ export async function calculateAwardProgress(userId, award, options = {}) { 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 }); @@ -156,6 +162,111 @@ export async function calculateAwardProgress(userId, award, options = {}) { }; } +/** + * 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 } = rules; + + logger.debug('Calculating DOK-based award progress', { userId, awardId: award.id, target }); + + // 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 }); + + // Track unique (DOK, band, mode) combinations + const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object + + for (const qso of allQSOs) { + 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, + qsoDate: qso.qsoDate, + dclQslRdate: null, + }); + } + + const detail = dokCombinations.get(combinationKey); + detail.worked = true; + + // Check for DCL confirmation + if (qso.dclQslRstatus === 'Y') { + if (!detail.confirmed) { + detail.confirmed = true; + detail.dclQslRdate = qso.dclQslRdate; + } + } + } + + 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: @@ -510,6 +621,11 @@ export async function getAwardEntityBreakdown(userId, awardId) { }; } + // 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 });