Compare commits

..

10 Commits

Author SHA1 Message Date
b296514356 fix: use entityId for QSO stats entity counting to match DXCC award
Changed QSO page statistics to count entities using entity_id (numeric
DXCC code) instead of entity (text field) for consistency with DXCC
award progress calculations.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 18:33:54 +01:00
70858836d0 Spinner 2026-01-24 16:01:05 +01:00
257ebf6c5d fix: increase page max-width from 1200px to 1600px for better table display
Tables were too narrow and required horizontal scrolling on larger screens.
Increased max-width across all pages to better utilize available screen space.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:56:56 +01:00
caf7703073 fix: add missing updateUserRole import in admin service
The updateUserRole function was being called but not imported from
auth.service.js, causing "updateUserRole is not defined" error when
changing user roles via admin interface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:44:52 +01:00
fa6420d149 fix: use CSS variables for dark mode support in settings page
Replace hard-coded colors with CSS variables from the theme system to
properly support both light and dark modes. Also add proper input
styling and strong tag emphasis colors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 18:57:50 +01:00
aa55158347 feat: add WAE (Worked All Europe) award implementation
Implement DARC's WAE award with dual metrics tracking (countries + bandpoints).

Features:
- 54 European countries with correct DXCC entityIds from ARRL
- 8 WAE-specific entities (Shetland, Sicily, Sardinia, Crete, etc.)
- Bandpoints calculation: 1 pt/band (2 pts for 160m/80m), max 5 bands/country
- 5 award levels: WAE III (40/100), WAE II (50/150), WAE I (60/200),
  WAE TOP (70/300), WAE Trophy (all/365)
- Mode groups: CW, SSB, RTTY, FT8, Digi-Modes, Mixed-Mode
- Admin UI support for creating/editing WAE awards
- Award detail page with dual metrics display

Files:
- award-data/wae-country-list.json: WAE country definitions
- award-definitions/wae.json: Award configuration
- src/backend/services/awards.service.js: WAE calculation functions
- src/frontend: Admin and award detail views

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 18:07:52 +01:00
joerg
e4e7f3c208 Awawrdd 2026-01-23 14:18:56 +01:00
a35731f626 fix: use smart default for displayField based on entityType
When displayField is omitted in award definitions, the backend now
selects the appropriate default field based on the entityType:
- dxcc → entity (country name)
- state → state
- grid → grid (4-character)
- callsign → callsign

Previously, it used a fixed fallback order that prioritized entity
over other fields, causing grid-based awards to show DXCC names.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 14:14:05 +01:00
2ae47232cb fix: improve dark mode contrast for Points and Target badges in award detail view
Replace inline styles with CSS classes that use semi-transparent
backgrounds in dark mode instead of bright solid colors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 14:07:23 +01:00
8b846bffbe docs: add super-admin role documentation
Add comprehensive documentation for the new super-admin role feature:

README.md:
- Update Users Table schema with isAdmin, isSuperAdmin, lastSeen fields
- Add Admin API section with all endpoints
- Add User Roles and Permissions section with security rules

docs/DOCUMENTATION.md:
- Update Users Table schema
- Add Admin System section with overview, roles, security rules
- Document all admin API endpoints
- Add audit logging details
- Include JWT token structure
- Add setup and deployment instructions

CLAUDE.md:
- Add Admin System and User Roles section
- Document admin service functions
- Include security rules
- Add JWT token claims structure
- Document frontend admin interface

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 13:53:48 +01:00
24 changed files with 1626 additions and 101 deletions

View File

