feat: implement award definition editor with safety validation

Add comprehensive admin-only UI for managing award definitions stored as JSON files.

Backend changes:
- Add awards-admin.service.js with file operations and validation
- Add clearAwardCache() function to invalidate in-memory cache after changes
- Add API routes: GET/POST/PUT/DELETE /api/admin/awards, POST /api/admin/awards/:id/test
- Support testing unsaved awards by passing award definition directly

Frontend changes:
- Add awards list view at /admin/awards
- Add create form at /admin/awards/create with safety checks for:
  - Impossible filter combinations (e.g., mode=CW AND mode=SSB)
  - Redundant filters and mode groups
  - Logical contradictions (e.g., satellite_only with HF-only bands)
  - Duplicate callsigns, empty mode groups, etc.
- Add edit form at /admin/awards/[id] with same validation
- Add FilterBuilder component for nested filter structures
- Add TestAwardModal with deep validation and test calculation
- Add Awards tab to admin dashboard

Safety validation includes:
- Schema validation (required fields, types, formats)
- Business rule validation (valid rule types, operators, bands, modes)
- Cross-field validation (filter contradictions, allowed_bands conflicts)
- Edge case detection (complex filters, impossible targets)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-23 08:16:28 +01:00
parent b9b6afedb8
commit bd89ea0855
11 changed files with 4951 additions and 1 deletions

View File

@@ -0,0 +1,25 @@
{
"id": "qo100grids",
"name": "QO100 Grids",
"description": "Work as much Grids as possible on QO100 Satellite",
"caption": "Work as much Grids as possible on QO100 Satellite",
"category": "satellite",
"rules": {
"type": "entity",
"satellite_only": true,
"filters": {
"operator": "AND",
"filters": [
{
"field": "satName",
"operator": "eq",
"value": "QO-100"
}
]
},
"entityType": "grid",
"target": 100,
"displayField": "grid"
},
"modeGroups": {}
}

View File

