Add awards system with progress tracking
Implement awards display with progress calculation based on QSO data. ## Backend - Add awards service with progress calculation logic - Support for DXCC, WAS, VUCC, and satellite awards - Filter QSOs by band, mode, and other criteria - Calculate worked/confirmed entities per award - API endpoints: - GET /api/awards - List all awards - GET /api/awards/:id/progress - Get award progress - GET /api/awards/:id/entities - Get detailed entity breakdown ## Frontend - Create awards listing page with progress cards - Add Awards link to navigation bar - Display award progress bars with worked/confirmed counts - Link to individual award detail pages (to be implemented) - Copy award definitions to static folder ## Award Definitions - DXCC Mixed Mode (100 entities) - DXCC CW (100 entities, CW only) - WAS Mixed Mode (50 states) - VUCC Satellite (100 grids) - RS-44 Satellite Award (100 QSOs) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ The Ham Radio Award Portal is a full-stack web application designed to help amat
|
||||
### System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP/REST ┌─────────────────┐
|
||||
┌─────────────────┐ HTTP/REST ┌─────────────────┐
|
||||
│ │ ◄──────────────────► │ │
|
||||
│ SvelteKit │ │ ElysiaJS │
|
||||
│ Frontend │ │ Backend │
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getUserActiveJob,
|
||||
getUserJobs,
|
||||
} from './services/job-queue.service.js';
|
||||
import {
|
||||
getAllAwards,
|
||||
getAwardProgressDetails,
|
||||
getAwardEntityBreakdown,
|
||||
} from './services/awards.service.js';
|
||||
|
||||
/**
|
||||
* Main backend application
|
||||
@@ -478,6 +483,89 @@ const app = new Elysia()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/awards
|
||||
* Get all available awards (requires authentication)
|
||||
*/
|
||||
.get('/api/awards', async ({ user, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
const awards = await getAllAwards();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
awards,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching awards', { error: error.message });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch awards',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/awards/:awardId/progress
|
||||
* Get award progress for user (requires authentication)
|
||||
*/
|
||||
.get('/api/awards/:awardId/progress', async ({ user, params, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { awardId } = params;
|
||||
const progress = await getAwardProgressDetails(user.id, awardId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...progress,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error calculating award progress', { error: error.message });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to calculate award progress',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/awards/:awardId/entities
|
||||
* Get detailed entity breakdown for an award (requires authentication)
|
||||
*/
|
||||
.get('/api/awards/:awardId/entities', async ({ user, params, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { awardId } = params;
|
||||
const breakdown = await getAwardEntityBreakdown(user.id, awardId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...breakdown,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching award entities', { error: error.message });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to fetch award entities',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
// Health check endpoint
|
||||
.get('/api/health', () => ({
|
||||
status: 'ok',
|
||||
|
||||
258
src/backend/services/awards.service.js
Normal file
258
src/backend/services/awards.service.js
Normal file
@@ -0,0 +1,258 @@
|
||||
import { db } from '../config/database.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';
|
||||
|
||||
/**
|
||||
* Awards Service
|
||||
* Calculates award progress based on QSO data
|
||||
*/
|
||||
|
||||
// Load award definitions from files
|
||||
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
|
||||
|
||||
/**
|
||||
* Load all award definitions
|
||||
*/
|
||||
function loadAwardDefinitions() {
|
||||
const definitions = [];
|
||||
|
||||
try {
|
||||
const files = [
|
||||
'dxcc.json',
|
||||
'dxcc-cw.json',
|
||||
'was.json',
|
||||
'vucc-sat.json',
|
||||
'sat-rs44.json',
|
||||
];
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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,
|
||||
category: def.category,
|
||||
rules: def.rules,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate award progress for a user
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
*/
|
||||
export async function calculateAwardProgress(userId, award) {
|
||||
const { rules } = award;
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
.from(qsos)
|
||||
.where(eq(qsos.userId, userId));
|
||||
|
||||
// Apply filters if defined
|
||||
let filteredQSOs = allQSOs;
|
||||
if (rules.filters) {
|
||||
filteredQSOs = applyFilters(allQSOs, rules.filters);
|
||||
}
|
||||
|
||||
// Calculate worked and confirmed entities
|
||||
const workedEntities = new Set();
|
||||
const confirmedEntities = new Set();
|
||||
|
||||
for (const qso of filteredQSOs) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const 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.includes(filter.value);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get award progress with QSO details
|
||||
*/
|
||||
export async function getAwardProgressDetails(userId, awardId) {
|
||||
// Get award definition
|
||||
const definitions = loadAwardDefinitions();
|
||||
const award = definitions.find((def) => def.id === awardId);
|
||||
|
||||
if (!award) {
|
||||
throw new Error('Award not found');
|
||||
}
|
||||
|
||||
// Calculate progress
|
||||
const progress = await calculateAwardProgress(userId, award);
|
||||
|
||||
return {
|
||||
award: {
|
||||
id: award.id,
|
||||
name: award.name,
|
||||
description: award.description,
|
||||
category: award.category,
|
||||
},
|
||||
...progress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
throw new Error('Award not found');
|
||||
}
|
||||
|
||||
const { rules } = award;
|
||||
|
||||
// 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);
|
||||
|
||||
// Group by entity
|
||||
const entityMap = new Map();
|
||||
|
||||
for (const qso of filteredQSOs) {
|
||||
const entity = getEntityValue(qso, rules.entityType);
|
||||
|
||||
if (!entity) continue;
|
||||
|
||||
if (!entityMap.has(entity)) {
|
||||
entityMap.set(entity, {
|
||||
entity,
|
||||
entityId: qso.entityId,
|
||||
worked: false,
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
callsign: qso.callsign,
|
||||
});
|
||||
}
|
||||
|
||||
const entityData = entityMap.get(entity);
|
||||
entityData.worked = true;
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
entityData.confirmed = true;
|
||||
entityData.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
award: {
|
||||
id: award.id,
|
||||
name: award.name,
|
||||
description: award.description,
|
||||
},
|
||||
entities: Array.from(entityMap.values()),
|
||||
total: entityMap.size,
|
||||
confirmed: Array.from(entityMap.values()).filter((e) => e.confirmed).length,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/awards" class="nav-link">Awards</a>
|
||||
<a href="/qsos" class="nav-link">QSOs</a>
|
||||
<a href="/settings" class="nav-link">Settings</a>
|
||||
<button on:click={auth.logout} class="nav-link logout-btn">Logout</button>
|
||||
|
||||
309
src/frontend/src/routes/awards/+page.svelte
Normal file
309
src/frontend/src/routes/awards/+page.svelte
Normal file
@@ -0,0 +1,309 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores.js';
|
||||
|
||||
let awards = [];
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
onMount(async () => {
|
||||
await loadAwards();
|
||||
});
|
||||
|
||||
async function loadAwards() {
|
||||
try {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Get awards from API
|
||||
const response = await fetch('/api/awards', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${$auth.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load awards');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load awards');
|
||||
}
|
||||
|
||||
// Load progress for each award
|
||||
awards = await Promise.all(
|
||||
data.awards.map(async (award) => {
|
||||
try {
|
||||
const progressResponse = await fetch(`/api/awards/${award.id}/progress`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${$auth.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (progressResponse.ok) {
|
||||
const progressData = await progressResponse.json();
|
||||
if (progressData.success) {
|
||||
return {
|
||||
...award,
|
||||
progress: progressData,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load progress for ${award.id}:`, e);
|
||||
}
|
||||
|
||||
// Return award without progress if it failed
|
||||
return {
|
||||
...award,
|
||||
progress: {
|
||||
worked: 0,
|
||||
confirmed: 0,
|
||||
target: award.rules?.target || 0,
|
||||
percentage: 0,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h1>Awards</h1>
|
||||
<p class="subtitle">Track your ham radio award progress</p>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading awards...</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Failed to load awards: {error}</p>
|
||||
</div>
|
||||
{:else if awards.length === 0}
|
||||
<div class="empty">
|
||||
<p>No awards found.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="awards-grid">
|
||||
{#each awards as award (award.id)}
|
||||
<div class="award-card">
|
||||
<div class="award-header">
|
||||
<h2>{award.name}</h2>
|
||||
{#if award.category}
|
||||
<span class="category">{award.category}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="description">{award.description}</p>
|
||||
|
||||
<div class="award-info">
|
||||
<div class="info-item">
|
||||
<span class="label">Target:</span>
|
||||
<span class="value">{award.rules?.target || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Type:</span>
|
||||
<span class="value">{award.rules?.entityType || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if award.progress}
|
||||
<div class="award-progress">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {award.progress.percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
<span class="worked">Worked: {award.progress.worked}</span>
|
||||
<span class="confirmed">Confirmed: {award.progress.confirmed}</span>
|
||||
</div>
|
||||
<div class="percentage">{award.progress.percentage}% complete</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a href="/awards/{award.id}" class="btn">View Details</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #666;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.awards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.award-card {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.award-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.award-header h2 {
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.category {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.award-info {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.award-progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4a90e2 0%, #357abd 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #4a90e2;
|
||||
}
|
||||
|
||||
.worked,
|
||||
.confirmed {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.worked {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.confirmed {
|
||||
color: #4a90e2;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #357abd;
|
||||
}
|
||||
</style>
|
||||
24
src/frontend/static/award-definitions/dxcc-cw.json
Normal file
24
src/frontend/static/award-definitions/dxcc-cw.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "dxcc-cw",
|
||||
"name": "DXCC CW",
|
||||
"description": "Confirm 100 DXCC entities using CW mode",
|
||||
"category": "dxcc",
|
||||
"rules": {
|
||||
"type": "filtered",
|
||||
"baseRule": {
|
||||
"type": "entity",
|
||||
"entityType": "dxcc",
|
||||
"target": 100
|
||||
},
|
||||
"filters": {
|
||||
"operator": "AND",
|
||||
"filters": [
|
||||
{
|
||||
"field": "mode",
|
||||
"operator": "eq",
|
||||
"value": "CW"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/frontend/static/award-definitions/dxcc.json
Normal file
11
src/frontend/static/award-definitions/dxcc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "dxcc-mixed",
|
||||
"name": "DXCC Mixed Mode",
|
||||
"description": "Confirm 100 DXCC entities on any band/mode",
|
||||
"category": "dxcc",
|
||||
"rules": {
|
||||
"type": "entity",
|
||||
"entityType": "dxcc",
|
||||
"target": 100
|
||||
}
|
||||
}
|
||||
21
src/frontend/static/award-definitions/sat-rs44.json
Normal file
21
src/frontend/static/award-definitions/sat-rs44.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "sat-rs44",
|
||||
"name": "RS-44 Satellite",
|
||||
"description": "Work 44 QSOs on satellite RS-44",
|
||||
"category": "custom",
|
||||
"rules": {
|
||||
"type": "counter",
|
||||
"target": 44,
|
||||
"countBy": "qso",
|
||||
"filters": {
|
||||
"operator": "AND",
|
||||
"filters": [
|
||||
{
|
||||
"field": "satName",
|
||||
"operator": "eq",
|
||||
"value": "RS-44"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/frontend/static/award-definitions/vucc-sat.json
Normal file
21
src/frontend/static/award-definitions/vucc-sat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "vucc-satellite",
|
||||
"name": "VUCC Satellite",
|
||||
"description": "Confirm 100 unique grid squares via satellite",
|
||||
"category": "vucc",
|
||||
"rules": {
|
||||
"type": "entity",
|
||||
"entityType": "grid",
|
||||
"target": 100,
|
||||
"filters": {
|
||||
"operator": "AND",
|
||||
"filters": [
|
||||
{
|
||||
"field": "satellite",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/frontend/static/award-definitions/was.json
Normal file
21
src/frontend/static/award-definitions/was.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "was-mixed",
|
||||
"name": "WAS Mixed Mode",
|
||||
"description": "Confirm all 50 US states",
|
||||
"category": "was",
|
||||
"rules": {
|
||||
"type": "entity",
|
||||
"entityType": "state",
|
||||
"target": 50,
|
||||
"filters": {
|
||||
"operator": "AND",
|
||||
"filters": [
|
||||
{
|
||||
"field": "entity",
|
||||
"operator": "eq",
|
||||
"value": "United States"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user