@@ -553,3 +553,76 @@ const params = new URLSearchParams({
**QSO Management**:
- Fixed DELETE /api/qsos/all to handle foreign key constraints
- Added cache invalidation after QSO deletion
### Admin System and User Roles
The application supports three user roles with different permission levels:
**User Roles**:
- **Regular User**: View own QSOs, sync from LoTW/DCL, track award progress
- **Admin**: All user permissions + view system stats + manage users + impersonate regular users
- **Super Admin**: All admin permissions + promote/demote admins + impersonate admins
**Database Schema** (`src/backend/db/schema/index.js`):
- `isAdmin`: Boolean flag for admin users (default: false)
- `isSuperAdmin`: Boolean flag for super-admin users (default: false)
**Admin Service** (`src/backend/services/admin.service.js`):
- `isAdmin(userId)`: Check if user is admin
- `isSuperAdmin(userId)`: Check if user is super-admin
- `changeUserRole(adminId, targetUserId, newRole)`: Change user role ('user', 'admin', 'super-admin')
- `impersonateUser(adminId, targetUserId)`: Start impersonating a user
- `verifyImpersonation(token)`: Verify impersonation token validity
- `stopImpersonation(adminId, targetUserId)`: Stop impersonation
- `logAdminAction(adminId, actionType, targetUserId, details)`: Log admin actions
**Security Rules**:
1. Only super-admins can promote/demote super-admins
2. Regular admins cannot promote users to super-admin
3. Super-admins cannot demote themselves (prevents lockout)
4. Cannot demote the last super-admin
5. Regular admins can only impersonate regular users
6. Super-admins can impersonate any user (including other admins)
**Backend API Routes** (`src/backend/index.js`):
- `POST /api/admin/users/:userId/role`: Change user role
- Body: `{ "role": "user" | "admin" | "super-admin" }`
- `POST /api/admin/impersonate/:userId`: Start impersonating
- `POST /api/admin/impersonate/stop`: Stop impersonating
- `GET /api/admin/impersonation/status`: Check impersonation status
- `GET /api/admin/stats`: System statistics
- `GET /api/admin/users`: List all users
- `GET /api/admin/actions`: Admin action log
- `DELETE /api/admin/users/:userId`: Delete user
**JWT Token Claims**:
```javascript
{
userId: number,
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean, // Super-admin flag
impersonatedBy: number, // Present when impersonating
exp: number
}
```
**Frontend Admin Page** (`src/frontend/src/routes/admin/+page.svelte`):
- System statistics dashboard
- User management with filtering (all, super-admin, admin, user)
- Role change modal (user → admin → super-admin)
- Impersonate button (enabled for super-admins targeting admins)
- Admin action log viewing
**To create the first super-admin**:
1. Register a user account
2. Access database: `sqlite3 src/backend/award.db`
3. Run: `UPDATE users SET is_super_admin = 1 WHERE email = 'your@email.com';`
4. Log out and log back in to get updated JWT token
**To promote via admin interface**:
1. Log in as existing super-admin
2. Navigate to `/admin`
3. Find user in Users tab
4. Click "Promote" and select "Super Admin"

View File

@@ -277,6 +277,52 @@ The application will be available at:
### Health
- `GET /api/health` - Health check endpoint
### Admin API (Admin Only)
All admin endpoints require authentication and admin privileges.
- `GET /api/admin/stats` - Get system-wide statistics
- `GET /api/admin/users` - Get all users with statistics
- `GET /api/admin/users/:userId` - Get detailed information about a specific user
- `POST /api/admin/users/:userId/role` - Update user role (`user`, `admin`, `super-admin`)
- `DELETE /api/admin/users/:userId` - Delete a user
- `POST /api/admin/impersonate/:userId` - Start impersonating a user
- `POST /api/admin/impersonate/stop` - Stop impersonating and return to admin account
- `GET /api/admin/impersonation/status` - Get current impersonation status
- `GET /api/admin/actions` - Get admin actions log
- `GET /api/admin/actions/my` - Get current admin's action log
### User Roles and Permissions
The application supports three user roles with different permission levels:
**Regular User**
- View own QSOs
- Sync from LoTW and DCL
- Track award progress
- Manage own credentials
**Admin**
- All user permissions
- View system statistics
- View all users
- Promote/demote regular users to/from admin
- Delete regular users
- Impersonate regular users (for support)
- View admin action log
**Super Admin**
- All admin permissions
- Promote/demote admins to/from super-admin
- Impersonate other admins (for support)
- Full access to all admin functions
**Security Rules:**
- Only super-admins can promote or demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin
## Database Schema
### Users Table
@@ -289,6 +335,9 @@ CREATE TABLE users (
lotwUsername TEXT,
lotwPassword TEXT,
dclApiKey TEXT, -- DCL API key (for future use)
isAdmin INTEGER DEFAULT 0 NOT NULL,
isSuperAdmin INTEGER DEFAULT 0 NOT NULL,
lastSeen INTEGER,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);

View File

@@ -0,0 +1,115 @@
{
"dxccBased": [
{ "entityId": 230, "country": "Germany", "prefix": "DL", "deleted": false },
{ "entityId": 227, "country": "France", "prefix": "F", "deleted": false },
{ "entityId": 248, "country": "Italy", "prefix": "I", "deleted": false },
{ "entityId": 223, "country": "England", "prefix": "G", "deleted": false },
{ "entityId": 279, "country": "Scotland", "prefix": "GM", "deleted": false },
{ "entityId": 265, "country": "Northern Ireland", "prefix": "GI", "deleted": false },
{ "entityId": 294, "country": "Wales", "prefix": "GW", "deleted": false },
{ "entityId": 114, "country": "Isle of Man", "prefix": "GD", "deleted": false },
{ "entityId": 122, "country": "Jersey", "prefix": "GJ", "deleted": false },
{ "entityId": 106, "country": "Guernsey", "prefix": "GU", "deleted": false },
{ "entityId": 236, "country": "Greece", "prefix": "SV", "deleted": false },
{ "entityId": 209, "country": "Belgium", "prefix": "ON", "deleted": false },
{ "entityId": 263, "country": "Netherlands", "prefix": "PA", "deleted": false },
{ "entityId": 287, "country": "Switzerland", "prefix": "HB", "deleted": false },
{ "entityId": 281, "country": "Spain", "prefix": "EA", "deleted": false },
{ "entityId": 272, "country": "Portugal", "prefix": "CT", "deleted": false },
{ "entityId": 206, "country": "Austria", "prefix": "OE", "deleted": false },
{ "entityId": 503, "country": "Czech Republic", "prefix": "OK", "deleted": false },
{ "entityId": 504, "country": "Slovakia", "prefix": "OM", "deleted": false },
{ "entityId": 239, "country": "Hungary", "prefix": "HA", "deleted": false },
{ "entityId": 269, "country": "Poland", "prefix": "SP", "deleted": false },
{ "entityId": 284, "country": "Sweden", "prefix": "SM", "deleted": false },
{ "entityId": 266, "country": "Norway", "prefix": "LA", "deleted": false },
{ "entityId": 221, "country": "Denmark", "prefix": "OZ", "deleted": false },
{ "entityId": 224, "country": "Finland", "prefix": "OH", "deleted": false },
{ "entityId": 52, "country": "Estonia", "prefix": "ES", "deleted": false },
{ "entityId": 145, "country": "Latvia", "prefix": "YL", "deleted": false },
{ "entityId": 146, "country": "Lithuania", "prefix": "LY", "deleted": false },
{ "entityId": 27, "country": "Belarus", "prefix": "EU", "deleted": false },
{ "entityId": 288, "country": "Ukraine", "prefix": "UR", "deleted": false },
{ "entityId": 179, "country": "Moldova", "prefix": "ER", "deleted": false },
{ "entityId": 275, "country": "Romania", "prefix": "YO", "deleted": false },
{ "entityId": 212, "country": "Bulgaria", "prefix": "LZ", "deleted": false },
{ "entityId": 296, "country": "Serbia", "prefix": "YT", "deleted": false },
{ "entityId": 497, "country": "Croatia", "prefix": "9A", "deleted": false },
{ "entityId": 499, "country": "Slovenia", "prefix": "S5", "deleted": false },
{ "entityId": 501, "country": "Bosnia and Herzegovina", "prefix": "E7", "deleted": false },
{ "entityId": 502, "country": "North Macedonia", "prefix": "Z3", "deleted": false },
{ "entityId": 7, "country": "Albania", "prefix": "ZA", "deleted": false },
{ "entityId": 514, "country": "Montenegro", "prefix": "4O", "deleted": false },
{ "entityId": 54, "country": "Russia (European)", "prefix": "UA", "deleted": false },
{ "entityId": 126, "country": "Kaliningrad", "prefix": "UA2", "deleted": false },
{ "entityId": 390, "country": "Turkey", "prefix": "TA", "deleted": false },
{ "entityId": 215, "country": "Cyprus", "prefix": "5B", "deleted": false },
{ "entityId": 257, "country": "Malta", "prefix": "9H", "deleted": false },
{ "entityId": 242, "country": "Iceland", "prefix": "TF", "deleted": false },
{ "entityId": 245, "country": "Ireland", "prefix": "EI", "deleted": false },
{ "entityId": 254, "country": "Luxembourg", "prefix": "LX", "deleted": false },
{ "entityId": 260, "country": "Monaco", "prefix": "3A", "deleted": false },
{ "entityId": 203, "country": "Andorra", "prefix": "C3", "deleted": false },
{ "entityId": 278, "country": "San Marino", "prefix": "T7", "deleted": false },
{ "entityId": 295, "country": "Vatican City", "prefix": "HV", "deleted": false },
{ "entityId": 251, "country": "Liechtenstein", "prefix": "HB0", "deleted": false }
],
"waeSpecific": [
{
"country": "Shetland Islands",
"prefix": "GM/S",
"callsigns": ["GM/S*", "GS/S*", "2M/S*"],
"parentDxcc": 279
},
{
"country": "European Turkey",
"prefix": "TA1",
"callsigns": ["TA1*"],
"parentDxcc": 390
},
{
"country": "Sardinia",
"prefix": "IS0",
"callsigns": ["IS0*"],
"parentDxcc": 248
},
{
"country": "Sicily",
"prefix": "IT9",
"callsigns": ["IT9*"],
"parentDxcc": 248
},
{
"country": "Corsica",
"prefix": "TK",
"callsigns": ["TK*"],
"parentDxcc": 227
},
{
"country": "Crete",
"prefix": "SV9",
"callsigns": ["SV9*", "J49*"],
"parentDxcc": 236
},
{
"country": "ITU Headquarters Geneva",
"prefix": "4U1I",
"callsigns": ["4U1I"],
"parentDxcc": null
},
{
"country": "UN Vienna",
"prefix": "4U1V",
"callsigns": ["4U1V"],
"parentDxcc": null
}
],
"deletedCountries": [
{
"country": "German Democratic Republic",
"prefix": "Y2",
"deleted": "1990-10-03",
"formerEntityId": 229
}
]
}

View File

@@ -36,5 +36,27 @@
"FM",
"SSB"
]
}
},
"achievements": [
{
"name": "DLD50",
"threshold": 50
},
{
"name": "DLD100",
"threshold": 100
},
{
"name": "DLD200",
"threshold": 200
},
{
"name": "DLD500",
"threshold": 500
},
{
"name": "DLD1000",
"threshold": 1000
}
]
}

View File

@@ -9,14 +9,57 @@
"target": 50,
"countMode": "perBandMode",
"stations": [
{ "callsign": "DF2ET", "points": 10 },
{ "callsign": "DJ7NT", "points": 10 },
{ "callsign": "HB9HIL", "points": 10 },
{ "callsign": "LA8AJA", "points": 10 },
{ "callsign": "DB4SCW", "points": 5 },
{ "callsign": "DG2RON", "points": 5 },
{ "callsign": "DG0TM", "points": 5 },
{ "callsign": "DO8MKR", "points": 5 }
{
"callsign": "DF2ET",
"points": 10
},
{
"callsign": "DJ7NT",
"points": 10
},
{
"callsign": "HB9HIL",
"points": 10
},
{
"callsign": "LA8AJA",
"points": 10
},
{
"callsign": "DB4SCW",
"points": 5
},
{
"callsign": "DG2RON",
"points": 5
},
{
"callsign": "DG0TM",
"points": 5
},
{
"callsign": "DO8MKR",
"points": 5
}
]
}
},
"modeGroups": {},
"achievements": [
{
"name": "Unicorn",
"threshold": 10
},
{
"name": "Few Devs",
"threshold": 20
},
{
"name": "More Devs",
"threshold": 40
},
{
"name": "Gold",
"threshold": 50
}
]
}