@@ -44,6 +44,14 @@ import {
getAwardProgressDetails, getAwardProgressDetails,
getAwardEntityBreakdown, getAwardEntityBreakdown,
} from './services/awards.service.js'; } from './services/awards.service.js';
import {
getAllAwardDefinitions,
getAwardDefinition,
createAwardDefinition,
updateAwardDefinition,
deleteAwardDefinition,
testAwardCalculation,
} from './services/awards-admin.service.js';
import { import {
getAutoSyncSettings, getAutoSyncSettings,
updateAutoSyncSettings, updateAutoSyncSettings,
@@ -1445,6 +1453,220 @@ const app = new Elysia()
} }
}) })
/**
* ================================================================
* AWARD MANAGEMENT ROUTES (Admin Only)
* ================================================================
*/
/**
* GET /api/admin/awards
* Get all award definitions (admin only)
*/
.get('/api/admin/awards', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const awards = await getAllAwardDefinitions();
return {
success: true,
awards,
};
} catch (error) {
logger.error('Error fetching award definitions', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award definitions',
};
}
})
/**
* GET /api/admin/awards/:id
* Get a single award definition (admin only)
*/
.get('/api/admin/awards/:id', async ({ user, params, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await getAwardDefinition(params.id);
if (!award) {
set.status = 404;
return {
success: false,
error: 'Award not found',
};
}
return {
success: true,
award,
};
} catch (error) {
logger.error('Error fetching award definition', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award definition',
};
}
})
/**
* POST /api/admin/awards
* Create a new award definition (admin only)
*/
.post(
'/api/admin/awards',
async ({ user, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await createAwardDefinition(body);
return {
success: true,
award,
message: 'Award definition created successfully',
};
} catch (error) {
logger.error('Error creating award definition', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
id: t.String(),
name: t.String(),
description: t.String(),
caption: t.String(),
category: t.String(),
rules: t.Any(),
modeGroups: t.Optional(t.Any()),
}),
}
)
/**
* PUT /api/admin/awards/:id
* Update an award definition (admin only)
*/
.put(
'/api/admin/awards/:id',
async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await updateAwardDefinition(params.id, body);
return {
success: true,
award,
message: 'Award definition updated successfully',
};
} catch (error) {
logger.error('Error updating award definition', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
id: t.Optional(t.String()),
name: t.String(),
description: t.String(),
caption: t.String(),
category: t.String(),
rules: t.Any(),
modeGroups: t.Optional(t.Any()),
}),
}
)
/**
* DELETE /api/admin/awards/:id
* Delete an award definition (admin only)
*/
.delete('/api/admin/awards/:id', async ({ user, params, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const result = await deleteAwardDefinition(params.id);
return {
success: true,
...result,
message: 'Award definition deleted successfully',
};
} catch (error) {
logger.error('Error deleting award definition', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
})
/**
* POST /api/admin/awards/:id/test
* Test award calculation (admin only)
*/
.post(
'/api/admin/awards/:id/test',
async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
// Use provided userId or admin's own account
const testUserId = body.userId || user.id;
const awardDefinition = body.awardDefinition || null;
const result = await testAwardCalculation(params.id, testUserId, awardDefinition);
return {
success: true,
...result,
};
} catch (error) {
logger.error('Error testing award calculation', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
userId: t.Optional(t.Integer()),
awardDefinition: t.Optional(t.Any()),
}),
}
)
/** /**
* ================================================================ * ================================================================
* AUTO-SYNC SETTINGS ROUTES * AUTO-SYNC SETTINGS ROUTES

View File

@@ -0,0 +1,547 @@
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from 'fs';
import { join } from 'path';
import { logger } from '../config.js';
import { calculateAwardProgress, getAwardById, clearAwardCache } from './awards.service.js';
/**
* Awards Admin Service
* Manages award definition JSON files for admin operations
*/
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
// Valid entity types for entity rule type
const VALID_ENTITY_TYPES = ['dxcc', 'state', 'grid', 'callsign'];
// Valid rule types
const VALID_RULE_TYPES = ['entity', 'dok', 'points', 'filtered', 'counter'];
// Valid count modes for points rule type
const VALID_COUNT_MODES = ['perStation', 'perBandMode', 'perQso'];
// Valid filter operators
const VALID_FILTER_OPERATORS = ['eq', 'ne', 'in', 'nin', 'contains'];
// Valid filter fields
const VALID_FILTER_FIELDS = [
'band', 'mode', 'callsign', 'entity', 'entityId', 'state', 'grid', 'satName', 'satellite'
];
// Valid bands
const VALID_BANDS = [
'160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m',
'6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'
];
// Valid modes
const VALID_MODES = [
'CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9',
'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'
];
/**
* Load all award definitions with file metadata
*/
export async function getAllAwardDefinitions() {
const definitions = [];
try {
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);
// Add file metadata
definitions.push({
...definition,
_filename: file,
_filepath: filePath,
});
} catch (error) {
logger.warn('Failed to load award definition', { file, error: error.message });
}
}
} catch (error) {
logger.error('Error reading award definitions directory', { error: error.message });
}
return definitions;
}
/**
* Get a single award definition by ID
*/
export async function getAwardDefinition(id) {
const definitions = await getAllAwardDefinitions();
return definitions.find(def => def.id === id) || null;
}
/**
* Validate an award definition
* @returns {Object} { valid: boolean, errors: string[], warnings: string[] }
*/
export function validateAwardDefinition(definition, existingDefinitions = []) {
const errors = [];
const warnings = [];
// Check required top-level fields
const requiredFields = ['id', 'name', 'description', 'caption', 'category', 'rules'];
for (const field of requiredFields) {
if (!definition[field]) {
errors.push(`Missing required field: ${field}`);
}
}
// Validate ID
if (definition.id) {
if (typeof definition.id !== 'string') {
errors.push('ID must be a string');
} else if (!/^[a-z0-9-]+$/.test(definition.id)) {
errors.push('ID must contain only lowercase letters, numbers, and hyphens');
} else {
// Check for duplicate ID (unless updating existing award)
const existingIds = existingDefinitions.map(d => d.id);
const isUpdate = existingDefinitions.find(d => d.id === definition.id);
const duplicates = existingDefinitions.filter(d => d.id === definition.id);
if (duplicates.length > 1 || (duplicates.length === 1 && !isUpdate)) {
errors.push(`Award ID "${definition.id}" already exists`);
}
}
}
// Validate name
if (definition.name && typeof definition.name !== 'string') {
errors.push('Name must be a string');
}
// Validate description
if (definition.description && typeof definition.description !== 'string') {
errors.push('Description must be a string');
}
// Validate caption
if (definition.caption && typeof definition.caption !== 'string') {
errors.push('Caption must be a string');
}
// Validate category
if (definition.category && typeof definition.category !== 'string') {
errors.push('Category must be a string');
}
// Validate modeGroups if present
if (definition.modeGroups) {
if (typeof definition.modeGroups !== 'object') {
errors.push('modeGroups must be an object');
} else {
for (const [groupName, modes] of Object.entries(definition.modeGroups)) {
if (!Array.isArray(modes)) {
errors.push(`modeGroups "${groupName}" must be an array of mode strings`);
} else {
for (const mode of modes) {
if (typeof mode !== 'string') {
errors.push(`mode "${mode}" in group "${groupName}" must be a string`);
} else if (!VALID_MODES.includes(mode)) {
warnings.push(`Unknown mode "${mode}" in group "${groupName}"`);
}
}
}
}
}
}
// Validate rules
if (!definition.rules) {
errors.push('Rules object is required');
} else if (typeof definition.rules !== 'object') {
errors.push('Rules must be an object');
} else {
const ruleValidation = validateRules(definition.rules);
errors.push(...ruleValidation.errors);
warnings.push(...ruleValidation.warnings);
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate rules object
*/
function validateRules(rules) {
const errors = [];
const warnings = [];
// Check rule type
if (!rules.type) {
errors.push('Rules must have a type');
} else if (!VALID_RULE_TYPES.includes(rules.type)) {
errors.push(`Invalid rule type: ${rules.type}. Must be one of: ${VALID_RULE_TYPES.join(', ')}`);
}
// Validate based on rule type
switch (rules.type) {
case 'entity':
validateEntityRule(rules, errors, warnings);
break;
case 'dok':
validateDOKRule(rules, errors, warnings);
break;
case 'points':
validatePointsRule(rules, errors, warnings);
break;
case 'filtered':
validateFilteredRule(rules, errors, warnings);
break;
case 'counter':
validateCounterRule(rules, errors, warnings);
break;
}
// Validate filters if present
if (rules.filters) {
const filterValidation = validateFilters(rules.filters);
errors.push(...filterValidation.errors);
warnings.push(...filterValidation.warnings);
}
return { errors, warnings };
}
/**
* Validate entity rule
*/
function validateEntityRule(rules, errors, warnings) {
if (!rules.entityType) {
errors.push('Entity rule requires entityType');
} else if (!VALID_ENTITY_TYPES.includes(rules.entityType)) {
errors.push(`Invalid entityType: ${rules.entityType}. Must be one of: ${VALID_ENTITY_TYPES.join(', ')}`);
}
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
errors.push('Entity rule requires a positive target number');
}
if (rules.allowed_bands) {
if (!Array.isArray(rules.allowed_bands)) {
errors.push('allowed_bands must be an array');
} else {
for (const band of rules.allowed_bands) {
if (!VALID_BANDS.includes(band)) {
warnings.push(`Unknown band in allowed_bands: ${band}`);
}
}
}
}
if (rules.satellite_only !== undefined && typeof rules.satellite_only !== 'boolean') {
errors.push('satellite_only must be a boolean');
}
if (rules.displayField && typeof rules.displayField !== 'string') {
errors.push('displayField must be a string');
}
}
/**
* Validate DOK rule
*/
function validateDOKRule(rules, errors, warnings) {
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
errors.push('DOK rule requires a positive target number');
}
if (rules.confirmationType && rules.confirmationType !== 'dcl') {
warnings.push('DOK rule confirmationType should be "dcl"');
}
if (rules.displayField && typeof rules.displayField !== 'string') {
errors.push('displayField must be a string');
}
}
/**
* Validate points rule
*/
function validatePointsRule(rules, errors, warnings) {
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
errors.push('Points rule requires a positive target number');
}
if (!rules.stations || !Array.isArray(rules.stations)) {
errors.push('Points rule requires a stations array');
} else if (rules.stations.length === 0) {
errors.push('Points rule stations array cannot be empty');
} else {
for (let i = 0; i < rules.stations.length; i++) {
const station = rules.stations[i];
if (!station.callsign || typeof station.callsign !== 'string') {
errors.push(`Station ${i + 1} missing callsign`);
}
if (typeof station.points !== 'number' || station.points <= 0) {
errors.push(`Station ${i + 1} must have positive points value`);
}
}
}
if (rules.countMode && !VALID_COUNT_MODES.includes(rules.countMode)) {
errors.push(`Invalid countMode: ${rules.countMode}. Must be one of: ${VALID_COUNT_MODES.join(', ')}`);
}
}
/**
* Validate filtered rule
*/
function validateFilteredRule(rules, errors, warnings) {
if (!rules.baseRule) {
errors.push('Filtered rule requires baseRule');
} else {
// Recursively validate base rule
const baseValidation = validateRules(rules.baseRule);
errors.push(...baseValidation.errors);
warnings.push(...baseValidation.warnings);
}
if (!rules.filters) {
warnings.push('Filtered rule has no filters - baseRule will be used as-is');
}
}
/**
* Validate counter rule
*/
function validateCounterRule(rules, errors, warnings) {
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
errors.push('Counter rule requires a positive target number');
}
if (!rules.countBy) {
errors.push('Counter rule requires countBy');
} else if (!['qso', 'callsign'].includes(rules.countBy)) {
errors.push(`Invalid countBy: ${rules.countBy}. Must be one of: qso, callsign`);
}
if (rules.displayField && typeof rules.displayField !== 'string') {
errors.push('displayField must be a string');
}
}
/**
* Validate filters object
*/
function validateFilters(filters, depth = 0) {
const errors = [];
const warnings = [];
if (!filters) {
return { errors, warnings };
}
// Prevent infinite recursion
if (depth > 10) {
errors.push('Filters are too deeply nested (maximum 10 levels)');
return { errors, warnings };
}
if (filters.operator && !['AND', 'OR'].includes(filters.operator)) {
errors.push(`Invalid filter operator: ${filters.operator}. Must be AND or OR`);
}
if (filters.filters) {
if (!Array.isArray(filters.filters)) {
errors.push('Filters must be an array');
} else {
for (const filter of filters.filters) {
if (filter.filters) {
// Nested filter group
const nestedValidation = validateFilters(filter, depth + 1);
errors.push(...nestedValidation.errors);
warnings.push(...nestedValidation.warnings);
} else {
// Leaf filter
if (!filter.field) {
errors.push('Filter missing field');
} else if (!VALID_FILTER_FIELDS.includes(filter.field)) {
warnings.push(`Unknown filter field: ${filter.field}`);
}
if (!filter.operator) {
errors.push('Filter missing operator');
} else if (!VALID_FILTER_OPERATORS.includes(filter.operator)) {
errors.push(`Invalid filter operator: ${filter.operator}`);
}
if (filter.value === undefined) {
errors.push('Filter missing value');
}
if (['in', 'nin'].includes(filter.operator) && !Array.isArray(filter.value)) {
errors.push(`Filter operator ${filter.operator} requires an array value`);
}
}
}
}
}
return { errors, warnings };
}
/**
* Create a new award definition
*/
export async function createAwardDefinition(definition) {
// Get all existing definitions for duplicate check
const existing = await getAllAwardDefinitions();
// Validate the definition
const validation = validateAwardDefinition(definition, existing);
if (!validation.valid) {
throw new Error(`Validation failed: ${validation.errors.join('; ')}`);
}
// Create filename from ID
const filename = `${definition.id}.json`;
const filepath = join(AWARD_DEFINITIONS_DIR, filename);
// Check if file already exists
if (existsSync(filepath)) {
throw new Error(`Award file "${filename}" already exists`);
}
// Remove metadata fields before saving
const { _filename, _filepath, ...cleanDefinition } = definition;
// Write to file
writeFileSync(filepath, JSON.stringify(cleanDefinition, null, 2), 'utf-8');
// Clear the cache so new award is immediately available
clearAwardCache();
logger.info('Created award definition', { id: definition.id, filename });
return {
...cleanDefinition,
_filename: filename,
_filepath: filepath,
};
}
/**
* Update an existing award definition
*/
export async function updateAwardDefinition(id, updatedDefinition) {
// Get existing definition
const existing = await getAwardDefinition(id);
if (!existing) {
throw new Error(`Award "${id}" not found`);
}
// Ensure ID matches
if (updatedDefinition.id && updatedDefinition.id !== id) {
throw new Error('Cannot change award ID');
}
// Set the ID from the parameter
updatedDefinition.id = id;
// Get all definitions for validation
const allDefinitions = await getAllAwardDefinitions();
// Validate the updated definition
const validation = validateAwardDefinition(updatedDefinition, allDefinitions);
if (!validation.valid) {
throw new Error(`Validation failed: ${validation.errors.join('; ')}`);
}
// Keep the same filename
const filename = existing._filename;
const filepath = existing._filepath;
// Remove metadata fields before saving
const { _filename, _filepath, ...cleanDefinition } = updatedDefinition;
// Write to file
writeFileSync(filepath, JSON.stringify(cleanDefinition, null, 2), 'utf-8');
// Clear the cache so updated award is immediately available
clearAwardCache();
logger.info('Updated award definition', { id, filename });
return {
...cleanDefinition,
_filename: filename,
_filepath: filepath,
};
}
/**
* Delete an award definition
*/
export async function deleteAwardDefinition(id) {
const existing = await getAwardDefinition(id);
if (!existing) {
throw new Error(`Award "${id}" not found`);
}
// Delete the file
unlinkSync(existing._filepath);
// Clear the cache so deleted award is immediately removed
clearAwardCache();
logger.info('Deleted award definition', { id, filename: existing._filename });
return { success: true, id };
}
/**
* Test award calculation for a user
* @param {string} id - Award ID (must exist unless awardDefinition is provided)
* @param {number} userId - User ID to test with
* @param {Object} awardDefinition - Optional award definition (for testing unsaved awards)
*/
export async function testAwardCalculation(id, userId, awardDefinition = null) {
// Get award definition - either from parameter or from cache
let award = awardDefinition;
if (!award) {
award = getAwardById(id);
if (!award) {
throw new Error(`Award "${id}" not found`);
}
}
// Calculate progress
const progress = await calculateAwardProgress(userId, award);
// Warn if no matches
const warnings = [];
if (progress.worked === 0 && progress.confirmed === 0) {
warnings.push('No QSOs matched the award criteria. Check filters and band/mode restrictions.');
}
// Get sample entities
const sampleEntities = (progress.confirmedEntities || []).slice(0, 10);
return {
award: {
id: award.id,
name: award.name,
description: award.description,
},
worked: progress.worked,
confirmed: progress.confirmed,
target: progress.target,
percentage: progress.percentage,
sampleEntities,
warnings,
};
}

