Compare commits
13 Commits
239963ed89
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
b296514356
|
|||
|
70858836d0
|
|||
|
257ebf6c5d
|
|||
|
caf7703073
|
|||
|
fa6420d149
|
|||
|
aa55158347
|
|||
|
|
e4e7f3c208 | ||
|
a35731f626
|
|||
|
2ae47232cb
|
|||
|
8b846bffbe
|
|||
|
ed433902d9
|
|||
|
a5f0e3b96f
|
|||
|
b09e2b3ea2
|
73
CLAUDE.md
73
CLAUDE.md
@@ -553,3 +553,76 @@ const params = new URLSearchParams({
|
|||||||
**QSO Management**:
|
**QSO Management**:
|
||||||
- Fixed DELETE /api/qsos/all to handle foreign key constraints
|
- Fixed DELETE /api/qsos/all to handle foreign key constraints
|
||||||
- Added cache invalidation after QSO deletion
|
- 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"
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -277,6 +277,52 @@ The application will be available at:
|
|||||||
### Health
|
### Health
|
||||||
- `GET /api/health` - Health check endpoint
|
- `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
|
## Database Schema
|
||||||
|
|
||||||
### Users Table
|
### Users Table
|
||||||
@@ -289,6 +335,9 @@ CREATE TABLE users (
|
|||||||
lotwUsername TEXT,
|
lotwUsername TEXT,
|
||||||
lotwPassword TEXT,
|
lotwPassword TEXT,
|
||||||
dclApiKey TEXT, -- DCL API key (for future use)
|
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,
|
createdAt TEXT NOT NULL,
|
||||||
updatedAt TEXT NOT NULL
|
updatedAt TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|||||||
115
award-data/wae-country-list.json
Normal file
115
award-data/wae-country-list.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,16 +4,59 @@
|
|||||||
"description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes",
|
"description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes",
|
||||||
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on different band/mode combinations. Each unique DOK on a unique band/mode counts as one point. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
|
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on different band/mode combinations. Each unique DOK on a unique band/mode counts as one point. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
|
||||||
"category": "darc",
|
"category": "darc",
|
||||||
"modeGroups": {
|
|
||||||
"Digi-Modes": ["FT8", "FT4", "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": {
|
"rules": {
|
||||||
"type": "dok",
|
"type": "dok",
|
||||||
"target": 100,
|
"target": 100,
|
||||||
"confirmationType": "dcl",
|
"confirmationType": "dcl",
|
||||||
"displayField": "darcDok"
|
"displayField": "darcDok",
|
||||||
|
"stations": []
|
||||||
|
},
|
||||||
|
"modeGroups": {
|
||||||
|
"Digi-Modes": [
|
||||||
|
"FT4",
|
||||||
|
"FT8",
|
||||||
|
"MFSK",
|
||||||
|
"PSK31",
|
||||||
|
"RTTY"
|
||||||
|
],
|
||||||
|
"Classic Digi-Modes": [
|
||||||
|
"PSK31",
|
||||||
|
"RTTY"
|
||||||
|
],
|
||||||
|
"Mixed-Mode w/o WSJT-Modes": [
|
||||||
|
"AM",
|
||||||
|
"CW",
|
||||||
|
"FM",
|
||||||
|
"PSK31",
|
||||||
|
"RTTY",
|
||||||
|
"SSB"
|
||||||
|
],
|
||||||
|
"Phone-Modes": [
|
||||||
|
"AM",
|
||||||
|
"FM",
|
||||||
|
"SSB"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "DLD50",
|
||||||
|
"threshold": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLD100",
|
||||||
|
"threshold": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLD200",
|
||||||
|
"threshold": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLD500",
|
||||||
|
"threshold": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLD1000",
|
||||||
|
"threshold": 1000
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -4,17 +4,71 @@
|
|||||||
"description": "Confirm 100 DXCC entities on HF bands",
|
"description": "Confirm 100 DXCC entities on HF bands",
|
||||||
"caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m). Only HF band QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
|
"caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m). Only HF band QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
|
||||||
"category": "dxcc",
|
"category": "dxcc",
|
||||||
"modeGroups": {
|
|
||||||
"Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"],
|
|
||||||
"Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"],
|
|
||||||
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
|
|
||||||
"Phone-Modes": ["AM", "SSB", "FM"]
|
|
||||||
},
|
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "entity",
|
"type": "entity",
|
||||||
"entityType": "dxcc",
|
"entityType": "dxcc",
|
||||||
"target": 100,
|
"target": 100,
|
||||||
"displayField": "entity",
|
"displayField": "entity",
|
||||||
"allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
|
"allowed_bands": [
|
||||||
|
"160m",
|
||||||
|
"80m",
|
||||||
|
"60m",
|
||||||
|
"40m",
|
||||||
|
"30m",
|
||||||
|
"20m",
|
||||||
|
"17m",
|
||||||
|
"15m",
|
||||||
|
"12m",
|
||||||
|
"10m"
|
||||||
|
],
|
||||||
|
"stations": []
|
||||||
|
},
|
||||||
|
"modeGroups": {
|
||||||
|
"Digi-Modes": [
|
||||||
|
"FT4",
|
||||||
|
"FT8",
|
||||||
|
"JT65",
|
||||||
|
"JT9",
|
||||||
|
"MFSK",
|
||||||
|
"PSK31",
|
||||||
|
"RTTY"
|
||||||
|
],
|
||||||
|
"Classic Digi-Modes": [
|
||||||
|
"JT65",
|
||||||
|
"JT9",
|
||||||
|
"PSK31",
|
||||||
|
"RTTY"
|
||||||
|
],
|
||||||
|
"Mixed-Mode w/o WSJT-Modes": [
|
||||||
|
"AM",
|
||||||
|
"CW",
|
||||||
|
"FM",
|
||||||
|
"PSK31",
|
||||||
|
"RTTY",
|
||||||
|
"SSB"
|
||||||
|
],
|
||||||
|
"Phone-Modes": [
|
||||||
|
"AM",
|
||||||
|
"FM",
|
||||||
|
"SSB"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "Silver",
|
||||||
|
"threshold": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gold",
|
||||||
|
"threshold": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Platinum",
|
||||||
|
"threshold": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "All",
|
||||||
|
"threshold": 341
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -9,14 +9,57 @@
|
|||||||
"target": 50,
|
"target": 50,
|
||||||
"countMode": "perBandMode",
|
"countMode": "perBandMode",
|
||||||
"stations": [
|
"stations": [
|
||||||
{ "callsign": "DF2ET", "points": 10 },
|
{
|
||||||
{ "callsign": "DJ7NT", "points": 10 },
|
"callsign": "DF2ET",
|
||||||
{ "callsign": "HB9HIL", "points": 10 },
|
"points": 10
|
||||||
{ "callsign": "LA8AJA", "points": 10 },
|
},
|
||||||
{ "callsign": "DB4SCW", "points": 5 },
|
{
|
||||||
{ "callsign": "DG2RON", "points": 5 },
|
"callsign": "DJ7NT",
|
||||||
{ "callsign": "DG0TM", "points": 5 },
|
"points": 10
|
||||||
{ "callsign": "DO8MKR", "points": 5 }
|
},
|
||||||
]
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
31
award-definitions/vucc6m.json
Normal file
31
award-definitions/vucc6m.json
Normal 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": []
|
||||||
|
}
|
||||||
33
award-definitions/wae.json
Normal file
33
award-definitions/wae.json
Normal 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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "was-mixed",
|
"id": "was-mixed",
|
||||||
"name": "WAS Mixed Mode",
|
"name": "WAS",
|
||||||
"description": "Confirm all 50 US states",
|
"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.",
|
"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",
|
"category": "was",
|
||||||
@@ -14,10 +14,22 @@
|
|||||||
"filters": [
|
"filters": [
|
||||||
{
|
{
|
||||||
"field": "entityId",
|
"field": "entityId",
|
||||||
"operator": "eq",
|
"operator": "in",
|
||||||
"value": 291
|
"value": [
|
||||||
}
|
291,
|
||||||
|
6,
|
||||||
|
110
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stations": []
|
||||||
|
},
|
||||||
|
"modeGroups": {},
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "WAS Award",
|
||||||
|
"threshold": 50
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -276,6 +276,9 @@ award/
|
|||||||
callsign: text (not null)
|
callsign: text (not null)
|
||||||
lotwUsername: text (nullable)
|
lotwUsername: text (nullable)
|
||||||
lotwPassword: text (nullable, encrypted)
|
lotwPassword: text (nullable, encrypted)
|
||||||
|
isAdmin: boolean (default: false)
|
||||||
|
isSuperAdmin: boolean (default: false)
|
||||||
|
lastSeen: timestamp (nullable)
|
||||||
createdAt: timestamp
|
createdAt: timestamp
|
||||||
updatedAt: 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/)
|
- [ARRL LoTW](https://lotw.arrl.org/)
|
||||||
- [DARC Community Logbook (DCL)](https://dcl.darc.de/)
|
- [DARC Community Logbook (DCL)](https://dcl.darc.de/)
|
||||||
|
|||||||
17
drizzle/0004_overrated_havok.sql
Normal file
17
drizzle/0004_overrated_havok.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE `auto_sync_settings` (
|
||||||
|
`user_id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`lotw_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`lotw_interval_hours` integer DEFAULT 24 NOT NULL,
|
||||||
|
`lotw_last_sync_at` integer,
|
||||||
|
`lotw_next_sync_at` integer,
|
||||||
|
`dcl_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`dcl_interval_hours` integer DEFAULT 24 NOT NULL,
|
||||||
|
`dcl_last_sync_at` integer,
|
||||||
|
`dcl_next_sync_at` integer,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `is_super_admin` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `last_seen` integer;
|
||||||
868
drizzle/meta/0004_snapshot.json
Normal file
868
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,868 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "0d928d09-61c6-4311-beb8-0f597172e510",
|
||||||
|
"prevId": "071c98fb-6721-4da7-98cb-c16cb6aaf0c1",
|
||||||
|
"tables": {
|
||||||
|
"admin_actions": {
|
||||||
|
"name": "admin_actions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"admin_id": {
|
||||||
|
"name": "admin_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"action_type": {
|
||||||
|
"name": "action_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"target_user_id": {
|
||||||
|
"name": "target_user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"name": "details",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"admin_actions_admin_id_users_id_fk": {
|
||||||
|
"name": "admin_actions_admin_id_users_id_fk",
|
||||||
|
"tableFrom": "admin_actions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"admin_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"admin_actions_target_user_id_users_id_fk": {
|
||||||
|
"name": "admin_actions_target_user_id_users_id_fk",
|
||||||
|
"tableFrom": "admin_actions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"target_user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"auto_sync_settings": {
|
||||||
|
"name": "auto_sync_settings",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_enabled": {
|
||||||
|
"name": "lotw_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"lotw_interval_hours": {
|
||||||
|
"name": "lotw_interval_hours",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 24
|
||||||
|
},
|
||||||
|
"lotw_last_sync_at": {
|
||||||
|
"name": "lotw_last_sync_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_next_sync_at": {
|
||||||
|
"name": "lotw_next_sync_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_enabled": {
|
||||||
|
"name": "dcl_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"dcl_interval_hours": {
|
||||||
|
"name": "dcl_interval_hours",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 24
|
||||||
|
},
|
||||||
|
"dcl_last_sync_at": {
|
||||||
|
"name": "dcl_last_sync_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_next_sync_at": {
|
||||||
|
"name": "dcl_next_sync_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"auto_sync_settings_user_id_users_id_fk": {
|
||||||
|
"name": "auto_sync_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "auto_sync_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"award_progress": {
|
||||||
|
"name": "award_progress",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"award_id": {
|
||||||
|
"name": "award_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"worked_count": {
|
||||||
|
"name": "worked_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"confirmed_count": {
|
||||||
|
"name": "confirmed_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"total_required": {
|
||||||
|
"name": "total_required",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"worked_entities": {
|
||||||
|
"name": "worked_entities",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"confirmed_entities": {
|
||||||
|
"name": "confirmed_entities",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_calculated_at": {
|
||||||
|
"name": "last_calculated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_qso_sync_at": {
|
||||||
|
"name": "last_qso_sync_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"award_progress_user_id_users_id_fk": {
|
||||||
|
"name": "award_progress_user_id_users_id_fk",
|
||||||
|
"tableFrom": "award_progress",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"award_progress_award_id_awards_id_fk": {
|
||||||
|
"name": "award_progress_award_id_awards_id_fk",
|
||||||
|
"tableFrom": "award_progress",
|
||||||
|
"tableTo": "awards",
|
||||||
|
"columnsFrom": [
|
||||||
|
"award_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"awards": {
|
||||||
|
"name": "awards",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"definition": {
|
||||||
|
"name": "definition",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"qso_changes": {
|
||||||
|
"name": "qso_changes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"job_id": {
|
||||||
|
"name": "job_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"qso_id": {
|
||||||
|
"name": "qso_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"change_type": {
|
||||||
|
"name": "change_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before_data": {
|
||||||
|
"name": "before_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after_data": {
|
||||||
|
"name": "after_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"qso_changes_job_id_sync_jobs_id_fk": {
|
||||||
|
"name": "qso_changes_job_id_sync_jobs_id_fk",
|
||||||
|
"tableFrom": "qso_changes",
|
||||||
|
"tableTo": "sync_jobs",
|
||||||
|
"columnsFrom": [
|
||||||
|
"job_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"qso_changes_qso_id_qsos_id_fk": {
|
||||||
|
"name": "qso_changes_qso_id_qsos_id_fk",
|
||||||
|
"tableFrom": "qso_changes",
|
||||||
|
"tableTo": "qsos",
|
||||||
|
"columnsFrom": [
|
||||||
|
"qso_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"qsos": {
|
||||||
|
"name": "qsos",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"callsign": {
|
||||||
|
"name": "callsign",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"qso_date": {
|
||||||
|
"name": "qso_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"time_on": {
|
||||||
|
"name": "time_on",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"band": {
|
||||||
|
"name": "band",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"name": "mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"freq": {
|
||||||
|
"name": "freq",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"freq_rx": {
|
||||||
|
"name": "freq_rx",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"name": "entity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "entity_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"name": "grid",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"grid_source": {
|
||||||
|
"name": "grid_source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"continent": {
|
||||||
|
"name": "continent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cq_zone": {
|
||||||
|
"name": "cq_zone",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"itu_zone": {
|
||||||
|
"name": "itu_zone",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"county": {
|
||||||
|
"name": "county",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sat_name": {
|
||||||
|
"name": "sat_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sat_mode": {
|
||||||
|
"name": "sat_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"my_darc_dok": {
|
||||||
|
"name": "my_darc_dok",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"darc_dok": {
|
||||||
|
"name": "darc_dok",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_qsl_rdate": {
|
||||||
|
"name": "lotw_qsl_rdate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_qsl_rstatus": {
|
||||||
|
"name": "lotw_qsl_rstatus",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_qsl_rdate": {
|
||||||
|
"name": "dcl_qsl_rdate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_qsl_rstatus": {
|
||||||
|
"name": "dcl_qsl_rstatus",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_synced_at": {
|
||||||
|
"name": "lotw_synced_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"qsos_user_id_users_id_fk": {
|
||||||
|
"name": "qsos_user_id_users_id_fk",
|
||||||
|
"tableFrom": "qsos",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sync_jobs": {
|
||||||
|
"name": "sync_jobs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"name": "result",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sync_jobs_user_id_users_id_fk": {
|
||||||
|
"name": "sync_jobs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sync_jobs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"callsign": {
|
||||||
|
"name": "callsign",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_username": {
|
||||||
|
"name": "lotw_username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_password": {
|
||||||
|
"name": "lotw_password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_api_key": {
|
||||||
|
"name": "dcl_api_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_admin": {
|
||||||
|
"name": "is_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"is_super_admin": {
|
||||||
|
"name": "is_super_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"last_seen": {
|
||||||
|
"name": "last_seen",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1768989260562,
|
"when": 1768989260562,
|
||||||
"tag": "0003_tired_warpath",
|
"tag": "0003_tired_warpath",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769171258085,
|
||||||
|
"tag": "0004_overrated_havok",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|||||||
* @property {string|null} lotwPassword
|
* @property {string|null} lotwPassword
|
||||||
* @property {string|null} dclApiKey
|
* @property {string|null} dclApiKey
|
||||||
* @property {boolean} isAdmin
|
* @property {boolean} isAdmin
|
||||||
|
* @property {boolean} isSuperAdmin
|
||||||
* @property {Date|null} lastSeen
|
* @property {Date|null} lastSeen
|
||||||
* @property {Date} createdAt
|
* @property {Date} createdAt
|
||||||
* @property {Date} updatedAt
|
* @property {Date} updatedAt
|
||||||
@@ -24,6 +25,7 @@ export const users = sqliteTable('users', {
|
|||||||
lotwPassword: text('lotw_password'), // Encrypted
|
lotwPassword: text('lotw_password'), // Encrypted
|
||||||
dclApiKey: text('dcl_api_key'), // DCL API key for future use
|
dclApiKey: text('dcl_api_key'), // DCL API key for future use
|
||||||
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
isSuperAdmin: integer('is_super_admin', { mode: 'boolean' }).notNull().default(false),
|
||||||
lastSeen: integer('last_seen', { mode: 'timestamp' }),
|
lastSeen: integer('last_seen', { mode: 'timestamp' }),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ const app = new Elysia()
|
|||||||
email: payload.email,
|
email: payload.email,
|
||||||
callsign: payload.callsign,
|
callsign: payload.callsign,
|
||||||
isAdmin: payload.isAdmin,
|
isAdmin: payload.isAdmin,
|
||||||
|
isSuperAdmin: payload.isSuperAdmin,
|
||||||
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
|
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
|
||||||
},
|
},
|
||||||
isImpersonation,
|
isImpersonation,
|
||||||
@@ -360,6 +361,8 @@ const app = new Elysia()
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isSuperAdmin: user.isSuperAdmin,
|
||||||
exp,
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -429,6 +432,7 @@ const app = new Elysia()
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
|
isSuperAdmin: user.isSuperAdmin,
|
||||||
exp,
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1209,7 +1213,7 @@ const app = new Elysia()
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/users/:userId/role
|
* POST /api/admin/users/:userId/role
|
||||||
* Update user admin status (admin only)
|
* Update user role (admin only)
|
||||||
*/
|
*/
|
||||||
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
|
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
|
||||||
if (!user || !user.isAdmin) {
|
if (!user || !user.isAdmin) {
|
||||||
@@ -1223,21 +1227,27 @@ const app = new Elysia()
|
|||||||
return { success: false, error: 'Invalid user ID' };
|
return { success: false, error: 'Invalid user ID' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isAdmin } = body;
|
const { role } = body;
|
||||||
|
|
||||||
if (typeof isAdmin !== 'boolean') {
|
if (typeof role !== 'string') {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { success: false, error: 'isAdmin (boolean) is required' };
|
return { success: false, error: 'role (string) is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRoles = ['user', 'admin', 'super-admin'];
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
set.status = 400;
|
||||||
|
return { success: false, error: `Invalid role. Must be one of: ${validRoles.join(', ')}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await changeUserRole(user.id, targetUserId, isAdmin);
|
await changeUserRole(user.id, targetUserId, role);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'User admin status updated successfully',
|
message: 'User role updated successfully',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating user admin status', { error: error.message, userId: user.id });
|
logger.error('Error updating user role', { error: error.message, userId: user.id });
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1304,6 +1314,7 @@ const app = new Elysia()
|
|||||||
email: targetUser.email,
|
email: targetUser.email,
|
||||||
callsign: targetUser.callsign,
|
callsign: targetUser.callsign,
|
||||||
isAdmin: targetUser.isAdmin,
|
isAdmin: targetUser.isAdmin,
|
||||||
|
isSuperAdmin: targetUser.isSuperAdmin,
|
||||||
impersonatedBy: user.id, // Admin ID who started impersonation
|
impersonatedBy: user.id, // Admin ID who started impersonation
|
||||||
exp,
|
exp,
|
||||||
});
|
});
|
||||||
@@ -1364,6 +1375,7 @@ const app = new Elysia()
|
|||||||
email: adminUser.email,
|
email: adminUser.email,
|
||||||
callsign: adminUser.callsign,
|
callsign: adminUser.callsign,
|
||||||
isAdmin: adminUser.isAdmin,
|
isAdmin: adminUser.isAdmin,
|
||||||
|
isSuperAdmin: adminUser.isSuperAdmin,
|
||||||
exp,
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1607,6 +1619,7 @@ const app = new Elysia()
|
|||||||
category: t.String(),
|
category: t.String(),
|
||||||
rules: t.Any(),
|
rules: t.Any(),
|
||||||
modeGroups: t.Optional(t.Any()),
|
modeGroups: t.Optional(t.Any()),
|
||||||
|
achievements: t.Optional(t.Any()),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq, sql, desc } from 'drizzle-orm';
|
import { eq, sql, desc } from 'drizzle-orm';
|
||||||
import { db, sqlite, logger } from '../config.js';
|
import { db, sqlite, logger } from '../config.js';
|
||||||
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
|
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
|
||||||
import { getUserByIdFull, isAdmin } from './auth.service.js';
|
import { getUserByIdFull, isAdmin, isSuperAdmin, updateUserRole } from './auth.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log an admin action for audit trail
|
* Log an admin action for audit trail
|
||||||
@@ -160,7 +160,7 @@ export async function getUserStats() {
|
|||||||
* @param {number} adminId - Admin user ID
|
* @param {number} adminId - Admin user ID
|
||||||
* @param {number} targetUserId - Target user ID to impersonate
|
* @param {number} targetUserId - Target user ID to impersonate
|
||||||
* @returns {Promise<Object>} Target user object
|
* @returns {Promise<Object>} Target user object
|
||||||
* @throws {Error} If not admin or trying to impersonate another admin
|
* @throws {Error} If not admin or trying to impersonate another admin (without super-admin)
|
||||||
*/
|
*/
|
||||||
export async function impersonateUser(adminId, targetUserId) {
|
export async function impersonateUser(adminId, targetUserId) {
|
||||||
// Verify the requester is an admin
|
// Verify the requester is an admin
|
||||||
@@ -175,9 +175,17 @@ export async function impersonateUser(adminId, targetUserId) {
|
|||||||
throw new Error('Target user not found');
|
throw new Error('Target user not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if target is also an admin (prevent admin impersonation)
|
// Check if target is also an admin
|
||||||
if (targetUser.isAdmin) {
|
if (targetUser.isAdmin) {
|
||||||
throw new Error('Cannot impersonate another admin user');
|
// Only super-admins can impersonate other admins
|
||||||
|
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
|
||||||
|
if (!requesterIsSuperAdmin) {
|
||||||
|
throw new Error('Cannot impersonate another admin user (super-admin required)');
|
||||||
|
}
|
||||||
|
// Prevent self-impersonation (edge case)
|
||||||
|
if (adminId === targetUserId) {
|
||||||
|
throw new Error('Cannot impersonate yourself');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log impersonation action
|
// Log impersonation action
|
||||||
@@ -271,48 +279,69 @@ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
|||||||
* Update user admin status (admin operation)
|
* Update user admin status (admin operation)
|
||||||
* @param {number} adminId - Admin user ID making the change
|
* @param {number} adminId - Admin user ID making the change
|
||||||
* @param {number} targetUserId - User ID to update
|
* @param {number} targetUserId - User ID to update
|
||||||
* @param {boolean} newIsAdmin - New admin flag
|
* @param {string} newRole - New role: 'user', 'admin', or 'super-admin'
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
* @throws {Error} If not admin or would remove last admin
|
* @throws {Error} If not admin or violates security rules
|
||||||
*/
|
*/
|
||||||
export async function changeUserRole(adminId, targetUserId, newIsAdmin) {
|
export async function changeUserRole(adminId, targetUserId, newRole) {
|
||||||
|
// Validate role
|
||||||
|
const validRoles = ['user', 'admin', 'super-admin'];
|
||||||
|
if (!validRoles.includes(newRole)) {
|
||||||
|
throw new Error('Invalid role. Must be one of: user, admin, super-admin');
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the requester is an admin
|
// Verify the requester is an admin
|
||||||
const requesterIsAdmin = await isAdmin(adminId);
|
const requesterIsAdmin = await isAdmin(adminId);
|
||||||
if (!requesterIsAdmin) {
|
if (!requesterIsAdmin) {
|
||||||
throw new Error('Only admins can change user admin status');
|
throw new Error('Only admins can change user roles');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get requester super-admin status
|
||||||
|
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
|
||||||
|
|
||||||
// Get target user
|
// Get target user
|
||||||
const targetUser = await getUserByIdFull(targetUserId);
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
throw new Error('Target user not found');
|
throw new Error('Target user not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If demoting from admin, check if this would remove the last admin
|
// Security rules for super-admin role changes
|
||||||
if (targetUser.isAdmin && !newIsAdmin) {
|
const targetWillBeSuperAdmin = newRole === 'super-admin';
|
||||||
const adminCount = await db
|
const targetIsCurrentlySuperAdmin = targetUser.isSuperAdmin;
|
||||||
|
|
||||||
|
// Only super-admins can promote/demote super-admins
|
||||||
|
if (targetWillBeSuperAdmin || targetIsCurrentlySuperAdmin) {
|
||||||
|
if (!requesterIsSuperAdmin) {
|
||||||
|
throw new Error('Only super-admins can promote or demote super-admins');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent self-demotion (super-admins cannot demote themselves)
|
||||||
|
if (adminId === targetUserId) {
|
||||||
|
if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
|
||||||
|
throw new Error('Cannot demote yourself from super-admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot demote the last super-admin
|
||||||
|
if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
|
||||||
|
const superAdminCount = await db
|
||||||
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.isAdmin, 1));
|
.where(eq(users.isSuperAdmin, 1));
|
||||||
|
|
||||||
if (adminCount[0].count === 1) {
|
if (superAdminCount[0].count === 1) {
|
||||||
throw new Error('Cannot demote the last admin user');
|
throw new Error('Cannot demote the last super-admin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update admin status
|
// Update role (use the auth service function)
|
||||||
await db
|
await updateUserRole(targetUserId, newRole);
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
isAdmin: newIsAdmin ? 1 : 0,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(users.id, targetUserId));
|
|
||||||
|
|
||||||
// Log action
|
// Log action
|
||||||
await logAdminAction(adminId, 'role_change', targetUserId, {
|
await logAdminAction(adminId, 'role_change', targetUserId, {
|
||||||
oldIsAdmin: targetUser.isAdmin,
|
oldRole: targetUser.isSuperAdmin ? 'super-admin' : (targetUser.isAdmin ? 'admin' : 'user'),
|
||||||
newIsAdmin: newIsAdmin,
|
newRole: newRole,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,21 @@ export async function isAdmin(userId) {
|
|||||||
return user?.isAdmin === true || user?.isAdmin === 1;
|
return user?.isAdmin === true || user?.isAdmin === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is super-admin
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {Promise<boolean>} True if user is super-admin
|
||||||
|
*/
|
||||||
|
export async function isSuperAdmin(userId) {
|
||||||
|
const [user] = await db
|
||||||
|
.select({ isSuperAdmin: users.isSuperAdmin })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return user?.isSuperAdmin === true || user?.isSuperAdmin === 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all admin users
|
* Get all admin users
|
||||||
* @returns {Promise<Array>} Array of admin users (without passwords)
|
* @returns {Promise<Array>} Array of admin users (without passwords)
|
||||||
@@ -178,16 +193,20 @@ export async function getAdminUsers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update user admin status
|
* Update user role
|
||||||
* @param {number} userId - User ID
|
* @param {number} userId - User ID
|
||||||
* @param {boolean} isAdmin - Admin flag
|
* @param {string} role - Role: 'user', 'admin', or 'super-admin'
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function updateUserRole(userId, isAdmin) {
|
export async function updateUserRole(userId, role) {
|
||||||
|
const isAdmin = role === 'admin' || role === 'super-admin';
|
||||||
|
const isSuperAdmin = role === 'super-admin';
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
isAdmin: isAdmin ? 1 : 0,
|
isAdmin: isAdmin ? 1 : 0,
|
||||||
|
isSuperAdmin: isSuperAdmin ? 1 : 0,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
@@ -204,6 +223,7 @@ export async function getAllUsers() {
|
|||||||
email: users.email,
|
email: users.email,
|
||||||
callsign: users.callsign,
|
callsign: users.callsign,
|
||||||
isAdmin: users.isAdmin,
|
isAdmin: users.isAdmin,
|
||||||
|
isSuperAdmin: users.isSuperAdmin,
|
||||||
lastSeen: users.lastSeen,
|
lastSeen: users.lastSeen,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
updatedAt: users.updatedAt,
|
updatedAt: users.updatedAt,
|
||||||
@@ -226,6 +246,7 @@ export async function getUserByIdFull(userId) {
|
|||||||
email: users.email,
|
email: users.email,
|
||||||
callsign: users.callsign,
|
callsign: users.callsign,
|
||||||
isAdmin: users.isAdmin,
|
isAdmin: users.isAdmin,
|
||||||
|
isSuperAdmin: users.isSuperAdmin,
|
||||||
lotwUsername: users.lotwUsername,
|
lotwUsername: users.lotwUsername,
|
||||||
dclApiKey: users.dclApiKey,
|
dclApiKey: users.dclApiKey,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
|
|||||||
@@ -134,6 +134,29 @@ export function validateAwardDefinition(definition, existingDefinitions = []) {
|
|||||||
errors.push('Category must be a string');
|
errors.push('Category must be a string');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate achievements if present
|
||||||
|
if (definition.achievements) {
|
||||||
|
if (!Array.isArray(definition.achievements)) {
|
||||||
|
errors.push('achievements must be an array');
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < definition.achievements.length; i++) {
|
||||||
|
const achievement = definition.achievements[i];
|
||||||
|
if (!achievement.name || typeof achievement.name !== 'string') {
|
||||||
|
errors.push(`Achievement ${i + 1} must have a name`);
|
||||||
|
}
|
||||||
|
if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) {
|
||||||
|
errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for duplicate thresholds
|
||||||
|
const thresholds = definition.achievements.map(a => a.threshold);
|
||||||
|
const uniqueThresholds = new Set(thresholds);
|
||||||
|
if (thresholds.length !== uniqueThresholds.size) {
|
||||||
|
errors.push('Achievements must have unique thresholds');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate modeGroups if present
|
// Validate modeGroups if present
|
||||||
if (definition.modeGroups) {
|
if (definition.modeGroups) {
|
||||||
if (typeof definition.modeGroups !== 'object') {
|
if (typeof definition.modeGroups !== 'object') {
|
||||||
|
|||||||
@@ -84,9 +84,65 @@ function loadAwardDefinitions() {
|
|||||||
*/
|
*/
|
||||||
export function clearAwardCache() {
|
export function clearAwardCache() {
|
||||||
cachedAwardDefinitions = null;
|
cachedAwardDefinitions = null;
|
||||||
|
cachedWAECountryList = null;
|
||||||
logger.info('Award cache cleared');
|
logger.info('Award cache cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate achievement progress for an award
|
||||||
|
* @param {number} currentCount - Current confirmed count (entities or points)
|
||||||
|
* @param {Array} achievements - Array of achievement definitions
|
||||||
|
* @returns {Object|null} Achievement progress info or null if no achievements defined
|
||||||
|
*/
|
||||||
|
function calculateAchievementProgress(currentCount, achievements) {
|
||||||
|
if (!achievements || achievements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort achievements by threshold
|
||||||
|
const sorted = [...achievements].sort((a, b) => a.threshold - b.threshold);
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
if (currentCount >= achievement.threshold) {
|
||||||
|
earned.push(achievement);
|
||||||
|
currentLevel = achievement;
|
||||||
|
} else {
|
||||||
|
nextLevel = achievement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress toward next level
|
||||||
|
let progressPercent = 100;
|
||||||
|
let progressCurrent = currentCount;
|
||||||
|
let progressNeeded = 0;
|
||||||
|
|
||||||
|
if (nextLevel) {
|
||||||
|
const prevThreshold = currentLevel ? currentLevel.threshold : 0;
|
||||||
|
const range = nextLevel.threshold - prevThreshold;
|
||||||
|
const progressInLevel = currentCount - prevThreshold;
|
||||||
|
progressPercent = Math.round((progressInLevel / range) * 100);
|
||||||
|
progressNeeded = nextLevel.threshold - currentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
earned,
|
||||||
|
currentLevel,
|
||||||
|
nextLevel,
|
||||||
|
progressPercent,
|
||||||
|
progressCurrent,
|
||||||
|
progressNeeded,
|
||||||
|
totalAchievements: sorted.length,
|
||||||
|
earnedCount: earned.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all available awards
|
* Get all available awards
|
||||||
*/
|
*/
|
||||||
@@ -101,6 +157,7 @@ export async function getAllAwards() {
|
|||||||
category: def.category,
|
category: def.category,
|
||||||
rules: def.rules,
|
rules: def.rules,
|
||||||
modeGroups: def.modeGroups || null,
|
modeGroups: def.modeGroups || null,
|
||||||
|
achievements: def.achievements || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +182,7 @@ export function getAwardById(awardId) {
|
|||||||
category: award.category,
|
category: award.category,
|
||||||
rules: award.rules,
|
rules: award.rules,
|
||||||
modeGroups: award.modeGroups || null,
|
modeGroups: award.modeGroups || null,
|
||||||
|
achievements: award.achievements || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +243,11 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
return calculatePointsAwardProgress(userId, award, { includeDetails });
|
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
|
// Get all QSOs for user
|
||||||
const allQSOs = await db
|
const allQSOs = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -234,7 +297,7 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
worked: workedEntities.size,
|
worked: workedEntities.size,
|
||||||
confirmed: confirmedEntities.size,
|
confirmed: confirmedEntities.size,
|
||||||
target: rules.target || 0,
|
target: rules.target || 0,
|
||||||
@@ -242,6 +305,13 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
workedEntities: Array.from(workedEntities),
|
workedEntities: Array.from(workedEntities),
|
||||||
confirmedEntities: Array.from(confirmedEntities),
|
confirmedEntities: Array.from(confirmedEntities),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add achievement progress if award has achievements defined
|
||||||
|
if (award.achievements && award.achievements.length > 0) {
|
||||||
|
result.achievements = calculateAchievementProgress(confirmedEntities.size, award.achievements);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -362,6 +432,11 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
|
|||||||
result.confirmed = result.entities.filter((e) => e.confirmed).length;
|
result.confirmed = result.entities.filter((e) => e.confirmed).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add achievement progress if award has achievements defined
|
||||||
|
if (award.achievements && award.achievements.length > 0) {
|
||||||
|
result.achievements = calculateAchievementProgress(confirmedDOKs.size, award.achievements);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +698,12 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
result.stationDetails = stationDetails;
|
result.stationDetails = stationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add achievement progress if award has achievements defined
|
||||||
|
// For point-based awards, use totalPoints instead of confirmed count
|
||||||
|
if (award.achievements && award.achievements.length > 0) {
|
||||||
|
result.achievements = calculateAchievementProgress(totalPoints, award.achievements);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,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
|
* Get award progress with QSO details
|
||||||
*/
|
*/
|
||||||
@@ -776,6 +1354,11 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
|||||||
return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
|
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
|
// Get all QSOs for user
|
||||||
const allQSOs = await db
|
const allQSOs = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -821,7 +1404,19 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
|||||||
}
|
}
|
||||||
displayName = String(rawValue || entity);
|
displayName = String(rawValue || entity);
|
||||||
} else {
|
} 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)) {
|
if (!slotMap.has(slotKey)) {
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ export async function getQSOStats(userId) {
|
|||||||
}).from(qsos).where(eq(qsos.userId, userId)),
|
}).from(qsos).where(eq(qsos.userId, userId)),
|
||||||
|
|
||||||
db.select({
|
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)`,
|
uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`,
|
||||||
uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)`
|
uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)`
|
||||||
}).from(qsos).where(eq(qsos.userId, userId))
|
}).from(qsos).where(eq(qsos.userId, userId))
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ export const adminAPI = {
|
|||||||
|
|
||||||
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
|
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
|
||||||
|
|
||||||
updateUserRole: (userId, isAdmin) => apiRequest(`/admin/users/${userId}/role`, {
|
updateUserRole: (userId, role) => apiRequest(`/admin/users/${userId}/role`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ isAdmin }),
|
body: JSON.stringify({ role }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {
|
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.impersonation-content {
|
.impersonation-content {
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -141,19 +141,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRoleChange(userId, newIsAdmin) {
|
async function handleRoleChange(userId, newRole) {
|
||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
const data = await adminAPI.updateUserRole(userId, newIsAdmin);
|
const data = await adminAPI.updateUserRole(userId, newRole);
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(data.message);
|
alert(data.message);
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to update user admin status: ' + (data.error || 'Unknown error'));
|
alert('Failed to update user role: ' + (data.error || 'Unknown error'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to update user admin status: ' + err.message);
|
alert('Failed to update user role: ' + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
showRoleChangeModal = false;
|
showRoleChangeModal = false;
|
||||||
@@ -197,7 +197,8 @@
|
|||||||
user.callsign.toLowerCase().includes(userSearch.toLowerCase());
|
user.callsign.toLowerCase().includes(userSearch.toLowerCase());
|
||||||
|
|
||||||
const matchesFilter = userFilter === 'all' ||
|
const matchesFilter = userFilter === 'all' ||
|
||||||
(userFilter === 'admin' && user.isAdmin) ||
|
(userFilter === 'super-admin' && user.isSuperAdmin) ||
|
||||||
|
(userFilter === 'admin' && user.isAdmin && !user.isSuperAdmin) ||
|
||||||
(userFilter === 'user' && !user.isAdmin);
|
(userFilter === 'user' && !user.isAdmin);
|
||||||
|
|
||||||
return matchesSearch && matchesFilter;
|
return matchesSearch && matchesFilter;
|
||||||
@@ -317,6 +318,7 @@
|
|||||||
/>
|
/>
|
||||||
<select class="filter-select" bind:value={userFilter}>
|
<select class="filter-select" bind:value={userFilter}>
|
||||||
<option value="all">All Users</option>
|
<option value="all">All Users</option>
|
||||||
|
<option value="super-admin">Super Admins Only</option>
|
||||||
<option value="admin">Admins Only</option>
|
<option value="admin">Admins Only</option>
|
||||||
<option value="user">Regular Users Only</option>
|
<option value="user">Regular Users Only</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -347,8 +349,8 @@
|
|||||||
<td>{user.email}</td>
|
<td>{user.email}</td>
|
||||||
<td>{user.callsign}</td>
|
<td>{user.callsign}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="role-badge {user.isAdmin ? 'admin' : 'user'}">
|
<span class="role-badge {user.isSuperAdmin ? 'super-admin' : (user.isAdmin ? 'admin' : 'user')}">
|
||||||
{user.isAdmin ? 'Admin' : 'User'}
|
{user.isSuperAdmin ? 'Super Admin' : (user.isAdmin ? 'Admin' : 'User')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{user.qsoCount || 0}</td>
|
<td>{user.qsoCount || 0}</td>
|
||||||
@@ -361,7 +363,7 @@
|
|||||||
<button
|
<button
|
||||||
class="action-button impersonate-btn"
|
class="action-button impersonate-btn"
|
||||||
on:click={() => openImpersonationModal(user)}
|
on:click={() => openImpersonationModal(user)}
|
||||||
disabled={user.isAdmin}
|
disabled={user.isAdmin && !$auth.user.isSuperAdmin}
|
||||||
>
|
>
|
||||||
Impersonate
|
Impersonate
|
||||||
</button>
|
</button>
|
||||||
@@ -495,25 +497,34 @@
|
|||||||
<div class="modal-content" on:click|stopPropagation>
|
<div class="modal-content" on:click|stopPropagation>
|
||||||
<h2>Change User Role</h2>
|
<h2>Change User Role</h2>
|
||||||
<p>User: <strong>{selectedUser.email}</strong></p>
|
<p>User: <strong>{selectedUser.email}</strong></p>
|
||||||
<p>Current Role: <strong>{selectedUser.isAdmin ? 'Admin' : 'User'}</strong></p>
|
<p>Current Role: <strong>{selectedUser.isSuperAdmin ? 'Super Admin' : (selectedUser.isAdmin ? 'Admin' : 'User')}</strong></p>
|
||||||
<p>New Role:</p>
|
<p>New Role:</p>
|
||||||
<div class="role-options">
|
<div class="role-options">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="role" value="user" checked={!selectedUser.isAdmin} />
|
<input type="radio" name="role" value="user" checked={!selectedUser.isAdmin && !selectedUser.isSuperAdmin} />
|
||||||
Regular User
|
Regular User
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="role" value="admin" checked={selectedUser.isAdmin} />
|
<input type="radio" name="role" value="admin" checked={selectedUser.isAdmin && !selectedUser.isSuperAdmin} />
|
||||||
Admin
|
Admin
|
||||||
</label>
|
</label>
|
||||||
|
{#if $auth.user.isSuperAdmin}
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="role" value="super-admin" checked={selectedUser.isSuperAdmin} />
|
||||||
|
Super Admin
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !$auth.user.isSuperAdmin && (selectedUser.isSuperAdmin || (selectedUser.isAdmin && selectedUser.email !== selectedUser.email))}
|
||||||
|
<p class="warning">Note: Only super-admins can promote or demote super-admins.</p>
|
||||||
|
{/if}
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="modal-button cancel" on:click={() => showRoleChangeModal = false}>
|
<button class="modal-button cancel" on:click={() => showRoleChangeModal = false}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="modal-button confirm"
|
class="modal-button confirm"
|
||||||
on:click={() => handleRoleChange(selectedUser.id, !selectedUser.isAdmin)}
|
on:click={() => handleRoleChange(selectedUser.id, document.querySelector('input[name="role"]:checked')?.value || 'user')}
|
||||||
>
|
>
|
||||||
Change Role
|
Change Role
|
||||||
</button>
|
</button>
|
||||||
@@ -550,7 +561,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.admin-dashboard {
|
.admin-dashboard {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1400px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,6 +787,11 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-badge.super-admin {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.role-badge.user {
|
.role-badge.user {
|
||||||
background-color: var(--color-secondary);
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -60,7 +60,8 @@
|
|||||||
'dok': 'DOK',
|
'dok': 'DOK',
|
||||||
'points': 'Points',
|
'points': 'Points',
|
||||||
'filtered': 'Filtered',
|
'filtered': 'Filtered',
|
||||||
'counter': 'Counter'
|
'counter': 'Counter',
|
||||||
|
'wae': 'WAE'
|
||||||
};
|
};
|
||||||
return names[ruleType] || ruleType;
|
return names[ruleType] || ruleType;
|
||||||
}
|
}
|
||||||
@@ -168,7 +169,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.awards-admin {
|
.awards-admin {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1400px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let showTestModal = false;
|
let showTestModal = false;
|
||||||
let activeTab = 'basic'; // basic, modeGroups, rules
|
let activeTab = 'basic'; // basic, modeGroups, achievements, rules
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
let formData = {
|
let formData = {
|
||||||
@@ -26,11 +26,16 @@
|
|||||||
caption: '',
|
caption: '',
|
||||||
category: '',
|
category: '',
|
||||||
modeGroups: {},
|
modeGroups: {},
|
||||||
|
achievements: [],
|
||||||
rules: {
|
rules: {
|
||||||
type: 'entity',
|
type: 'entity',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Achievements editor state
|
||||||
|
let newAchievementName = '';
|
||||||
|
let newAchievementThreshold = 100;
|
||||||
|
|
||||||
// Update stations store when formData changes
|
// Update stations store when formData changes
|
||||||
$: if (formData.rules?.stations) {
|
$: if (formData.rules?.stations) {
|
||||||
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
||||||
@@ -66,6 +71,7 @@
|
|||||||
caption: data.award.caption || '',
|
caption: data.award.caption || '',
|
||||||
category: data.award.category || '',
|
category: data.award.category || '',
|
||||||
modeGroups: data.award.modeGroups || {},
|
modeGroups: data.award.modeGroups || {},
|
||||||
|
achievements: data.award.achievements || [],
|
||||||
rules: data.award.rules || { type: 'entity' },
|
rules: data.award.rules || { type: 'entity' },
|
||||||
};
|
};
|
||||||
awardId = id;
|
awardId = id;
|
||||||
@@ -115,11 +121,17 @@
|
|||||||
case 'counter':
|
case 'counter':
|
||||||
validateCounterRule(errors, warnings);
|
validateCounterRule(errors, warnings);
|
||||||
break;
|
break;
|
||||||
|
case 'wae':
|
||||||
|
validateWAERule(errors, warnings);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode groups validation
|
// Mode groups validation
|
||||||
validateModeGroups(errors, warnings);
|
validateModeGroups(errors, warnings);
|
||||||
|
|
||||||
|
// Achievements validation
|
||||||
|
validateAchievements(errors, warnings);
|
||||||
|
|
||||||
// Cross-field validation
|
// Cross-field validation
|
||||||
performCrossFieldValidation(errors, warnings);
|
performCrossFieldValidation(errors, warnings);
|
||||||
|
|
||||||
@@ -227,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) {
|
function validateModeGroups(errors, warnings) {
|
||||||
if (!formData.modeGroups) return;
|
if (!formData.modeGroups) return;
|
||||||
|
|
||||||
@@ -270,6 +305,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateAchievements(errors, warnings) {
|
||||||
|
if (!formData.achievements || formData.achievements.length === 0) {
|
||||||
|
return; // Achievements are optional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate thresholds
|
||||||
|
const thresholds = formData.achievements.map(a => a.threshold);
|
||||||
|
const uniqueThresholds = new Set(thresholds);
|
||||||
|
if (thresholds.length !== uniqueThresholds.size) {
|
||||||
|
errors.push('Achievements must have unique thresholds');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid threshold values
|
||||||
|
formData.achievements.forEach((achievement, i) => {
|
||||||
|
if (!achievement.name || !achievement.name.trim()) {
|
||||||
|
errors.push(`Achievement ${i + 1} is missing a name`);
|
||||||
|
}
|
||||||
|
if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) {
|
||||||
|
errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Warn if achievements are not in ascending order (they should be sorted)
|
||||||
|
for (let i = 1; i < formData.achievements.length; i++) {
|
||||||
|
if (formData.achievements[i].threshold < formData.achievements[i - 1].threshold) {
|
||||||
|
warnings.push('Achievements are not in ascending order by threshold');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first achievement threshold equals or is less than the base target
|
||||||
|
const baseTarget = formData.rules?.target;
|
||||||
|
if (baseTarget && formData.achievements.length > 0) {
|
||||||
|
const firstThreshold = formData.achievements[0].threshold;
|
||||||
|
if (firstThreshold < baseTarget) {
|
||||||
|
warnings.push(`First achievement threshold (${firstThreshold}) is less than base target (${baseTarget}) - this may be intentional for "milestone below target" achievements`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function performCrossFieldValidation(errors, warnings) {
|
function performCrossFieldValidation(errors, warnings) {
|
||||||
// Check if filters contradict satellite_only
|
// Check if filters contradict satellite_only
|
||||||
if (formData.rules.satellite_only && formData.rules.filters) {
|
if (formData.rules.satellite_only && formData.rules.filters) {
|
||||||
@@ -434,6 +509,48 @@
|
|||||||
performSafetyValidation();
|
performSafetyValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Achievements management
|
||||||
|
function addAchievement() {
|
||||||
|
if (!newAchievementName.trim()) {
|
||||||
|
alert('Please enter an achievement name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newAchievementThreshold || newAchievementThreshold <= 0) {
|
||||||
|
alert('Please enter a valid threshold (positive number)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate threshold
|
||||||
|
const exists = formData.achievements?.some(a => a.threshold === newAchievementThreshold);
|
||||||
|
if (exists) {
|
||||||
|
alert('An achievement with this threshold already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formData = {
|
||||||
|
...formData,
|
||||||
|
achievements: [
|
||||||
|
...(formData.achievements || []),
|
||||||
|
{ name: newAchievementName.trim(), threshold: newAchievementThreshold }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort achievements by threshold
|
||||||
|
formData.achievements.sort((a, b) => a.threshold - b.threshold);
|
||||||
|
|
||||||
|
newAchievementName = '';
|
||||||
|
newAchievementThreshold = 100;
|
||||||
|
performSafetyValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAchievement(index) {
|
||||||
|
formData = {
|
||||||
|
...formData,
|
||||||
|
achievements: formData.achievements.filter((_, i) => i !== index)
|
||||||
|
};
|
||||||
|
performSafetyValidation();
|
||||||
|
}
|
||||||
|
|
||||||
function testAward() {
|
function testAward() {
|
||||||
showTestModal = true;
|
showTestModal = true;
|
||||||
}
|
}
|
||||||
@@ -492,6 +609,12 @@
|
|||||||
>
|
>
|
||||||
Mode Groups
|
Mode Groups
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {activeTab === 'achievements' ? 'active' : ''}"
|
||||||
|
on:click={() => activeTab = 'achievements'}
|
||||||
|
>
|
||||||
|
Achievements
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab {activeTab === 'rules' ? 'active' : ''}"
|
class="tab {activeTab === 'rules' ? 'active' : ''}"
|
||||||
on:click={() => activeTab = 'rules'}
|
on:click={() => activeTab = 'rules'}
|
||||||
@@ -611,6 +734,60 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if activeTab === 'achievements'}
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>Achievements</h2>
|
||||||
|
<p class="help-text">Define achievement levels (milestones) for this award. These are optional and represent additional goals beyond the base target.</p>
|
||||||
|
|
||||||
|
{#if formData.achievements && formData.achievements.length > 0}
|
||||||
|
<div class="achievements-list">
|
||||||
|
<h3>Defined Achievements</h3>
|
||||||
|
{#each formData.achievements as achievement, i (i)}
|
||||||
|
<div class="achievement-item">
|
||||||
|
<div class="achievement-info">
|
||||||
|
<strong>{achievement.name}</strong>
|
||||||
|
<span class="achievement-threshold">{achievement.threshold} pts/entities</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-remove" on:click={() => removeAchievement(i)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-state">No achievements defined yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="add-achievement">
|
||||||
|
<h3>Add Achievement</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="achievement-name">Achievement Name</label>
|
||||||
|
<input
|
||||||
|
id="achievement-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={newAchievementName}
|
||||||
|
placeholder="e.g., Silver, Gold, Platinum"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="achievement-threshold">Threshold (entities/points required) *</label>
|
||||||
|
<input
|
||||||
|
id="achievement-threshold"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={newAchievementThreshold}
|
||||||
|
placeholder="e.g., 100, 200, 500"
|
||||||
|
/>
|
||||||
|
<small>The number of confirmed entities or points required to earn this achievement</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" on:click={addAchievement}>Add Achievement</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Achievement Progress:</strong> Users will see earned achievements as gold badges. A progress bar shows progress toward the next achievement level.
|
||||||
|
Achievements are sorted by threshold (lowest first). The first achievement is typically at or above the base target.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if activeTab === 'rules'}
|
{#if activeTab === 'rules'}
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Award Rules</h2>
|
<h2>Award Rules</h2>
|
||||||
@@ -623,6 +800,7 @@
|
|||||||
<option value="points">Points (sum points from stations)</option>
|
<option value="points">Points (sum points from stations)</option>
|
||||||
<option value="filtered">Filtered (base rule with filters)</option>
|
<option value="filtered">Filtered (base rule with filters)</option>
|
||||||
<option value="counter">Counter (count QSOs or callsigns)</option>
|
<option value="counter">Counter (count QSOs or callsigns)</option>
|
||||||
|
<option value="wae">WAE (Worked All Europe)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -653,7 +831,7 @@
|
|||||||
bind:value={formData.rules.displayField}
|
bind:value={formData.rules.displayField}
|
||||||
placeholder="e.g., entity, state, grid"
|
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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -844,6 +1022,93 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
|
|
||||||
<!-- Filters section (common to all rule types) -->
|
<!-- Filters section (common to all rule types) -->
|
||||||
@@ -872,7 +1137,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.award-editor {
|
.award-editor {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,6 +1353,58 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Achievements styles */
|
||||||
|
.achievements-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements-list h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-info strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-threshold {
|
||||||
|
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
|
||||||
|
color: #5d4037;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--border-radius-pill);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-achievement {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-achievement h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.mode-selector, .bands-selector {
|
.mode-selector, .bands-selector {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
|||||||
@@ -197,16 +197,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if displayField matches the default for the entity type
|
// Check if displayField matches the default for the entity type
|
||||||
if (rules.entityType && rules.displayField) {
|
if (rules.entityType) {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
'dxcc': 'entity',
|
'dxcc': 'entity',
|
||||||
'state': 'state',
|
'state': 'state',
|
||||||
'grid': 'grid',
|
'grid': 'grid',
|
||||||
'callsign': 'callsign'
|
'callsign': 'callsign'
|
||||||
};
|
};
|
||||||
if (defaults[rules.entityType] === rules.displayField) {
|
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.`);
|
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}".`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { warnings, info };
|
return { warnings, info };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let showTestModal = false;
|
let showTestModal = false;
|
||||||
let activeTab = 'basic'; // basic, modeGroups, rules
|
let activeTab = 'basic'; // basic, modeGroups, achievements, rules
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
let formData = {
|
let formData = {
|
||||||
@@ -27,16 +27,36 @@
|
|||||||
caption: '',
|
caption: '',
|
||||||
category: '',
|
category: '',
|
||||||
modeGroups: {},
|
modeGroups: {},
|
||||||
|
achievements: [],
|
||||||
rules: {
|
rules: {
|
||||||
type: 'entity',
|
type: 'entity',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Achievements editor state
|
||||||
|
let newAchievementName = '';
|
||||||
|
let newAchievementThreshold = 100;
|
||||||
|
|
||||||
// Update stations store when formData changes
|
// Update stations store when formData changes
|
||||||
$: if (formData.rules?.stations) {
|
$: if (formData.rules?.stations) {
|
||||||
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
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
|
// 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_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'];
|
const ALL_MODES = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
|
||||||
@@ -52,12 +72,11 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Check if we're editing an existing award
|
// Check if we're editing an existing award
|
||||||
const pathParts = window.location.pathname.split('/');
|
const pathParts = window.location.pathname.split('/');
|
||||||
if (pathParts.includes('[id]') || pathParts.length > 5) {
|
const lastPart = pathParts[pathParts.length - 1];
|
||||||
// Extract award ID from path
|
|
||||||
const idPart = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
|
// If the last part is not 'create' and looks like an award ID, load the award
|
||||||
if (idPart && idPart !== 'create') {
|
if (lastPart && lastPart !== 'create' && lastPart !== 'awards') {
|
||||||
loadAward(idPart);
|
loadAward(lastPart);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +91,7 @@
|
|||||||
caption: data.award.caption || '',
|
caption: data.award.caption || '',
|
||||||
category: data.award.category || '',
|
category: data.award.category || '',
|
||||||
modeGroups: data.award.modeGroups || {},
|
modeGroups: data.award.modeGroups || {},
|
||||||
|
achievements: data.award.achievements || [],
|
||||||
rules: data.award.rules || { type: 'entity' },
|
rules: data.award.rules || { type: 'entity' },
|
||||||
};
|
};
|
||||||
awardId = id;
|
awardId = id;
|
||||||
@@ -122,11 +142,17 @@
|
|||||||
case 'counter':
|
case 'counter':
|
||||||
validateCounterRule(errors, warnings);
|
validateCounterRule(errors, warnings);
|
||||||
break;
|
break;
|
||||||
|
case 'wae':
|
||||||
|
validateWAERule(errors, warnings);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode groups validation
|
// Mode groups validation
|
||||||
validateModeGroups(errors, warnings);
|
validateModeGroups(errors, warnings);
|
||||||
|
|
||||||
|
// Achievements validation
|
||||||
|
validateAchievements(errors, warnings);
|
||||||
|
|
||||||
// Cross-field validation
|
// Cross-field validation
|
||||||
performCrossFieldValidation(errors, warnings);
|
performCrossFieldValidation(errors, warnings);
|
||||||
|
|
||||||
@@ -234,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) {
|
function validateModeGroups(errors, warnings) {
|
||||||
if (!formData.modeGroups) return;
|
if (!formData.modeGroups) return;
|
||||||
|
|
||||||
@@ -277,6 +326,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateAchievements(errors, warnings) {
|
||||||
|
if (!formData.achievements || formData.achievements.length === 0) {
|
||||||
|
return; // Achievements are optional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate thresholds
|
||||||
|
const thresholds = formData.achievements.map(a => a.threshold);
|
||||||
|
const uniqueThresholds = new Set(thresholds);
|
||||||
|
if (thresholds.length !== uniqueThresholds.size) {
|
||||||
|
errors.push('Achievements must have unique thresholds');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid threshold values
|
||||||
|
formData.achievements.forEach((achievement, i) => {
|
||||||
|
if (!achievement.name || !achievement.name.trim()) {
|
||||||
|
errors.push(`Achievement ${i + 1} is missing a name`);
|
||||||
|
}
|
||||||
|
if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) {
|
||||||
|
errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Warn if achievements are not in ascending order (they should be sorted)
|
||||||
|
for (let i = 1; i < formData.achievements.length; i++) {
|
||||||
|
if (formData.achievements[i].threshold < formData.achievements[i - 1].threshold) {
|
||||||
|
warnings.push('Achievements are not in ascending order by threshold');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first achievement threshold equals or is less than the base target
|
||||||
|
const baseTarget = formData.rules?.target;
|
||||||
|
if (baseTarget && formData.achievements.length > 0) {
|
||||||
|
const firstThreshold = formData.achievements[0].threshold;
|
||||||
|
if (firstThreshold < baseTarget) {
|
||||||
|
warnings.push(`First achievement threshold (${firstThreshold}) is less than base target (${baseTarget}) - this may be intentional for "milestone below target" achievements`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function performCrossFieldValidation(errors, warnings) {
|
function performCrossFieldValidation(errors, warnings) {
|
||||||
// Check if filters contradict satellite_only
|
// Check if filters contradict satellite_only
|
||||||
if (formData.rules.satellite_only && formData.rules.filters) {
|
if (formData.rules.satellite_only && formData.rules.filters) {
|
||||||
@@ -445,6 +534,48 @@
|
|||||||
performSafetyValidation();
|
performSafetyValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Achievements management
|
||||||
|
function addAchievement() {
|
||||||
|
if (!newAchievementName.trim()) {
|
||||||
|
alert('Please enter an achievement name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newAchievementThreshold || newAchievementThreshold <= 0) {
|
||||||
|
alert('Please enter a valid threshold (positive number)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate threshold
|
||||||
|
const exists = formData.achievements?.some(a => a.threshold === newAchievementThreshold);
|
||||||
|
if (exists) {
|
||||||
|
alert('An achievement with this threshold already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formData = {
|
||||||
|
...formData,
|
||||||
|
achievements: [
|
||||||
|
...(formData.achievements || []),
|
||||||
|
{ name: newAchievementName.trim(), threshold: newAchievementThreshold }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort achievements by threshold
|
||||||
|
formData.achievements.sort((a, b) => a.threshold - b.threshold);
|
||||||
|
|
||||||
|
newAchievementName = '';
|
||||||
|
newAchievementThreshold = 100;
|
||||||
|
performSafetyValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAchievement(index) {
|
||||||
|
formData = {
|
||||||
|
...formData,
|
||||||
|
achievements: formData.achievements.filter((_, i) => i !== index)
|
||||||
|
};
|
||||||
|
performSafetyValidation();
|
||||||
|
}
|
||||||
|
|
||||||
function testAward() {
|
function testAward() {
|
||||||
showTestModal = true;
|
showTestModal = true;
|
||||||
}
|
}
|
||||||
@@ -514,6 +645,12 @@
|
|||||||
>
|
>
|
||||||
Mode Groups
|
Mode Groups
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {activeTab === 'achievements' ? 'active' : ''}"
|
||||||
|
on:click={() => activeTab = 'achievements'}
|
||||||
|
>
|
||||||
|
Achievements
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab {activeTab === 'rules' ? 'active' : ''}"
|
class="tab {activeTab === 'rules' ? 'active' : ''}"
|
||||||
on:click={() => activeTab = 'rules'}
|
on:click={() => activeTab = 'rules'}
|
||||||
@@ -636,6 +773,60 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if activeTab === 'achievements'}
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>Achievements</h2>
|
||||||
|
<p class="help-text">Define achievement levels (milestones) for this award. These are optional and represent additional goals beyond the base target.</p>
|
||||||
|
|
||||||
|
{#if formData.achievements && formData.achievements.length > 0}
|
||||||
|
<div class="achievements-list">
|
||||||
|
<h3>Defined Achievements</h3>
|
||||||
|
{#each formData.achievements as achievement, i (i)}
|
||||||
|
<div class="achievement-item">
|
||||||
|
<div class="achievement-info">
|
||||||
|
<strong>{achievement.name}</strong>
|
||||||
|
<span class="achievement-threshold">{achievement.threshold} pts/entities</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-remove" on:click={() => removeAchievement(i)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-state">No achievements defined yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="add-achievement">
|
||||||
|
<h3>Add Achievement</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="achievement-name">Achievement Name</label>
|
||||||
|
<input
|
||||||
|
id="achievement-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={newAchievementName}
|
||||||
|
placeholder="e.g., Silver, Gold, Platinum"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="achievement-threshold">Threshold (entities/points required) *</label>
|
||||||
|
<input
|
||||||
|
id="achievement-threshold"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={newAchievementThreshold}
|
||||||
|
placeholder="e.g., 100, 200, 500"
|
||||||
|
/>
|
||||||
|
<small>The number of confirmed entities or points required to earn this achievement</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" on:click={addAchievement}>Add Achievement</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Achievement Progress:</strong> Users will see earned achievements as gold badges. A progress bar shows progress toward the next achievement level.
|
||||||
|
Achievements are sorted by threshold (lowest first). The first achievement is typically at or above the base target.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if activeTab === 'rules'}
|
{#if activeTab === 'rules'}
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Award Rules</h2>
|
<h2>Award Rules</h2>
|
||||||
@@ -648,6 +839,7 @@
|
|||||||
<option value="points">Points (sum points from stations)</option>
|
<option value="points">Points (sum points from stations)</option>
|
||||||
<option value="filtered">Filtered (base rule with filters)</option>
|
<option value="filtered">Filtered (base rule with filters)</option>
|
||||||
<option value="counter">Counter (count QSOs or callsigns)</option>
|
<option value="counter">Counter (count QSOs or callsigns)</option>
|
||||||
|
<option value="wae">WAE (Worked All Europe)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -678,7 +870,7 @@
|
|||||||
bind:value={formData.rules.displayField}
|
bind:value={formData.rules.displayField}
|
||||||
placeholder="e.g., entity, state, grid"
|
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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -870,6 +1062,93 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
|
|
||||||
<!-- Filters section (common to all rule types) -->
|
<!-- Filters section (common to all rule types) -->
|
||||||
@@ -898,7 +1177,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.award-editor {
|
.award-editor {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,6 +1393,58 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Achievements styles */
|
||||||
|
.achievements-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements-list h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-info strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-threshold {
|
||||||
|
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
|
||||||
|
color: #5d4037;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--border-radius-pill);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-achievement {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-achievement h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.mode-selector, .bands-selector {
|
.mode-selector, .bands-selector {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
let selectedSlotQSOs = [];
|
let selectedSlotQSOs = [];
|
||||||
let selectedSlotInfo = null; // { entityName, band, mode }
|
let selectedSlotInfo = null; // { entityName, band, mode }
|
||||||
|
|
||||||
|
// Achievement progress (reactive to mode changes via filteredEntities)
|
||||||
|
let achievementProgress = null;
|
||||||
|
$: if (award && filteredEntities) {
|
||||||
|
achievementProgress = calculateAchievementProgress(award, filteredEntities);
|
||||||
|
}
|
||||||
|
|
||||||
// Get available modes from entities
|
// Get available modes from entities
|
||||||
// Structure: Mixed Mode, Mode Groups (if any), Separator, Individual Modes
|
// Structure: Mixed Mode, Mode Groups (if any), Separator, Individual Modes
|
||||||
$: availableModes = (() => {
|
$: availableModes = (() => {
|
||||||
@@ -527,6 +533,155 @@
|
|||||||
if (status === '?') return { label: 'Unknown', class: 'unknown' };
|
if (status === '?') return { label: 'Unknown', class: 'unknown' };
|
||||||
return { label: 'No Data', class: 'no-data' };
|
return { label: 'No Data', class: 'no-data' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calculateAchievementProgress(award, entities) {
|
||||||
|
if (!award.achievements || award.achievements.length === 0) {
|
||||||
|
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) {
|
||||||
|
// Point-based award
|
||||||
|
currentCount = entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0);
|
||||||
|
} else {
|
||||||
|
// Entity-based award - count unique confirmed entities
|
||||||
|
const uniqueEntities = new Set();
|
||||||
|
entities.forEach(e => {
|
||||||
|
if (e.confirmed) {
|
||||||
|
uniqueEntities.add(e.entityName || e.entity || 'Unknown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentCount = uniqueEntities.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort achievements by threshold
|
||||||
|
const sorted = [...award.achievements].sort((a, b) => a.threshold - b.threshold);
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
if (currentCount >= achievement.threshold) {
|
||||||
|
earned.push(achievement);
|
||||||
|
currentLevel = achievement;
|
||||||
|
} else {
|
||||||
|
nextLevel = achievement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress toward next level
|
||||||
|
let progressPercent = 100;
|
||||||
|
let progressCurrent = currentCount;
|
||||||
|
let progressNeeded = 0;
|
||||||
|
|
||||||
|
if (nextLevel) {
|
||||||
|
const prevThreshold = currentLevel ? currentLevel.threshold : 0;
|
||||||
|
const range = nextLevel.threshold - prevThreshold;
|
||||||
|
const progressInLevel = currentCount - prevThreshold;
|
||||||
|
progressPercent = Math.round((progressInLevel / range) * 100);
|
||||||
|
progressNeeded = nextLevel.threshold - currentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
earned,
|
||||||
|
currentLevel,
|
||||||
|
nextLevel,
|
||||||
|
progressPercent,
|
||||||
|
progressCurrent,
|
||||||
|
progressNeeded,
|
||||||
|
totalAchievements: sorted.length,
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -548,7 +703,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary">
|
<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 earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
||||||
{@const targetPoints = award.rules?.target}
|
{@const targetPoints = award.rules?.target}
|
||||||
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
|
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
|
||||||
@@ -560,7 +742,7 @@
|
|||||||
<span class="summary-label">Confirmed:</span>
|
<span class="summary-label">Confirmed:</span>
|
||||||
<span class="summary-value">{filteredEntities.filter((e) => e.confirmed).length}</span>
|
<span class="summary-value">{filteredEntities.filter((e) => e.confirmed).length}</span>
|
||||||
</div>
|
</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-label">Points:</span>
|
||||||
<span class="summary-value">{earnedPoints}</span>
|
<span class="summary-value">{earnedPoints}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -569,7 +751,7 @@
|
|||||||
<span class="summary-label">Needed:</span>
|
<span class="summary-label">Needed:</span>
|
||||||
<span class="summary-value">{neededPoints}</span>
|
<span class="summary-value">{neededPoints}</span>
|
||||||
</div>
|
</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-label">Target:</span>
|
||||||
<span class="summary-value">{targetPoints}</span>
|
<span class="summary-value">{targetPoints}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,7 +776,7 @@
|
|||||||
<span class="summary-label">Needed:</span>
|
<span class="summary-label">Needed:</span>
|
||||||
<span class="summary-value">{neededCount}</span>
|
<span class="summary-value">{neededCount}</span>
|
||||||
</div>
|
</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-label">Target:</span>
|
||||||
<span class="summary-value">{targetCount}</span>
|
<span class="summary-value">{targetCount}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -602,6 +784,68 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Achievements Section -->
|
||||||
|
{#if award?.achievements && award.achievements.length > 0 && achievementProgress}
|
||||||
|
<div class="achievements-section">
|
||||||
|
<h2>Achievements</h2>
|
||||||
|
|
||||||
|
<!-- Earned Achievements -->
|
||||||
|
{#if achievementProgress.earned.length > 0}
|
||||||
|
<div class="earned-achievements">
|
||||||
|
{#each achievementProgress.earned as achievement}
|
||||||
|
<div class="achievement-badge earned">
|
||||||
|
<span class="achievement-icon">★</span>
|
||||||
|
<span class="achievement-name">{achievement.name}</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>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Next Achievement Progress -->
|
||||||
|
{#if achievementProgress.nextLevel}
|
||||||
|
<div class="next-achievement">
|
||||||
|
<div class="next-achievement-header">
|
||||||
|
<span class="next-achievement-title">Next: {achievementProgress.nextLevel.name}</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
|
||||||
|
class="achievement-progress-fill"
|
||||||
|
style="width: {achievementProgress.progressPercent}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="next-achievement-footer">
|
||||||
|
{#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}
|
||||||
|
<div class="all-achievements-complete">
|
||||||
|
<span class="complete-icon">★</span>
|
||||||
|
<span>All achievements complete!</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mode-filter">
|
<div class="mode-filter">
|
||||||
<label for="mode-select">Filter by mode:</label>
|
<label for="mode-select">Filter by mode:</label>
|
||||||
<select id="mode-select" bind:value={selectedMode}>
|
<select id="mode-select" bind:value={selectedMode}>
|
||||||
@@ -905,7 +1149,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
@@ -1024,6 +1268,38 @@
|
|||||||
background-color: var(--color-warning-bg);
|
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 {
|
.summary-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -1432,6 +1708,130 @@
|
|||||||
background-color: var(--border-color-light);
|
background-color: var(--border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Achievements Section */
|
||||||
|
.achievements-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievements-section h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Earned Achievements */
|
||||||
|
.earned-achievements {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--border-radius-pill);
|
||||||
|
font-weight: 500;
|
||||||
|
border: 2px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-badge.earned {
|
||||||
|
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
|
||||||
|
border-color: #ff8f00;
|
||||||
|
color: #5d4037;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-threshold {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Next Achievement */
|
||||||
|
.next-achievement {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-achievement-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-achievement-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-achievement-count {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.achievement-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--color-primary) 0%, #6a1b9a 100%);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-achievement-footer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.needed-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All Complete */
|
||||||
|
.all-achievements-complete {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: #5d4037;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* QSO Count Link */
|
/* QSO Count Link */
|
||||||
.qso-count-link {
|
.qso-count-link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -877,7 +877,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
disabled={isRunning || deleting}
|
disabled={isRunning || deleting}
|
||||||
>
|
>
|
||||||
{#if isRunning}
|
{#if isRunning}
|
||||||
|
<span class="spinner"></span>
|
||||||
{label} Syncing...
|
{label} Syncing...
|
||||||
{:else}
|
{:else}
|
||||||
Sync from {label}
|
Sync from {label}
|
||||||
@@ -37,4 +38,22 @@
|
|||||||
.dcl-btn:hover:not(:disabled) {
|
.dcl-btn:hover:not(:disabled) {
|
||||||
background-color: #d35400;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -440,40 +440,46 @@
|
|||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button {
|
.back-button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background-color: #6c757d;
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: var(--text-inverted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button:hover {
|
.back-button:hover {
|
||||||
background-color: #5a6268;
|
background-color: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
background: #f8f9fa;
|
background: var(--bg-secondary);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info h2 {
|
.user-info h2 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info p {
|
.user-info p {
|
||||||
margin: 0.5rem 0;
|
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 {
|
.settings-section {
|
||||||
@@ -483,44 +489,44 @@
|
|||||||
.settings-section h2 {
|
.settings-section h2 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-text {
|
.help-text {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-info {
|
.alert-info {
|
||||||
background-color: #d1ecf1;
|
background-color: var(--color-info-bg);
|
||||||
border: 1px solid #bee5eb;
|
border: 1px solid var(--color-info);
|
||||||
color: #0c5460;
|
color: var(--color-info-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-error {
|
.alert-error {
|
||||||
background-color: #f8d7da;
|
background-color: var(--color-error-bg);
|
||||||
border: 1px solid #f5c6cb;
|
border: 1px solid var(--color-error);
|
||||||
color: #721c24;
|
color: var(--color-error-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-success {
|
.alert-success {
|
||||||
background-color: #d4edda;
|
background-color: var(--color-success-bg);
|
||||||
border: 1px solid #c3e6cb;
|
border: 1px solid var(--color-success);
|
||||||
color: #155724;
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-form {
|
.settings-form {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,34 +538,36 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input {
|
.form-group input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus {
|
.form-group input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4a90e2;
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -567,12 +575,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: #4a90e2;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: var(--text-inverted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background-color: #357abd;
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
@@ -581,34 +589,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: #6c757d;
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: var(--text-inverted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background-color: #5a6268;
|
background-color: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background: #e8f4fd;
|
background: var(--color-info-bg);
|
||||||
border-left: 4px solid #4a90e2;
|
border-left: 4px solid var(--color-primary);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box h3 {
|
.info-box h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box p {
|
.info-box p {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box a {
|
.info-box a {
|
||||||
color: #4a90e2;
|
color: var(--text-link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +642,7 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-group input[type="checkbox"] {
|
.checkbox-group input[type="checkbox"] {
|
||||||
@@ -649,18 +658,18 @@
|
|||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: 1px solid var(--border-color);
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.next-sync-info {
|
.next-sync-info {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background-color: #e3f2fd;
|
background-color: var(--color-info-bg);
|
||||||
border-left: 4px solid #4a90e2;
|
border-left: 4px solid var(--color-primary);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
1
start.sh
1
start.sh
@@ -2,4 +2,5 @@
|
|||||||
# Production start script
|
# Production start script
|
||||||
# Run backend server (Elysia errors are harmless warnings that don't affect functionality)
|
# Run backend server (Elysia errors are harmless warnings that don't affect functionality)
|
||||||
|
|
||||||
|
export LOG_LEVEL=debug
|
||||||
exec bun src/backend/index.js
|
exec bun src/backend/index.js
|
||||||
|
|||||||
Reference in New Issue
Block a user