View File

@@ -0,0 +1,31 @@
{
"id": "vucc6m",
"name": "VUCC 6M",
"description": "Shows confirmed gridsquares on 6M",
"caption": "Shows confirmed gridsquares on 6M",
"category": "vucc",
"rules": {
"type": "entity",
"satellite_only": false,
"filters": {
"operator": "AND",
"filters": [
{
"field": "band",
"operator": "eq",
"value": "6m"
}
]
},
"entityType": "grid",
"countMode": "perStation",
"target": 100,
"allowed_bands": [
"6m"
],
"stations": [],
"displayField": "grid"
},
"modeGroups": {},
"achievements": []
}

View File

@@ -0,0 +1,33 @@
{
"id": "wae",
"name": "WAE",
"description": "Worked All Europe - Contact and confirm European countries from the WAE Country List",
"caption": "Worked All Europe Award. Bandpoints: 1 point per band (2 points for 80m/160m), maximum 5 bands per country. Available in multiple mode variants.",
"category": "darc",
"modeGroups": {
"CW": ["CW"],
"SSB": ["SSB", "AM", "FM"],
"RTTY": ["RTTY"],
"FT8": ["FT8"],
"Digi-Modes": ["FT4", "FT8", "JT65", "JT9", "MFSK", "PSK31", "RTTY"],
"Classic Digi-Modes": ["PSK31", "RTTY"],
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
"Phone-Modes": ["AM", "SSB", "FM"]
},
"rules": {
"type": "wae",
"targetCountries": 40,
"targetBandpoints": 100,
"doublePointBands": ["160m", "80m"],
"maxBandsPerCountry": 5,
"excludeDeletedForTop": true,
"waeCountryList": "wae-country-list.json"
},
"achievements": [
{ "name": "WAE III", "thresholdCountries": 40, "thresholdBandpoints": 100 },
{ "name": "WAE II", "thresholdCountries": 50, "thresholdBandpoints": 150 },
{ "name": "WAE I", "thresholdCountries": 60, "thresholdBandpoints": 200 },
{ "name": "WAE TOP", "thresholdCountries": 70, "thresholdBandpoints": 300, "excludeDeleted": true },
{ "name": "WAE Trophy", "thresholdCountries": 999, "thresholdBandpoints": 365, "requireAllCountries": true }
]
}

View File

@@ -1,12 +1,9 @@
{
"id": "was-mixed",
"name": "WAS Mixed Mode",
"name": "WAS",
"description": "Confirm all 50 US states",
"caption": "Contact and confirm all 50 US states. Only QSOs with stations located in United States states count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "was",
"achievements": [
{ "name": "WAS Award", "threshold": 50 }
],
"rules": {
"type": "entity",
"entityType": "state",
@@ -18,9 +15,21 @@
{
"field": "entityId",
"operator": "in",
"value": [291, 6, 110]
"value": [
291,
6,
110
]
}
]
},
"stations": []
},
"modeGroups": {},
"achievements": [
{
"name": "WAS Award",
"threshold": 50
}
}
]
}

View File

@@ -276,6 +276,9 @@ award/
callsign: text (not null)
lotwUsername: text (nullable)
lotwPassword: text (nullable, encrypted)
isAdmin: boolean (default: false)
isSuperAdmin: boolean (default: false)
lastSeen: timestamp (nullable)
createdAt: timestamp
updatedAt: timestamp
}
@@ -1034,7 +1037,197 @@ When adding new awards or modifying the award system:
---
## Resources
## Admin System
### Overview
The admin system provides user management, role-based access control, and account impersonation capabilities for support and administrative purposes.
### User Roles
The application supports three user roles with increasing permissions:
#### Regular User
- View own QSOs and statistics
- Sync from LoTW and DCL
- Track award progress
- Manage own credentials (LoTW, DCL)
#### Admin
- All user permissions
- View system-wide statistics
- View all users and their activity
- Promote/demote regular users to/from admin role
- Delete regular users
- Impersonate regular users (for support)
- View admin action log
#### Super Admin
- All admin permissions
- Promote/demote admins to/from super-admin role
- Impersonate other admins (for support)
- Cannot be demoted by regular admins
- Protected from accidental lockout
### Security Rules
**Role Change Restrictions:**
- Only super-admins can promote or demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin (prevents lockout)
**Impersonation Restrictions:**
- Regular admins can only impersonate regular users
- Super-admins can impersonate any user (including other admins)
- All impersonation actions are logged to audit trail
- Impersonation tokens expire after 1 hour
### Admin API Endpoints
**Statistics and Monitoring:**
- `GET /api/admin/stats` - System-wide statistics (users, QSOs, jobs)
- `GET /api/admin/users` - List all users with statistics
- `GET /api/admin/users/:userId` - Get detailed user information
- `GET /api/admin/actions` - View admin action log
- `GET /api/admin/actions/my` - View current admin's actions
**User Management:**
- `POST /api/admin/users/:userId/role` - Change user role
- Body: `{ "role": "user" | "admin" | "super-admin" }`
- `DELETE /api/admin/users/:userId` - Delete a user
**Impersonation:**
- `POST /api/admin/impersonate/:userId` - Start impersonating a user
- `POST /api/admin/impersonate/stop` - Stop impersonation
- `GET /api/admin/impersonation/status` - Check impersonation status
### Admin Service
**File:** `src/backend/services/admin.service.js`
**Key Functions:**
```javascript
// Check user permissions
await isAdmin(userId)
await isSuperAdmin(userId)
// Role management
await changeUserRole(adminId, targetUserId, newRole)
// Impersonation
await impersonateUser(adminId, targetUserId)
await verifyImpersonation(impersonationToken)
await stopImpersonation(adminId, targetUserId)
// Audit logging
await logAdminAction(adminId, actionType, targetUserId, details)
```
### Audit Logging
All admin actions are logged to the `admin_actions` table for audit purposes:
**Action Types:**
- `impersonate_start` - Started impersonating a user
- `impersonate_stop` - Stopped impersonation
- `role_change` - Changed user role
- `user_delete` - Deleted a user
**Log Entry Structure:**
```javascript
{
id: integer,
adminId: integer,
actionType: string,
targetUserId: integer (nullable),
details: string (JSON),
createdAt: timestamp
}
```
### Frontend Admin Interface
**Route:** `/admin` (admin only)
**Features:**
- **Overview Tab:** System statistics dashboard
- **Users Tab:** User management with filtering
- **Awards Tab:** Award definition management
- **Action Log Tab:** Audit trail of admin actions
**User Management Actions:**
- **Impersonate** - Switch to user account (disabled for admins unless super-admin)
- **Promote/Demote** - Change user role
- **Delete** - Remove user and all associated data
### JWT Token Claims
Admin tokens include additional claims:
```javascript
{
userId: number,
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean, // New: Super-admin flag
exp: number
}
```
**Impersonation Token:**
```javascript
{
userId: number, // Target user ID
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean,
impersonatedBy: number, // Admin ID who started impersonation
exp: number // 1 hour expiration
}
```
### Setup
**To create the first super-admin:**
1. Register a user account normally
2. Access the database directly:
```bash
sqlite3 src/backend/award.db
```
3. Update the user to super-admin:
```sql
UPDATE users SET is_super_admin = 1 WHERE email = 'your@email.com';
```
4. Log out and log back in to get the updated JWT token
**To promote users via the admin interface:**
1. Log in as a super-admin
2. Navigate to `/admin`
3. Find the user in the Users tab
4. Click "Promote" and select "Super Admin"
### Production Deployment
After pulling the latest code:
```bash
# Apply database migration (adds is_super_admin column)
sqlite3 src/backend/award.db "ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0 NOT NULL;"
# Restart backend
pm2 restart award-backend
# Promote a user to super-admin via database or existing admin interface
```
---
- [ARRL LoTW](https://lotw.arrl.org/)
- [DARC Community Logbook (DCL)](https://dcl.darc.de/)

View File

@@ -1,7 +1,7 @@
import { eq, sql, desc } from 'drizzle-orm';
import { db, sqlite, logger } from '../config.js';
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
import { getUserByIdFull, isAdmin, isSuperAdmin } from './auth.service.js';
import { getUserByIdFull, isAdmin, isSuperAdmin, updateUserRole } from './auth.service.js';
/**
* Log an admin action for audit trail

View File

@@ -84,6 +84,7 @@ function loadAwardDefinitions() {
*/
export function clearAwardCache() {
cachedAwardDefinitions = null;
cachedWAECountryList = null;
logger.info('Award cache cleared');
}
@@ -242,6 +243,11 @@ export async function calculateAwardProgress(userId, award, options = {}) {
return calculatePointsAwardProgress(userId, award, { includeDetails });
}
// Handle WAE-based awards (Worked All Europe)
if (rules.type === 'wae') {
return calculateWAEAwardProgress(userId, award, { includeDetails });
}
// Get all QSOs for user
const allQSOs = await db
.select()
@@ -768,6 +774,503 @@ function matchesFilter(qso, filter) {
}
}
// ============================================================================
// WAE (Worked All Europe) Award Functions
// ============================================================================
// In-memory cache for WAE country list
let cachedWAECountryList = null;
/**
* Load WAE country list from JSON file
*/
function loadWAECountryList() {
if (cachedWAECountryList) {
return cachedWAECountryList;
}
try {
const filePath = join(process.cwd(), 'award-data', 'wae-country-list.json');
const content = readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
// Build lookup maps for efficient matching
const dxccMap = new Map();
const waeSpecificMap = new Map();
const deletedPrefixes = new Set();
// Index DXCC-based countries
if (data.dxccBased) {
for (const entry of data.dxccBased) {
dxccMap.set(entry.entityId, {
country: entry.country,
prefix: entry.prefix,
deleted: entry.deleted || false,
});
}
}
// Index WAE-specific countries with callsign patterns
if (data.waeSpecific) {
for (const entry of data.waeSpecific) {
waeSpecificMap.set(entry.prefix, {
country: entry.country,
prefix: entry.prefix,
callsigns: entry.callsigns || [],
parentDxcc: entry.parentDxcc,
});
}
}
// Index deleted countries
if (data.deletedCountries) {
for (const entry of data.deletedCountries) {
deletedPrefixes.add(entry.prefix);
}
}
cachedWAECountryList = {
dxccMap,
waeSpecificMap,
deletedPrefixes,
rawData: data,
};
logger.debug('WAE country list loaded', {
dxccCount: dxccMap.size,
waeSpecificCount: waeSpecificMap.size,
deletedCount: deletedPrefixes.size,
});
return cachedWAECountryList;
} catch (error) {
logger.error('Failed to load WAE country list', { error: error.message });
return { dxccMap: new Map(), waeSpecificMap: new Map(), deletedPrefixes: new Set(), rawData: null };
}
}
/**
* Match a callsign to WAE country
* Only matches if the country is explicitly in the WAE country list
* @param {string} callsign - The callsign to match
* @param {number} entityId - The DXCC entityId from QSO
* @returns {Object|null} WAE country info or null if not a WAE country
*/
function matchWAECountry(callsign, entityId) {
const waeList = loadWAECountryList();
if (!callsign) return null;
const normalizedCallsign = callsign.toUpperCase().trim();
// First check WAE-specific patterns (these override DXCC)
for (const [prefix, info] of waeList.waeSpecificMap) {
for (const pattern of info.callsigns) {
if (pattern.includes('*')) {
// Wildcard pattern matching
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
if (regex.test(normalizedCallsign)) {
return {
country: info.country,
prefix: info.prefix,
isDeleted: false,
isWAESpecific: true,
};
}
} else {
// Exact match
if (normalizedCallsign === pattern) {
return {
country: info.country,
prefix: info.prefix,
isDeleted: false,
isWAESpecific: true,
};
}
}
}
}
// Only match DXCC entities that are EXPLICITLY in the WAE country list
// Do NOT fall back to matching any DXCC entity - WAE has its own list
if (entityId && waeList.dxccMap.has(entityId)) {
const dxccInfo = waeList.dxccMap.get(entityId);
return {
country: dxccInfo.country,
prefix: dxccInfo.prefix,
isDeleted: dxccInfo.deleted,
isWAESpecific: false,
};
}
// Not a WAE country (includes all non-European entities like US, JA, etc.)
return null;
}
/**
* Get bandpoint value for a band
* @param {string} band - The band name
* @param {Array} doublePointBands - Bands that count double
* @returns {number} Point value for this band
*/
function getBandpointValue(band, doublePointBands = []) {
if (doublePointBands && doublePointBands.includes(band)) {
return 2;
}
return 1;
}
/**
* Sort bands by point value (descending) for max bands per country calculation
* @param {Array} bands - Array of band names
* @param {Array} doublePointBands - Bands that count double
* @returns {Array} Bands sorted by point value
*/
function sortBandsByPointValue(bands, doublePointBands = []) {
return bands.sort((a, b) => {
const pointsA = getBandpointValue(a, doublePointBands);
const pointsB = getBandpointValue(b, doublePointBands);
if (pointsA !== pointsB) {
return pointsB - pointsA; // Higher points first
}
// Tie-breaker: prefer lower frequency (longer wavelength) bands
const bandOrder = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
const indexA = bandOrder.indexOf(a);
const indexB = bandOrder.indexOf(b);
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
return a.localeCompare(b);
});
}
/**
* Calculate progress for WAE awards
* WAE tracks dual metrics: unique countries AND bandpoints
* @param {number} userId - User ID
* @param {Object} award - Award definition
* @param {Object} options - Options
* @param {boolean} options.includeDetails - Include detailed entity breakdown
*/
async function calculateWAEAwardProgress(userId, award, options = {}) {
const { includeDetails = false } = options;
const { rules } = award;
const {
targetCountries = 40,
targetBandpoints = 100,
doublePointBands = ['160m', '80m'],
maxBandsPerCountry = 5,
excludeDeletedForTop = true,
} = rules;
logger.debug('Calculating WAE award progress', {
userId,
awardId: award.id,
targetCountries,
targetBandpoints,
doublePointBands,
maxBandsPerCountry,
excludeDeletedForTop,
});
// Get all QSOs for user
const allQSOs = await db
.select()
.from(qsos)
.where(eq(qsos.userId, userId));
logger.debug('Total QSOs for WAE calculation', { count: allQSOs.length });
// Track per-country data
// Map: country -> { confirmed: boolean, bands: Set, bandpoints: number, qsos: [] }
const countryData = new Map();
// Track all unique countries worked and confirmed
const workedCountries = new Set();
const confirmedCountries = new Set();
for (const qso of allQSOs) {
const waeCountry = matchWAECountry(qso.callsign, qso.entityId);
if (!waeCountry) {
// Not a WAE country, skip
continue;
}
const country = waeCountry.country;
// Track worked countries
workedCountries.add(country);
// Check for LoTW confirmation
if (qso.lotwQslRstatus === 'Y') {
confirmedCountries.add(country);
// Initialize country data if not exists
if (!countryData.has(country)) {
countryData.set(country, {
country,
prefix: waeCountry.prefix,
isDeleted: waeCountry.isDeleted,
isWAESpecific: waeCountry.isWAESpecific,
confirmed: true,
bands: new Set(),
bandpoints: 0,
qsos: [],
});
}
const data = countryData.get(country);
const band = qso.band || 'Unknown';
// Only count this band if we haven't seen it before for this country
if (!data.bands.has(band)) {
data.bands.add(band);
// Calculate bandpoints for this country
// Get all confirmed bands for this country, sort by point value, take top N
const allBands = Array.from(data.bands);
const sortedBands = sortBandsByPointValue(allBands, doublePointBands);
const bandsToCount = sortedBands.slice(0, maxBandsPerCountry);
// Recalculate total bandpoints
let newBandpoints = 0;
for (const b of bandsToCount) {
newBandpoints += getBandpointValue(b, doublePointBands);
}
data.bandpoints = newBandpoints;
}
// Add QSO to the qsos array for drill-down
data.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,
});
}
}
// Calculate total bandpoints across all countries
let totalBandpoints = 0;
for (const data of countryData.values()) {
totalBandpoints += data.bandpoints;
}
// For WAE TOP/Trophy, we may need to exclude deleted countries
let displayConfirmedCount = confirmedCountries.size;
let displayWorkedCount = workedCountries.size;
let displayTotalBandpoints = totalBandpoints;
// Check if this is for WAE TOP or Trophy (which exclude deleted countries)
if (excludeDeletedForTop) {
let confirmedWithoutDeleted = 0;
for (const country of confirmedCountries) {
const data = countryData.get(country);
if (data && !data.isDeleted) {
confirmedWithoutDeleted++;
}
}
// Store both counts
displayConfirmedCount = confirmedWithoutDeleted;
}
logger.debug('WAE award progress calculated', {
workedCountries: displayWorkedCount,
confirmedCountries: displayConfirmedCount,
totalBandpoints: displayTotalBandpoints,
targetCountries,
targetBandpoints,
});
// Build result
const result = {
worked: displayWorkedCount,
confirmed: displayConfirmedCount,
bandpoints: displayTotalBandpoints,
workedBandpoints: displayTotalBandpoints, // For consistency with worked/confirmed naming
targetCountries,
targetBandpoints,
percentage: targetCountries ? Math.round((displayConfirmedCount / targetCountries) * 100) : 0,
bandpointsPercentage: targetBandpoints ? Math.min(100, Math.round((displayTotalBandpoints / targetBandpoints) * 100)) : 0,
workedEntities: Array.from(workedCountries),
confirmedEntities: Array.from(confirmedCountries),
};
// Add details if requested
if (includeDetails) {
result.award = {
id: award.id,
name: award.name,
description: award.description,
caption: award.caption,
targetCountries,
targetBandpoints,
};
// Build entities array for detail view
// For WAE, we need to expand countries into (country, band, mode) slots
// to match the frontend's expected (entity, band, mode) structure
const expandedEntities = [];
for (const [countryName, data] of countryData) {
const { bands, bandpoints, qsos, prefix, isDeleted, isWAESpecific } = data;
// For each band, create a slot entry
for (const band of bands) {
// Get all modes used on this band for this country
const modesInBand = new Set();
for (const qso of qsos) {
if (qso.band === band) {
modesInBand.add(qso.mode || 'Unknown');
}
}
// Create a slot for each mode on this band
for (const mode of modesInBand) {
// Get QSOs for this specific (country, band, mode) combination
const slotQSOs = qsos.filter(q => q.band === band && q.mode === mode);
expandedEntities.push({
entity: countryName,
entityId: null,
entityName: countryName,
prefix,
isDeleted,
isWAESpecific,
band,
mode,
confirmed: true,
bandpoints: getBandpointValue(band, award.rules.doublePointBands),
worked: true,
qsos: slotQSOs,
});
}
}
// If no bands (shouldn't happen for confirmed countries, but handle edge case)
if (bands.length === 0 && confirmedCountries.has(countryName)) {
expandedEntities.push({
entity: countryName,
entityId: null,
entityName: countryName,
prefix,
isDeleted,
isWAESpecific,
band: 'Unknown',
mode: 'Unknown',
confirmed: true,
bandpoints: 0,
worked: true,
qsos: [],
});
}
}
result.entities = expandedEntities;
result.total = expandedEntities.length;
result.confirmed = expandedEntities.filter((e) => e.confirmed).length;
}
// Add achievement progress if award has achievements defined
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateWAEAchievementProgress(
displayConfirmedCount,
displayTotalBandpoints,
award.achievements,
excludeDeletedForTop
);
}
return result;
}
/**
* Calculate achievement progress for WAE awards (dual thresholds)
* @param {number} confirmedCountries - Number of confirmed countries
* @param {number} totalBandpoints - Total bandpoints earned
* @param {Array} achievements - Array of achievement definitions
* @param {boolean} excludeDeletedForTop - Whether deleted countries are excluded
* @returns {Object} Achievement progress info
*/
function calculateWAEAchievementProgress(confirmedCountries, totalBandpoints, achievements, excludeDeletedForTop) {
if (!achievements || achievements.length === 0) {
return null;
}
// Sort achievements by thresholdCountries
const sorted = [...achievements].sort((a, b) => a.thresholdCountries - b.thresholdCountries);
// Find earned achievements, current level, and next level
const earned = [];
let currentLevel = null;
let nextLevel = null;
for (let i = 0; i < sorted.length; i++) {
const achievement = sorted[i];
// Check if achievement criteria are met
// For achievements with excludeDeleted flag, we need both thresholds met
// Otherwise, just check country and bandpoint thresholds
const countriesMet = confirmedCountries >= achievement.thresholdCountries;
const bandpointsMet = totalBandpoints >= achievement.thresholdBandpoints;
// Special handling for "requireAllCountries" (WAE Trophy)
let allCountriesMet = false;
if (achievement.requireAllCountries) {
const waeList = loadWAECountryList();
const totalCountries = waeList.dxccMap.size + waeList.waeSpecificMap.size;
allCountriesMet = confirmedCountries >= totalCountries;
}
const criteriaMet = achievement.requireAllCountries
? (allCountriesMet && bandpointsMet)
: (countriesMet && bandpointsMet);
if (criteriaMet) {
earned.push(achievement);
currentLevel = achievement;
} else {
nextLevel = achievement;
break;
}
}
// Calculate progress toward next level
let progressPercent = 100;
let progressCurrent = confirmedCountries;
let progressNeeded = 0;
let progressBandpointsCurrent = totalBandpoints;
let progressBandpointsNeeded = 0;
if (nextLevel) {
const prevThreshold = currentLevel ? currentLevel.thresholdCountries : 0;
const range = nextLevel.thresholdCountries - prevThreshold;
const progressInLevel = confirmedCountries - prevThreshold;
progressPercent = Math.round((progressInLevel / range) * 100);
progressNeeded = nextLevel.thresholdCountries - confirmedCountries;
progressBandpointsNeeded = nextLevel.thresholdBandpoints - totalBandpoints;
}
return {
earned,
currentLevel,
nextLevel,
progressPercent,
progressCurrent: confirmedCountries,
progressNeeded,
progressBandpointsCurrent: totalBandpoints,
progressBandpointsNeeded,
totalAchievements: sorted.length,
earnedCount: earned.length,
};
}
/**
* Get award progress with QSO details
*/
@@ -851,6 +1354,11 @@ export async function getAwardEntityBreakdown(userId, awardId) {
return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
}
// Handle WAE-based awards - use the dedicated function
if (rules.type === 'wae') {
return await calculateWAEAwardProgress(userId, award, { includeDetails: true });
}
// Get all QSOs for user
const allQSOs = await db
.select()
@@ -896,7 +1404,19 @@ export async function getAwardEntityBreakdown(userId, awardId) {
}
displayName = String(rawValue || entity);
} else {
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
// Smart default based on entityType when displayField is not specified
const defaultDisplayField = {
'dxcc': 'entity',
'state': 'state',
'grid': 'grid',
'callsign': 'callsign'
}[rules.entityType] || 'entity';
let rawValue = qso[defaultDisplayField];
if (defaultDisplayField === 'grid' && rawValue && rawValue.length > 4) {
rawValue = rawValue.substring(0, 4);
}
displayName = String(rawValue || entity);
}
if (!slotMap.has(slotKey)) {

View File

@@ -552,7 +552,7 @@ export async function getQSOStats(userId) {
}).from(qsos).where(eq(qsos.userId, userId)),
db.select({
uniqueEntities: sql`CAST(COUNT(DISTINCT entity) AS INTEGER)`,
uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`,
uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)`
}).from(qsos).where(eq(qsos.userId, userId))

View File

@@ -137,7 +137,7 @@
}
.nav-container {
max-width: 1200px;
max-width: 1600px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
@@ -199,7 +199,7 @@
main {
flex: 1;
padding: 2rem 1rem;
max-width: 1200px;
max-width: 1600px;
width: 100%;
margin: 0 auto;
}
@@ -226,7 +226,7 @@
}
.impersonation-content {
max-width: 1200px;
max-width: 1600px;
margin: 0 auto;
display: flex;
align-items: center;

View File

@@ -561,7 +561,7 @@
<style>
.admin-dashboard {
padding: 2rem;
max-width: 1400px;
max-width: 1600px;
margin: 0 auto;
}

View File

@@ -60,7 +60,8 @@
'dok': 'DOK',
'points': 'Points',
'filtered': 'Filtered',
'counter': 'Counter'
'counter': 'Counter',
'wae': 'WAE'
};
return names[ruleType] || ruleType;
}
@@ -168,7 +169,7 @@
<style>
.awards-admin {
padding: 2rem;
max-width: 1400px;
max-width: 1600px;
margin: 0 auto;
}

View File

@@ -121,6 +121,9 @@
case 'counter':
validateCounterRule(errors, warnings);
break;
case 'wae':
validateWAERule(errors, warnings);
break;
}
// Mode groups validation
@@ -236,6 +239,29 @@
}
}
function validateWAERule(errors, warnings) {
if (!formData.rules.targetCountries || formData.rules.targetCountries <= 0) {
errors.push('WAE rule requires targetCountries (positive number)');
}
if (!formData.rules.targetBandpoints || formData.rules.targetBandpoints <= 0) {
errors.push('WAE rule requires targetBandpoints (positive number)');
}
if (!formData.rules.maxBandsPerCountry || formData.rules.maxBandsPerCountry <= 0) {
warnings.push('WAE rule should have maxBandsPerCountry (default: 5)');
}
// Check that double-point bands are valid
if (formData.rules.doublePointBands && Array.isArray(formData.rules.doublePointBands)) {
const validDoublePointBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
const invalid = formData.rules.doublePointBands.filter(b => !validDoublePointBands.includes(b));
if (invalid.length > 0) {
warnings.push(`Unusual double-point bands: ${invalid.join(', ')}`);
}
}
}
function validateModeGroups(errors, warnings) {
if (!formData.modeGroups) return;
@@ -774,6 +800,7 @@
<option value="points">Points (sum points from stations)</option>
<option value="filtered">Filtered (base rule with filters)</option>
<option value="counter">Counter (count QSOs or callsigns)</option>
<option value="wae">WAE (Worked All Europe)</option>
</select>
</div>
@@ -804,7 +831,7 @@
bind:value={formData.rules.displayField}
placeholder="e.g., entity, state, grid"
/>
<small>Field to display as entity name (defaults to entity value)</small>
<small>Field to display as entity name (defaults based on entityType)</small>
</div>
<div class="form-group">
@@ -995,6 +1022,93 @@
/>
</div>
</div>
{:else if formData.rules.type === 'wae'}
<div class="rule-config">
<h3>WAE Rule Configuration</h3>
<div class="form-group">
<label>Target Countries *</label>
<input
type="number"
min="1"
bind:value={formData.rules.targetCountries}
placeholder="40"
/>
<small>Number of unique WAE countries required (e.g., 40 for WAE III)</small>
</div>
<div class="form-group">
<label>Target Bandpoints *</label>
<input
type="number"
min="1"
bind:value={formData.rules.targetBandpoints}
placeholder="100"
/>
<small>Total bandpoints required (1 per band, 2 for 80m/160m)</small>
</div>
<div class="form-group">
<label>Double-Point Bands</label>
<div class="bands-selector">
{#each ['160m', '80m'] as band}
<label class="band-checkbox">
<input
type="checkbox"
checked={formData.rules.doublePointBands?.includes(band)}
on:change={(e) => {
if (!formData.rules.doublePointBands) {
formData = {
...formData,
rules: {
...formData.rules,
doublePointBands: []
}
};
}
if (e.target.checked) {
formData.rules.doublePointBands = [...formData.rules.doublePointBands, band];
} else {
formData.rules.doublePointBands = formData.rules.doublePointBands.filter(b => b !== band);
}
}}
/>
{band}
</label>
{/each}
</div>
<small>Bands that count double points (typically 160m and 80m)</small>
</div>
<div class="form-group">
<label>Maximum Bands Per Country *</label>
<input
type="number"
min="1"
max="10"
bind:value={formData.rules.maxBandsPerCountry}
placeholder="5"
/>
<small>Only top N bands count per country (default: 5)</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
bind:checked={formData.rules.excludeDeletedForTop}
/>
Exclude deleted countries for WAE TOP/Trophy
</label>
<small>WAE TOP and Trophy exclude deleted countries from the count</small>
</div>
<div class="info-box">
<strong>WAE Award Info:</strong> The WAE award tracks dual metrics - unique countries
AND bandpoints. Each confirmed country counts 1 bandpoint per band (2 points for 80m/160m),
with a maximum of 5 bands per country.
</div>
</div>
{/if}
<!-- Filters section (common to all rule types) -->
@@ -1023,7 +1137,7 @@
<style>
.award-editor {
padding: 2rem;
max-width: 1200px;
max-width: 1600px;
margin: 0 auto;
}

View File

@@ -197,15 +197,21 @@
}
// Check if displayField matches the default for the entity type
if (rules.entityType && rules.displayField) {
if (rules.entityType) {
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.`);
const defaultField = defaults[rules.entityType];
if (rules.displayField) {
if (defaultField === rules.displayField) {
info.push(`displayField="${rules.displayField}" is the default for entityType="${rules.entityType}". It can be omitted.`);
}
} else if (defaultField) {
info.push(`displayField will default to "${defaultField}" for entityType="${rules.entityType}".`);
}
}

View File

@@ -42,6 +42,21 @@
stationsStore.set(formData.rules.stations.map(s => ({...s})));
}
// Initialize WAE-specific fields when rule type changes to 'wae' (only for new awards, not editing)
$: if (formData.rules?.type === 'wae' && formData.rules.targetCountries === undefined && !isEdit) {
formData = {
...formData,
rules: {
...formData.rules,
targetCountries: 40,
targetBandpoints: 100,
doublePointBands: ['160m', '80m'],
maxBandsPerCountry: 5,
excludeDeletedForTop: true,
}
};
}
// Available bands and modes
const ALL_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
const ALL_MODES = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
@@ -57,12 +72,11 @@
onMount(() => {
// Check if we're editing an existing award
const pathParts = window.location.pathname.split('/');
if (pathParts.includes('[id]') || pathParts.length > 5) {
// Extract award ID from path
const idPart = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
if (idPart && idPart !== 'create') {
loadAward(idPart);
}
const lastPart = pathParts[pathParts.length - 1];
// If the last part is not 'create' and looks like an award ID, load the award
if (lastPart && lastPart !== 'create' && lastPart !== 'awards') {
loadAward(lastPart);
}
});
@@ -128,6 +142,9 @@
case 'counter':
validateCounterRule(errors, warnings);
break;
case 'wae':
validateWAERule(errors, warnings);
break;
}
// Mode groups validation
@@ -243,6 +260,29 @@
}
}
function validateWAERule(errors, warnings) {
if (!formData.rules.targetCountries || formData.rules.targetCountries <= 0) {
errors.push('WAE rule requires targetCountries (positive number)');
}
if (!formData.rules.targetBandpoints || formData.rules.targetBandpoints <= 0) {
errors.push('WAE rule requires targetBandpoints (positive number)');
}
if (!formData.rules.maxBandsPerCountry || formData.rules.maxBandsPerCountry <= 0) {
warnings.push('WAE rule should have maxBandsPerCountry (default: 5)');
}
// Check that double-point bands are valid
if (formData.rules.doublePointBands && Array.isArray(formData.rules.doublePointBands)) {
const validDoublePointBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
const invalid = formData.rules.doublePointBands.filter(b => !validDoublePointBands.includes(b));
if (invalid.length > 0) {
warnings.push(`Unusual double-point bands: ${invalid.join(', ')}`);
}
}
}
function validateModeGroups(errors, warnings) {
if (!formData.modeGroups) return;
@@ -799,6 +839,7 @@
<option value="points">Points (sum points from stations)</option>
<option value="filtered">Filtered (base rule with filters)</option>
<option value="counter">Counter (count QSOs or callsigns)</option>
<option value="wae">WAE (Worked All Europe)</option>
</select>
</div>
@@ -829,7 +870,7 @@
bind:value={formData.rules.displayField}
placeholder="e.g., entity, state, grid"
/>
<small>Field to display as entity name (defaults to entity value)</small>
<small>Field to display as entity name (defaults based on entityType)</small>
</div>
<div class="form-group">
@@ -1021,6 +1062,93 @@
/>
</div>
</div>
{:else if formData.rules.type === 'wae'}
<div class="rule-config">
<h3>WAE Rule Configuration</h3>
<div class="form-group">
<label>Target Countries *</label>
<input
type="number"
min="1"
bind:value={formData.rules.targetCountries}
placeholder="40"
/>
<small>Number of unique WAE countries required (e.g., 40 for WAE III)</small>
</div>
<div class="form-group">
<label>Target Bandpoints *</label>
<input
type="number"
min="1"
bind:value={formData.rules.targetBandpoints}
placeholder="100"
/>
<small>Total bandpoints required (1 per band, 2 for 80m/160m)</small>
</div>
<div class="form-group">
<label>Double-Point Bands</label>
<div class="bands-selector">
{#each ['160m', '80m'] as band}
<label class="band-checkbox">
<input
type="checkbox"
checked={formData.rules.doublePointBands?.includes(band)}
on:change={(e) => {
if (!formData.rules.doublePointBands) {
formData = {
...formData,
rules: {
...formData.rules,
doublePointBands: []
}
};
}
if (e.target.checked) {
formData.rules.doublePointBands = [...formData.rules.doublePointBands, band];
} else {
formData.rules.doublePointBands = formData.rules.doublePointBands.filter(b => b !== band);
}
}}
/>
{band}
</label>
{/each}
</div>
<small>Bands that count double points (typically 160m and 80m)</small>
</div>
<div class="form-group">
<label>Maximum Bands Per Country *</label>
<input
type="number"
min="1"
max="10"
bind:value={formData.rules.maxBandsPerCountry}
placeholder="5"
/>
<small>Only top N bands count per country (default: 5)</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
bind:checked={formData.rules.excludeDeletedForTop}
/>
Exclude deleted countries for WAE TOP/Trophy
</label>
<small>WAE TOP and Trophy exclude deleted countries from the count</small>
</div>
<div class="info-box">
<strong>WAE Award Info:</strong> The WAE award tracks dual metrics - unique countries
AND bandpoints. Each confirmed country counts 1 bandpoint per band (2 points for 80m/160m),
with a maximum of 5 bands per country.
</div>
</div>
{/if}
<!-- Filters section (common to all rule types) -->
@@ -1049,7 +1177,7 @@
<style>
.award-editor {
padding: 2rem;
max-width: 1200px;
max-width: 1600px;
margin: 0 auto;
}

View File

@@ -173,7 +173,7 @@
<style>
.container {
max-width: 1200px;
max-width: 1600px;
margin: 0 auto;
padding: 2rem;
}

View File

@@ -539,6 +539,11 @@
return null;
}
// Handle WAE awards with dual thresholds
if (award.rules?.type === 'wae') {
return calculateWAEAchievementProgress(award, entities);
}
// Get current count (confirmed entities or points)
let currentCount;
if (entities.length > 0 && entities[0].points !== undefined) {
@@ -598,6 +603,85 @@
earnedCount: earned.length,
};
}
function calculateWAEAchievementProgress(award, entities) {
// Count unique confirmed countries and total bandpoints
const uniqueCountries = new Set();
let totalBandpoints = 0;
entities.forEach(e => {
if (e.confirmed && !e.isDeleted) {
uniqueCountries.add(e.entityName || e.entity || 'Unknown');
}
totalBandpoints += e.bandpoints || 0;
});
const confirmedCountries = uniqueCountries.size;
// Sort achievements by thresholdCountries
const sorted = [...award.achievements].sort((a, b) => a.thresholdCountries - b.thresholdCountries);
// Find earned achievements, current level, and next level
const earned = [];
let currentLevel = null;
let nextLevel = null;
for (let i = 0; i < sorted.length; i++) {
const achievement = sorted[i];
// Check if both thresholds are met
const countriesMet = confirmedCountries >= achievement.thresholdCountries;
const bandpointsMet = totalBandpoints >= achievement.thresholdBandpoints;
// Special handling for "requireAllCountries" (WAE Trophy)
let allCountriesMet = false;
if (achievement.requireAllCountries) {
// This would require knowing total WAE countries - for now use a large number
allCountriesMet = confirmedCountries >= 75; // Approximate WAE country count
}
const criteriaMet = achievement.requireAllCountries
? (allCountriesMet && bandpointsMet)
: (countriesMet && bandpointsMet);
if (criteriaMet) {
earned.push(achievement);
currentLevel = achievement;
} else {
nextLevel = achievement;
break;
}
}
// Calculate progress toward next level
let progressPercent = 100;
let progressCurrent = confirmedCountries;
let progressNeeded = 0;
let progressBandpointsCurrent = totalBandpoints;
let progressBandpointsNeeded = 0;
if (nextLevel) {
const prevThreshold = currentLevel ? currentLevel.thresholdCountries : 0;
const range = nextLevel.thresholdCountries - prevThreshold;
const progressInLevel = confirmedCountries - prevThreshold;
progressPercent = Math.round((progressInLevel / range) * 100);
progressNeeded = nextLevel.thresholdCountries - confirmedCountries;
progressBandpointsNeeded = nextLevel.thresholdBandpoints - totalBandpoints;
}
return {
earned,
currentLevel,
nextLevel,
progressPercent,
progressCurrent,
progressNeeded,
progressBandpointsCurrent,
progressBandpointsNeeded,
totalAchievements: sorted.length,
earnedCount: earned.length,
};
}
</script>
<div class="container">
@@ -619,7 +703,34 @@
</div>
<div class="summary">
{#if entities.length > 0 && entities[0].points !== undefined}
{#if award?.rules?.type === 'wae'}
{@const targetCountries = award.rules?.targetCountries}
{@const targetBandpoints = award.rules?.targetBandpoints}
{@const confirmedCountries = uniqueEntityProgress.confirmed}
{@const workedCountries = uniqueEntityProgress.worked}
{@const neededCountries = targetCountries ? Math.max(0, targetCountries - confirmedCountries) : null}
{@const totalBandpoints = filteredEntities.reduce((sum, e) => sum + (e.bandpoints || 0), 0)}
{@const neededBandpoints = targetBandpoints ? Math.max(0, targetBandpoints - totalBandpoints) : null}
<div class="summary-card wae-countries">
<span class="summary-label">Countries:</span>
<span class="summary-value">{confirmedCountries} / {targetCountries}</span>
</div>
<div class="summary-card wae-bandpoints">
<span class="summary-label">Bandpoints:</span>
<span class="summary-value">{totalBandpoints} / {targetBandpoints}</span>
</div>
{#if neededCountries !== null}
<div class="summary-card unworked">
<span class="summary-label">Need:</span>
<span class="summary-value">{neededCountries} countries</span>
</div>
<div class="summary-card unworked">
<span class="summary-label">Need:</span>
<span class="summary-value">{neededBandpoints} bandpoints</span>
</div>
{/if}
{:else if entities.length > 0 && entities[0].points !== undefined}
{@const earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
{@const targetPoints = award.rules?.target}
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
@@ -631,7 +742,7 @@
<span class="summary-label">Confirmed:</span>
<span class="summary-value">{filteredEntities.filter((e) => e.confirmed).length}</span>
</div>
<div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;">
<div class="summary-card points">
<span class="summary-label">Points:</span>
<span class="summary-value">{earnedPoints}</span>
</div>
@@ -640,7 +751,7 @@
<span class="summary-label">Needed:</span>
<span class="summary-value">{neededPoints}</span>
</div>
<div class="summary-card" style="background-color: #e3f2fd; border-color: #2196f3;">
<div class="summary-card target">
<span class="summary-label">Target:</span>
<span class="summary-value">{targetPoints}</span>
</div>
@@ -665,7 +776,7 @@
<span class="summary-label">Needed:</span>
<span class="summary-value">{neededCount}</span>
</div>
<div class="summary-card" style="background-color: #e3f2fd; border-color: #2196f3;">
<div class="summary-card target">
<span class="summary-label">Target:</span>
<span class="summary-value">{targetCount}</span>
</div>
@@ -685,7 +796,11 @@
<div class="achievement-badge earned">
<span class="achievement-icon"></span>
<span class="achievement-name">{achievement.name}</span>
<span class="achievement-threshold">{achievement.threshold}</span>
{#if achievement.thresholdCountries !== undefined}
<span class="achievement-threshold">{achievement.thresholdCountries} countries / {achievement.thresholdBandpoints} bandpoints</span>
{:else}
<span class="achievement-threshold">{achievement.threshold}</span>
{/if}
</div>
{/each}
</div>
@@ -696,9 +811,15 @@
<div class="next-achievement">
<div class="next-achievement-header">
<span class="next-achievement-title">Next: {achievementProgress.nextLevel.name}</span>
<span class="next-achievement-count">
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
</span>
{#if achievementProgress.nextLevel.thresholdCountries !== undefined}
<span class="next-achievement-count">
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.thresholdCountries} countries
</span>
{:else}
<span class="next-achievement-count">
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
</span>
{/if}
</div>
<div class="achievement-progress-bar">
<div
@@ -707,7 +828,13 @@
></div>
</div>
<div class="next-achievement-footer">
<span class="needed-text">{achievementProgress.progressNeeded} more to {achievementProgress.nextLevel.name}</span>
{#if achievementProgress.progressBandpointsNeeded !== undefined}
<span class="needed-text">
{achievementProgress.progressNeeded} countries and {achievementProgress.progressBandpointsNeeded} bandpoints more to {achievementProgress.nextLevel.name}
</span>
{:else}
<span class="needed-text">{achievementProgress.progressNeeded} more to {achievementProgress.nextLevel.name}</span>
{/if}
</div>
</div>
{:else}
@@ -1022,7 +1149,7 @@
<style>
.container {
max-width: 1200px;
max-width: 1600px;
margin: 0 auto;
padding: 2rem;
}
@@ -1141,6 +1268,38 @@
background-color: var(--color-warning-bg);
}
.summary-card.points {
border-color: #ff9800;
background-color: var(--bg-secondary);
}
:global(.dark) .summary-card.points {
background-color: rgba(255, 152, 0, 0.15);
}
.summary-card.target {
border-color: var(--color-primary);
background-color: var(--bg-secondary);
}
:global(.dark) .summary-card.target {
background-color: rgba(33, 150, 243, 0.15);
}
.summary-card.wae-countries {
border-color: var(--color-primary);
background-color: var(--color-primary-light);
}
.summary-card.wae-bandpoints {
border-color: #9c27b0;
background-color: rgba(156, 39, 176, 0.1);
}
:global(.dark) .summary-card.wae-bandpoints {
background-color: rgba(156, 39, 176, 0.2);
}
.summary-label {
display: block;
font-size: 0.875rem;

View File

@@ -877,7 +877,7 @@
<style>
.container {
max-width: 1200px;
max-width: 1600px;
margin: 0 auto;
padding: 2rem 1rem;
}

View File

@@ -15,6 +15,7 @@
disabled={isRunning || deleting}
>
{#if isRunning}
<span class="spinner"></span>
{label} Syncing...
{:else}
Sync from {label}
@@ -37,4 +38,22 @@
.dcl-btn:hover:not(:disabled) {
background-color: #d35400;
}
/* Spinner animation */
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -440,40 +440,46 @@
.header h1 {
margin: 0;
color: #333;
color: var(--text-primary);
}
.back-button {
padding: 0.5rem 1rem;
background-color: #6c757d;
color: white;
background-color: var(--color-secondary);
color: var(--text-inverted);
text-decoration: none;
border-radius: 4px;
border-radius: var(--border-radius);
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.back-button:hover {
background-color: #5a6268;
background-color: var(--color-secondary-hover);
}
.user-info {
background: #f8f9fa;
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 8px;
border-radius: var(--border-radius-lg);
margin-bottom: 2rem;
}
.user-info h2 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
color: #333;
color: var(--text-primary);
}
.user-info p {
margin: 0.5rem 0;
color: #666;
color: var(--text-secondary);
}
.user-info :global(strong),
.settings-form :global(strong),
.next-sync-info :global(strong) {
color: var(--text-primary);
}
.settings-section {
@@ -483,44 +489,44 @@
.settings-section h2 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
color: #333;
color: var(--text-primary);
}
.help-text {
color: #666;
color: var(--text-secondary);
margin-bottom: 1.5rem;
line-height: 1.6;
}
.alert {
padding: 1rem;
border-radius: 4px;
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
}
.alert-info {
background-color: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
background-color: var(--color-info-bg);
border: 1px solid var(--color-info);
color: var(--color-info-text);
}
.alert-error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
background-color: var(--color-error-bg);
border: 1px solid var(--color-error);
color: var(--color-error-text);
}
.alert-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
background-color: var(--color-success-bg);
border: 1px solid var(--color-success);
color: var(--color-success);
}
.settings-form {
background: white;
background: var(--bg-card);
padding: 2rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
margin-bottom: 2rem;
}
@@ -532,34 +538,36 @@
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
color: var(--text-primary);
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 1rem;
background: var(--bg-input);
color: var(--text-primary);
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.hint {
font-size: 0.875rem;
color: #666;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
border-radius: var(--border-radius);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
@@ -567,12 +575,12 @@
}
.btn-primary {
background-color: #4a90e2;
color: white;
background-color: var(--color-primary);
color: var(--text-inverted);
}
.btn-primary:hover:not(:disabled) {
background-color: #357abd;
background-color: var(--color-primary-hover);
}
.btn-primary:disabled {
@@ -581,34 +589,34 @@
}
.btn-secondary {
background-color: #6c757d;
color: white;
background-color: var(--color-secondary);
color: var(--text-inverted);
}
.btn-secondary:hover {
background-color: #5a6268;
background-color: var(--color-secondary-hover);
}
.info-box {
background: #e8f4fd;
border-left: 4px solid #4a90e2;
background: var(--color-info-bg);
border-left: 4px solid var(--color-primary);
padding: 1.5rem;
border-radius: 4px;
border-radius: var(--border-radius);
}
.info-box h3 {
margin: 0 0 1rem 0;
color: #333;
color: var(--text-primary);
}
.info-box p {
margin: 0.5rem 0;
color: #666;
color: var(--text-secondary);
line-height: 1.6;
}
.info-box a {
color: #4a90e2;
color: var(--text-link);
text-decoration: none;
}
@@ -634,6 +642,7 @@
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
color: var(--text-primary);
}
.checkbox-group input[type="checkbox"] {
@@ -649,18 +658,18 @@
.divider {
border: none;
border-top: 1px solid #e0e0e0;
border-top: 1px solid var(--border-color);
margin: 2rem 0;
}
.next-sync-info {
padding: 0.75rem 1rem;
background-color: #e3f2fd;
border-left: 4px solid #4a90e2;
border-radius: 4px;
background-color: var(--color-info-bg);
border-left: 4px solid var(--color-primary);
border-radius: var(--border-radius);
margin-top: 1rem;
font-size: 0.9rem;
color: #333;
color: var(--text-primary);
}
@media (max-width: 640px) {

View File

@@ -2,4 +2,5 @@
# Production start script
# Run backend server (Elysia errors are harmless warnings that don't affect functionality)
export LOG_LEVEL=debug
exec bun src/backend/index.js