View File

@@ -53,6 +53,15 @@ function loadAwardDefinitions() {
return definitions; return definitions;
} }
/**
* Clear the cached award definitions
* Call this after creating, updating, or deleting award definitions
*/
export function clearAwardCache() {
cachedAwardDefinitions = null;
logger.info('Award cache cleared');
}
/** /**
* Get all available awards * Get all available awards
*/ */

View File

@@ -130,3 +130,29 @@ export const autoSyncAPI = {
getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'), getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'),
}; };
// Awards Admin API
export const awardsAdminAPI = {
getAll: () => apiRequest('/admin/awards'),
getById: (id) => apiRequest(`/admin/awards/${id}`),
create: (data) => apiRequest('/admin/awards', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id, data) => apiRequest(`/admin/awards/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id) => apiRequest(`/admin/awards/${id}`, {
method: 'DELETE',
}),
test: (id, userId, awardDefinition) => apiRequest(`/admin/awards/${id}/test`, {
method: 'POST',
body: JSON.stringify({ userId, awardDefinition }),
}),
};

View File

@@ -12,7 +12,7 @@
let impersonationStatus = null; let impersonationStatus = null;
// UI state // UI state
let selectedTab = 'overview'; // 'overview', 'users', 'actions' let selectedTab = 'overview'; // 'overview', 'users', 'awards', 'actions'
let showImpersonationModal = false; let showImpersonationModal = false;
let showDeleteUserModal = false; let showDeleteUserModal = false;
let showRoleChangeModal = false; let showRoleChangeModal = false;
@@ -226,6 +226,12 @@
> >
Users Users
</button> </button>
<button
class="tab {selectedTab === 'awards' ? 'active' : ''}"
on:click={() => selectedTab = 'awards'}
>
Awards
</button>
<button <button
class="tab {selectedTab === 'actions' ? 'active' : ''}" class="tab {selectedTab === 'actions' ? 'active' : ''}"
on:click={() => selectedTab = 'actions'} on:click={() => selectedTab = 'actions'}
@@ -382,6 +388,30 @@
</div> </div>
{/if} {/if}
<!-- Awards Tab -->
{#if selectedTab === 'awards'}
<div class="tab-content">
<h2>Award Definitions</h2>
<p class="help-text">Manage award definitions. Create, edit, and delete awards.</p>
<div class="awards-quick-actions">
<a href="/admin/awards" class="btn btn-primary">Manage Awards</a>
</div>
<div class="awards-info">
<h3>Award Management</h3>
<p>From the Awards management page, you can:</p>
<ul>
<li><strong>Create</strong> new award definitions</li>
<li><strong>Edit</strong> existing award definitions</li>
<li><strong>Delete</strong> awards</li>
<li><strong>Test</strong> award calculations with sample user data</li>
</ul>
<p>All award definitions are stored as JSON files in the <code>award-definitions/</code> directory.</p>
</div>
</div>
{/if}
<!-- Actions Tab --> <!-- Actions Tab -->
{#if selectedTab === 'actions'} {#if selectedTab === 'actions'}
<div class="tab-content"> <div class="tab-content">
@@ -919,6 +949,50 @@
cursor: pointer; cursor: pointer;
} }
.help-text {
color: #666;
font-style: italic;
margin-bottom: 1rem;
}
.awards-quick-actions {
margin-bottom: 2rem;
}
.awards-info {
background-color: #f9f9f9;
border-left: 4px solid #667eea;
padding: 1.5rem;
border-radius: 4px;
}
.awards-info h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
}
.awards-info p {
margin-bottom: 1rem;
}
.awards-info ul {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.awards-info li {
margin-bottom: 0.5rem;
}
.awards-info code {
background-color: #e0e0e0;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.9rem;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.users-header { .users-header {
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,379 @@
<script>
import { onMount } from 'svelte';
import { auth } from '$lib/stores.js';
import { awardsAdminAPI } from '$lib/api.js';
import { browser } from '$app/environment';
let loading = true;
let error = null;
let awards = [];
let searchQuery = '';
let categoryFilter = 'all';
onMount(async () => {
if (!$auth.user) {
window.location.href = '/auth/login';
return;
}
if (!$auth.user.isAdmin) {
error = 'Admin access required';
loading = false;
return;
}
await loadAwards();
loading = false;
});
async function loadAwards() {
try {
const data = await awardsAdminAPI.getAll();
awards = data.awards || [];
} catch (err) {
error = err.message;
}
}
async function handleDelete(id) {
const award = awards.find(a => a.id === id);
if (!award) return;
if (!confirm(`Are you sure you want to delete award "${award.name}"?\n\nThis action cannot be undone.`)) {
return;
}
try {
loading = true;
await awardsAdminAPI.delete(id);
await loadAwards();
} catch (err) {
alert('Failed to delete award: ' + err.message);
} finally {
loading = false;
}
}
function getRuleTypeDisplayName(ruleType) {
const names = {
'entity': 'Entity',
'dok': 'DOK',
'points': 'Points',
'filtered': 'Filtered',
'counter': 'Counter'
};
return names[ruleType] || ruleType;
}
function getCategoryColor(category) {
const colors = {
'dxcc': 'purple',
'darc': 'orange',
'vucc': 'blue',
'was': 'green',
'special': 'red',
};
return colors[category] || 'gray';
}
$: filteredAwards = awards.filter(award => {
const matchesSearch = !searchQuery ||
award.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
award.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
award.category.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === 'all' || award.category === categoryFilter;
return matchesSearch && matchesCategory;
});
$: categories = [...new Set(awards.map(a => a.category))].sort();
</script>
{#if loading && awards.length === 0}
<div class="loading">Loading award definitions...</div>
{:else if error}
<div class="error">{error}</div>
{:else}
<div class="awards-admin">
<div class="header">
<h1>Award Definitions</h1>
<a href="/admin/awards/create" class="btn btn-primary">Create New Award</a>
</div>
<div class="filters">
<input
type="text"
class="search-input"
placeholder="Search by name, ID, or category..."
bind:value={searchQuery}
/>
<select class="category-filter" bind:value={categoryFilter}>
<option value="all">All Categories</option>
{#each categories as category}
<option value={category}>{category}</option>
{/each}
</select>
</div>
<div class="awards-table-container">
<table class="awards-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Rule Type</th>
<th>Target</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each filteredAwards as award}
<tr>
<td class="id-cell">{award.id}</td>
<td>
<div class="name-cell">
<strong>{award.name}</strong>
<small>{award.description}</small>
</div>
</td>
<td>
<span class="category-badge {getCategoryColor(award.category)}">
{award.category}
</span>
</td>
<td>{getRuleTypeDisplayName(award.rules.type)}</td>
<td>{award.rules.target || '-'}</td>
<td class="actions-cell">
<a href="/admin/awards/{award.id}" class="action-btn edit-btn">Edit</a>
<a href="/awards/{award.id}" target="_blank" class="action-btn view-btn">View</a>
<button
class="action-btn delete-btn"
on:click={() => handleDelete(award.id)}
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="count">Showing {filteredAwards.length} award(s)</p>
</div>
{/if}
<style>
.awards-admin {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.loading {
text-align: center;
padding: 3rem;
font-size: 1.2rem;
color: #666;
}
.error {
background-color: #fee;
border: 1px solid #fcc;
padding: 1rem;
border-radius: 4px;
color: #c00;
margin: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.header h1 {
margin: 0;
color: #333;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.search-input,
.category-filter {
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.search-input {
flex: 1;
min-width: 250px;
}
.category-filter {
min-width: 150px;
}
.btn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-primary {
background-color: #667eea;
color: white;
}
.btn-primary:hover {
background-color: #5568d3;
}
.awards-table-container {
overflow-x: auto;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.awards-table {
width: 100%;
border-collapse: collapse;
}
.awards-table th,
.awards-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.awards-table th {
background-color: #f5f5f5;
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
color: #666;
}
.awards-table tr:hover {
background-color: #f9f9f9;
}
.id-cell {
font-family: monospace;
color: #666;
font-size: 0.9rem;
}
.name-cell {
display: flex;
flex-direction: column;
}
.name-cell small {
color: #888;
font-size: 0.85rem;
margin-top: 0.25rem;
}
.category-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
display: inline-block;
}
.category-badge.purple { background-color: #9b59b6; color: white; }
.category-badge.orange { background-color: #e67e22; color: white; }
.category-badge.blue { background-color: #3498db; color: white; }
.category-badge.green { background-color: #27ae60; color: white; }
.category-badge.red { background-color: #e74c3c; color: white; }
.category-badge.gray { background-color: #95a5a6; color: white; }
.actions-cell {
white-space: nowrap;
}
.action-btn {
padding: 0.4rem 0.8rem;
margin-right: 0.3rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.edit-btn {
background-color: #3498db;
color: white;
}
.edit-btn:hover {
background-color: #2980b9;
}
.view-btn {
background-color: #27ae60;
color: white;
}
.view-btn:hover {
background-color: #219a52;
}
.delete-btn {
background-color: #e74c3c;
color: white;
}
.delete-btn:hover {
background-color: #c0392b;
}
.count {
margin-top: 1rem;
color: #666;
font-style: italic;
}
@media (max-width: 768px) {
.awards-admin {
padding: 1rem;
}
.header {
flex-direction: column;
align-items: stretch;
}
.filters {
flex-direction: column;
}
.search-input {
width: 100%;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
<script>
import { createEventDispatcher } from 'svelte';
export let filters = null;
export let onChange = () => {};
const dispatch = createEventDispatcher();
const ALL_FIELDS = [
{ value: 'band', label: 'Band' },
{ value: 'mode', label: 'Mode' },
{ value: 'callsign', label: 'Callsign' },
{ value: 'entity', label: 'Entity (Country)' },
{ value: 'entityId', label: 'Entity ID' },
{ value: 'state', label: 'State' },
{ value: 'grid', label: 'Grid Square' },
{ value: 'satName', label: 'Satellite Name' },
{ value: 'satellite', label: 'Is Satellite QSO' },
];
const OPERATORS = [
{ value: 'eq', label: 'Equals', needsArray: false },
{ value: 'ne', label: 'Not Equals', needsArray: false },
{ value: 'in', label: 'In', needsArray: true },
{ value: 'nin', label: 'Not In', needsArray: true },
{ value: 'contains', label: 'Contains', needsArray: false },
];
const BAND_OPTIONS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
const MODE_OPTIONS = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
// Add a new filter
function addFilter() {
if (!filters) {
filters = { operator: 'AND', filters: [] };
}
if (!filters.filters) {
filters.filters = [];
}
filters.filters.push({
field: 'band',
operator: 'eq',
value: ''
});
updateFilters();
}
// Add a nested filter group
function addFilterGroup() {
if (!filters) {
filters = { operator: 'AND', filters: [] };
}
if (!filters.filters) {
filters.filters = [];
}
filters.filters.push({
operator: 'AND',
filters: []
});
updateFilters();
}
// Remove a filter at index
function removeFilter(index) {
if (filters && filters.filters) {
filters.filters.splice(index, 1);
// If no filters left, set to null
if (filters.filters.length === 0) {
filters = null;
}
updateFilters();
}
}
// Update a filter at index
function updateFilter(index, key, value) {
if (filters && filters.filters) {
filters.filters[index][key] = value;
updateFilters();
}
}
// Update filter operator (AND/OR)
function updateOperator(operator) {
if (filters) {
filters.operator = operator;
updateFilters();
}
}
// Get input type based on field and operator
function getInputType(field, operator) {
const opConfig = OPERATORS.find(o => o.value === operator);
const needsArray = opConfig?.needsArray || false;
if (field === 'band' && needsArray) return 'band-multi';
if (field === 'band') return 'band';
if (field === 'mode' && needsArray) return 'mode-multi';
if (field === 'mode') return 'mode';
if (field === 'satellite') return 'boolean';
if (needsArray) return 'text-array';
return 'text';
}
// Notify parent of changes
function updateFilters() {
// Deep clone to avoid reactivity issues
const cloned = filters ? JSON.parse(JSON.stringify(filters)) : null;
onChange(cloned);
dispatch('change', cloned);
}
// Check if a filter is a group (has nested filters)
function isFilterGroup(filter) {
return filter && typeof filter === 'object' && filter.filters !== undefined;
}
</script>
<div class="filter-builder">
{#if !filters || !filters.filters || filters.filters.length === 0}
<div class="no-filters">
<p>No filters defined. All QSOs will be evaluated.</p>
<button class="btn btn-secondary" on:click={addFilter}>Add Filter</button>
</div>
{:else}
<div class="filter-group">
<div class="filter-group-header">
<h4>Filters</h4>
<div class="operator-selector">
<label>
<input
type="radio"
bind:group={filters.operator}
value="AND"
on:change={() => updateOperator('AND')}
/>
AND
</label>
<label>
<input
type="radio"
bind:group={filters.operator}
value="OR"
on:change={() => updateOperator('OR')}
/>
OR
</label>
</div>
</div>
<div class="filter-list">
{#each filters.filters as filter, index}
<div class="filter-item">
{#if isFilterGroup(filter)}
<!-- Nested filter group -->
<div class="nested-filter-group">
<div class="nested-header">
<span class="group-label">Group ({filter.operator})</span>
<button class="btn-remove" on:click={() => removeFilter(index)}>Remove</button>
</div>
<svelte:self filters={filter} onChange={(nested) => updateFilter(index, nested)} />
</div>
{:else}
<!-- Single filter -->
<div class="single-filter">
<select
class="field-select"
bind:value={filter.field}
on:change={() => updateFilter(index, 'field', filter.field)}
>
{#each ALL_FIELDS as field}
<option value={field.value}>{field.label}</option>
{/each}
</select>
<select
class="operator-select"
bind:value={filter.operator}
on:change={() => updateFilter(index, 'operator', filter.operator)}
>
{#each OPERATORS as op}
<option value={op.value}>{op.label}</option>
{/each}
</select>
<div class="filter-value">
{#if getInputType(filter.field, filter.operator) === 'band'}
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
<option value="">Select band</option>
{#each BAND_OPTIONS as band}
<option value={band}>{band}</option>
{/each}
</select>
{:else if getInputType(filter.field, filter.operator) === 'band-multi'}
<div class="multi-select">
{#each BAND_OPTIONS as band}
<label class="checkbox-option">
<input
type="checkbox"
checked={Array.isArray(filter.value) && filter.value.includes(band)}
on:change={(e) => {
if (!Array.isArray(filter.value)) filter.value = [];
if (e.target.checked) {
filter.value = [...filter.value, band];
} else {
filter.value = filter.value.filter(v => v !== band);
}
updateFilter(index, 'value', filter.value);
}}
/>
{band}
</label>
{/each}
</div>
{:else if getInputType(filter.field, filter.operator) === 'mode'}
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
<option value="">Select mode</option>
{#each MODE_OPTIONS as mode}
<option value={mode}>{mode}</option>
{/each}
</select>
{:else if getInputType(filter.field, filter.operator) === 'mode-multi'}
<div class="multi-select">
{#each MODE_OPTIONS as mode}
<label class="checkbox-option">
<input
type="checkbox"
checked={Array.isArray(filter.value) && filter.value.includes(mode)}
on:change={(e) => {
if (!Array.isArray(filter.value)) filter.value = [];
if (e.target.checked) {
filter.value = [...filter.value, mode];
} else {
filter.value = filter.value.filter(v => v !== mode);
}
updateFilter(index, 'value', filter.value);
}}
/>
{mode}
</label>
{/each}
</div>
{:else if getInputType(filter.field, filter.operator) === 'boolean'}
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
{:else if getInputType(filter.field, filter.operator) === 'text-array'}
<input
type="text"
placeholder="comma-separated values"
value={Array.isArray(filter.value) ? filter.value.join(', ') : filter.value}
on:change={(e) => {
const values = e.target.value.split(',').map(v => v.trim()).filter(v => v);
updateFilter(index, 'value', values);
}}
/>
{:else}
<input
type="text"
placeholder="Value"
bind:value={filter.value}
on:change={() => updateFilter(index, 'value', filter.value)}
/>
{/if}
</div>
<button class="btn-remove" on:click={() => removeFilter(index)}>Remove</button>
</div>
{/if}
</div>
{/each}
</div>
<div class="filter-actions">
<button class="btn btn-secondary" on:click={addFilter}>Add Filter</button>
<button class="btn btn-secondary" on:click={addFilterGroup}>Add Filter Group</button>
<button class="btn btn-danger" on:click={() => { filters = null; updateFilters(); }}>Clear All Filters</button>
</div>
</div>
{/if}
</div>
<style>
.filter-builder {
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
background: #fafafa;
}
.no-filters {
text-align: center;
padding: 1rem;
color: #666;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.filter-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.filter-group-header h4 {
margin: 0;
color: #333;
}
.operator-selector {
display: flex;
gap: 1rem;
}
.operator-selector label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.filter-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.filter-item {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1rem;
}
.single-filter {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.single-filter select,
.single-filter input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.field-select {
min-width: 150px;
}
.operator-select {
min-width: 120px;
}
.filter-value {
flex: 1;
min-width: 200px;
}
.filter-value select,
.filter-value input {
width: 100%;
}
.nested-filter-group {
border-left: 3px solid #667eea;
padding-left: 1rem;
}
.nested-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.group-label {
font-weight: 500;
color: #667eea;
}
.multi-select {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.checkbox-option {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #f5f5f5;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.checkbox-option input {
cursor: pointer;
}
.btn-remove {
background-color: #dc3545;
color: white;
padding: 0.4rem 0.8rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-remove:hover {
background-color: #c82333;
}
.filter-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
@media (max-width: 768px) {
.single-filter {
flex-direction: column;
align-items: stretch;
}
.field-select,
.operator-select,
.filter-value {
width: 100%;
min-width: unset;
}
.filter-actions {
flex-direction: column;
}
.filter-actions button {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,836 @@
<script>
import { onMount } from 'svelte';
import { awardsAdminAPI, adminAPI } from '$lib/api.js';
export let awardId = null;
export let awardDefinition = null;
export let onClose = () => {};
let loading = false;
let testResult = null;
let testError = null;
let users = [];
let selectedUserId = null;
// Extended validation results
let logicValidation = null;
onMount(async () => {
await loadUsers();
if (awardDefinition) {
performLogicValidation();
}
});
async function loadUsers() {
try {
const data = await adminAPI.getUsers();
users = data.users || [];
} catch (err) {
console.error('Failed to load users:', err);
}
}
async function runTest() {
if (!awardId) return;
loading = true;
testResult = null;
testError = null;
try {
// Pass awardDefinition for unsaved awards (testing during create/edit)
const data = await awardsAdminAPI.test(awardId, selectedUserId, awardDefinition);
testResult = data;
} catch (err) {
testError = err.message;
} finally {
loading = false;
}
}
// Perform deep logic validation on award definition
function performLogicValidation() {
if (!awardDefinition) return;
const issues = {
errors: [],
warnings: [],
info: []
};
const rules = awardDefinition.rules;
// 1. Check for impossible filter combinations
if (rules.filters) {
const impossibleFilterCombos = checkImpossibleFilters(rules.filters, rules);
issues.errors.push(...impossibleFilterCombos.errors);
issues.warnings.push(...impossibleFilterCombos.warnings);
}
// 2. Check for redundancy
const redundancies = checkRedundancies(rules);
issues.warnings.push(...redundancies.warnings);
issues.info.push(...redundancies.info);
// 3. Check for logical contradictions
const contradictions = checkContradictions(rules);
issues.errors.push(...contradictions.errors);
issues.warnings.push(...contradictions.warnings);
// 4. Check for edge cases that might cause issues
const edgeCases = checkEdgeCases(rules);
issues.info.push(...edgeCases);
// 5. Provide helpful suggestions
const suggestions = provideSuggestions(rules);
issues.info.push(...suggestions);
logicValidation = issues;
}
// Check for impossible filter combinations
function checkImpossibleFilters(filters, rules) {
const errors = [];
const warnings = [];
function analyze(filterNode, depth = 0) {
if (!filterNode || !filterNode.filters) return;
// Group filters by field to check for contradictions
const fieldFilters = {};
for (const f of filterNode.filters) {
if (f.field) {
if (!fieldFilters[f.field]) fieldFilters[f.field] = [];
fieldFilters[f.field].push(f);
} else if (f.filters) {
analyze(f, depth + 1);
}
}
// Check for contradictions in AND groups
if (filterNode.operator === 'AND') {
for (const [field, fieldFiltersList] of Object.entries(fieldFilters)) {
// Check for direct contradictions: field=X AND field=Y
const eqFilters = fieldFiltersList.filter(f => f.operator === 'eq');
const neFilters = fieldFiltersList.filter(f => f.operator === 'ne');
for (const eq1 of eqFilters) {
for (const eq2 of eqFilters) {
if (eq1 !== eq2 && eq1.value !== eq2.value) {
errors.push(`Impossible filter: ${field} cannot be both "${eq1.value}" AND "${eq2.value}"`);
}
}
for (const ne of neFilters) {
if (eq1.value === ne.value) {
errors.push(`Impossible filter: ${field} cannot be "${eq1.value}" AND not "${ne.value}" at the same time`);
}
}
}
// Check for in/nin contradictions
const inFilters = fieldFiltersList.filter(f => f.operator === 'in');
const ninFilters = fieldFiltersList.filter(f => f.operator === 'nin');
for (const inF of inFilters) {
if (Array.isArray(inF.value)) {
for (const ninF of ninFilters) {
if (Array.isArray(ninF.value)) {
const overlap = inF.value.filter(v => ninF.value.includes(v));
if (overlap.length > 0 && overlap.length === inF.value.length) {
errors.push(`Impossible filter: ${field} must be in ${inF.value.join(', ')} AND not in ${overlap.join(', ')}`);
} else if (overlap.length > 0) {
warnings.push(`Suspicious filter: ${field} filter has overlapping values: ${overlap.join(', ')}`);
}
}
}
}
}
}
}
// Check for redundant OR groups (field=X OR field=X)
if (filterNode.operator === 'OR') {
for (const [field, fieldFiltersList] of Object.entries(fieldFilters)) {
const eqFilters = fieldFiltersList.filter(f => f.operator === 'eq');
for (let i = 0; i < eqFilters.length; i++) {
for (let j = i + 1; j < eqFilters.length; j++) {
if (eqFilters[i].value === eqFilters[j].value) {
warnings.push(`Redundant filter: ${field}="${eqFilters[i].value}" appears multiple times in OR group`);
}
}
}
}
}
}
analyze(filters);
return { errors, warnings };
}
// Check for redundancies in the definition
function checkRedundancies(rules) {
const warnings = [];
const info = [];
// Check if satellite_only is redundant when filters already check for satellite
if (rules.satellite_only && rules.filters) {
const satFilter = findSatelliteFilter(rules.filters);
if (satFilter && satFilter.operator === 'eq' && satFilter.value === true) {
info.push('satellite_only=true is set, but filters already check for satellite QSOs. The filter is redundant.');
}
}
// Check if allowed_bands makes filters redundant
if (rules.allowed_bands && rules.allowed_bands.length > 0 && rules.filters) {
const bandFilters = extractBandFilters(rules.filters);
for (const bf of bandFilters) {
if (bf.operator === 'in' && Array.isArray(bf.value)) {
const allCovered = bf.value.every(b => rules.allowed_bands.includes(b));
if (allCovered) {
info.push(`allowed_bands already includes all bands in the filter. Consider removing the filter.`);
}
}
}
}
// Check if displayField matches the default for the entity type
if (rules.entityType && rules.displayField) {
const defaults = {
'dxcc': 'entity',
'state': 'state',
'grid': 'grid',
'callsign': 'callsign'
};
if (defaults[rules.entityType] === rules.displayField) {
info.push(`displayField="${rules.displayField}" is the default for entityType="${rules.entityType}". It can be omitted.`);
}
}
return { warnings, info };
}
// Check for logical contradictions
function checkContradictions(rules) {
const errors = [];
const warnings = [];
// Check satellite_only with HF-only allowed_bands
if (rules.satellite_only && rules.allowed_bands) {
const hfBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
const hasHfOnly = rules.allowed_bands.length > 0 &&
rules.allowed_bands.every(b => hfBands.includes(b));
if (hasHfOnly) {
warnings.push('satellite_only is set but allowed_bands only includes HF bands. Satellite work typically uses VHF/UHF bands.');
}
}
// For DOK rules, verify confirmation type
if (rules.type === 'dok' && rules.confirmationType && rules.confirmationType !== 'dcl') {
warnings.push('DOK awards typically require DCL confirmation (confirmationType="dcl").');
}
// Check for impossible targets
if (rules.target) {
if (rules.type === 'entity' && rules.entityType === 'dxcc' && rules.target > 340) {
warnings.push(`Target (${rules.target}) exceeds the total number of DXCC entities (~340).`);
}
if (rules.type === 'dok' && rules.target > 700) {
info.push(`Target (${rules.target}) is high. There are ~700 DOKs in Germany.`);
}
}
return { errors, warnings };
}
// Check for edge cases
function checkEdgeCases(rules) {
const info = [];
if (rules.filters) {
const filterCount = countFilters(rules.filters);
if (filterCount > 10) {
info.push(`Complex filter structure (${filterCount} filters). Consider simplifying for better performance.`);
}
}
if (rules.modeGroups) {
const totalModes = Object.values(rules.modeGroups).reduce((sum, modes) => sum + (modes?.length || 0), 0);
if (totalModes > 20) {
info.push('Many mode groups defined. Make sure users understand the grouping logic.');
}
}
if (rules.type === 'points' && rules.stations) {
const totalPossiblePoints = rules.stations.reduce((sum, s) => sum + (s.points || 0), 0);
if (totalPossiblePoints < rules.target) {
info.push(`Even with all stations confirmed, max points (${totalPossiblePoints}) is less than target (${rules.target}). Award is impossible to complete.`);
}
}
return info;
}
// Provide helpful suggestions
function provideSuggestions(rules) {
const info = [];
// Suggest common award patterns
if (rules.type === 'entity' && rules.entityType === 'dxcc' && !rules.allowed_bands) {
info.push('Consider adding allowed_bands to restrict to specific bands (e.g., HF only: ["160m", "80m", ...]).');
}
if (rules.type === 'entity' && !rules.modeGroups && ['dxcc', 'dld'].includes(rules.entityType)) {
info.push('Consider adding modeGroups to help users filter by mode type (e.g., "Digi-Modes", "Phone-Modes").');
}
if (rules.type === 'dok' && !rules.filters) {
info.push('DOK awards can have band/mode filters via the filters property. Consider adding them for specific variations.');
}
return info;
}
// Helper: Find satellite-related filter
function findSatelliteFilter(filters, depth = 0) {
if (!filters || depth > 5) return null;
if (filters.field === 'satellite' || filters.field === 'satName') {
return filters;
}
if (filters.filters) {
for (const f of filters.filters) {
const found = findSatelliteFilter(f, depth + 1);
if (found) return found;
}
}
return null;
}
// Helper: Extract band filters
function extractBandFilters(filters, depth = 0) {
if (!filters || depth > 5) return [];
const result = [];
if (filters.field === 'band') {
result.push(filters);
}
if (filters.filters) {
for (const f of filters.filters) {
result.push(...extractBandFilters(f, depth + 1));
}
}
return result;
}
// Helper: Count total filters
function countFilters(filters, depth = 0) {
if (!filters || depth > 5) return 0;
let count = 0;
if (filters.filters) {
for (const f of filters.filters) {
if (f.filters) {
count += 1 + countFilters(f, depth + 1);
} else {
count += 1;
}
}
}
return count;
}
function getSeverityClass(type) {
switch (type) {
case 'error': return 'severity-error';
case 'warning': return 'severity-warning';
case 'info': return 'severity-info';
default: return '';
}
}
</script>
{#if logicValidation || testResult || testError}
<div class="modal-overlay" on:click={onClose}>
<div class="modal-content large" on:click|stopPropagation>
<div class="modal-header">
<h2>{testResult ? 'Test Results' : 'Award Validation'}{awardId ? `: ${awardId}` : ''}</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
<!-- Logic Validation Section -->
{#if logicValidation && (logicValidation.errors.length > 0 || logicValidation.warnings.length > 0 || logicValidation.info.length > 0)}
<div class="validation-section">
<h3>Logic Validation</h3>
{#if logicValidation.errors.length > 0}
<div class="validation-block errors">
<h4>Errors (must fix)</h4>
<ul>
{#each logicValidation.errors as err}
<li class="severity-error">{err}</li>
{/each}
</ul>
</div>
{/if}
{#if logicValidation.warnings.length > 0}
<div class="validation-block warnings">
<h4>Warnings</h4>
<ul>
{#each logicValidation.warnings as warn}
<li class="severity-warning">{warn}</li>
{/each}
</ul>
</div>
{/if}
{#if logicValidation.info.length > 0}
<div class="validation-block info">
<h4>Suggestions</h4>
<ul>
{#each logicValidation.info as info}
<li class="severity-info">{info}</li>
{/each}
</ul>
</div>
{/if}
{#if logicValidation.errors.length === 0 && logicValidation.warnings.length === 0}
<div class="validation-block success">
<p>No issues found. The award definition looks good!</p>
</div>
{/if}
</div>
{/if}
<!-- Test Configuration -->
<div class="test-config">
<h3>Test Calculation</h3>
<p class="help-text">Select a user to test the award calculation with their QSO data.</p>
<div class="user-selector">
<label for="test-user">Test with user:</label>
<select id="test-user" bind:value={selectedUserId}>
<option value="">-- Select a user --</option>
{#each users as user}
<option value={user.id}>{user.callsign} ({user.email}) - {user.qsoCount || 0} QSOs</option>
{/each}
</select>
</div>
<button
class="btn btn-primary"
on:click={runTest}
disabled={loading || !selectedUserId || !awardId}
>
{loading ? 'Testing...' : 'Run Test'}
</button>
</div>
<!-- Test Results -->
{#if testError}
<div class="test-results error">
<h4>Test Failed</h4>
<p>{testError}</p>
</div>
{:else if testResult}
<div class="test-results success">
<h4>Test Results</h4>
<div class="result-summary">
<div class="result-item">
<span class="label">Award:</span>
<span class="value">{testResult.award?.name || awardId}</span>
</div>
<div class="result-item">
<span class="label">Worked:</span>
<span class="value">{testResult.worked || 0}</span>
</div>
<div class="result-item">
<span class="label">Confirmed:</span>
<span class="value confirmed">{testResult.confirmed || 0}</span>
</div>
<div class="result-item">
<span class="label">Target:</span>
<span class="value">{testResult.target || 0}</span>
</div>
<div class="result-item">
<span class="label">Progress:</span>
<span class="value progress">{testResult.percentage || 0}%</span>
</div>
</div>
{#if testResult.warnings && testResult.warnings.length > 0}
<div class="result-warnings">
<h5>Warnings:</h5>
<ul>
{#each testResult.warnings as warning}
<li>{warning}</li>
{/each}
</ul>
</div>
{/if}
{#if testResult.sampleEntities && testResult.sampleEntities.length > 0}
<div class="sample-entities">
<h5>Sample Matched Entities (first {testResult.sampleEntities.length}):</h5>
<div class="entities-list">
{#each testResult.sampleEntities as entity}
<span class="entity-tag">{entity}</span>
{/each}
</div>
</div>
{:else}
<div class="no-matches">
<p>No entities matched. Check filters and band/mode restrictions.</p>
</div>
{/if}
</div>
{/if}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" on:click={onClose}>Close</button>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: white;
border-radius: 8px;
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.modal-content.large {
max-width: 900px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
}
.modal-header h2 {
margin: 0;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #888;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.validation-section {
margin-bottom: 2rem;
}
.validation-section h3 {
margin: 0 0 1rem 0;
color: #333;
}
.validation-block {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.validation-block h4 {
margin: 0 0 0.75rem 0;
font-size: 1rem;
}
.validation-block.errors {
background-color: #fee;
border-left: 4px solid #dc3545;
}
.validation-block.warnings {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
}
.validation-block.info {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
}
.validation-block.success {
background-color: #d4edda;
border-left: 4px solid #28a745;
}
.validation-block ul {
margin: 0;
padding-left: 1.5rem;
}
.validation-block li {
margin-bottom: 0.5rem;
}
.severity-error {
color: #dc3545;
}
.severity-warning {
color: #856404;
}
.severity-info {
color: #0d47a1;
}
.test-config {
border-top: 1px solid #e0e0e0;
padding-top: 1.5rem;
}
.test-config h3 {
margin: 0 0 0.5rem 0;
color: #333;
}
.help-text {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.user-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.user-selector label {
font-weight: 500;
}
.user-selector select {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.test-results {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 4px;
}
.test-results.error {
background-color: #fee;
border-left: 4px solid #dc3545;
}
.test-results.success {
background-color: #f9f9f9;
border: 1px solid #ddd;
}
.test-results h4 {
margin: 0 0 1rem 0;
}
.result-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.result-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background: white;
border-radius: 4px;
}
.result-item .label {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.25rem;
}
.result-item .value {
font-size: 1.25rem;
font-weight: bold;
color: #333;
}
.result-item .value.confirmed {
color: #28a745;
}
.result-item .value.progress {
color: #667eea;
}
.result-warnings {
background-color: #fff3cd;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.result-warnings h5 {
margin: 0 0 0.5rem 0;
color: #856404;
}
.result-warnings ul {
margin: 0;
padding-left: 1.5rem;
}
.sample-entities {
background-color: #e3f2fd;
padding: 0.75rem;
border-radius: 4px;
}
.sample-entities h5 {
margin: 0 0 0.5rem 0;
color: #0d47a1;
}
.entities-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.entity-tag {
background-color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
color: #0d47a1;
}
.no-matches {
background-color: #fff3cd;
padding: 0.75rem;
border-radius: 4px;
text-align: center;
color: #856404;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
}
.btn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #5568d3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
@media (max-width: 600px) {
.modal-content {
height: 100vh;
max-height: 100vh;
border-radius: 0;
}
.user-selector {
flex-direction: column;
align-items: stretch;
}
.result-summary {
grid-template-columns: 1fr 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff