Compare commits

...

32 Commits

Author SHA1 Message Date
239963ed89 feat: implement theme switching system with light and dark modes
Add complete theme switching system supporting Light Mode, Dark Mode, and
System preference (follows OS setting). Uses CSS custom properties for all
colors and Svelte store for state management with localStorage persistence.

New files:
- src/frontend/src/lib/stores/theme.js: Theme state management store
- src/frontend/src/app.css: CSS variables for light/dark themes
- src/frontend/src/lib/components/ThemeSwitcher.svelte: Theme switcher UI

Updated all components to use CSS variables instead of hardcoded colors:
- Main pages (home, awards, QSOs, settings, auth)
- Admin dashboard and award management pages
- Shared components (BackButton, ErrorDisplay, Loading)

Features:
- localStorage persistence for user preference
- System preference detection via matchMedia API
- FOUC prevention with inline script in app.html
- Smooth theme transitions across entire application

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 11:46:41 +01:00
d1e4c39ad6 feat: add last_seen tracking for users
Adds last_seen field to track when users last accessed the tool:
- Add lastSeen column to users table schema (nullable timestamp)
- Create migration to add last_seen column to existing databases
- Add updateLastSeen() function to auth.service.js
- Update auth derive middleware to update last_seen on each authenticated request (async, non-blocking)
- Add lastSeen to admin getUserStats() query for display in admin users table
- Add "Last Seen" column to admin users table in frontend

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 09:57:45 +01:00
24e0e3bfdb fix: correct target and needed calculation in award detail view
Fixes incorrect calculation of target and needed values in award detail page:
- Changed award.target to award.rules?.target (correct path in JSON structure)
- Added missing Target display card for non-points awards (DXCC, DLD, etc.)
- Added null checks to prevent broken calculations for awards without targets

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 09:33:42 +01:00
36453c8922 fix: resolve stations editor reactivity issue in award admin
Use Svelte stores with value/on:input instead of bind:value for
stations array to properly track nested property changes. Add invisible
reactivity triggers to force Svelte to track station callsign/points properties.

Also update award sorting to handle numeric prefixes in numerical order
(44 before 73) and update RS-44 satellite award category.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 09:27:08 +01:00
bd89ea0855 feat: implement award definition editor with safety validation
Add comprehensive admin-only UI for managing award definitions stored as JSON files.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 08:16:28 +01:00
b9b6afedb8 docs: add modeGroups feature to award system specification
Document the modeGroups property for award definitions, which allows
creating convenient multi-mode filters in the award detail view.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 07:27:29 +01:00
85d171adc8 docs: document mode groups feature in CLAUDE.md and README.md
Add documentation for the new configurable mode groups feature:
- CLAUDE.md: Add modeGroups to Award Rule Options section
- CLAUDE.md: Update Award Detail View section with mode group info
- CLAUDE.md: Add to Recent Development Work (January 2026)
- README.md: Add GET /api/awards/:awardId endpoint
- README.md: Add new Mode Groups section in Features in Detail

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 07:19:49 +01:00
cd361115fe feat: add configurable mode groups to award detail view
Add per-award configurable mode groups for filtering multiple modes
together in the award detail view. Mode groups are displayed with
visual separators in the mode filter dropdown.

Backend changes:
- Add modeGroups to getAllAwards() return mapping
- Add getAwardById() function to fetch single award definition
- Add GET /api/awards/:awardId endpoint

Frontend changes:
- Fetch award definition separately to get modeGroups
- Update availableModes to include mode groups with separator
- Update filteredEntities logic to handle mode groups
- Update groupDataForTable() and applyFilter() for mode groups
- Disable separator option in dropdown

Award definitions:
- DXCC: Add Digi-Modes, Classic Digi-Modes, Mixed-Mode w/o WSJT-Modes,
  Phone-Modes groups
- DLD: Add same mode groups (adjusted for available modes)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 07:06:12 +01:00
69b33720b3 fix: prevent infinite retry loop for auto-sync users without credentials
When auto-sync is enabled but credentials (LoTW/DCL) are removed, the
scheduler would continuously try to sync every minute, logging the same
warning forever.

Now:
- Split pending users into those with and without credentials
- For users without credentials, update nextSyncAt to retry in 24 hours
- Log a warning with affected user IDs
- Only return users with valid credentials for job processing

This prevents log spam and unnecessary database queries while still
periodically checking if credentials have been restored.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 13:17:16 +01:00
648cf2c5a5 feat: implement auto-sync scheduler for LoTW and DCL
Add automatic synchronization scheduler that allows users to configure
periodic sync intervals for LoTW and DCL via the settings page.

Features:
- Users can enable/disable auto-sync per service (LoTW/DCL)
- Configurable sync intervals (1-720 hours)
- Settings page UI for managing auto-sync preferences
- Dashboard shows upcoming scheduled auto-sync jobs
- Scheduler runs every minute, triggers syncs when due
- Survives server restarts via database persistence
- Graceful shutdown support (SIGINT/SIGTERM)

Backend:
- New autoSyncSettings table with user preferences
- auto-sync.service.js for CRUD operations and scheduling logic
- scheduler.service.js for periodic tick processing
- API endpoints: GET/PUT /auto-sync/settings, GET /auto-sync/scheduler/status

Frontend:
- Auto-sync settings section in settings page
- Upcoming auto-sync section on dashboard with scheduled job cards
- Purple-themed UI for scheduled jobs with countdown animation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 12:40:55 +01:00
cce520a00e chore: code cleanup - remove duplicates and add caching
- Delete duplicate getCacheStats() function in cache.service.js
- Fix date calculation bug in lotw.service.js (was Date.now()-Date.now())
- Extract duplicate helper functions (yieldToEventLoop, getQSOKey) to sync-helpers.js
- Cache award definitions in memory to avoid repeated file I/O
- Delete unused parseDCLJSONResponse() function
- Remove unused imports (getPerformanceSummary, resetPerformanceMetrics)
- Auto-discover award JSON files instead of hardcoded list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 10:22:00 +01:00
d9e0e462b9 docs: update CLAUDE.md with recent changes
Rewrote documentation to reflect current state of the application:

- Updated award list (DXCC, DXCC SAT, DLD, WAS, VUCC SAT, SAT-RS44, 73 on 73)
- Added allowed_bands and satellite_only rule options
- Added DXCC SAT award documentation
- Added QSO deletion endpoint
- Updated award detail view features (unique entity progress, satellite grouping, band sorting)
- Removed outdated DLD variant examples
- Streamlined and reorganized sections

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:47:11 +01:00
ebdd75e03f fix: invalidate caches after deleting QSOs
After deleting all QSOs, invalidate the stats and user caches so the
QSO page shows updated statistics instead of stale cached data.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:28:37 +01:00
205b311244 fix: handle foreign key constraints when deleting QSOs
The qso_changes table has a foreign key reference to qsos.id, which
was preventing QSO deletion. Now deletes related qso_changes records
first before deleting QSOs.

Also added better error logging to the DELETE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:26:43 +01:00
6bc0a2f9b2 fix: return correct count from deleteQSOs function
The db.delete() returns a result object with a 'changes' property
indicating the number of affected rows, not the count directly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:22:13 +01:00
8550b91255 feat: add DXCC SAT award for satellite-only QSOs
Added new award "DXCC SAT" that only counts satellite QSOs (QSOs with
satName field set). This adds a new "satellite_only" key to award
definitions that filters to only include satellite communications.

Award definition:
- ID: dxcc-sat
- Name: DXCC SAT
- Target: 100 DXCC entities
- Only satellite QSOs count

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:25:13 +01:00
a93d4ff85b refactor: remove DLD 80m CW award variant
Removed dld-80m-cw.json award definition. Only the main DLD award
remains, which covers all bands and modes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:22:58 +01:00
f3ee1be651 refactor: remove DLD variant awards (80m, 40m, CW)
Removed the following DLD award variants:
- dld-80m.json
- dld-40m.json
- dld-cw.json

Kept dld-80m-cw.json as it represents a more specific combination.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:22:09 +01:00
6c9aa1efe7 feat: add allowed_bands filter to award definitions
Adds a new "allowed_bands" key to award definitions that restricts which
bands count toward an award. If absent, all bands are allowed (default
behavior).

Applied to DXCC award to only count HF bands (160m-10m), excluding
VHF/UHF bands like 6m, 2m, and 70cm.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:19:32 +01:00
14c7319c9e refactor: remove DXCC CW award and rename DXCC Mixed Mode to DXCC
- Removed dxcc-cw.json award definition
- Renamed "DXCC Mixed Mode" to "DXCC" in dxcc.json
- Changed award ID from "dxcc-mixed" to "dxcc"
- Removed dxcc-cw.json from awards service file list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:14:33 +01:00
5792a98dca feat: sort band columns by wavelength instead of alphabetically
Band columns are now sorted by wavelength (longest to shortest):
160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm, SAT.

Unknown bands are sorted to the end.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:10:24 +01:00
aa25d21c6b fix: count unique entities in column sums instead of QSO counts
Column sums now correctly count unique entities (e.g., unique DXCC
countries per band) instead of counting individual entity entries or
QSOs. This matches the award progress semantics.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:07:38 +01:00
e14da11a93 fix: correct column sum calculation for satellite QSOs
The SAT column sum was always showing 0 because it was filtering by
e.band === 'SAT', but entities still have their original band in the
data. Now it correctly identifies satellite QSOs by checking if any
QSOs have satName.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:04:51 +01:00
dc34fc20b1 feat: group satellite QSOs under SAT column in award detail
Satellite QSOs are now grouped under a "SAT" column instead of their
frequency band. The backend now includes satName in QSO data, and the
frontend detects satellite QSOs and groups them appropriately.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:03:03 +01:00
c75e55d130 feat: show unique entity progress in award summary
Summary cards now display unique entity counts (e.g., unique DXCC countries)
instead of per-band/mode slot counts. This shows actual award progress:
Total entities worked, confirmed, and needed to reach the award target.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:58:21 +01:00
89edd07722 feat: make award summary respect mode filter and remove mode from table headers
Summary cards (Total, Confirmed, Worked, Needed) now update based on the
selected mode filter. Also removed redundant mode display from table column
headers since the mode is already visible in the filter dropdown.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:52:13 +01:00
dd3beef9af feat: add award detail view with QSO count per slot and mode filter
- Award detail page now shows QSO counts per (entity, band, mode) slot
- Click count to open modal with all QSOs for that slot
- Click QSO in list to view full details
- Add mode filter: "Mixed Mode" aggregates by band, specific modes show (band, mode) columns
- Backend groups by slot and collects all confirmed QSOs in qsos array
- Frontend displays clickable count links (removed blue bubbles)

Backend changes:
- calculateDOKAwardProgress(): groups by (DOK, band, mode), collects qsos array
- calculatePointsAwardProgress(): updated for all count modes with qsos array
- getAwardEntityBreakdown(): groups by (entity, band, mode) slots

Frontend changes:
- Add mode filter dropdown with "Mixed Mode" default
- Update grouping logic to handle mixed mode vs specific mode
- Replace count badges with simple clickable links
- Add QSO list modal showing all QSOs per slot
- Add Mode column to QSO list (useful in mixed mode)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:34:55 +01:00
695000e35c docs: add comprehensive award system specification
Add complete specification document for the JSON-driven award
calculation system. Documents all rule types, filter operators,
QSO schema, and implementation guidance suitable for porting
to any programming language.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:50:38 +01:00
bdd8aa497d fix: admin action log and impersonation improvements
- Fix admin action log not displaying entries (use raw sqlite for self-join)
- Add global impersonation banner to all pages during impersonation
- Fix timestamp display in action log (convert Unix seconds to milliseconds)
- Add loginWithToken method to auth store for direct token authentication
- Fix /api/auth/me to include impersonatedBy field from JWT
- Remove duplicate impersonation code from admin page

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:26:20 +01:00
7c209e3270 fix: correct last-sync date and logout redirect issues
- Fix admin users last-sync showing 1970 instead of actual sync date
  - Changed from MAX(qsos.createdAt) to MAX(syncJobs.completedAt)
  - Added timestamp conversion (seconds to milliseconds) for proper Date serialization
- Fix logout redirect not working from admin dashboard
  - Changed from goto() to window.location.href for hard redirect
  - Ensures proper navigation after auth state changes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 17:49:27 +01:00
6d3291e331 chore: consolidate env templates and remove Docker docs from master
- Merge .env.production.template into .env.example
- Remove Docker Deployment section from CLAUDE.md (now on docker branch)
- Update README.md to reference .env.example
- Update environment variable documentation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:08:27 +01:00
c0a471f7c2 chore: remove Docker files from master branch
Master is now for standalone/run-on-metal deployment only.
Docker-related files moved to dedicated 'docker' branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:05:39 +01:00
58 changed files with 9715 additions and 1733 deletions

View File

@@ -1,61 +0,0 @@
# Dependencies
node_modules
# Note: bun.lock is needed by Dockerfile for --frozen-lockfile
# Environment
.env
.env.*
!.env.example
# Database - will be in volume mount
**/*.db
**/*.db-shm
**/*.db-wal
# Build outputs - built in container
src/frontend/build/
src/frontend/.svelte-kit/
src/frontend/dist/
build/
dist/
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Documentation (keep docs in image but don't need in build context)
# README.md
docs/
*.md
# Logs
logs/
*.log
backend.log
# Tests
*.test.js
*.test.ts
coverage/
# Docker files
Dockerfile
docker-compose.yml
.dockerignore
# CI/CD
.github/
.gitlab-ci.yml
# Data directory (for volume mount)
data/

View File

@@ -1,26 +0,0 @@
# Docker Environment Configuration
# Copy this file to .env and update with your values
# ============================================
# Application Settings
# ============================================
NODE_ENV=production
PORT=3001
LOG_LEVEL=debug
# ============================================
# Security (IMPORTANT: Change in production!)
# ============================================
# Generate a secure JWT secret with: openssl rand -base64 32
JWT_SECRET=change-this-in-production-use-openssl-rand-base64-32
# ============================================
# CORS Configuration
# ============================================
# Your application's public URL (e.g., https://awards.example.com)
VITE_APP_URL=
# Comma-separated list of allowed origins for CORS
# Only needed if not using same domain deployment
# Example: https://awards.example.com,https://www.awards.example.com
ALLOWED_ORIGINS=

View File

@@ -1,22 +1,47 @@
# Application Configuration # Application Configuration
# Copy this file to .env and update with your values # Copy this file to .env and update with your values
# Hostname for the application (e.g., https://awards.dj7nt.de) # ===================================================================
# Environment
# ===================================================================
# Development: development
# Production: production
NODE_ENV=development
# Log Level (debug, info, warn, error)
# Development: debug
# Production: info
LOG_LEVEL=debug
# Server Port (default: 3001)
PORT=3001
# ===================================================================
# URLs
# ===================================================================
# Frontend URL (e.g., https://awards.dj7nt.de)
# Leave empty for development (uses localhost) # Leave empty for development (uses localhost)
VITE_APP_URL= VITE_APP_URL=
# API Base URL (in production, can be same domain or separate) # API Base URL (leave empty for same-domain deployment)
# Leave empty to use relative paths (recommended for same-domain deployment) # Only set if API is on different domain
VITE_API_BASE_URL= VITE_API_BASE_URL=
# Allowed CORS origins for backend (comma-separated) # Allowed CORS origins for backend (comma-separated)
# Only needed for production if not using same domain # Add all domains that should access the API
# Example: https://awards.dj7nt.de,https://www.awards.dj7nt.de # Example: https://awards.dj7nt.de,https://www.awards.dj7nt.de
ALLOWED_ORIGINS= ALLOWED_ORIGINS=
# JWT Secret (for production, use a strong random string) # ===================================================================
# Generate with: openssl rand -base64 32 # Security
# ===================================================================
# JWT Secret (REQUIRED for production)
# Development: uses default if not set
# Production: Generate with: openssl rand -base64 32
JWT_SECRET=change-this-in-production JWT_SECRET=change-this-in-production
# Node Environment # ===================================================================
NODE_ENV=development # Database (Optional)
# ===================================================================
# Leave empty to use default SQLite database
# DATABASE_URL=file:/path/to/custom.db

View File

@@ -1,30 +0,0 @@
# Production Configuration Template
# Copy this file to .env.production and update with your production values
# Application Environment
NODE_ENV=production
# Log Level (debug, info, warn, error)
# Recommended: info for production
LOG_LEVEL=info
# Server Port (default: 3001)
PORT=3001
# Frontend URL (e.g., https://awards.dj7nt.de)
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL=
# Allowed CORS origins (comma-separated)
# Add all domains that should access the API
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
# JWT Secret (REQUIRED - generate a strong secret!)
# Generate with: openssl rand -base64 32
JWT_SECRET=REPLACE_WITH_SECURE_RANDOM_STRING
# Database (if using external database)
# Leave empty to use default SQLite database
# DATABASE_URL=file:/path/to/production.db

481
CLAUDE.md
View File

@@ -77,58 +77,6 @@ test("hello world", () => {
}); });
``` ```
## Docker Deployment
The application supports Docker deployment with single-port architecture and host-mounted database persistence.
**Quick Start**:
```bash
# Create environment file
cp .env.docker.example .env
# Generate JWT secret
openssl rand -base64 32 # Add to .env as JWT_SECRET
# Start application
docker-compose up -d --build
# Access at http://localhost:3001
```
**Architecture**:
- **Single Port**: Port 3001 serves both API (`/api/*`) and frontend (all other routes)
- **Database Persistence**: SQLite database stored at `./data/award.db` on host
- **Auto-initialization**: Database created from template on first startup
- **Health Checks**: Built-in health monitoring at `/api/health`
**Key Docker Files**:
- `Dockerfile`: Multi-stage build using official Bun runtime
- `docker-compose.yml`: Stack orchestration with volume mounts
- `docker-entrypoint.sh`: Database initialization logic
- `.env.docker.example`: Environment variable template
- `DOCKER.md`: Complete deployment documentation
**Environment Variables**:
- `NODE_ENV`: Environment mode (default: production)
- `PORT`: Application port (default: 3001)
- `LOG_LEVEL`: Logging level (debug/info/warn/error)
- `JWT_SECRET`: JWT signing secret (required, change in production!)
- `VITE_APP_URL`: Your application's public URL
- `ALLOWED_ORIGINS`: CORS allowed origins (comma-separated)
**Database Management**:
- Database location: `./data/award.db` (host-mounted volume)
- Backups: `cp data/award.db data/award.db.backup.$(date +%Y%m%d)`
- Reset: `docker-compose down -v && docker-compose up -d`
**Important Notes**:
- Database persists across container restarts/recreations
- Frontend dependencies are reinstalled in container to ensure correct platform binaries
- Uses custom init script (`src/backend/scripts/init-db.js`) with `bun:sqlite`
- Architecture-agnostic (works on x86, ARM64, etc.)
For detailed documentation, see `DOCKER.md`.
## Frontend ## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
@@ -222,6 +170,8 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
1. **`entity`**: Count unique entities (DXCC countries, states, grid squares) 1. **`entity`**: Count unique entities (DXCC countries, states, grid squares)
- `entityType`: What to count ("dxcc", "state", "grid", "callsign") - `entityType`: What to count ("dxcc", "state", "grid", "callsign")
- `target`: Number required for award - `target`: Number required for award
- `allowed_bands`: Optional array of bands that count (e.g., `["160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]` for HF only)
- `satellite_only`: Optional boolean to only count satellite QSOs (QSOs with `satName` field)
- `filters`: Optional filters (band, mode, etc.) - `filters`: Optional filters (band, mode, etc.)
- `displayField`: Optional field to display - `displayField`: Optional field to display
@@ -231,7 +181,6 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- `filters`: Optional filters (band, mode, etc.) for award variants - `filters`: Optional filters (band, mode, etc.) for award variants
- Counts unique (DOK, band, mode) combinations - Counts unique (DOK, band, mode) combinations
- Only DCL-confirmed QSOs count - Only DCL-confirmed QSOs count
- Example variants: DLD 80m, DLD CW, DLD 80m CW
3. **`points`**: Point-based awards 3. **`points`**: Point-based awards
- `stations`: Array of {callsign, points} - `stations`: Array of {callsign, points}
@@ -244,6 +193,16 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
5. **`counter`**: Count QSOs or callsigns 5. **`counter`**: Count QSOs or callsigns
### Current Awards
- **DXCC**: HF bands only (160m-10m), 100 entities required
- **DXCC SAT**: Satellite QSOs only, 100 entities required
- **WAS**: Worked All States award
- **VUCC SAT**: VUCC Satellite award
- **SAT-RS44**: Special satellite award
- **73 on 73**: Special stations award
- **DLD**: Deutschland Diplom, 100 unique DOKs required
### Key Files ### Key Files
**Backend Award Service**: `src/backend/services/awards.service.js` **Backend Award Service**: `src/backend/services/awards.service.js`
@@ -253,11 +212,13 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- `calculatePointsAwardProgress(userId, award, options)`: Point-based calculation - `calculatePointsAwardProgress(userId, award, options)`: Point-based calculation
- `getAwardEntityBreakdown(userId, awardId)`: Detailed entity breakdown - `getAwardEntityBreakdown(userId, awardId)`: Detailed entity breakdown
- `getAwardProgressDetails(userId, awardId)`: Progress with details - `getAwardProgressDetails(userId, awardId)`: Progress with details
- Implements `allowed_bands` and `satellite_only` filtering
**Database Schema**: `src/backend/db/schema/index.js` **Database Schema**: `src/backend/db/schema/index.js`
- QSO fields include: `darcDok`, `dclQslRstatus`, `dclQslRdate` - QSO fields include: `darcDok`, `dclQslRstatus`, `dclQslRdate`, `satName`
- DOK fields support DLD award tracking - DOK fields support DLD award tracking
- DCL confirmation fields separate from LoTW - DCL confirmation fields separate from LoTW
- `satName` field for satellite QSO tracking
**Award Definitions**: `award-definitions/*.json` **Award Definitions**: `award-definitions/*.json`
- Add new awards by creating JSON definition files - Add new awards by creating JSON definition files
@@ -268,7 +229,6 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- Handles case-insensitive `<EOR>` delimiters (supports `<EOR>`, `<eor>`, `<Eor>`) - Handles case-insensitive `<EOR>` delimiters (supports `<EOR>`, `<eor>`, `<Eor>`)
- Uses `matchAll()` for reliable field parsing - Uses `matchAll()` for reliable field parsing
- Skips header records automatically - Skips header records automatically
- `parseDCLResponse(response)`: Parse DCL's JSON response format `{ "adif": "..." }`
- `normalizeBand(band)`: Standardize band names (80m, 40m, etc.) - `normalizeBand(band)`: Standardize band names (80m, 40m, etc.)
- `normalizeMode(mode)`: Standardize mode names (CW, FT8, SSB, etc.) - `normalizeMode(mode)`: Standardize mode names (CW, FT8, SSB, etc.)
- Used by both LoTW and DCL services for consistency - Used by both LoTW and DCL services for consistency
@@ -289,6 +249,7 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- `POST /api/dcl/sync`: Queue DCL sync job - `POST /api/dcl/sync`: Queue DCL sync job
- `GET /api/jobs/:jobId`: Get job status - `GET /api/jobs/:jobId`: Get job status
- `GET /api/jobs/active`: Get active job for current user - `GET /api/jobs/active`: Get active job for current user
- `DELETE /api/qsos/all`: Delete all QSOs for authenticated user
- `GET /*`: Serves static files from `src/frontend/build/` with SPA fallback - `GET /*`: Serves static files from `src/frontend/build/` with SPA fallback
**SPA Routing**: The backend serves the SvelteKit frontend build from `src/frontend/build/`. **SPA Routing**: The backend serves the SvelteKit frontend build from `src/frontend/build/`.
@@ -314,9 +275,9 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- Fully implemented and functional - Fully implemented and functional
- **Note**: DCL API is a custom prototype by DARC; contact DARC for API specification details - **Note**: DCL API is a custom prototype by DARC; contact DARC for API specification details
### DLD Award Implementation (COMPLETED) ### DLD Award Implementation
The DLD (Deutschland Diplom) award was recently implemented: The DLD (Deutschland Diplom) award:
**Definition**: `award-definitions/dld.json` **Definition**: `award-definitions/dld.json`
```json ```json
@@ -336,7 +297,7 @@ The DLD (Deutschland Diplom) award was recently implemented:
``` ```
**Implementation Details**: **Implementation Details**:
- Function: `calculateDOKAwardProgress()` in `src/backend/services/awards.service.js` (lines 173-268) - Function: `calculateDOKAwardProgress()` in `src/backend/services/awards.service.js`
- Counts unique (DOK, band, mode) combinations - Counts unique (DOK, band, mode) combinations
- Only DCL-confirmed QSOs count (`dclQslRstatus === 'Y'`) - Only DCL-confirmed QSOs count (`dclQslRstatus === 'Y'`)
- Each unique DOK on each unique band/mode counts separately - Each unique DOK on each unique band/mode counts separately
@@ -349,8 +310,6 @@ The DLD (Deutschland Diplom) award was recently implemented:
- `dclQslRstatus`: DCL confirmation status ('Y' = confirmed) - `dclQslRstatus`: DCL confirmation status ('Y' = confirmed)
- `dclQslRdate`: DCL confirmation date - `dclQslRdate`: DCL confirmation date
**Documentation**: See `docs/DOCUMENTATION.md` for complete documentation including DLD award example.
**Frontend**: `src/frontend/src/routes/qsos/+page.svelte` **Frontend**: `src/frontend/src/routes/qsos/+page.svelte`
- Separate sync buttons for LoTW (blue) and DCL (orange) - Separate sync buttons for LoTW (blue) and DCL (orange)
- Independent progress tracking for each sync type - Independent progress tracking for each sync type
@@ -374,79 +333,59 @@ To add a new award:
3. If new rule type needed, add calculation function 3. If new rule type needed, add calculation function
4. Add type handling in `calculateAwardProgress()` switch statement 4. Add type handling in `calculateAwardProgress()` switch statement
5. Add type handling in `getAwardEntityBreakdown()` if needed 5. Add type handling in `getAwardEntityBreakdown()` if needed
6. Update documentation in `docs/DOCUMENTATION.md` 6. Update documentation
7. Test with sample QSO data 7. Test with sample QSO data
### Creating DLD Award Variants ### Award Rule Options
The DOK award type supports filters to create award variants. Examples: **allowed_bands**: Restrict which bands count toward an award
**DLD on 80m** (`dld-80m.json`):
```json
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact 100 different DOKs on the 80m band.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" }
]
}
}
}
```
**DLD in CW mode** (`dld-cw.json`):
```json ```json
{ {
"rules": { "rules": {
"type": "dok", "type": "entity",
"target": 100, "allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
"confirmationType": "dcl",
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
} }
} }
``` ```
- If absent or empty, all bands are allowed (default behavior)
- Used for DXCC to restrict to HF bands only
**DLD on 80m using CW** (combined filters, `dld-80m-cw.json`): **satellite_only**: Only count satellite QSOs
```json ```json
{ {
"rules": { "rules": {
"type": "dok", "type": "entity",
"target": 100, "satellite_only": true
"confirmationType": "dcl",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
} }
} }
``` ```
- If `true`, only QSOs with `satName` field set are counted
- Used for DXCC SAT award
**Available filter operators**: **modeGroups**: Define mode groups for filtering in award detail view
```json
{
"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"]
}
}
```
- Optional field at award definition level (not in `rules`)
- Key is the display name shown in the mode filter dropdown
- Value is an array of mode strings to include in the group
- Used to create convenient mode filters that combine multiple modes
- Awards without `modeGroups` work as before (backward compatible)
**filters**: Additional filtering options
- `eq`: equals - `eq`: equals
- `ne`: not equals - `ne`: not equals
- `in`: in array - `in`: in array
- `nin`: not in array - `nin`: not in array
- `contains`: contains substring - `contains`: contains substring
- Can filter any QSO field (band, mode, callsign, grid, state, etc.)
**Available filter fields**: Any QSO field (band, mode, callsign, grid, state, satName, etc.)
### Confirmation Systems ### Confirmation Systems
@@ -466,13 +405,8 @@ The DOK award type supports filters to create award variants. Examples:
- Required for DLD award - Required for DLD award
- German amateur radio specific - German amateur radio specific
- Request format: POST JSON `{ key, limit, qsl_since, qso_since, cnf_only }` - Request format: POST JSON `{ key, limit, qsl_since, qso_since, cnf_only }`
- `cnf_only: null` - Fetch all QSOs (confirmed + unconfirmed)
- `cnf_only: true` - Fetch only confirmed QSOs
- `qso_since` - QSOs since this date (YYYYMMDD)
- `qsl_since` - QSL confirmations since this date (YYYYMMDD)
- Response format: JSON with ADIF string in `adif` field - Response format: JSON with ADIF string in `adif` field
- Syncs ALL QSOs (both confirmed and unconfirmed) - Syncs ALL QSOs (both confirmed and unconfirmed)
- Unconfirmed QSOs stored but don't count toward awards
- Updates QSOs only if confirmation data has changed - Updates QSOs only if confirmation data has changed
### ADIF Format ### ADIF Format
@@ -491,138 +425,13 @@ Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
- `MY_DARC_DOK`: User's own DOK - `MY_DARC_DOK`: User's own DOK
- `STATION_CALLSIGN`: User's callsign - `STATION_CALLSIGN`: User's callsign
### Recent Commits ### QSO Management
- `aeeb75c`: feat: add QSO count display to filter section **Delete All QSOs**: `DELETE /api/qsos/all`
- Shows count of QSOs matching current filters next to "Filters" heading - Deletes all QSOs for authenticated user
- Displays "Showing X filtered QSOs" when filters are active - Also deletes related `qso_changes` records to satisfy foreign key constraints
- Displays "Showing X total QSOs" when no filters applied - Invalidates stats and user caches after deletion
- Dynamically updates when filters change - Returns count of deleted QSOs
- `bee02d1`: fix: count QSOs confirmed by either LoTW or DCL in stats
- QSO stats were only counting LoTW-confirmed QSOs (`lotwQslRstatus === 'Y'`)
- QSOs confirmed only by DCL were excluded from "confirmed" count
- Fixed by changing filter to: `q.lotwQslRstatus === 'Y' || q.dclQslRstatus === 'Y'`
- Now correctly shows all QSOs confirmed by at least one system
- `233888c`: fix: make ADIF parser case-insensitive for EOR delimiter
- **Critical bug**: LoTW uses lowercase `<eor>` tags, parser was splitting on uppercase `<EOR>`
- Caused 242K+ QSOs to be parsed as 1 giant record with fields overwriting each other
- Changed to case-insensitive regex: `new RegExp('<eor>', 'gi')`
- Replaced `regex.exec()` while loop with `matchAll()` for-of iteration
- Now correctly imports all QSOs from large LoTW reports
- `645f786`: fix: add missing timeOn field to LoTW duplicate detection
- LoTW sync was missing `timeOn` in duplicate detection query
- Multiple QSOs with same callsign/date/band/mode but different times were treated as duplicates
- Now matches DCL sync logic: `userId, callsign, qsoDate, timeOn, band, mode`
- `7f77c3a`: feat: add filter support for DOK awards
- DOK award type now supports filtering by band, mode, and other QSO fields
- Allows creating award variants like DLD 80m, DLD CW, DLD 80m CW
- Uses existing filter system with eq, ne, in, nin, contains operators
- Example awards created: dld-80m, dld-40m, dld-cw, dld-80m-cw
- `9e73704`: docs: update CLAUDE.md with DLD award variants documentation
- `7201446`: fix: return proper HTML for SPA routes instead of Bun error page
- When accessing client-side routes (like /qsos) via curl or non-JS clients,
the server attempted to open them as static files, causing Bun to throw
an unhandled ENOENT error that showed an ugly error page
- Now checks if a path has a file extension before attempting to serve it
- Paths without extensions are immediately served index.html for SPA routing
- Also improves the 503 error page with user-friendly HTML when frontend build is missing
- `223461f`: fix: enable debug logging and improve DCL sync observability
- `27d2ef1`: fix: preserve DOK data when DCL doesn't send values
- DCL sync only updates DOK/grid fields when DCL provides non-empty values
- Prevents accidentally clearing DOK data from manual entry or other sources
- Preserves existing DOK when DCL syncs QSO without DOK information
- `e09ab94`: feat: skip QSOs with unchanged confirmation data
- LoTW/DCL sync only updates QSOs if confirmation data has changed
- Tracks added, updated, and skipped QSO counts
- LoTW: Checks if lotwQslRstatus or lotwQslRdate changed
- DCL: Checks if dclQslRstatus, dclQslRdate, darcDok, myDarcDok, or grid changed
- `3592dbb`: feat: add import log showing synced QSOs
- Backend returns addedQSOs and updatedQSOs arrays in sync result
- Frontend displays import log with callsign, date, band, mode for each QSO
- Separate sections for "New QSOs" and "Updated QSOs"
- Sync summary shows total, added, updated, skipped counts
- `8a1a580`: feat: implement DCL ADIF parser and service integration
- Add shared ADIF parser utility (src/backend/utils/adif-parser.js)
- Implement DCL service with API integration
- Refactor LoTW service to use shared parser
- Tested with example DCL payload (6 QSOs parsed successfully)
- `c982dcd`: feat: implement DLD (Deutschland Diplom) award
- `322ccaf`: docs: add DLD (Deutschland Diplom) award documentation
### Sync Behavior
**Import Log**: After each sync, displays a table showing:
- New QSOs: Callsign, Date, Band, Mode
- Updated QSOs: Callsign, Date, Band, Mode (only if data changed)
- Skipped QSOs: Counted but not shown (data unchanged)
**Duplicate Handling**:
- QSOs matched by: userId, callsign, qsoDate, timeOn, band, mode
- If confirmation data unchanged: Skipped (not updated)
- If confirmation data changed: Updated with new values
- Prevents unnecessary database writes and shows accurate import counts
**DOK Update Behavior**:
- If QSO imported via LoTW (no DOK) and later DCL confirms with DOK: DOK is added ✓
- If QSO already has DOK and DCL sends different DOK: DOK is updated ✓
- If QSO has DOK and DCL syncs without DOK (empty): Existing DOK is preserved ✓
- LoTW never sends DOK data; only DCL provides DOK fields
**Important**: DCL sync only updates DOK/grid fields when DCL provides non-empty values. This prevents accidentally clearing DOK data that was manually entered or imported from other sources.
### DCL Sync Strategy
**Current Behavior**: DCL syncs ALL QSOs (confirmed + unconfirmed)
The application syncs both confirmed and unconfirmed QSOs from DCL:
- **Confirmed QSOs**: `dclQslRstatus = 'Y'` - Count toward awards
- **Unconfirmed QSOs**: `dclQslRstatus = 'N'` - Stored but don't count toward awards
**Purpose of syncing unconfirmed QSOs**:
- Users can see who they've worked (via "Not Confirmed" filter)
- Track QSOs awaiting confirmation
- QSOs can get confirmed later and will be updated on next sync
**Award Calculation**: Always uses confirmed QSOs only (e.g., `dclQslRstatus === 'Y'` for DLD award)
### DCL Incremental Sync Strategy
**Challenge**: Need to fetch both new QSOs AND confirmation updates to old QSOs
**Example Scenario**:
1. Full sync on 2026-01-20 → Last QSO date: 2026-01-20
2. User works 3 new QSOs on 2026-01-25 (unconfirmed)
3. Old QSO from 2026-01-10 gets confirmed on 2026-01-26
4. Next sync needs both: new QSOs (2026-01-25) AND confirmation update (2026-01-10)
**Solution**: Use both `qso_since` and `qsl_since` parameters with OR logic
```javascript
// Proposed sync logic (requires OR logic from DCL API)
const lastQSODate = await getLastDCLQSODate(userId); // Track QSO dates
const lastQSLDate = await getLastDCLQSLDate(userId); // Track QSL dates
const requestBody = {
key: dclApiKey,
limit: 50000,
qso_since: lastQSODate, // Get new QSOs since last contact
qsl_since: lastQSLDate, // Get QSL confirmations since last sync
cnf_only: null, // Fetch all QSOs
};
```
**Required API Behavior (OR Logic)**:
- Return QSOs where `(qso_date >= qso_since) OR (qsl_date >= qsl_since)`
- This ensures we get both new QSOs and confirmation updates
**Current DCL API Status**:
- Unknown if current API uses AND or OR logic for combined filters
- **Action Needed**: Request OR logic implementation from DARC
- Test current behavior to confirm API response pattern
**Why OR Logic is Needed**:
- With AND logic: Old QSOs getting confirmed are missed (qso_date too old)
- With OR logic: All updates captured efficiently in one API call
### QSO Page Filters ### QSO Page Filters
@@ -630,34 +439,50 @@ The QSO page (`src/frontend/src/routes/qsos/+page.svelte`) includes advanced fil
**Available Filters**: **Available Filters**:
- **Search Box**: Full-text search across callsign, entity (DXCC country), and grid square fields - **Search Box**: Full-text search across callsign, entity (DXCC country), and grid square fields
- Press Enter to apply search
- Case-insensitive partial matching
- **Band Filter**: Dropdown to filter by amateur band (160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm) - **Band Filter**: Dropdown to filter by amateur band (160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm)
- **Mode Filter**: Dropdown to filter by mode (CW, SSB, AM, FM, RTTY, PSK31, FT8, FT4, JT65, JT9) - **Mode Filter**: Dropdown to filter by mode (CW, SSB, AM, FM, RTTY, PSK31, FT8, FT4, JT65, JT9)
- **Confirmation Type Filter**: Filter by confirmation status - **Confirmation Type Filter**: Filter by confirmation status
- "All QSOs": Shows all QSOs (no filter) - "All QSOs", "LoTW Only", "DCL Only", "Both Confirmed", "Not Confirmed"
- "LoTW Only": Shows QSOs confirmed by LoTW but NOT DCL - **Clear Button**: Resets all filters
- "DCL Only": Shows QSOs confirmed by DCL but NOT LoTW
- "Both Confirmed": Shows QSOs confirmed by BOTH LoTW AND DCL
- "Not Confirmed": Shows QSOs confirmed by NEITHER LoTW nor DCL
- **Clear Button**: Resets all filters and reloads all QSOs
**Backend Implementation** (`src/backend/services/lotw.service.js`): **Backend Implementation** (`src/backend/services/lotw.service.js`):
- `getUserQSOs(userId, filters, options)`: Main filtering function - `getUserQSOs(userId, filters, options)`: Main filtering function
- Supports pagination with `page` and `limit` options - Supports pagination with `page` and `limit` options
- Filter logic uses Drizzle ORM query builders for safe SQL generation - Filter logic uses Drizzle ORM query builders for safe SQL generation
- Debug logging when `LOG_LEVEL=debug` shows applied filters
**Frontend API** (`src/frontend/src/lib/api.js`): **Frontend API** (`src/frontend/src/lib/api.js`):
- `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters - `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters
- Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL` - Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL`
**QSO Count Display**: ### Award Detail View
- Shows count of QSOs matching current filters next to "Filters" heading
- **With filters active**: "Showing **X** filtered QSOs" **Overview**: The award detail page (`src/frontend/src/routes/awards/[id]/+page.svelte`) displays award progress in a pivot table format.
- **No filters**: "Showing **X** total QSOs"
- Dynamically updates when filters are applied or cleared **Key Features**:
- Uses `pagination.totalCount` from backend API response - **Summary Cards**: Show total, confirmed, worked, needed counts for unique entities
- **Mode Filter**: Filter by specific mode, mode group, or view "Mixed Mode" (aggregates all modes by band)
- Awards can define `modeGroups` to create convenient multi-mode filters
- Example groups: "Digi-Modes", "Classic Digi-Modes", "Phone-Modes", "Mixed-Mode w/o WSJT-Modes"
- Visual separator (`─────`) appears between mode groups and individual modes
- **Table Columns**: Show bands (or band/mode combinations) as columns
- **QSO Counts**: Each cell shows count of confirmed QSOs for that (entity, band, mode) slot
- **Drill-Down**: Click a count to open modal showing all QSOs for that slot
- **QSO Detail**: Click any QSO to view full QSO details
- **Satellite Grouping**: Satellite QSOs grouped under "SAT" column instead of frequency band
**Column Sorting**: Bands sorted by wavelength (longest to shortest):
160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm, SAT
**Column Sums**: Show unique entity count per column (not QSO counts)
**Backend Changes** (`src/backend/services/awards.service.js`):
- `getAllAwards()`: Returns award definitions including `modeGroups`
- `getAwardById(awardId)`: Returns single award definition with `modeGroups`
- `calculateDOKAwardProgress()`: Groups by (DOK, band, mode) slots, collects QSOs in `qsos` array
- `calculatePointsAwardProgress()`: Handles all count modes with `qsos` array
- `getAwardEntityBreakdown()`: Groups by (entity, band, mode) slots
- Includes `satName` in QSO data for satellite grouping
- Implements `allowed_bands` and `satellite_only` filtering
### DXCC Entity Priority Logic ### DXCC Entity Priority Logic
@@ -665,58 +490,18 @@ When syncing QSOs from multiple confirmation sources, the system follows a prior
**Priority Order**: LoTW > DCL **Priority Order**: LoTW > DCL
**Implementation** (`src/backend/services/dcl.service.js`):
```javascript
// DXCC priority: LoTW > DCL
// Only update entity fields from DCL if:
// 1. QSO is NOT LoTW confirmed, AND
// 2. DCL actually sent entity data, AND
// 3. Current entity is missing
const hasLoTWConfirmation = existingQSO.lotwQslRstatus === 'Y';
const hasDCLData = dbQSO.entity || dbQSO.entityId;
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
// Fill in entity data from DCL (only if DCL provides it)
updateData.entity = dbQSO.entity;
updateData.entityId = dbQSO.entityId;
// ... other entity fields
}
```
**Rules**: **Rules**:
1. **LoTW-confirmed QSOs**: Always use LoTW's DXCC data (most reliable) 1. **LoTW-confirmed QSOs**: Always use LoTW's DXCC data (most reliable)
2. **DCL-only QSOs**: Use DCL's DXCC data IF available in ADIF payload 2. **DCL-only QSOs**: Use DCL's DXCC data IF available in ADIF payload
3. **Empty entity fields**: If DCL doesn't send DXCC data, entity remains empty 3. **Empty entity fields**: If DCL doesn't send DXCC data, entity remains empty
4. **Never overwrite**: Once LoTW confirms with entity data, DCL sync won't change it 4. **Never overwrite**: Once LoTW confirms with entity data, DCL sync won't change it
**Important Note**: DCL API currently doesn't send DXCC/entity fields in their ADIF export. This is a limitation of the DCL API, not the application. If DCL adds these fields in the future, the system will automatically use them for DCL-only QSOs. **Important Note**: DCL API currently doesn't send DXCC/entity fields in their ADIF export.
### Recent Development Work (January 2025) ### Critical LoTW Sync Behavior
**QSO Page Enhancements**:
- Added confirmation type filter with exclusive logic (LoTW Only, DCL Only, Both Confirmed, Not Confirmed)
- Added search box for filtering by callsign, entity, or grid square
- Renamed "All Confirmation" to "All QSOs" for clarity
- Fixed filter logic to properly handle exclusive confirmation types
**Bug Fixes**:
- Fixed confirmation filter showing wrong QSOs (e.g., "LoTW Only" was also showing DCL QSOs)
- Implemented proper SQL conditions for exclusive filters using separate condition pushes
- Added debug logging to track filter application
**DXCC Entity Handling**:
- Clarified that DCL API doesn't send DXCC fields (current limitation)
- Implemented priority logic: LoTW entity data takes precedence over DCL
- System ready to auto-use DCL DXCC data if they add it in future API updates
### Critical LoTW Sync Behavior (LEARNED THE HARD WAY)
**⚠️ IMPORTANT: LoTW sync MUST only import confirmed QSOs** **⚠️ IMPORTANT: LoTW sync MUST only import confirmed QSOs**
After attempting to implement "QSO Delta" sync (all QSOs, confirmed + unconfirmed), we discovered:
**The Problem:**
LoTW ADIF export with `qso_qsl=no` (all QSOs mode) only includes: LoTW ADIF export with `qso_qsl=no` (all QSOs mode) only includes:
- `CALL` (callsign) - `CALL` (callsign)
- `QSL_RCVD` (confirmation status: Y/N) - `QSL_RCVD` (confirmation status: Y/N)
@@ -724,9 +509,7 @@ LoTW ADIF export with `qso_qsl=no` (all QSOs mode) only includes:
**Missing Fields for Unconfirmed QSOs:** **Missing Fields for Unconfirmed QSOs:**
- `DXCC` (entity ID) ← **CRITICAL for awards!** - `DXCC` (entity ID) ← **CRITICAL for awards!**
- `COUNTRY` (entity name) - `COUNTRY` (entity name)
- `CONTINENT` - `CONTINENT`, `CQ_ZONE`, `ITU_ZONE`
- `CQ_ZONE`
- `ITU_ZONE`
**Result:** Unconfirmed QSOs have `entityId: null` and `entity: ""`, breaking award calculations. **Result:** Unconfirmed QSOs have `entityId: null` and `entity: ""`, breaking award calculations.
@@ -742,67 +525,31 @@ const params = new URLSearchParams({
}); });
``` ```
**Why This Matters:** ### Recent Development Work (January 2026)
- Awards require `entityId` to count entities
- Without `entityId`, QSOs can't be counted toward DXCC, WAS, etc.
- Users can still see "worked" stations in QSO list, but awards only count confirmed
- DCL sync can import all QSOs because it provides entity data via callsign lookup
**Attempted Solution (REVERTED):** **Award System Enhancements**:
- Tried implementing callsign prefix lookup to populate missing `entityId` - Added `allowed_bands` filter to restrict which bands count toward awards
- Created `src/backend/utils/callsign-lookup.js` with basic prefix mappings - Added `satellite_only` flag for satellite-only awards
- Complexity: 1000+ DXCC entities, many special event callsigns, portable designators - DXCC restricted to HF bands (160m-10m) only
- Decision: Too complex, reverted (commit 310b154) - Added DXCC SAT award for satellite-only QSOs
- Removed redundant award variants (DXCC CW, DLD variants)
- Added `modeGroups` for configurable multi-mode filters in award detail view
- Per-award configuration of mode groups (Digi-Modes, Phone-Modes, etc.)
- Visual separator in mode filter dropdown between groups and individual modes
- DXCC and DLD awards include: Digi-Modes, Classic Digi-Modes, Mixed-Mode w/o WSJT-Modes, Phone-Modes
**Takeaway:** LoTW confirmed QSOs have reliable DXCC data. Don't try to workaround this fundamental limitation. **Award Detail View Improvements**:
- Summary shows unique entity progress instead of QSO counts
- Column sums count unique entities per column
- Satellite QSOs grouped under "SAT" column
- Bands sorted by wavelength instead of alphabetically
- Mode removed from table headers (visible in filter dropdown)
- Mode groups allow filtering multiple modes together (e.g., all digital modes)
### QSO Confirmation Filters **Backend API Additions**:
- Added `GET /api/awards/:awardId` endpoint for fetching single award definition
- `getAllAwards()` now includes `modeGroups` field
Added "Confirmed by at least 1 service" filter to QSO view (commit 688b0fc): **QSO Management**:
- Fixed DELETE /api/qsos/all to handle foreign key constraints
**Filter Options:** - Added cache invalidation after QSO deletion
- "All QSOs" - No filter
- "Confirmed by at least 1 service" (NEW) - LoTW OR DCL confirmed
- "LoTW Only" - Confirmed by LoTW but NOT DCL
- "DCL Only" - Confirmed by DCL but NOT LoTW
- "Both Confirmed" - Confirmed by BOTH LoTW AND DCL
- "Not Confirmed" - Confirmed by NEITHER
**SQL Logic:**
```sql
-- "Confirmed by at least 1 service"
WHERE lotwQslRstatus = 'Y' OR dclQslRstatus = 'Y'
-- "LoTW Only"
WHERE lotwQslRstatus = 'Y' AND (dclQslRstatus IS NULL OR dclQslRstatus != 'Y')
-- "DCL Only"
WHERE dclQslRstatus = 'Y' AND (lotwQslRstatus IS NULL OR lotwQslRstatus != 'Y')
-- "Both Confirmed"
WHERE lotwQslRstatus = 'Y' AND dclQslRstatus = 'Y'
-- "Not Confirmed"
WHERE (lotwQslRstatus IS NULL OR lotwQslRstatus != 'Y')
AND (dclQslRstatus IS NULL OR dclQslRstatus != 'Y')
```
### Recent Development Work (January 2025)
**Sync Type Support (ATTEMPTED & REVERTED):**
- Commit 5b78935: Added LoTW sync type support (QSL/QSO delta/full)
- Commit 310b154: Reverted - LoTW doesn't provide entity data for unconfirmed QSOs
- **Lesson:** Keep it simple - only sync confirmed QSOs from LoTW
**Dashboard Enhancements:**
- Added sync job history display with real-time polling (every 2 seconds)
- Shows job progress, status, and import logs
- Cancel button for stale/failed jobs with rollback capability
- Tracks all QSO changes in `qso_changes` table for rollback
**Rollback System:**
- `cancelJob(jobId, userId)` - Cancels and rolls back sync jobs
- Tracks added QSOs (deletes them on rollback)
- Tracks updated QSOs (restores previous state)
- Only allows canceling failed jobs or stale running jobs (>1 hour)
- Server-side validation prevents unauthorized cancellations

219
DOCKER.md
View File

@@ -1,219 +0,0 @@
# Docker Deployment Guide
This guide covers deploying Quickawards using Docker.
## Quick Start
1. **Create environment file:**
```bash
cp .env.docker.example .env
```
2. **Generate secure JWT secret:**
```bash
openssl rand -base64 32
```
Copy the output and set it as `JWT_SECRET` in `.env`.
3. **Update `.env` with your settings:**
- `JWT_SECRET`: Strong random string (required)
- `VITE_APP_URL`: Your domain (e.g., `https://awards.example.com`)
- `ALLOWED_ORIGINS`: Your domain(s) for CORS
4. **Start the application:**
```bash
docker-compose up -d
```
5. **Access the application:**
- URL: http://localhost:3001
- Health check: http://localhost:3001/api/health
## Architecture
### Single Port Design
The Docker stack exposes a single port (3001) which serves both:
- **Backend API** (`/api/*`)
- **Frontend SPA** (all other routes)
### Database Persistence
- **Location**: `./data/award.db` (host-mounted volume)
- **Initialization**: Automatic on first startup
- **Persistence**: Database survives container restarts/recreations
### Startup Behavior
1. **First startup**: Database is created from template
2. **Subsequent startups**: Existing database is used
3. **Container recreation**: Database persists in volume
## Commands
### Start the application
```bash
docker-compose up -d
```
### View logs
```bash
docker-compose logs -f
```
### Stop the application
```bash
docker-compose down
```
### Rebuild after code changes
```bash
docker-compose up -d --build
```
### Stop and remove everything (including database volume)
```bash
docker-compose down -v
```
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NODE_ENV` | No | `production` | Environment mode |
| `PORT` | No | `3001` | Application port |
| `LOG_LEVEL` | No | `info` | Logging level (debug/info/warn/error) |
| `JWT_SECRET` | **Yes** | - | JWT signing secret (change this!) |
| `VITE_APP_URL` | No | - | Your application's public URL |
| `ALLOWED_ORIGINS` | No | - | CORS allowed origins (comma-separated) |
## Database Management
### Backup the database
```bash
cp data/award.db data/award.db.backup.$(date +%Y%m%d)
```
### Restore from backup
```bash
docker-compose down
cp data/award.db.backup.YYYYMMDD data/award.db
docker-compose up -d
```
### Reset the database
```bash
docker-compose down -v
docker-compose up -d
```
## Troubleshooting
### Container won't start
```bash
# Check logs
docker-compose logs -f
# Check container status
docker-compose ps
```
### Database errors
```bash
# Check database file exists
ls -la data/
# Check database permissions
stat data/award.db
```
### Port already in use
Change the port mapping in `docker-compose.yml`:
```yaml
ports:
- "8080:3001" # Maps host port 8080 to container port 3001
```
### Health check failing
```bash
# Check if container is responding
curl http://localhost:3001/api/health
# Check container logs
docker-compose logs quickawards
```
## Production Deployment
### Using a Reverse Proxy (nginx)
Example nginx configuration:
```nginx
server {
listen 80;
server_name awards.example.com;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### SSL/TLS with Let's Encrypt
Use certbot with nginx:
```bash
sudo certbot --nginx -d awards.example.com
```
### Security Checklist
- [ ] Set strong `JWT_SECRET`
- [ ] Set `NODE_ENV=production`
- [ ] Set `LOG_LEVEL=info` (or `warn` in production)
- [ ] Configure `ALLOWED_ORIGINS` to your domain only
- [ ] Use HTTPS/TLS in production
- [ ] Regular database backups
- [ ] Monitor logs for suspicious activity
- [ ] Keep Docker image updated
## File Structure After Deployment
```
project/
├── data/
│ └── award.db # Persisted database (volume mount)
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env # Your environment variables
└── ... (source code)
```
## Building Without docker-compose
If you prefer to use `docker` directly:
```bash
# Build the image
docker build -t quickawards .
# Run the container
docker run -d \
--name quickawards \
-p 3001:3001 \
-v $(pwd)/data:/data \
-e JWT_SECRET=your-secret-here \
-e NODE_ENV=production \
quickawards
```

View File

@@ -1,72 +0,0 @@
# Multi-stage Dockerfile for Quickawards
# Uses official Bun runtime image
# ============================================
# Stage 1: Dependencies & Database Init
# ============================================
FROM oven/bun:1 AS builder
WORKDIR /app
# Install ALL dependencies (including devDependencies for drizzle-kit)
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy source code (node_modules excluded by .dockerignore)
COPY . .
# Reinstall frontend dependencies to get correct platform binaries
RUN cd src/frontend && bun install
# Initialize database using custom script
# This creates a fresh database with the correct schema using bun:sqlite
RUN bun src/backend/scripts/init-db.js
# Build frontend
RUN bun run build
# ============================================
# Stage 2: Production Image
# ============================================
FROM oven/bun:1 AS production
WORKDIR /app
# Install production dependencies only
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
# Copy backend source and schema files
COPY src/backend ./src/backend
COPY award-definitions ./award-definitions
COPY drizzle.config.ts ./
# Copy frontend build from builder stage
COPY --from=builder /app/src/frontend/build ./src/frontend/build
# Copy initialized database from builder (will be used as template)
COPY --from=builder /app/src/backend/award.db /app/award.db.template
# Copy drizzle migrations (if they exist)
COPY --from=builder /app/drizzle ./drizzle
# Create directory for database volume mount
RUN mkdir -p /data
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set environment variables
ENV NODE_ENV=production \
PORT=3001 \
LOG_LEVEL=info
# Expose the application port
EXPOSE 3001
# Use entrypoint script to handle database initialization
ENTRYPOINT ["docker-entrypoint.sh"]
# Start the backend server
CMD ["bun", "run", "src/backend/index.js"]

View File

@@ -116,7 +116,7 @@ award/
│ └── package.json │ └── package.json
├── award-definitions/ # Award rule definitions (JSON) ├── award-definitions/ # Award rule definitions (JSON)
├── award.db # SQLite database (auto-created) ├── award.db # SQLite database (auto-created)
├── .env.production.template # Production configuration template ├── .env.example # Environment configuration template
├── bunfig.toml # Bun configuration ├── bunfig.toml # Bun configuration
├── drizzle.config.js # Drizzle ORM configuration ├── drizzle.config.js # Drizzle ORM configuration
├── package.json ├── package.json
@@ -149,20 +149,32 @@ cp .env.example .env
Edit `.env` with your configuration: Edit `.env` with your configuration:
```env ```env
# Application URL (for production deployment) # Environment (development/production)
VITE_APP_URL=https://awards.dj7nt.de NODE_ENV=development
# Log Level (debug/info/warn/error)
LOG_LEVEL=debug
# Server Port (default: 3001)
PORT=3001
# Frontend URL (e.g., https://awards.dj7nt.de)
# Leave empty for development (uses localhost)
VITE_APP_URL=
# API Base URL (leave empty for same-domain deployment) # API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL= VITE_API_BASE_URL=
# JWT Secret (generate with: openssl rand -base64 32) # Allowed CORS origins (comma-separated)
JWT_SECRET=your-generated-secret-here # Add all domains that should access the API
ALLOWED_ORIGINS=
# Environment # JWT Secret (generate with: openssl rand -base64 32)
NODE_ENV=production JWT_SECRET=change-this-in-production
``` ```
**For development**: You can leave `.env` empty or use defaults. **For development**: Use defaults above.
**For production**: Set `NODE_ENV=production`, `LOG_LEVEL=info`, and generate a strong `JWT_SECRET`.
4. Initialize the database with performance indexes: 4. Initialize the database with performance indexes:
```bash ```bash
@@ -246,6 +258,7 @@ The application will be available at:
### Awards ### Awards
- `GET /api/awards` - Get all available awards - `GET /api/awards` - Get all available awards
- `GET /api/awards/:awardId` - Get single award definition (includes mode groups)
- `GET /api/awards/batch/progress` - Get progress for all awards (optimized, single request) - `GET /api/awards/batch/progress` - Get progress for all awards (optimized, single request)
- `GET /api/awards/:awardId/progress` - Get award progress for a specific award - `GET /api/awards/:awardId/progress` - Get award progress for a specific award
- `GET /api/awards/:awardId/entities` - Get entity breakdown - `GET /api/awards/:awardId/entities` - Get entity breakdown
@@ -414,20 +427,26 @@ bun run build
Create `.env` in the project root: Create `.env` in the project root:
```bash ```bash
# Application URL
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (empty for same-domain)
VITE_API_BASE_URL=
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your-generated-secret-here
# Environment # Environment
NODE_ENV=production NODE_ENV=production
# Database path (absolute path recommended) # Log Level (debug/info/warn/error)
DATABASE_PATH=/path/to/award/award.db LOG_LEVEL=info
# Server Port (default: 3001)
PORT=3001
# Frontend URL
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL=
# Allowed CORS origins (comma-separated)
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your-generated-secret-here
``` ```
**Security**: Ensure `.env` has restricted permissions: **Security**: Ensure `.env` has restricted permissions:
@@ -782,6 +801,26 @@ bun run db:studio
## Features in Detail ## Features in Detail
### Mode Groups (Award Detail View)
The award detail view includes configurable mode groups for filtering multiple modes together:
**Available Mode Groups** (varies by award):
- **Mixed Mode**: All modes aggregated by band (default view)
- **Digi-Modes**: FT8, FT4, MFSK, PSK31, RTTY, JT65, JT9
- **Classic Digi-Modes**: PSK31, RTTY, JT65, JT9 (excludes FT8, FT4, MFSK)
- **Mixed-Mode w/o WSJT-Modes**: PSK31, RTTY, AM, SSB, FM, CW (excludes WSJT modes)
- **Phone-Modes**: AM, SSB, FM
- **Individual Modes**: CW, SSB, FT8, etc.
The mode filter dropdown displays:
1. Mixed Mode (default)
2. Mode groups (configurable per award)
3. Visual separator (`─────`)
4. Individual modes
Awards can define custom `modeGroups` in their JSON definition to add additional mode groupings.
### Background Job Queue ### Background Job Queue
The application uses an in-memory job queue system for async operations: The application uses an in-memory job queue system for async operations:

View File

@@ -1,19 +0,0 @@
{
"id": "dld-40m",
"name": "DLD 40m",
"description": "Confirm 100 unique DOKs on 40m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 40m band. Only DCL-confirmed QSOs with valid DOK information on 40m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "40m" }
]
}
}
}

View File

@@ -1,20 +0,0 @@
{
"id": "dld-80m-cw",
"name": "DLD 80m CW",
"description": "Confirm 100 unique DOKs on 80m using CW",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band using CW mode. Only DCL-confirmed QSOs with valid DOK information on 80m CW count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band. Only DCL-confirmed QSOs with valid DOK information on 80m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" }
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-cw",
"name": "DLD CW",
"description": "Confirm 100 unique DOKs using CW mode",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) using CW (Morse code). Each unique DOK on CW counts separately. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

View File

@@ -4,6 +4,12 @@
"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,

View File

@@ -1,27 +0,0 @@
{
"id": "dxcc-cw",
"name": "DXCC CW",
"description": "Confirm 100 DXCC entities using CW mode",
"caption": "Contact and confirm 100 different DXCC entities using CW mode only. Only QSOs made with CW mode count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc",
"rules": {
"target": 100,
"type": "filtered",
"baseRule": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
},
"filters": {
"operator": "AND",
"filters": [
{
"field": "mode",
"operator": "eq",
"value": "CW"
}
]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"id": "dxcc-sat",
"name": "DXCC SAT",
"description": "Confirm 100 DXCC entities via satellite",
"caption": "Contact and confirm 100 different DXCC entities using satellite communications. Only satellite QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc",
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity",
"satellite_only": true
}
}

View File

@@ -1,13 +1,20 @@
{ {
"id": "dxcc-mixed", "id": "dxcc",
"name": "DXCC Mixed Mode", "name": "DXCC",
"description": "Confirm 100 DXCC entities on any band/mode", "description": "Confirm 100 DXCC entities on HF bands",
"caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts. 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"]
} }
} }

View File

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

View File

@@ -1,9 +1,9 @@
{ {
"id": "sat-rs44", "id": "sat-rs44",
"name": "RS-44 Satellite", "name": "44 on RS-44",
"description": "Work 44 QSOs on satellite RS-44", "description": "Work 44 QSOs on satellite RS-44",
"caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.", "caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.",
"category": "custom", "category": "satellite",
"rules": { "rules": {
"type": "counter", "type": "counter",
"target": 44, "target": 44,
@@ -19,5 +19,6 @@
} }
] ]
} }
} },
} "modeGroups": {}
}

View File

@@ -1,31 +0,0 @@
services:
quickawards:
build:
context: .
dockerfile: Dockerfile
container_name: quickawards
restart: unless-stopped
ports:
- "3001:3001"
environment:
# Application settings
NODE_ENV: production
PORT: 3001
LOG_LEVEL: info
# Security - IMPORTANT: Change these in production!
JWT_SECRET: ${JWT_SECRET:-change-this-in-production}
# CORS - Set to your domain in production
VITE_APP_URL: ${VITE_APP_URL:-}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
volumes:
# Host-mounted database directory
# Database will be created at ./data/award.db on first startup
- ./data:/data
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View File

@@ -1,62 +0,0 @@
#!/bin/sh
set -e
# Docker container entrypoint script
# Handles database initialization on first startup
echo "=========================================="
echo "Quickawards - Docker Entrypoint"
echo "=========================================="
# Database location in volume mount
DB_PATH="/data/award.db"
TEMPLATE_DB="/app/award.db.template"
APP_DB_PATH="/app/src/backend/award.db"
# Check if database exists in the volume
if [ ! -f "$DB_PATH" ]; then
echo ""
echo "📦 Database not found in volume mount."
echo " Initializing from template database..."
echo ""
# Copy the template database (created during build with drizzle-kit push)
cp "$TEMPLATE_DB" "$DB_PATH"
# Ensure proper permissions
chmod 644 "$DB_PATH"
echo "✅ Database initialized at: $DB_PATH"
echo " This database will persist in the Docker volume."
else
echo ""
echo "✅ Existing database found at: $DB_PATH"
echo " Using existing database from volume mount."
fi
# Create symlink from app's expected db location to volume mount
# The app expects the database at src/backend/award.db
# We create a symlink so it points to the volume-mounted database
if [ -L "$APP_DB_PATH" ]; then
# Symlink already exists, remove it to refresh
rm "$APP_DB_PATH"
elif [ -e "$APP_DB_PATH" ]; then
# File or directory exists (shouldn't happen in production, but handle it)
echo "⚠ Warning: Found existing database at $APP_DB_PATH, removing..."
rm -f "$APP_DB_PATH"
fi
# Create symlink to the volume-mounted database
ln -s "$DB_PATH" "$APP_DB_PATH"
echo "✅ Created symlink: $APP_DB_PATH -> $DB_PATH"
echo ""
echo "=========================================="
echo "Starting Quickawards application..."
echo "Port: ${PORT:-3001}"
echo "Environment: ${NODE_ENV:-production}"
echo "=========================================="
echo ""
# Execute the main command (passed as CMD in Dockerfile)
exec "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -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 {Date|null} lastSeen
* @property {Date} createdAt * @property {Date} createdAt
* @property {Date} updatedAt * @property {Date} updatedAt
*/ */
@@ -23,6 +24,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),
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()),
}); });
@@ -223,5 +225,39 @@ export const adminActions = sqliteTable('admin_actions', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}); });
/**
* @typedef {Object} AutoSyncSettings
* @property {number} userId
* @property {boolean} lotwEnabled
* @property {number} lotwIntervalHours
* @property {Date|null} lotwLastSyncAt
* @property {Date|null} lotwNextSyncAt
* @property {boolean} dclEnabled
* @property {number} dclIntervalHours
* @property {Date|null} dclLastSyncAt
* @property {Date|null} dclNextSyncAt
* @property {Date} createdAt
* @property {Date} updatedAt
*/
export const autoSyncSettings = sqliteTable('auto_sync_settings', {
userId: integer('user_id').primaryKey().references(() => users.id),
// LoTW auto-sync settings
lotwEnabled: integer('lotw_enabled', { mode: 'boolean' }).notNull().default(false),
lotwIntervalHours: integer('lotw_interval_hours').notNull().default(24),
lotwLastSyncAt: integer('lotw_last_sync_at', { mode: 'timestamp' }),
lotwNextSyncAt: integer('lotw_next_sync_at', { mode: 'timestamp' }),
// DCL auto-sync settings
dclEnabled: integer('dcl_enabled', { mode: 'boolean' }).notNull().default(false),
dclIntervalHours: integer('dcl_interval_hours').notNull().default(24),
dclLastSyncAt: integer('dcl_last_sync_at', { mode: 'timestamp' }),
dclNextSyncAt: integer('dcl_next_sync_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
// Export all schemas // Export all schemas
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions }; export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings };

View File

@@ -12,10 +12,12 @@ import {
getUserById, getUserById,
updateLoTWCredentials, updateLoTWCredentials,
updateDCLCredentials, updateDCLCredentials,
updateLastSeen,
} from './services/auth.service.js'; } from './services/auth.service.js';
import { import {
getSystemStats, getSystemStats,
getUserStats, getUserStats,
getAdminActions,
impersonateUser, impersonateUser,
verifyImpersonation, verifyImpersonation,
stopImpersonation, stopImpersonation,
@@ -39,9 +41,28 @@ import {
} from './services/job-queue.service.js'; } from './services/job-queue.service.js';
import { import {
getAllAwards, getAllAwards,
getAwardById,
getAwardProgressDetails, getAwardProgressDetails,
getAwardEntityBreakdown, getAwardEntityBreakdown,
} from './services/awards.service.js'; } from './services/awards.service.js';
import {
getAllAwardDefinitions,
getAwardDefinition,
createAwardDefinition,
updateAwardDefinition,
deleteAwardDefinition,
testAwardCalculation,
} from './services/awards-admin.service.js';
import {
getAutoSyncSettings,
updateAutoSyncSettings,
} from './services/auto-sync.service.js';
import {
startScheduler,
stopScheduler,
getSchedulerStatus,
triggerSchedulerTick,
} from './services/scheduler.service.js';
/** /**
* Main backend application * Main backend application
@@ -187,6 +208,14 @@ const app = new Elysia()
return { user: null }; return { user: null };
} }
// Update last_seen timestamp asynchronously (don't await)
updateLastSeen(payload.userId).catch((err) => {
// Silently fail - last_seen update failure shouldn't block requests
if (LOG_LEVEL === 'debug') {
logger.warn('Failed to update last_seen', { error: err.message });
}
});
// Check if this is an impersonation token // Check if this is an impersonation token
const isImpersonation = !!payload.impersonatedBy; const isImpersonation = !!payload.impersonatedBy;
@@ -434,9 +463,15 @@ const app = new Elysia()
return { success: false, error: 'User not found' }; return { success: false, error: 'User not found' };
} }
// Include impersonatedBy from JWT if present (not stored in database)
const responseUser = {
...userData,
impersonatedBy: user.impersonatedBy,
};
return { return {
success: true, success: true,
user: userData, user: responseUser,
}; };
}) })
@@ -860,6 +895,7 @@ const app = new Elysia()
message: `Deleted ${deleted} QSO(s)`, message: `Deleted ${deleted} QSO(s)`,
}; };
} catch (error) { } catch (error) {
logger.error('Failed to delete QSOs', { error: error.message, stack: error.stack });
set.status = 500; set.status = 500;
return { return {
success: false, success: false,
@@ -895,6 +931,42 @@ const app = new Elysia()
} }
}) })
/**
* GET /api/awards/:awardId
* Get a single award by ID (requires authentication)
*/
.get('/api/awards/:awardId', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const { awardId } = params;
const award = getAwardById(awardId);
if (!award) {
set.status = 404;
return {
success: false,
error: 'Award not found',
};
}
return {
success: true,
award,
};
} catch (error) {
logger.error('Error fetching award', { error: error.message });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award',
};
}
})
/** /**
* GET /api/awards/:awardId/progress * GET /api/awards/:awardId/progress
* Get award progress for user (requires authentication) * Get award progress for user (requires authentication)
@@ -1390,6 +1462,347 @@ const app = new Elysia()
} }
}) })
/**
* ================================================================
* AWARD MANAGEMENT ROUTES (Admin Only)
* ================================================================
*/
/**
* GET /api/admin/awards
* Get all award definitions (admin only)
*/
.get('/api/admin/awards', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const awards = await getAllAwardDefinitions();
return {
success: true,
awards,
};
} catch (error) {
logger.error('Error fetching award definitions', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award definitions',
};
}
})
/**
* GET /api/admin/awards/:id
* Get a single award definition (admin only)
*/
.get('/api/admin/awards/:id', async ({ user, params, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await getAwardDefinition(params.id);
if (!award) {
set.status = 404;
return {
success: false,
error: 'Award not found',
};
}
return {
success: true,
award,
};
} catch (error) {
logger.error('Error fetching award definition', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award definition',
};
}
})
/**
* POST /api/admin/awards
* Create a new award definition (admin only)
*/
.post(
'/api/admin/awards',
async ({ user, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await createAwardDefinition(body);
return {
success: true,
award,
message: 'Award definition created successfully',
};
} catch (error) {
logger.error('Error creating award definition', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
id: t.String(),
name: t.String(),
description: t.String(),
caption: t.String(),
category: t.String(),
rules: t.Any(),
modeGroups: t.Optional(t.Any()),
}),
}
)
/**
* PUT /api/admin/awards/:id
* Update an award definition (admin only)
*/
.put(
'/api/admin/awards/:id',
async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await updateAwardDefinition(params.id, body);
return {
success: true,
award,
message: 'Award definition updated successfully',
};
} catch (error) {
logger.error('Error updating award definition', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
id: t.Optional(t.String()),
name: t.String(),
description: t.String(),
caption: t.String(),
category: t.String(),
rules: t.Any(),
modeGroups: t.Optional(t.Any()),
}),
}
)
/**
* DELETE /api/admin/awards/:id
* Delete an award definition (admin only)
*/
.delete('/api/admin/awards/:id', async ({ user, params, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const result = await deleteAwardDefinition(params.id);
return {
success: true,
...result,
message: 'Award definition deleted successfully',
};
} catch (error) {
logger.error('Error deleting award definition', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
})
/**
* POST /api/admin/awards/:id/test
* Test award calculation (admin only)
*/
.post(
'/api/admin/awards/:id/test',
async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
// Use provided userId or admin's own account
const testUserId = body.userId || user.id;
const awardDefinition = body.awardDefinition || null;
const result = await testAwardCalculation(params.id, testUserId, awardDefinition);
return {
success: true,
...result,
};
} catch (error) {
logger.error('Error testing award calculation', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
userId: t.Optional(t.Integer()),
awardDefinition: t.Optional(t.Any()),
}),
}
)
/**
* ================================================================
* AUTO-SYNC SETTINGS ROUTES
* ================================================================
* All auto-sync routes require authentication
*/
/**
* GET /api/auto-sync/settings
* Get user's auto-sync settings (requires authentication)
*/
.get('/api/auto-sync/settings', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const settings = await getAutoSyncSettings(user.id);
return {
success: true,
settings,
};
} catch (error) {
logger.error('Error fetching auto-sync settings', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch auto-sync settings',
};
}
})
/**
* PUT /api/auto-sync/settings
* Update user's auto-sync settings (requires authentication)
*/
.put(
'/api/auto-sync/settings',
async ({ user, body, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const settings = await updateAutoSyncSettings(user.id, body);
return {
success: true,
settings,
message: 'Auto-sync settings updated successfully',
};
} catch (error) {
logger.error('Error updating auto-sync settings', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
lotwEnabled: t.Optional(t.Boolean()),
lotwIntervalHours: t.Optional(t.Number()),
dclEnabled: t.Optional(t.Boolean()),
dclIntervalHours: t.Optional(t.Number()),
}),
}
)
/**
* GET /api/auto-sync/scheduler/status
* Get scheduler status (admin only)
*/
.get('/api/auto-sync/scheduler/status', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const status = getSchedulerStatus();
return {
success: true,
scheduler: status,
};
} catch (error) {
logger.error('Error fetching scheduler status', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch scheduler status',
};
}
})
/**
* POST /api/auto-sync/scheduler/trigger
* Manually trigger scheduler tick (admin only, for testing)
*/
.post('/api/auto-sync/scheduler/trigger', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
await triggerSchedulerTick();
return {
success: true,
message: 'Scheduler tick triggered successfully',
};
} catch (error) {
logger.error('Error triggering scheduler tick', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to trigger scheduler tick',
};
}
})
// Serve static files and SPA fallback for all non-API routes // Serve static files and SPA fallback for all non-API routes
.get('/*', ({ request }) => { .get('/*', ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
@@ -1546,3 +1959,21 @@ logger.info('Server started', {
nodeEnv: process.env.NODE_ENV || 'unknown', nodeEnv: process.env.NODE_ENV || 'unknown',
logLevel: LOG_LEVEL, logLevel: LOG_LEVEL,
}); });
// Start the auto-sync scheduler
startScheduler();
// Graceful shutdown handlers
const gracefulShutdown = async (signal) => {
logger.info(`Received ${signal}, shutting down gracefully...`);
// Stop the scheduler
await stopScheduler();
logger.info('Graceful shutdown complete');
process.exit(0);
};
// Handle shutdown signals
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));

View File

@@ -0,0 +1,111 @@
/**
* Migration: Add auto_sync_settings table
*
* This script creates the auto_sync_settings table for managing
* automatic sync intervals for DCL and LoTW services.
* Users can enable/disable auto-sync and configure sync intervals.
*/
import Database from 'bun:sqlite';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../award.db');
const sqlite = new Database(dbPath);
async function migrate() {
console.log('Starting migration: Add auto-sync settings...');
try {
// Check if auto_sync_settings table already exists
const tableExists = sqlite.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='auto_sync_settings'
`).get();
if (tableExists) {
console.log('Table auto_sync_settings already exists. Skipping...');
} else {
// Create auto_sync_settings table
sqlite.exec(`
CREATE TABLE auto_sync_settings (
user_id INTEGER PRIMARY KEY,
lotw_enabled INTEGER NOT NULL DEFAULT 0,
lotw_interval_hours INTEGER NOT NULL DEFAULT 24,
lotw_last_sync_at INTEGER,
lotw_next_sync_at INTEGER,
dcl_enabled INTEGER NOT NULL DEFAULT 0,
dcl_interval_hours INTEGER NOT NULL DEFAULT 24,
dcl_last_sync_at INTEGER,
dcl_next_sync_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create index for faster queries on next_sync_at
sqlite.exec(`
CREATE INDEX idx_auto_sync_settings_lotw_next_sync_at
ON auto_sync_settings(lotw_next_sync_at)
WHERE lotw_enabled = 1
`);
sqlite.exec(`
CREATE INDEX idx_auto_sync_settings_dcl_next_sync_at
ON auto_sync_settings(dcl_next_sync_at)
WHERE dcl_enabled = 1
`);
console.log('Created auto_sync_settings table with indexes');
}
console.log('Migration complete! Auto-sync settings table added to database.');
} catch (error) {
console.error('Migration failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
async function rollback() {
console.log('Starting rollback: Remove auto-sync settings...');
try {
// Drop indexes first
sqlite.exec(`DROP INDEX IF EXISTS idx_auto_sync_settings_lotw_next_sync_at`);
sqlite.exec(`DROP INDEX IF EXISTS idx_auto_sync_settings_dcl_next_sync_at`);
// Drop table
sqlite.exec(`DROP TABLE IF EXISTS auto_sync_settings`);
console.log('Rollback complete! Auto-sync settings table removed from database.');
} catch (error) {
console.error('Rollback failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
// Check if this is a rollback
const args = process.argv.slice(2);
if (args.includes('--rollback') || args.includes('-r')) {
rollback().then(() => {
console.log('Rollback script completed successfully');
process.exit(0);
});
} else {
// Run migration
migrate().then(() => {
console.log('Migration script completed successfully');
process.exit(0);
});
}

View File

@@ -0,0 +1,86 @@
/**
* Migration: Add last_seen column to users table
*
* This script adds the last_seen column to track when users last accessed the tool.
*/
import Database from 'bun:sqlite';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../award.db');
const sqlite = new Database(dbPath);
async function migrate() {
console.log('Starting migration: Add last_seen column...');
try {
// Check if last_seen column already exists
const columnExists = sqlite.query(`
SELECT COUNT(*) as count
FROM pragma_table_info('users')
WHERE name='last_seen'
`).get();
if (columnExists.count > 0) {
console.log('Column last_seen already exists. Skipping...');
} else {
// Add last_seen column
sqlite.exec(`
ALTER TABLE users
ADD COLUMN last_seen INTEGER
`);
console.log('Added last_seen column to users table');
}
console.log('Migration complete! last_seen column added to database.');
} catch (error) {
console.error('Migration failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
async function rollback() {
console.log('Starting rollback: Remove last_seen column...');
try {
// SQLite doesn't support DROP COLUMN directly before version 3.35.5
// For older versions, we need to recreate the table
console.log('Note: SQLite does not support DROP COLUMN. Manual cleanup required.');
console.log('To rollback: Recreate users table without last_seen column');
// For SQLite 3.35.5+, you can use:
// sqlite.exec(`ALTER TABLE users DROP COLUMN last_seen`);
console.log('Rollback note issued.');
} catch (error) {
console.error('Rollback failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
// Check if this is a rollback
const args = process.argv.slice(2);
if (args.includes('--rollback') || args.includes('-r')) {
rollback().then(() => {
console.log('Rollback script completed');
process.exit(0);
});
} else {
// Run migration
migrate().then(() => {
console.log('Migration script completed successfully');
process.exit(0);
});
}

View File

@@ -34,31 +34,35 @@ export async function logAdminAction(adminId, actionType, targetUserId = null, d
* @returns {Promise<Array>} Array of admin actions * @returns {Promise<Array>} Array of admin actions
*/ */
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) { export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
let query = db // Use raw SQL for the self-join (admin users and target users from same users table)
.select({ // Using bun:sqlite prepared statements for raw SQL
id: adminActions.id, let query = `
adminId: adminActions.adminId, SELECT
adminEmail: users.email, aa.id as id,
adminCallsign: users.callsign, aa.admin_id as adminId,
actionType: adminActions.actionType, admin_user.email as adminEmail,
targetUserId: adminActions.targetUserId, admin_user.callsign as adminCallsign,
targetEmail: sql`target_users.email`.as('targetEmail'), aa.action_type as actionType,
targetCallsign: sql`target_users.callsign`.as('targetCallsign'), aa.target_user_id as targetUserId,
details: adminActions.details, target_user.email as targetEmail,
createdAt: adminActions.createdAt, target_user.callsign as targetCallsign,
}) aa.details as details,
.from(adminActions) aa.created_at as createdAt
.leftJoin(users, eq(adminActions.adminId, users.id)) FROM admin_actions aa
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id'))) LEFT JOIN users admin_user ON admin_user.id = aa.admin_id
.orderBy(desc(adminActions.createdAt)) LEFT JOIN users target_user ON target_user.id = aa.target_user_id
.limit(limit) `;
.offset(offset);
if (adminId) { const params = [];
query = query.where(eq(adminActions.adminId, adminId)); if (adminId !== null) {
query += ` WHERE aa.admin_id = ?`;
params.push(adminId);
} }
return await query; query += ` ORDER BY aa.created_at DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
return sqlite.prepare(query).all(...params);
} }
/** /**
@@ -123,11 +127,17 @@ export async function getUserStats() {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
lastSeen: users.lastSeen,
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`, qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
lastSync: sql`MAX(${qsos.createdAt})`, lastSync: sql`(
SELECT MAX(${syncJobs.completedAt})
FROM ${syncJobs}
WHERE ${syncJobs.userId} = ${users.id}
AND ${syncJobs.status} = 'completed'
)`.mapWith(Number),
createdAt: users.createdAt, createdAt: users.createdAt,
}) })
.from(users) .from(users)
@@ -135,7 +145,14 @@ export async function getUserStats() {
.groupBy(users.id) .groupBy(users.id)
.orderBy(sql`COUNT(${qsos.id}) DESC`); .orderBy(sql`COUNT(${qsos.id}) DESC`);
return stats; // Convert timestamps (seconds) to Date objects for JSON serialization
// Note: lastSeen from Drizzle is already a Date object (timestamp mode)
// lastSync is raw SQL returning seconds, needs conversion
return stats.map(stat => ({
...stat,
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
// lastSeen is already a Date object from Drizzle, don't convert
}));
} }
/** /**
@@ -228,24 +245,26 @@ export async function stopImpersonation(adminId, targetUserId) {
* @returns {Promise<Array>} Array of recent impersonation actions * @returns {Promise<Array>} Array of recent impersonation actions
*/ */
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) { export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
const impersonations = await db // Use raw SQL for the self-join to avoid Drizzle alias issues
.select({ // Using bun:sqlite prepared statements for raw SQL
id: adminActions.id, const query = `
actionType: adminActions.actionType, SELECT
targetUserId: adminActions.targetUserId, aa.id as id,
targetEmail: sql`target_users.email`, aa.action_type as actionType,
targetCallsign: sql`target_users.callsign`, aa.target_user_id as targetUserId,
details: adminActions.details, u.email as targetEmail,
createdAt: adminActions.createdAt, u.callsign as targetCallsign,
}) aa.details as details,
.from(adminActions) aa.created_at as createdAt
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id'))) FROM admin_actions aa
.where(eq(adminActions.adminId, adminId)) LEFT JOIN users u ON u.id = aa.target_user_id
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`) WHERE aa.admin_id = ?
.orderBy(desc(adminActions.createdAt)) AND aa.action_type LIKE 'impersonate%'
.limit(limit); ORDER BY aa.created_at DESC
LIMIT ?
`;
return impersonations; return sqlite.prepare(query).all(adminId, limit);
} }
/** /**

View File

@@ -204,6 +204,7 @@ export async function getAllUsers() {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
lastSeen: users.lastSeen,
createdAt: users.createdAt, createdAt: users.createdAt,
updatedAt: users.updatedAt, updatedAt: users.updatedAt,
}) })
@@ -236,3 +237,17 @@ export async function getUserByIdFull(userId) {
return user || null; return user || null;
} }
/**
* Update user's last seen timestamp
* @param {number} userId - User ID
* @returns {Promise<void>}
*/
export async function updateLastSeen(userId) {
await db
.update(users)
.set({
lastSeen: new Date(),
})
.where(eq(users.id, userId));
}

View File

@@ -0,0 +1,373 @@
import { db, logger } from '../config.js';
import { autoSyncSettings, users } from '../db/schema/index.js';
import { eq, and, lte, or } from 'drizzle-orm';
/**
* Auto-Sync Settings Service
* Manages user preferences for automatic DCL and LoTW synchronization
*/
// Validation constants
export const MIN_INTERVAL_HOURS = 1;
export const MAX_INTERVAL_HOURS = 720; // 30 days
export const DEFAULT_INTERVAL_HOURS = 24;
/**
* Get auto-sync settings for a user
* Creates default settings if they don't exist
*
* @param {number} userId - User ID
* @returns {Promise<Object>} Auto-sync settings
*/
export async function getAutoSyncSettings(userId) {
try {
let [settings] = await db
.select()
.from(autoSyncSettings)
.where(eq(autoSyncSettings.userId, userId));
// Create default settings if they don't exist
if (!settings) {
logger.debug('Creating default auto-sync settings for user', { userId });
[settings] = await db
.insert(autoSyncSettings)
.values({
userId,
lotwEnabled: false,
lotwIntervalHours: DEFAULT_INTERVAL_HOURS,
dclEnabled: false,
dclIntervalHours: DEFAULT_INTERVAL_HOURS,
})
.returning();
}
return {
lotwEnabled: settings.lotwEnabled,
lotwIntervalHours: settings.lotwIntervalHours,
lotwLastSyncAt: settings.lotwLastSyncAt,
lotwNextSyncAt: settings.lotwNextSyncAt,
dclEnabled: settings.dclEnabled,
dclIntervalHours: settings.dclIntervalHours,
dclLastSyncAt: settings.dclLastSyncAt,
dclNextSyncAt: settings.dclNextSyncAt,
};
} catch (error) {
logger.error('Failed to get auto-sync settings', { error: error.message, userId });
throw error;
}
}
/**
* Validate interval hours
* @param {number} hours - Interval hours to validate
* @returns {Object} Validation result
*/
function validateIntervalHours(hours) {
if (typeof hours !== 'number' || isNaN(hours)) {
return { valid: false, error: 'Interval must be a number' };
}
if (!Number.isInteger(hours)) {
return { valid: false, error: 'Interval must be a whole number of hours' };
}
if (hours < MIN_INTERVAL_HOURS) {
return { valid: false, error: `Interval must be at least ${MIN_INTERVAL_HOURS} hour` };
}
if (hours > MAX_INTERVAL_HOURS) {
return { valid: false, error: `Interval must be at most ${MAX_INTERVAL_HOURS} hours (30 days)` };
}
return { valid: true };
}
/**
* Calculate next sync time based on interval
* @param {number} intervalHours - Interval in hours
* @returns {Date} Next sync time
*/
function calculateNextSyncTime(intervalHours) {
const nextSync = new Date();
nextSync.setHours(nextSync.getHours() + intervalHours);
return nextSync;
}
/**
* Update auto-sync settings for a user
*
* @param {number} userId - User ID
* @param {Object} settings - Settings to update
* @returns {Promise<Object>} Updated settings
*/
export async function updateAutoSyncSettings(userId, settings) {
try {
// Get current settings
let [currentSettings] = await db
.select()
.from(autoSyncSettings)
.where(eq(autoSyncSettings.userId, userId));
// Create default settings if they don't exist
if (!currentSettings) {
[currentSettings] = await db
.insert(autoSyncSettings)
.values({
userId,
lotwEnabled: false,
lotwIntervalHours: DEFAULT_INTERVAL_HOURS,
dclEnabled: false,
dclIntervalHours: DEFAULT_INTERVAL_HOURS,
})
.returning();
}
// Prepare update data
const updateData = {
updatedAt: new Date(),
};
// Validate and update LoTW settings
if (settings.lotwEnabled !== undefined) {
if (typeof settings.lotwEnabled !== 'boolean') {
throw new Error('lotwEnabled must be a boolean');
}
updateData.lotwEnabled = settings.lotwEnabled;
// If enabling for the first time or interval changed, set next sync time
if (settings.lotwEnabled && (!currentSettings.lotwEnabled || settings.lotwIntervalHours)) {
const intervalHours = settings.lotwIntervalHours || currentSettings.lotwIntervalHours;
const validation = validateIntervalHours(intervalHours);
if (!validation.valid) {
throw new Error(`LoTW interval: ${validation.error}`);
}
updateData.lotwNextSyncAt = calculateNextSyncTime(intervalHours);
} else if (!settings.lotwEnabled) {
// Clear next sync when disabling
updateData.lotwNextSyncAt = null;
}
}
if (settings.lotwIntervalHours !== undefined) {
const validation = validateIntervalHours(settings.lotwIntervalHours);
if (!validation.valid) {
throw new Error(`LoTW interval: ${validation.error}`);
}
updateData.lotwIntervalHours = settings.lotwIntervalHours;
// Update next sync time if LoTW is enabled
if (currentSettings.lotwEnabled || settings.lotwEnabled) {
updateData.lotwNextSyncAt = calculateNextSyncTime(settings.lotwIntervalHours);
}
}
// Validate and update DCL settings
if (settings.dclEnabled !== undefined) {
if (typeof settings.dclEnabled !== 'boolean') {
throw new Error('dclEnabled must be a boolean');
}
updateData.dclEnabled = settings.dclEnabled;
// If enabling for the first time or interval changed, set next sync time
if (settings.dclEnabled && (!currentSettings.dclEnabled || settings.dclIntervalHours)) {
const intervalHours = settings.dclIntervalHours || currentSettings.dclIntervalHours;
const validation = validateIntervalHours(intervalHours);
if (!validation.valid) {
throw new Error(`DCL interval: ${validation.error}`);
}
updateData.dclNextSyncAt = calculateNextSyncTime(intervalHours);
} else if (!settings.dclEnabled) {
// Clear next sync when disabling
updateData.dclNextSyncAt = null;
}
}
if (settings.dclIntervalHours !== undefined) {
const validation = validateIntervalHours(settings.dclIntervalHours);
if (!validation.valid) {
throw new Error(`DCL interval: ${validation.error}`);
}
updateData.dclIntervalHours = settings.dclIntervalHours;
// Update next sync time if DCL is enabled
if (currentSettings.dclEnabled || settings.dclEnabled) {
updateData.dclNextSyncAt = calculateNextSyncTime(settings.dclIntervalHours);
}
}
// Update settings in database
const [updated] = await db
.update(autoSyncSettings)
.set(updateData)
.where(eq(autoSyncSettings.userId, userId))
.returning();
logger.info('Updated auto-sync settings', {
userId,
lotwEnabled: updated.lotwEnabled,
lotwIntervalHours: updated.lotwIntervalHours,
dclEnabled: updated.dclEnabled,
dclIntervalHours: updated.dclIntervalHours,
});
return {
lotwEnabled: updated.lotwEnabled,
lotwIntervalHours: updated.lotwIntervalHours,
lotwLastSyncAt: updated.lotwLastSyncAt,
lotwNextSyncAt: updated.lotwNextSyncAt,
dclEnabled: updated.dclEnabled,
dclIntervalHours: updated.dclIntervalHours,
dclLastSyncAt: updated.dclLastSyncAt,
dclNextSyncAt: updated.dclNextSyncAt,
};
} catch (error) {
logger.error('Failed to update auto-sync settings', { error: error.message, userId });
throw error;
}
}
/**
* Get users with pending syncs for a specific service
*
* @param {string} service - 'lotw' or 'dcl'
* @returns {Promise<Array>} List of users with pending syncs
*/
export async function getPendingSyncUsers(service) {
try {
if (service !== 'lotw' && service !== 'dcl') {
throw new Error('Service must be "lotw" or "dcl"');
}
const enabledField = service === 'lotw' ? autoSyncSettings.lotwEnabled : autoSyncSettings.dclEnabled;
const nextSyncField = service === 'lotw' ? autoSyncSettings.lotwNextSyncAt : autoSyncSettings.dclNextSyncAt;
const credentialField = service === 'lotw' ? users.lotwUsername : users.dclApiKey;
const intervalField = service === 'lotw' ? autoSyncSettings.lotwIntervalHours : autoSyncSettings.dclIntervalHours;
const now = new Date();
// Get users with auto-sync enabled and next sync time in the past
const results = await db
.select({
userId: autoSyncSettings.userId,
lotwEnabled: autoSyncSettings.lotwEnabled,
lotwIntervalHours: autoSyncSettings.lotwIntervalHours,
lotwNextSyncAt: autoSyncSettings.lotwNextSyncAt,
dclEnabled: autoSyncSettings.dclEnabled,
dclIntervalHours: autoSyncSettings.dclIntervalHours,
dclNextSyncAt: autoSyncSettings.dclNextSyncAt,
hasCredentials: credentialField, // Just check if field exists (not null/empty)
})
.from(autoSyncSettings)
.innerJoin(users, eq(autoSyncSettings.userId, users.id))
.where(
and(
eq(enabledField, true),
lte(nextSyncField, now)
)
);
// Split into users with and without credentials
const withCredentials = results.filter(r => r.hasCredentials);
const withoutCredentials = results.filter(r => !r.hasCredentials);
// For users without credentials, update their next sync time to retry in 24 hours
// This prevents them from being continuously retried every minute
if (withoutCredentials.length > 0) {
const retryDate = new Date();
retryDate.setHours(retryDate.getHours() + 24);
for (const user of withoutCredentials) {
const updateData = {
updatedAt: new Date(),
};
if (service === 'lotw') {
updateData.lotwNextSyncAt = retryDate;
} else {
updateData.dclNextSyncAt = retryDate;
}
await db
.update(autoSyncSettings)
.set(updateData)
.where(eq(autoSyncSettings.userId, user.userId));
}
logger.warn('Skipped auto-sync for users without credentials, will retry in 24 hours', {
service,
count: withoutCredentials.length,
userIds: withoutCredentials.map(u => u.userId),
});
}
logger.debug('Found pending sync users', {
service,
total: results.length,
withCredentials: withCredentials.length,
withoutCredentials: withoutCredentials.length,
});
return withCredentials;
} catch (error) {
logger.error('Failed to get pending sync users', { error: error.message, service });
return [];
}
}
/**
* Update sync timestamps after a successful sync
*
* @param {number} userId - User ID
* @param {string} service - 'lotw' or 'dcl'
* @param {Date} lastSyncDate - Date of last sync
* @returns {Promise<void>}
*/
export async function updateSyncTimestamps(userId, service, lastSyncDate) {
try {
if (service !== 'lotw' && service !== 'dcl') {
throw new Error('Service must be "lotw" or "dcl"');
}
// Get current settings to find the interval
const [currentSettings] = await db
.select()
.from(autoSyncSettings)
.where(eq(autoSyncSettings.userId, userId));
if (!currentSettings) {
logger.warn('No auto-sync settings found for user', { userId, service });
return;
}
const intervalHours = service === 'lotw'
? currentSettings.lotwIntervalHours
: currentSettings.dclIntervalHours;
// Calculate next sync time
const nextSyncAt = calculateNextSyncTime(intervalHours);
// Update timestamps
const updateData = {
updatedAt: new Date(),
};
if (service === 'lotw') {
updateData.lotwLastSyncAt = lastSyncDate;
updateData.lotwNextSyncAt = nextSyncAt;
} else {
updateData.dclLastSyncAt = lastSyncDate;
updateData.dclNextSyncAt = nextSyncAt;
}
await db
.update(autoSyncSettings)
.set(updateData)
.where(eq(autoSyncSettings.userId, userId));
logger.debug('Updated sync timestamps', {
userId,
service,
lastSyncAt: lastSyncDate.toISOString(),
nextSyncAt: nextSyncAt.toISOString(),
});
} catch (error) {
logger.error('Failed to update sync timestamps', { error: error.message, userId, service });
throw error;
}
}

View File

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

View File

@@ -1,7 +1,7 @@
import { db, logger } from '../config.js'; import { db, logger } from '../config.js';
import { qsos } from '../db/schema/index.js'; import { qsos } from '../db/schema/index.js';
import { eq, and, or, desc, sql } from 'drizzle-orm'; import { eq, and, or, desc, sql } from 'drizzle-orm';
import { readFileSync } from 'fs'; import { readFileSync, readdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.js'; import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.js';
@@ -13,27 +13,24 @@ import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.
// Load award definitions from files // Load award definitions from files
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions'); const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
// In-memory cache for award definitions (static, never changes at runtime)
let cachedAwardDefinitions = null;
/** /**
* Load all award definitions * Load all award definitions (cached in memory)
*/ */
function loadAwardDefinitions() { function loadAwardDefinitions() {
// Return cached definitions if available
if (cachedAwardDefinitions) {
return cachedAwardDefinitions;
}
const definitions = []; const definitions = [];
try { try {
const files = [ // Auto-discover all JSON files in the award-definitions directory
'dxcc.json', const files = readdirSync(AWARD_DEFINITIONS_DIR)
'dxcc-cw.json', .filter(f => f.endsWith('.json'));
'was.json',
'vucc-sat.json',
'sat-rs44.json',
'special-stations.json',
'dld.json',
'dld-80m.json',
'dld-40m.json',
'dld-cw.json',
'dld-80m-cw.json',
'73-on-73.json',
];
for (const file of files) { for (const file of files) {
try { try {
@@ -49,9 +46,47 @@ function loadAwardDefinitions() {
logger.error('Error loading award definitions', { error: error.message }); logger.error('Error loading award definitions', { error: error.message });
} }
// Sort by award name with numeric prefixes in numerical order
definitions.sort((a, b) => {
const nameA = a.name || '';
const nameB = b.name || '';
// Extract leading numbers if present
const matchA = nameA.match(/^(\d+)/);
const matchB = nameB.match(/^(\d+)/);
// If both start with numbers, compare numerically first
if (matchA && matchB) {
const numA = parseInt(matchA[1], 10);
const numB = parseInt(matchB[1], 10);
if (numA !== numB) {
return numA - numB;
}
// If numbers are equal, fall through to alphabetical
}
// If one starts with a number, it comes first
else if (matchA) return -1;
else if (matchB) return 1;
// Otherwise, alphabetical comparison (case-insensitive)
return nameA.toLowerCase().localeCompare(nameB.toLowerCase());
});
// Cache the definitions for future calls
cachedAwardDefinitions = definitions;
return definitions; return definitions;
} }
/**
* Clear the cached award definitions
* Call this after creating, updating, or deleting award definitions
*/
export function clearAwardCache() {
cachedAwardDefinitions = null;
logger.info('Award cache cleared');
}
/** /**
* Get all available awards * Get all available awards
*/ */
@@ -65,9 +100,34 @@ export async function getAllAwards() {
caption: def.caption, caption: def.caption,
category: def.category, category: def.category,
rules: def.rules, rules: def.rules,
modeGroups: def.modeGroups || null,
})); }));
} }
/**
* Get a single award by ID
* @param {string} awardId - Award ID
* @returns {Object|null} Award definition or null if not found
*/
export function getAwardById(awardId) {
const definitions = loadAwardDefinitions();
const award = definitions.find((def) => def.id === awardId);
if (!award) {
return null;
}
return {
id: award.id,
name: award.name,
description: award.description,
caption: award.caption,
category: award.category,
rules: award.rules,
modeGroups: award.modeGroups || null,
};
}
/** /**
* Calculate award progress for a user * Calculate award progress for a user
* @param {number} userId - User ID * @param {number} userId - User ID
@@ -140,11 +200,27 @@ export async function calculateAwardProgress(userId, award, options = {}) {
logger.debug('QSOs after filters', { count: filteredQSOs.length }); logger.debug('QSOs after filters', { count: filteredQSOs.length });
} }
// Apply allowed_bands filter if present
let finalQSOs = filteredQSOs;
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
finalQSOs = filteredQSOs.filter(qso => {
const band = qso.band;
return rules.allowed_bands.includes(band);
});
logger.debug('QSOs after allowed_bands filter', { count: finalQSOs.length });
}
// Apply satellite_only filter if present
if (rules.satellite_only) {
finalQSOs = finalQSOs.filter(qso => qso.satName);
logger.debug('QSOs after satellite_only filter', { count: finalQSOs.length });
}
// Calculate worked and confirmed entities // Calculate worked and confirmed entities
const workedEntities = new Set(); const workedEntities = new Set();
const confirmedEntities = new Set(); const confirmedEntities = new Set();
for (const qso of filteredQSOs) { for (const qso of finalQSOs) {
const entity = getEntityValue(qso, rules.entityType); const entity = getEntityValue(qso, rules.entityType);
if (entity) { if (entity) {
@@ -199,7 +275,7 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
} }
// Track unique (DOK, band, mode) combinations // Track unique (DOK, band, mode) combinations
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object with qsos array
for (const qso of filteredQSOs) { for (const qso of filteredQSOs) {
const dok = qso.darcDok; const dok = qso.darcDok;
@@ -212,29 +288,36 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
// Initialize combination if not exists // Initialize combination if not exists
if (!dokCombinations.has(combinationKey)) { if (!dokCombinations.has(combinationKey)) {
dokCombinations.set(combinationKey, { dokCombinations.set(combinationKey, {
qsoId: qso.id,
entity: dok, entity: dok,
entityId: null, entityId: null,
entityName: dok, entityName: dok,
band, band,
mode, mode,
callsign: qso.callsign,
worked: false, worked: false,
confirmed: false, confirmed: false,
qsoDate: qso.qsoDate, qsos: [], // Array of confirmed QSOs for this slot
dclQslRdate: null,
}); });
} }
const detail = dokCombinations.get(combinationKey); const detail = dokCombinations.get(combinationKey);
detail.worked = true; detail.worked = true;
// Check for DCL confirmation // Check for DCL confirmation and add to qsos array
if (qso.dclQslRstatus === 'Y') { if (qso.dclQslRstatus === 'Y') {
if (!detail.confirmed) { if (!detail.confirmed) {
detail.confirmed = true; detail.confirmed = true;
detail.dclQslRdate = qso.dclQslRdate;
} }
// Add this confirmed QSO to the qsos array
detail.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,
});
} }
} }
@@ -339,15 +422,13 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (!combinationMap.has(combinationKey)) { if (!combinationMap.has(combinationKey)) {
combinationMap.set(combinationKey, { combinationMap.set(combinationKey, {
qsoId: qso.id,
callsign, callsign,
band, band,
mode, mode,
points, points,
worked: true, worked: true,
confirmed: false, confirmed: false,
qsoDate: qso.qsoDate, qsos: [], // Array of confirmed QSOs for this slot
lotwQslRdate: null,
}); });
} }
@@ -355,8 +436,18 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const detail = combinationMap.get(combinationKey); const detail = combinationMap.get(combinationKey);
if (!detail.confirmed) { if (!detail.confirmed) {
detail.confirmed = true; detail.confirmed = true;
detail.lotwQslRdate = qso.lotwQslRdate;
} }
// Add this confirmed QSO to the qsos array
detail.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,
});
} }
} }
@@ -378,15 +469,11 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (!stationMap.has(callsign)) { if (!stationMap.has(callsign)) {
stationMap.set(callsign, { stationMap.set(callsign, {
qsoId: qso.id,
callsign, callsign,
points, points,
worked: true, worked: true,
confirmed: false, confirmed: false,
qsoDate: qso.qsoDate, qsos: [], // Array of confirmed QSOs for this station
band: qso.band,
mode: qso.mode,
lotwQslRdate: null,
}); });
} }
@@ -394,8 +481,18 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
const detail = stationMap.get(callsign); const detail = stationMap.get(callsign);
if (!detail.confirmed) { if (!detail.confirmed) {
detail.confirmed = true; detail.confirmed = true;
detail.lotwQslRdate = qso.lotwQslRdate;
} }
// Add this confirmed QSO to the qsos array
detail.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,
});
} }
} }
@@ -415,6 +512,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
if (qso.lotwQslRstatus === 'Y') { if (qso.lotwQslRstatus === 'Y') {
totalPoints += points; totalPoints += points;
// For perQso mode, each QSO is its own slot with a qsos array containing just itself
stationDetails.push({ stationDetails.push({
qsoId: qso.id, qsoId: qso.id,
callsign, callsign,
@@ -424,7 +522,16 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
qsoDate: qso.qsoDate, qsoDate: qso.qsoDate,
band: qso.band, band: qso.band,
mode: qso.mode, mode: qso.mode,
lotwQslRdate: qso.lotwQslRdate, qsos: [{
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
satName: qso.satName,
confirmed: true,
}],
}); });
} }
} }
@@ -465,6 +572,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode, mode: detail.mode,
callsign: detail.callsign, callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate, lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this slot
}; };
} else if (countMode === 'perStation') { } else if (countMode === 'perStation') {
return { return {
@@ -480,6 +588,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode, mode: detail.mode,
callsign: detail.callsign, callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate, lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this station
}; };
} else { } else {
return { return {
@@ -495,6 +604,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
mode: detail.mode, mode: detail.mode,
callsign: detail.callsign, callsign: detail.callsign,
lotwQslRdate: detail.lotwQslRdate, lotwQslRdate: detail.lotwQslRdate,
qsos: detail.qsos || [], // All confirmed QSOs for this slot (just this one QSO)
}; };
} }
}); });
@@ -675,48 +785,77 @@ export async function getAwardEntityBreakdown(userId, awardId) {
// Apply filters // Apply filters
const filteredQSOs = applyFilters(allQSOs, rules.filters); const filteredQSOs = applyFilters(allQSOs, rules.filters);
// Group by entity // Apply allowed_bands filter if present
const entityMap = new Map(); let finalQSOs = filteredQSOs;
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
finalQSOs = filteredQSOs.filter(qso => {
const band = qso.band;
return rules.allowed_bands.includes(band);
});
}
for (const qso of filteredQSOs) { // Apply satellite_only filter if present
if (rules.satellite_only) {
finalQSOs = finalQSOs.filter(qso => qso.satName);
}
// Group by (entity, band, mode) slot for entity awards
// This allows showing multiple QSOs per entity on different bands/modes
const slotMap = new Map(); // Key: "entity/band/mode" -> slot object
for (const qso of finalQSOs) {
const entity = getEntityValue(qso, rules.entityType); const entity = getEntityValue(qso, rules.entityType);
if (!entity) continue; if (!entity) continue;
if (!entityMap.has(entity)) { const band = qso.band || 'Unknown';
// Determine what to display as the entity name const mode = qso.mode || 'Unknown';
let displayName = String(entity); const slotKey = `${entity}/${band}/${mode}`;
if (rules.displayField) {
let rawValue = qso[rules.displayField];
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
rawValue = rawValue.substring(0, 4);
}
displayName = String(rawValue || entity);
} else {
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
}
entityMap.set(entity, { // Determine what to display as the entity name (only on first create)
qsoId: qso.id, let displayName = String(entity);
if (rules.displayField) {
let rawValue = qso[rules.displayField];
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
rawValue = rawValue.substring(0, 4);
}
displayName = String(rawValue || entity);
} else {
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
}
if (!slotMap.has(slotKey)) {
slotMap.set(slotKey, {
entity, entity,
entityId: qso.entityId, entityId: qso.entityId,
entityName: displayName, entityName: displayName,
band,
mode,
worked: false, worked: false,
confirmed: false, confirmed: false,
qsoDate: qso.qsoDate, qsos: [], // Array of confirmed QSOs for this slot
band: qso.band,
mode: qso.mode,
callsign: qso.callsign,
satName: qso.satName,
}); });
} }
const entityData = entityMap.get(entity); const slotData = slotMap.get(slotKey);
entityData.worked = true; slotData.worked = true;
// Check for LoTW confirmation and add to qsos array
if (qso.lotwQslRstatus === 'Y') { if (qso.lotwQslRstatus === 'Y') {
entityData.confirmed = true; if (!slotData.confirmed) {
entityData.lotwQslRdate = qso.lotwQslRdate; slotData.confirmed = true;
}
// Add this confirmed QSO to the qsos array
slotData.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,
});
} }
} }
@@ -728,8 +867,8 @@ export async function getAwardEntityBreakdown(userId, awardId) {
caption: award.caption, caption: award.caption,
target: rules.target || 0, target: rules.target || 0,
}, },
entities: Array.from(entityMap.values()), entities: Array.from(slotMap.values()),
total: entityMap.size, total: slotMap.size,
confirmed: Array.from(entityMap.values()).filter((e) => e.confirmed).length, confirmed: Array.from(slotMap.values()).filter((e) => e.confirmed).length,
}; };
} }

View File

@@ -86,32 +86,6 @@ export function clearAllCache() {
return size; return size;
} }
/**
* Get cache statistics (for monitoring/debugging)
* @returns {object} Cache stats
*/
export function getCacheStats() {
const now = Date.now();
let expired = 0;
let valid = 0;
for (const [, value] of awardCache) {
const age = now - value.timestamp;
if (age > CACHE_TTL) {
expired++;
} else {
valid++;
}
}
return {
total: awardCache.size,
valid,
expired,
ttl: CACHE_TTL
};
}
/** /**
* Clean up expired cache entries (maintenance function) * Clean up expired cache entries (maintenance function)
* Can be called periodically to free memory * Can be called periodically to free memory

View File

@@ -4,6 +4,7 @@ import { max, sql, eq, and, desc } from 'drizzle-orm';
import { updateJobProgress } from './job-queue.service.js'; import { updateJobProgress } from './job-queue.service.js';
import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js'; import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
import { invalidateUserCache, invalidateStatsCache } from './cache.service.js'; import { invalidateUserCache, invalidateStatsCache } from './cache.service.js';
import { yieldToEventLoop, getQSOKey } from '../utils/sync-helpers.js';
/** /**
* DCL (DARC Community Logbook) Service * DCL (DARC Community Logbook) Service
@@ -122,17 +123,6 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
} }
} }
/**
* Parse DCL API response from JSON
* Can be used for testing with example payloads
*
* @param {Object} jsonResponse - JSON response in DCL format
* @returns {Array} Array of parsed QSO records
*/
export function parseDCLJSONResponse(jsonResponse) {
return parseDCLResponse(jsonResponse);
}
/** /**
* Convert DCL ADIF QSO to database format * Convert DCL ADIF QSO to database format
* @param {Object} adifQSO - Parsed ADIF QSO record * @param {Object} adifQSO - Parsed ADIF QSO record
@@ -169,21 +159,6 @@ function convertQSODatabaseFormat(adifQSO, userId) {
}; };
} }
/**
* Yield to event loop to allow other requests to be processed
* This prevents blocking the server during long-running sync operations
*/
function yieldToEventLoop() {
return new Promise(resolve => setImmediate(resolve));
}
/**
* Get QSO key for duplicate detection
*/
function getQSOKey(qso) {
return `${qso.callsign}|${qso.qsoDate}|${qso.timeOn}|${qso.band}|${qso.mode}`;
}
/** /**
* Sync QSOs from DCL to database (optimized with batch operations) * Sync QSOs from DCL to database (optimized with batch operations)
* Updates existing QSOs with DCL confirmation data * Updates existing QSOs with DCL confirmation data

View File

@@ -1,10 +1,11 @@
import { db, logger } from '../config.js'; import { db, logger } from '../config.js';
import { qsos, qsoChanges } from '../db/schema/index.js'; import { qsos, qsoChanges, syncJobs, awardProgress } from '../db/schema/index.js';
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm'; import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
import { updateJobProgress } from './job-queue.service.js'; import { updateJobProgress } from './job-queue.service.js';
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js'; import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
import { invalidateUserCache, getCachedStats, setCachedStats, invalidateStatsCache } from './cache.service.js'; import { invalidateUserCache, getCachedStats, setCachedStats, invalidateStatsCache } from './cache.service.js';
import { trackQueryPerformance, getPerformanceSummary, resetPerformanceMetrics } from './performance.service.js'; import { trackQueryPerformance } from './performance.service.js';
import { yieldToEventLoop, getQSOKey } from '../utils/sync-helpers.js';
/** /**
* LoTW (Logbook of the World) Service * LoTW (Logbook of the World) Service
@@ -81,6 +82,7 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
* Fetch QSOs from LoTW with retry support * Fetch QSOs from LoTW with retry support
*/ */
async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
const startTime = Date.now();
const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi'; const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi';
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -176,7 +178,7 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
} }
} }
const totalTime = Math.round((Date.now() - Date.now()) / 1000); const totalTime = Math.round((Date.now() - startTime) / 1000);
return { return {
error: `LoTW sync failed: Report not ready after ${MAX_RETRIES} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.` error: `LoTW sync failed: Report not ready after ${MAX_RETRIES} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`
}; };
@@ -210,21 +212,6 @@ function convertQSODatabaseFormat(adifQSO, userId) {
}; };
} }
/**
* Yield to event loop to allow other requests to be processed
* This prevents blocking the server during long-running sync operations
*/
function yieldToEventLoop() {
return new Promise(resolve => setImmediate(resolve));
}
/**
* Get QSO key for duplicate detection
*/
function getQSOKey(qso) {
return `${qso.callsign}|${qso.qsoDate}|${qso.timeOn}|${qso.band}|${qso.mode}`;
}
/** /**
* Sync QSOs from LoTW to database (optimized with batch operations) * Sync QSOs from LoTW to database (optimized with batch operations)
* @param {number} userId - User ID * @param {number} userId - User ID
@@ -609,10 +596,58 @@ export async function getLastLoTWQSLDate(userId) {
/** /**
* Delete all QSOs for a user * Delete all QSOs for a user
* Also deletes related qso_changes records to satisfy foreign key constraints
*/ */
export async function deleteQSOs(userId) { export async function deleteQSOs(userId) {
logger.debug('Deleting all QSOs for user', { userId });
// Step 1: Delete qso_changes that reference QSOs for this user
// Need to use a subquery since qso_changes doesn't have userId directly
const qsoIdsResult = await db
.select({ id: qsos.id })
.from(qsos)
.where(eq(qsos.userId, userId));
const qsoIds = qsoIdsResult.map(r => r.id);
let deletedChanges = 0;
if (qsoIds.length > 0) {
// Delete qso_changes where qsoId is in the list of QSO IDs
const changesResult = await db
.delete(qsoChanges)
.where(sql`${qsoChanges.qsoId} IN ${sql.raw(`(${qsoIds.join(',')})`)}`);
deletedChanges = changesResult.changes || changesResult || 0;
logger.debug('Deleted qso_changes', { count: deletedChanges });
}
// Step 2: Delete the QSOs
const result = await db.delete(qsos).where(eq(qsos.userId, userId)); const result = await db.delete(qsos).where(eq(qsos.userId, userId));
return result; logger.debug('Delete result', { result, type: typeof result, keys: Object.keys(result || {}) });
// Drizzle with SQLite/bun:sqlite returns various formats depending on driver
let count = 0;
if (result) {
if (typeof result === 'number') {
count = result;
} else if (result.changes !== undefined) {
count = result.changes;
} else if (result.rows !== undefined) {
count = result.rows;
} else if (result.meta?.changes !== undefined) {
count = result.meta.changes;
} else if (result.meta?.rows !== undefined) {
count = result.meta.rows;
}
}
logger.info('Deleted QSOs', { userId, count, deletedChanges });
// Invalidate caches for this user
await invalidateStatsCache(userId);
await invalidateUserCache(userId);
return count;
} }
/** /**

View File

@@ -0,0 +1,234 @@
import { logger } from '../config.js';
import {
getPendingSyncUsers,
updateSyncTimestamps,
} from './auto-sync.service.js';
import {
enqueueJob,
getUserActiveJob,
} from './job-queue.service.js';
import { getUserById } from './auth.service.js';
/**
* Auto-Sync Scheduler Service
* Manages automatic synchronization of DCL and LoTW data
* Runs every minute to check for due syncs and enqueues jobs
*/
// Scheduler state
let schedulerInterval = null;
let isRunning = false;
let isShuttingDown = false;
// Scheduler configuration
const SCHEDULER_TICK_INTERVAL_MS = 60 * 1000; // 1 minute
const INITIAL_DELAY_MS = 5000; // 5 seconds after server start
// Allow faster tick interval for testing (set via environment variable)
const TEST_MODE = process.env.SCHEDULER_TEST_MODE === 'true';
const TEST_TICK_INTERVAL_MS = 10 * 1000; // 10 seconds in test mode
/**
* Get scheduler status
* @returns {Object} Scheduler status
*/
export function getSchedulerStatus() {
return {
isRunning,
isShuttingDown,
tickIntervalMs: TEST_MODE ? TEST_TICK_INTERVAL_MS : SCHEDULER_TICK_INTERVAL_MS,
activeInterval: !!schedulerInterval,
testMode: TEST_MODE,
};
}
/**
* Process pending syncs for a specific service
* @param {string} service - 'lotw' or 'dcl'
*/
async function processServiceSyncs(service) {
try {
const pendingUsers = await getPendingSyncUsers(service);
if (pendingUsers.length === 0) {
logger.debug('No pending syncs', { service });
return;
}
logger.info('Processing pending syncs', {
service,
count: pendingUsers.length,
});
for (const user of pendingUsers) {
if (isShuttingDown) {
logger.info('Scheduler shutting down, skipping pending sync', {
service,
userId: user.userId,
});
break;
}
try {
// Check if there's already an active job for this user and service
const activeJob = await getUserActiveJob(user.userId, `${service}_sync`);
if (activeJob) {
logger.debug('User already has active job, skipping', {
service,
userId: user.userId,
activeJobId: activeJob.id,
});
// Update the next sync time to try again later
// This prevents continuous checking while a job is running
await updateSyncTimestamps(user.userId, service, new Date());
continue;
}
// Enqueue the sync job
logger.info('Enqueuing auto-sync job', {
service,
userId: user.userId,
});
const result = await enqueueJob(user.userId, `${service}_sync`);
if (result.success) {
// Update timestamps immediately on successful enqueue
await updateSyncTimestamps(user.userId, service, new Date());
} else {
logger.warn('Failed to enqueue auto-sync job', {
service,
userId: user.userId,
reason: result.error,
});
}
} catch (error) {
logger.error('Error processing user sync', {
service,
userId: user.userId,
error: error.message,
});
}
}
} catch (error) {
logger.error('Error processing service syncs', {
service,
error: error.message,
});
}
}
/**
* Main scheduler tick function
* Checks for pending LoTW and DCL syncs and processes them
*/
async function schedulerTick() {
if (isShuttingDown) {
logger.debug('Scheduler shutdown in progress, skipping tick');
return;
}
try {
logger.debug('Scheduler tick started');
// Process LoTW syncs
await processServiceSyncs('lotw');
// Process DCL syncs
await processServiceSyncs('dcl');
logger.debug('Scheduler tick completed');
} catch (error) {
logger.error('Scheduler tick error', {
error: error.message,
stack: error.stack,
});
}
}
/**
* Start the scheduler
* Begins periodic checks for pending syncs
*/
export function startScheduler() {
if (isRunning) {
logger.warn('Scheduler already running');
return;
}
// Check if scheduler is disabled via environment variable
if (process.env.DISABLE_SCHEDULER === 'true') {
logger.info('Scheduler disabled via DISABLE_SCHEDULER environment variable');
return;
}
isRunning = true;
isShuttingDown = false;
const tickInterval = TEST_MODE ? TEST_TICK_INTERVAL_MS : SCHEDULER_TICK_INTERVAL_MS;
// Initial delay to allow server to fully start
logger.info('Scheduler starting, initial tick in 5 seconds', {
testMode: TEST_MODE,
tickIntervalMs: tickInterval,
});
// Schedule first tick
setTimeout(() => {
if (!isShuttingDown) {
schedulerTick();
// Set up recurring interval
schedulerInterval = setInterval(() => {
if (!isShuttingDown) {
schedulerTick();
}
}, tickInterval);
logger.info('Scheduler started', {
tickIntervalMs: tickInterval,
testMode: TEST_MODE,
});
}
}, INITIAL_DELAY_MS);
}
/**
* Stop the scheduler gracefully
* Waits for current tick to complete before stopping
*/
export async function stopScheduler() {
if (!isRunning) {
logger.debug('Scheduler not running');
return;
}
logger.info('Stopping scheduler...');
isShuttingDown = true;
// Clear the interval
if (schedulerInterval) {
clearInterval(schedulerInterval);
schedulerInterval = null;
}
// Wait a moment for any in-progress tick to complete
await new Promise(resolve => setTimeout(resolve, 100));
isRunning = false;
logger.info('Scheduler stopped');
}
/**
* Trigger an immediate scheduler tick (for testing or manual sync)
*/
export async function triggerSchedulerTick() {
if (!isRunning) {
throw new Error('Scheduler is not running');
}
logger.info('Manual scheduler tick triggered');
await schedulerTick();
}

View File

@@ -0,0 +1,23 @@
/**
* Sync Helper Utilities
*
* Shared utilities for LoTW and DCL sync operations
*/
/**
* Yield to event loop to allow other requests to be processed
* This prevents blocking the server during long-running sync operations
* @returns {Promise<void>}
*/
export function yieldToEventLoop() {
return new Promise(resolve => setImmediate(resolve));
}
/**
* Get QSO key for duplicate detection
* @param {object} qso - QSO object
* @returns {string} Unique key for the QSO
*/
export function getQSOKey(qso) {
return `${qso.callsign}|${qso.qsoDate}|${qso.timeOn}|${qso.band}|${qso.mode}`;
}

186
src/frontend/src/app.css Normal file
View File

@@ -0,0 +1,186 @@
/* Quickawards Theme System - CSS Variables */
/* Light Mode (default) */
:root, [data-theme="light"] {
/* Backgrounds */
--bg-body: #f5f5f5;
--bg-card: #ffffff;
--bg-navbar: #2c3e50;
--bg-footer: #2c3e50;
--bg-input: #ffffff;
--bg-hover: rgba(255, 255, 255, 0.1);
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
/* Text */
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--text-inverted: #ffffff;
--text-link: #4a90e2;
/* Primary colors */
--color-primary: #4a90e2;
--color-primary-hover: #357abd;
--color-primary-light: rgba(74, 144, 226, 0.1);
/* Secondary colors */
--color-secondary: #6c757d;
--color-secondary-hover: #5a6268;
/* Semantic colors */
--color-success: #065f46;
--color-success-bg: #d1fae5;
--color-success-light: #10b981;
--color-warning: #ffc107;
--color-warning-hover: #e0a800;
--color-warning-bg: #fff3cd;
--color-warning-text: #856404;
--color-error: #dc3545;
--color-error-hover: #c82333;
--color-error-bg: #fee2e2;
--color-error-text: #991b1b;
--color-info: #1e40af;
--color-info-bg: #dbeafe;
--color-info-text: #1e40af;
/* Badge/status colors */
--badge-pending-bg: #fef3c7;
--badge-pending-text: #92400e;
--badge-running-bg: #dbeafe;
--badge-running-text: #1e40af;
--badge-completed-bg: #d1fae5;
--badge-completed-text: #065f46;
--badge-failed-bg: #fee2e2;
--badge-failed-text: #991b1b;
--badge-cancelled-bg: #f3e8ff;
--badge-cancelled-text: #6b21a8;
--badge-purple-bg: #8b5cf6;
--badge-purple-text: #ffffff;
/* Borders */
--border-color: #e0e0e0;
--border-color-light: #e9ecef;
--border-radius: 4px;
--border-radius-lg: 8px;
--border-radius-pill: 12px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.12);
/* Focus */
--focus-ring: 0 0 0 2px rgba(74, 144, 226, 0.2);
/* Logout button */
--color-logout: #ff6b6b;
--color-logout-hover: #ff5252;
--color-logout-bg: rgba(255, 107, 107, 0.1);
/* Admin link */
--color-admin-bg: #ffc107;
--color-admin-hover: #e0a800;
--color-admin-text: #000000;
/* Impersonation banner */
--impersonation-bg: #fff3cd;
--impersonation-border: #ffc107;
--impersonation-text: #856404;
/* Gradient colors */
--gradient-primary: linear-gradient(90deg, #4a90e2 0%, #357abd 100%);
--gradient-purple: linear-gradient(90deg, #8b5cf6, #a78bfa);
--gradient-scheduled: linear-gradient(to right, #f8f7ff, white);
}
/* Dark Mode */
[data-theme="dark"] {
/* Backgrounds */
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--bg-navbar: #1f2937;
--bg-footer: #1f2937;
--bg-input: #2d2d2d;
--bg-hover: rgba(255, 255, 255, 0.1);
--bg-secondary: #252525;
--bg-tertiary: #333333;
/* Text */
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-muted: #707070;
--text-inverted: #ffffff;
--text-link: #5ba3f5;
/* Primary colors */
--color-primary: #5ba3f5;
--color-primary-hover: #4a8ae4;
--color-primary-light: rgba(91, 163, 245, 0.15);
/* Secondary colors */
--color-secondary: #6b7280;
--color-secondary-hover: #4b5563;
/* Semantic colors */
--color-success: #10b981;
--color-success-bg: #064e3b;
--color-success-light: #10b981;
--color-warning: #fbbf24;
--color-warning-hover: #f59e0b;
--color-warning-bg: #451a03;
--color-warning-text: #fef3c7;
--color-error: #f87171;
--color-error-hover: #ef4444;
--color-error-bg: #7f1d1d;
--color-error-text: #fecaca;
--color-info: #3b82f6;
--color-info-bg: #1e3a8a;
--color-info-text: #93c5fd;
/* Badge/status colors */
--badge-pending-bg: #451a03;
--badge-pending-text: #fef3c7;
--badge-running-bg: #1e3a8a;
--badge-running-text: #93c5fd;
--badge-completed-bg: #064e3b;
--badge-completed-text: #6ee7b7;
--badge-failed-bg: #7f1d1d;
--badge-failed-text: #fecaca;
--badge-cancelled-bg: #3b0a4d;
--badge-cancelled-text: #d8b4fe;
--badge-purple-bg: #7c3aed;
--badge-purple-text: #ffffff;
/* Borders */
--border-color: #404040;
--border-color-light: #4a4a4a;
/* Focus */
--focus-ring: 0 0 0 2px rgba(91, 163, 245, 0.2);
/* Logout button */
--color-logout: #f87171;
--color-logout-hover: #ef4444;
--color-logout-bg: rgba(248, 113, 113, 0.15);
/* Admin link */
--color-admin-bg: #f59e0b;
--color-admin-hover: #d97706;
--color-admin-text: #000000;
/* Impersonation banner */
--impersonation-bg: #451a03;
--impersonation-border: #f59e0b;
--impersonation-text: #fef3c7;
/* Gradient colors */
--gradient-primary: linear-gradient(90deg, #5ba3f5 0%, #4a8ae4 100%);
--gradient-purple: linear-gradient(90deg, #7c3aed, #8b5cf6);
--gradient-scheduled: linear-gradient(to right, #2d1f3d, #2d2d2d);
}

View File

@@ -4,6 +4,14 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
<script>
// Prevent flash of unstyled content (FOUC)
(function() {
const theme = localStorage.getItem('theme') || 'light';
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
})();
</script>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>

View File

@@ -118,3 +118,41 @@ export const adminAPI = {
getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`), getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`),
}; };
// Auto-Sync API
export const autoSyncAPI = {
getSettings: () => apiRequest('/auto-sync/settings'),
updateSettings: (settings) => apiRequest('/auto-sync/settings', {
method: 'PUT',
body: JSON.stringify(settings),
}),
getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'),
};
// Awards Admin API
export const awardsAdminAPI = {
getAll: () => apiRequest('/admin/awards'),
getById: (id) => apiRequest(`/admin/awards/${id}`),
create: (data) => apiRequest('/admin/awards', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id, data) => apiRequest(`/admin/awards/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id) => apiRequest(`/admin/awards/${id}`, {
method: 'DELETE',
}),
test: (id, userId, awardDefinition) => apiRequest(`/admin/awards/${id}/test`, {
method: 'POST',
body: JSON.stringify({ userId, awardDefinition }),
}),
};

View File

@@ -11,10 +11,10 @@
<style> <style>
.back-button { .back-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
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;
@@ -22,12 +22,12 @@
} }
.back-button:hover { .back-button:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.back-button.secondary { .back-button.secondary {
background-color: transparent; background-color: transparent;
color: #4a90e2; color: var(--color-primary);
padding: 0; padding: 0;
} }

View File

@@ -16,21 +16,21 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #d32f2f; color: var(--color-error);
} }
.btn { .btn {
display: inline-block; display: inline-block;
margin-top: 1rem; margin-top: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-weight: 500; font-weight: 500;
} }
.btn:hover { .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
</style> </style>

View File

@@ -11,6 +11,6 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #666; color: var(--text-secondary);
} }
</style> </style>

View File

@@ -0,0 +1,152 @@
<script>
import { theme } from '$lib/stores/theme.js';
import { browser } from '$app/environment';
let isOpen = false;
const themes = [
{ value: 'light', label: 'Light', icon: '☀️' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'system', label: 'System', icon: '💻' },
];
function selectTheme(value) {
theme.setTheme(value);
isOpen = false;
}
function toggle() {
isOpen = !isOpen;
}
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (!event.target.closest('.theme-switcher')) {
isOpen = false;
}
}
if (browser) {
document.addEventListener('click', handleClickOutside);
}
</script>
<svelte:head>
<style>
/* Ensure dropdown is above other content */
.theme-dropdown {
z-index: 1000;
}
</style>
</svelte:head>
<div class="theme-switcher">
<button
class="theme-button"
on:click={toggle}
aria-label="Change theme"
aria-expanded={isOpen}
>
<span class="current-icon">
{#if $theme === 'light'}
☀️
{:else if $theme === 'dark'}
🌙
{:else}
💻
{/if}
</span>
</button>
{#if isOpen}
<div class="theme-dropdown">
{#each themes as t}
<button
class="theme-option"
class:active={$theme === t.value}
on:click={() => selectTheme(t.value)}
>
<span class="theme-icon">{t.icon}</span>
<span class="theme-label">{t.label}</span>
{#if $theme === t.value}
<span class="checkmark"></span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.theme-switcher {
position: relative;
display: inline-block;
}
.theme-button {
background: transparent;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius);
transition: background-color 0.2s;
color: var(--text-inverted);
font-size: 1.25rem;
}
.theme-button:hover {
background-color: var(--bg-hover);
}
.theme-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
min-width: 140px;
overflow: hidden;
}
.theme-option {
width: 100%;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.9rem;
color: var(--text-primary);
transition: background-color 0.2s;
}
.theme-option:hover {
background-color: var(--bg-secondary);
}
.theme-option.active {
background-color: var(--bg-secondary);
font-weight: 600;
}
.theme-icon {
font-size: 1.1rem;
}
.theme-label {
flex: 1;
}
.checkmark {
color: var(--color-primary);
font-weight: bold;
}
</style>

View File

@@ -103,6 +103,15 @@ function createAuthStore() {
clearError: () => { clearError: () => {
update((s) => ({ ...s, error: null })); update((s) => ({ ...s, error: null }));
}, },
// Direct login with user object and token (for impersonation)
loginWithToken: (user, token) => {
if (browser) {
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
}
set({ user, token, loading: false, error: null });
},
}; };
} }

View File

@@ -0,0 +1,56 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
/**
* Theme store
* Manages theme state (light, dark, system) with localStorage persistence
*/
function createThemeStore() {
// Initialize state from localStorage
const initialState = browser ? localStorage.getItem('theme') || 'light' : 'light';
const { subscribe, set, update } = writable(initialState);
// Listen for system preference changes
let mediaQuery;
if (browser) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleMediaChange = () => {
// Trigger update to recompute isDark
update((n) => n);
};
mediaQuery.addEventListener('change', handleMediaChange);
}
// Derived store for whether dark mode should be active
const isDark = derived(initialState, ($theme) => {
if (!browser) return false;
if ($theme === 'dark') return true;
if ($theme === 'light') return false;
// system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
return {
subscribe,
isDark,
setTheme: (theme) => {
if (browser) {
localStorage.setItem('theme', theme);
// Apply data-theme attribute to document
const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light');
}
set(theme);
},
// Initialize theme on client-side
init: () => {
if (!browser) return;
const theme = localStorage.getItem('theme') || 'light';
const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light');
},
};
}
export const theme = createThemeStore();

View File

@@ -1,11 +1,52 @@
<script> <script>
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { theme } from '$lib/stores/theme.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { adminAPI, authAPI } from '$lib/api.js';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import '../app.css';
let stoppingImpersonation = false;
// Initialize theme on mount
onMount(() => {
theme.init();
});
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
goto('/auth/login'); // Use hard redirect to ensure proper navigation after logout
// goto() may not work properly due to SvelteKit client-side routing
if (browser) {
window.location.href = '/auth/login';
}
}
async function handleStopImpersonation() {
if (stoppingImpersonation) return;
try {
stoppingImpersonation = true;
const data = await adminAPI.stopImpersonation();
if (data.success) {
// Update auth store with admin user data and new token
auth.loginWithToken(data.user, data.token);
// Hard redirect to home page
if (browser) {
window.location.href = '/';
}
} else {
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Failed to stop impersonation: ' + err.message);
} finally {
stoppingImpersonation = false;
}
} }
</script> </script>
@@ -30,11 +71,32 @@
{#if $auth.user?.isAdmin} {#if $auth.user?.isAdmin}
<a href="/admin" class="nav-link admin-link">Admin</a> <a href="/admin" class="nav-link admin-link">Admin</a>
{/if} {/if}
<ThemeSwitcher />
<button on:click={handleLogout} class="nav-link logout-btn">Logout</button> <button on:click={handleLogout} class="nav-link logout-btn">Logout</button>
</div> </div>
</div> </div>
</nav> </nav>
{/if} {/if}
<!-- Impersonation Banner -->
{#if $auth.user?.impersonatedBy}
<div class="impersonation-banner">
<div class="impersonation-content">
<span class="warning-icon">⚠️</span>
<span class="impersonation-text">
You are currently impersonating <strong>{$auth.user.email}</strong>
</span>
<button
class="stop-impersonation-btn"
on:click={handleStopImpersonation}
disabled={stoppingImpersonation}
>
{stoppingImpersonation ? 'Stopping...' : 'Stop Impersonation'}
</button>
</div>
</div>
{/if}
<main> <main>
<slot /> <slot />
</main> </main>
@@ -59,7 +121,8 @@
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f5f5f5; background-color: var(--bg-body);
color: var(--text-primary);
} }
.app { .app {
@@ -69,8 +132,8 @@
} }
.navbar { .navbar {
background-color: #2c3e50; background-color: var(--bg-navbar);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
} }
.nav-container { .nav-container {
@@ -84,7 +147,7 @@
} }
.nav-brand .callsign { .nav-brand .callsign {
color: white; color: var(--text-inverted);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
@@ -96,11 +159,12 @@
} }
.nav-link { .nav-link {
color: rgba(255, 255, 255, 0.8); color: var(--text-inverted);
opacity: 0.8;
text-decoration: none; text-decoration: none;
font-size: 0.95rem; font-size: 0.95rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 4px; border-radius: var(--border-radius);
transition: all 0.2s; transition: all 0.2s;
background: none; background: none;
border: none; border: none;
@@ -109,27 +173,27 @@
} }
.nav-link:hover { .nav-link:hover {
color: white; opacity: 1;
background-color: rgba(255, 255, 255, 0.1); background-color: var(--bg-hover);
} }
.logout-btn { .logout-btn {
color: #ff6b6b; color: var(--color-logout);
opacity: 1;
} }
.logout-btn:hover { .logout-btn:hover {
color: #ff5252; background-color: var(--color-logout-bg);
background-color: rgba(255, 107, 107, 0.1);
} }
.admin-link { .admin-link {
background-color: #ffc107; background-color: var(--color-admin-bg);
color: #000; color: var(--color-admin-text);
font-weight: 600; font-weight: 600;
} }
.admin-link:hover { .admin-link:hover {
background-color: #e0a800; background-color: var(--color-admin-hover);
} }
main { main {
@@ -141,8 +205,9 @@
} }
footer { footer {
background-color: #2c3e50; background-color: var(--bg-footer);
color: rgba(255, 255, 255, 0.7); color: var(--text-inverted);
opacity: 0.7;
text-align: center; text-align: center;
padding: 1.5rem; padding: 1.5rem;
margin-top: auto; margin-top: auto;
@@ -152,4 +217,51 @@
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Impersonation Banner */
.impersonation-banner {
background-color: var(--impersonation-bg);
border: 2px solid var(--impersonation-border);
padding: 0.75rem 1rem;
}
.impersonation-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.warning-icon {
font-size: 1.25rem;
}
.impersonation-text {
flex: 1;
font-size: 0.95rem;
color: var(--impersonation-text);
}
.stop-impersonation-btn {
background-color: var(--color-warning);
color: var(--impersonation-text);
border: none;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.stop-impersonation-btn:hover:not(:disabled) {
background-color: var(--color-warning-hover);
}
.stop-impersonation-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount, onDestroy, tick } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { jobsAPI } from '$lib/api.js'; import { jobsAPI, autoSyncAPI } from '$lib/api.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
let jobs = []; let jobs = [];
@@ -9,6 +9,18 @@
let cancellingJobs = new Map(); // Track cancelling state per job let cancellingJobs = new Map(); // Track cancelling state per job
let pollingInterval = null; let pollingInterval = null;
// Auto-sync settings state
let autoSyncSettings = null;
let loadingAutoSync = false;
// Reactive: scheduled jobs derived from settings
// Note: Explicitly reference autoSyncSettings to ensure Svelte tracks it as a dependency
let scheduledJobs = [];
$: {
autoSyncSettings; // Touch variable so Svelte tracks reactivity
scheduledJobs = getScheduledJobs();
}
async function loadJobs() { async function loadJobs() {
try { try {
const response = await jobsAPI.getRecent(5); const response = await jobsAPI.getRecent(5);
@@ -22,6 +34,81 @@
} }
} }
async function loadAutoSyncSettings() {
try {
loadingAutoSync = true;
const response = await autoSyncAPI.getSettings();
autoSyncSettings = response.settings || null;
} catch (error) {
console.error('Failed to load auto-sync settings:', error);
// Don't show error, auto-sync is optional
} finally {
loadingAutoSync = false;
}
}
function getScheduledJobs() {
if (!autoSyncSettings) {
return [];
}
const scheduled = [];
if (autoSyncSettings.lotwEnabled) {
scheduled.push({
type: 'lotw_sync',
icon: '📡',
name: 'LoTW Auto-Sync',
interval: autoSyncSettings.lotwIntervalHours,
nextSyncAt: autoSyncSettings.lotwNextSyncAt,
lastSyncAt: autoSyncSettings.lotwLastSyncAt,
enabled: true,
});
}
if (autoSyncSettings.dclEnabled) {
scheduled.push({
type: 'dcl_sync',
icon: '🛰️',
name: 'DCL Auto-Sync',
interval: autoSyncSettings.dclIntervalHours,
nextSyncAt: autoSyncSettings.dclNextSyncAt,
lastSyncAt: autoSyncSettings.dclLastSyncAt,
enabled: true,
});
}
return scheduled;
}
function getNextSyncLabel(nextSyncAt, interval) {
if (!nextSyncAt) return 'Pending...';
const now = new Date();
const nextSync = new Date(nextSyncAt);
const diffMs = nextSync - now;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMs < 0) return 'Due now';
if (diffMins < 60) return `In ${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
return `In ${diffDays} day${diffDays !== 1 ? 's' : ''}`;
}
function formatNextSyncTime(nextSyncAt) {
if (!nextSyncAt) return null;
const date = new Date(nextSyncAt);
return date.toLocaleString();
}
function formatLastSyncTime(lastSyncAt) {
if (!lastSyncAt) return 'Never';
const date = new Date(lastSyncAt);
return formatDate(date);
}
function hasActiveJobs() { function hasActiveJobs() {
return jobs.some(job => job.status === 'pending' || job.status === 'running'); return jobs.some(job => job.status === 'pending' || job.status === 'running');
} }
@@ -58,6 +145,7 @@
// Load recent jobs if authenticated // Load recent jobs if authenticated
if ($auth.user) { if ($auth.user) {
await loadJobs(); await loadJobs();
await loadAutoSyncSettings();
loading = false; loading = false;
} }
}); });
@@ -187,6 +275,47 @@
</div> </div>
</div> </div>
<!-- Scheduled Auto-Sync Jobs -->
{#if scheduledJobs.length > 0}
<div class="scheduled-section">
<h2 class="section-title">⏰ Upcoming Auto-Sync</h2>
<div class="jobs-list">
{#each scheduledJobs as scheduled (scheduled.type)}
<div class="job-card job-card-scheduled">
<div class="job-header">
<div class="job-title">
<span class="job-icon">{scheduled.icon}</span>
<span class="job-name">{scheduled.name}</span>
<span class="job-badge scheduled-badge">Scheduled</span>
</div>
<span class="scheduled-interval">Every {scheduled.interval}h</span>
</div>
<div class="job-meta">
<span class="job-date">
Next: <strong title={formatNextSyncTime(scheduled.nextSyncAt)}>
{getNextSyncLabel(scheduled.nextSyncAt, scheduled.interval)}
</strong>
</span>
<span class="job-time">
Last: {formatLastSyncTime(scheduled.lastSyncAt)}
</span>
</div>
<div class="scheduled-countdown">
<div class="countdown-bar">
<div class="countdown-progress"></div>
</div>
<p class="countdown-text">
{formatNextSyncTime(scheduled.nextSyncAt)}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Recent Sync Jobs --> <!-- Recent Sync Jobs -->
<div class="jobs-section"> <div class="jobs-section">
<h2 class="section-title">🔄 Recent Sync Jobs</h2> <h2 class="section-title">🔄 Recent Sync Jobs</h2>
@@ -316,13 +445,13 @@
.welcome h1 { .welcome h1 {
font-size: 2.5rem; font-size: 2.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.subtitle { .subtitle {
font-size: 1.25rem; font-size: 1.25rem;
color: #666; color: var(--text-secondary);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -335,12 +464,12 @@
.dashboard h1 { .dashboard h1 {
font-size: 2rem; font-size: 2rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.welcome-section p { .welcome-section p {
color: #666; color: var(--text-secondary);
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -353,21 +482,21 @@
} }
.action-card { .action-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
.action-card h3 { .action-card h3 {
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);
} }
.action-card p { .action-card p {
color: #666; color: var(--text-secondary);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -375,7 +504,7 @@
display: inline-block; display: inline-block;
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;
text-decoration: none; text-decoration: none;
@@ -384,25 +513,25 @@
} }
.btn-primary { .btn-primary {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.action-card .btn { .action-card .btn {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
width: 100%; width: 100%;
text-align: center; text-align: center;
@@ -410,25 +539,25 @@
} }
.action-card .btn:hover { .action-card .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.info-box { .info-box {
background: #f8f9fa; background: var(--bg-secondary);
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 ol { .info-box ol {
margin: 0; margin: 0;
padding-left: 1.5rem; padding-left: 1.5rem;
color: #666; color: var(--text-secondary);
line-height: 1.8; line-height: 1.8;
} }
@@ -439,18 +568,18 @@
.section-title { .section-title {
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.loading-state, .loading-state,
.empty-state { .empty-state {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: #666; color: var(--text-secondary);
} }
.empty-actions { .empty-actions {
@@ -468,20 +597,25 @@
} }
.job-card { .job-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
} }
.job-card:hover { .job-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); box-shadow: var(--shadow-md);
} }
.job-card.failed { .job-card.failed {
border-left: 4px solid #dc3545; border-left: 4px solid var(--color-error);
}
.job-card-scheduled {
border-left: 4px solid var(--badge-purple-bg);
background: var(--gradient-scheduled);
} }
.job-header { .job-header {
@@ -503,69 +637,83 @@
.job-name { .job-name {
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 1.1rem; font-size: 1.1rem;
} }
.job-id { .job-id {
font-size: 0.85rem; font-size: 0.85rem;
color: #999; color: var(--text-muted);
font-family: monospace; font-family: monospace;
} }
.status-badge { .status-badge {
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border-radius: var(--border-radius-pill);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
text-transform: capitalize; text-transform: capitalize;
} }
.bg-yellow-100 { .bg-yellow-100 {
background-color: #fef3c7; background-color: var(--badge-pending-bg);
} }
.bg-blue-100 { .bg-blue-100 {
background-color: #dbeafe; background-color: var(--badge-running-bg);
} }
.bg-green-100 { .bg-green-100 {
background-color: #d1fae5; background-color: var(--badge-completed-bg);
} }
.bg-red-100 { .bg-red-100 {
background-color: #fee2e2; background-color: var(--badge-failed-bg);
} }
.text-yellow-800 { .text-yellow-800 {
color: #92400e; color: var(--badge-pending-text);
} }
.text-blue-800 { .text-blue-800 {
color: #1e40af; color: var(--badge-running-text);
} }
.text-green-800 { .text-green-800 {
color: #065f46; color: var(--badge-completed-text);
} }
.text-red-800 { .text-red-800 {
color: #991b1b; color: var(--badge-failed-text);
} }
.bg-purple-100 { .bg-purple-100 {
background-color: #f3e8ff; background-color: var(--badge-cancelled-bg);
} }
.text-purple-800 { .text-purple-800 {
color: #6b21a8; color: var(--badge-cancelled-text);
}
.job-badge {
padding: 0.2rem 0.6rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.scheduled-badge {
background-color: var(--badge-purple-bg);
color: var(--badge-purple-text);
} }
.job-meta { .job-meta {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;
color: #666; color: var(--text-secondary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -576,14 +724,14 @@
.job-time, .job-time,
.job-duration { .job-duration {
color: #999; color: var(--text-muted);
} }
.job-error { .job-error {
background: #fee2e2; background: var(--color-error-bg);
color: #991b1b; color: var(--color-error-text);
padding: 0.75rem; padding: 0.75rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.95rem; font-size: 0.95rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -597,29 +745,29 @@
.stat-item { .stat-item {
font-size: 0.9rem; font-size: 0.9rem;
color: #666; color: var(--text-secondary);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: #f8f9fa; background: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
} }
.stat-item strong { .stat-item strong {
color: #333; color: var(--text-primary);
} }
.stat-added { .stat-added {
color: #065f46; color: var(--color-success);
background: #d1fae5; background: var(--color-success-bg);
} }
.stat-updated { .stat-updated {
color: #1e40af; color: var(--color-info);
background: #dbeafe; background: var(--color-info-bg);
} }
.stat-skipped { .stat-skipped {
color: #92400e; color: var(--badge-pending-text);
background: #fef3c7; background: var(--badge-pending-bg);
} }
.job-progress { .job-progress {
@@ -627,7 +775,7 @@
} }
.progress-text { .progress-text {
color: #1e40af; color: var(--color-info);
font-size: 0.9rem; font-size: 0.9rem;
font-style: italic; font-style: italic;
} }
@@ -641,17 +789,17 @@
.btn-cancel { .btn-cancel {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
font-size: 0.85rem; font-size: 0.85rem;
border: 1px solid #dc3545; border: 1px solid var(--color-error);
background: white; background: var(--bg-card);
color: #dc3545; color: var(--color-error);
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-weight: 500; font-weight: 500;
} }
.btn-cancel:hover:not(:disabled) { .btn-cancel:hover:not(:disabled) {
background: #dc3545; background: var(--color-error);
color: white; color: white;
} }
@@ -659,4 +807,53 @@
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
/* Scheduled Jobs Section */
.scheduled-section {
margin-bottom: 2rem;
}
/* Scheduled job countdown */
.scheduled-countdown {
margin-top: 1rem;
}
.countdown-bar {
height: 6px;
background: var(--border-color-light);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.countdown-progress {
height: 100%;
background: var(--gradient-purple);
border-radius: 3px;
width: 100%;
animation: pulse-countdown 2s ease-in-out infinite;
}
@keyframes pulse-countdown {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.countdown-text {
margin: 0;
font-size: 0.85rem;
color: var(--badge-purple-bg);
text-align: center;
font-weight: 500;
}
@media (max-width: 640px) {
.scheduled-list {
grid-template-columns: 1fr;
}
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { adminAPI } from '$lib/api.js'; import { adminAPI, authAPI } from '$lib/api.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
let loading = true; let loading = true;
@@ -12,7 +12,7 @@
let impersonationStatus = null; let impersonationStatus = null;
// UI state // UI state
let selectedTab = 'overview'; // 'overview', 'users', 'actions' let selectedTab = 'overview'; // 'overview', 'users', 'awards', 'actions'
let showImpersonationModal = false; let showImpersonationModal = false;
let showDeleteUserModal = false; let showDeleteUserModal = false;
let showRoleChangeModal = false; let showRoleChangeModal = false;
@@ -90,16 +90,16 @@
const data = await adminAPI.impersonate(userId); const data = await adminAPI.impersonate(userId);
if (data.success) { if (data.success) {
// Store new token // Store the new impersonation token
if (browser) { if (browser) {
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
} }
// Update auth store with new user data // Fetch the full user profile (which includes impersonatedBy)
auth.login({ const profileData = await authAPI.getProfile();
...data.impersonating,
impersonatedBy: $auth.user.id, // Update auth store with complete user data
}); auth.loginWithToken(profileData.user, data.token);
// Redirect to home page // Redirect to home page
window.location.href = '/'; window.location.href = '/';
@@ -114,32 +114,6 @@
} }
} }
async function handleStopImpersonation() {
try {
loading = true;
const data = await adminAPI.stopImpersonation();
if (data.success) {
// Store admin token
if (browser) {
localStorage.setItem('auth_token', data.token);
}
// Update auth store
auth.login(data.user);
alert(data.message);
window.location.reload();
} else {
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Failed to stop impersonation: ' + err.message);
} finally {
loading = false;
}
}
async function handleDeleteUser(userId) { async function handleDeleteUser(userId) {
const user = users.find(u => u.id === userId); const user = users.find(u => u.id === userId);
if (!user) return; if (!user) return;
@@ -203,7 +177,11 @@
function formatDate(dateString) { function formatDate(dateString) {
if (!dateString) return 'N/A'; if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString('en-US', { // Handle Unix timestamps (seconds) by converting to milliseconds
const date = typeof dateString === 'number'
? new Date(dateString * 1000)
: new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -232,21 +210,6 @@
<div class="error">{error}</div> <div class="error">{error}</div>
{:else} {:else}
<div class="admin-dashboard"> <div class="admin-dashboard">
<!-- Impersonation Banner -->
{#if $auth.user?.impersonatedBy}
<div class="impersonation-banner">
<div class="impersonation-content">
<span class="warning-icon">⚠️</span>
<span class="impersonation-text">
You are currently impersonating <strong>{$auth.user.email}</strong>
</span>
<button class="stop-impersonation-btn" on:click={handleStopImpersonation}>
Stop Impersonation
</button>
</div>
</div>
{/if}
<h1>Admin Dashboard</h1> <h1>Admin Dashboard</h1>
<!-- Tab Navigation --> <!-- Tab Navigation -->
@@ -263,6 +226,12 @@
> >
Users Users
</button> </button>
<button
class="tab {selectedTab === 'awards' ? 'active' : ''}"
on:click={() => selectedTab = 'awards'}
>
Awards
</button>
<button <button
class="tab {selectedTab === 'actions' ? 'active' : ''}" class="tab {selectedTab === 'actions' ? 'active' : ''}"
on:click={() => selectedTab = 'actions'} on:click={() => selectedTab = 'actions'}
@@ -367,6 +336,7 @@
<th>DCL Conf.</th> <th>DCL Conf.</th>
<th>Total Conf.</th> <th>Total Conf.</th>
<th>Last Sync</th> <th>Last Sync</th>
<th>Last Seen</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -386,6 +356,7 @@
<td>{user.dclConfirmed || 0}</td> <td>{user.dclConfirmed || 0}</td>
<td>{user.totalConfirmed || 0}</td> <td>{user.totalConfirmed || 0}</td>
<td>{formatDate(user.lastSync)}</td> <td>{formatDate(user.lastSync)}</td>
<td>{formatDate(user.lastSeen)}</td>
<td class="actions-cell"> <td class="actions-cell">
<button <button
class="action-button impersonate-btn" class="action-button impersonate-btn"
@@ -419,6 +390,30 @@
</div> </div>
{/if} {/if}
<!-- Awards Tab -->
{#if selectedTab === 'awards'}
<div class="tab-content">
<h2>Award Definitions</h2>
<p class="help-text">Manage award definitions. Create, edit, and delete awards.</p>
<div class="awards-quick-actions">
<a href="/admin/awards" class="btn btn-primary">Manage Awards</a>
</div>
<div class="awards-info">
<h3>Award Management</h3>
<p>From the Awards management page, you can:</p>
<ul>
<li><strong>Create</strong> new award definitions</li>
<li><strong>Edit</strong> existing award definitions</li>
<li><strong>Delete</strong> awards</li>
<li><strong>Test</strong> award calculations with sample user data</li>
</ul>
<p>All award definitions are stored as JSON files in the <code>award-definitions/</code> directory.</p>
</div>
</div>
{/if}
<!-- Actions Tab --> <!-- Actions Tab -->
{#if selectedTab === 'actions'} {#if selectedTab === 'actions'}
<div class="tab-content"> <div class="tab-content">
@@ -563,58 +558,20 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--text-secondary);
} }
.error { .error {
background-color: #fee; background-color: var(--color-error-bg);
border: 1px solid #fcc; border: 1px solid var(--color-error);
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
color: #c00; color: var(--color-error-text);
}
/* Impersonation Banner */
.impersonation-banner {
background-color: #fff3cd;
border: 2px solid #ffc107;
border-radius: 4px;
padding: 1rem;
margin-bottom: 2rem;
}
.impersonation-content {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.warning-icon {
font-size: 1.5rem;
}
.impersonation-text {
flex: 1;
font-size: 1rem;
}
.stop-impersonation-btn {
background-color: #ffc107;
color: #000;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.stop-impersonation-btn:hover {
background-color: #e0a800;
} }
h1 { h1 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: #333; color: var(--text-primary);
} }
/* Tabs */ /* Tabs */
@@ -622,7 +579,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
border-bottom: 2px solid #ddd; border-bottom: 2px solid var(--border-color);
} }
.tab { .tab {
@@ -632,30 +589,30 @@
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
color: #666; color: var(--text-secondary);
position: relative; position: relative;
top: 2px; top: 2px;
} }
.tab:hover { .tab:hover {
color: #333; color: var(--text-primary);
} }
.tab.active { .tab.active {
color: #007bff; color: var(--color-primary);
border-bottom: 2px solid #007bff; border-bottom: 2px solid var(--color-primary);
} }
.tab-content { .tab-content {
background: white; background: var(--bg-card);
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: var(--border-radius);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
h2 { h2 {
margin-bottom: 1rem; margin-bottom: 1rem;
color: #333; color: var(--text-primary);
} }
/* Stats Grid */ /* Stats Grid */
@@ -670,8 +627,8 @@
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.stat-card h3 { .stat-card h3 {
@@ -714,9 +671,11 @@
.search-input, .search-input,
.filter-select { .filter-select {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input { .search-input {
@@ -731,8 +690,8 @@
.users-table { .users-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: white; background: var(--bg-card);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
} }
@@ -740,23 +699,23 @@
.users-table td { .users-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.users-table th { .users-table th {
background-color: #f5f5f5; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.8rem; font-size: 0.8rem;
color: #666; color: var(--text-secondary);
} }
.users-table tr:hover { .users-table tr:hover {
background-color: #f9f9f9; background-color: var(--bg-secondary);
} }
.admin-row { .admin-row {
background-color: #fff9e6 !important; background-color: var(--color-warning-bg) !important;
} }
.actions-cell { .actions-cell {
@@ -767,7 +726,7 @@
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
margin-right: 0.3rem; margin-right: 0.3rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
transition: all 0.2s; transition: all 0.2s;
@@ -788,21 +747,21 @@
} }
.role-btn { .role-btn {
background-color: #ffc107; background-color: var(--color-warning);
color: #000; color: #000;
} }
.role-btn:hover:not(:disabled) { .role-btn:hover:not(:disabled) {
background-color: #e0a800; background-color: var(--color-warning-hover);
} }
.delete-btn { .delete-btn {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.delete-btn:hover:not(:disabled) { .delete-btn:hover:not(:disabled) {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.role-badge { .role-badge {
@@ -818,13 +777,13 @@
} }
.role-badge.user { .role-badge.user {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.users-count { .users-count {
margin-top: 1rem; margin-top: 1rem;
color: #666; color: var(--text-secondary);
font-style: italic; font-style: italic;
} }
@@ -836,8 +795,8 @@
.actions-table { .actions-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: white; background: var(--bg-card);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
} }
@@ -845,15 +804,15 @@
.actions-table td { .actions-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.actions-table th { .actions-table th {
background-color: #f5f5f5; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.8rem; font-size: 0.8rem;
color: #666; color: var(--text-secondary);
} }
.action-type { .action-type {
@@ -863,13 +822,13 @@
font-weight: 600; font-weight: 600;
} }
.action-type.impostor_start { .action-type.impersonate_start {
background-color: #ffc107; background-color: var(--color-warning);
color: #000; color: #000;
} }
.action-type.impostor_stop { .action-type.impersonate_stop {
background-color: #28a745; background-color: var(--color-success-light);
color: white; color: white;
} }
@@ -879,14 +838,14 @@
} }
.action-type.user_delete { .action-type.user_delete {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.details-json { .details-json {
background-color: #f5f5f5; background-color: var(--bg-secondary);
padding: 0.5rem; padding: 0.5rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.8rem; font-size: 0.8rem;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
@@ -895,7 +854,7 @@
.no-actions { .no-actions {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
color: #666; color: var(--text-secondary);
} }
/* Modal */ /* Modal */
@@ -913,30 +872,30 @@
} }
.modal-content { .modal-content {
background: white; background: var(--bg-card);
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.modal-content h2 { .modal-content h2 {
margin-top: 0; margin-top: 0;
color: #333; color: var(--text-primary);
} }
.modal-content p { .modal-content p {
color: #666; color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
} }
.modal-content .warning { .modal-content .warning {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
border-left: 4px solid #ffc107; border-left: 4px solid var(--color-warning);
padding: 1rem; padding: 1rem;
margin: 1rem 0; margin: 1rem 0;
color: #856404; color: var(--badge-pending-text);
} }
.modal-actions { .modal-actions {
@@ -949,36 +908,36 @@
.modal-button { .modal-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
} }
.modal-button.cancel { .modal-button.cancel {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.modal-button.cancel:hover { .modal-button.cancel:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.modal-button.confirm { .modal-button.confirm {
background-color: #007bff; background-color: var(--color-primary);
color: white; color: white;
} }
.modal-button.confirm:hover { .modal-button.confirm:hover {
background-color: #0056b3; background-color: var(--color-primary-hover);
} }
.modal-button.delete-confirm { .modal-button.delete-confirm {
background-color: #dc3545; background-color: var(--color-error);
} }
.modal-button.delete-confirm:hover { .modal-button.delete-confirm:hover {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.role-options { .role-options {
@@ -995,6 +954,50 @@
cursor: pointer; cursor: pointer;
} }
.help-text {
color: var(--text-secondary);
font-style: italic;
margin-bottom: 1rem;
}
.awards-quick-actions {
margin-bottom: 2rem;
}
.awards-info {
background-color: var(--bg-secondary);
border-left: 4px solid #667eea;
padding: 1.5rem;
border-radius: var(--border-radius);
}
.awards-info h3 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--text-primary);
}
.awards-info p {
margin-bottom: 1rem;
}
.awards-info ul {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.awards-info li {
margin-bottom: 0.5rem;
}
.awards-info code {
background-color: var(--border-color);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.9rem;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.users-header { .users-header {
flex-direction: column; flex-direction: column;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -180,13 +180,13 @@
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.subtitle { .subtitle {
font-size: 1.25rem; font-size: 1.25rem;
color: #666; color: var(--text-secondary);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -205,28 +205,29 @@
.filter-group label { .filter-group label {
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 0.9rem; font-size: 0.9rem;
} }
.filter-group select { .filter-group select {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid #ccc; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
background-color: white; background-color: var(--bg-input);
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
min-width: 150px; min-width: 150px;
color: var(--text-primary);
} }
.filter-group select:hover { .filter-group select:hover {
border-color: #4a90e2; border-color: var(--color-primary);
} }
.filter-group select:focus { .filter-group select:focus {
outline: none; outline: none;
border-color: #4a90e2; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); box-shadow: var(--focus-ring);
} }
.loading, .loading,
@@ -235,11 +236,11 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #666; color: var(--text-secondary);
} }
.error { .error {
color: #d32f2f; color: var(--color-error);
} }
.awards-grid { .awards-grid {
@@ -249,11 +250,11 @@
} }
.award-card { .award-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -268,16 +269,16 @@
.award-header h2 { .award-header h2 {
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
margin: 0; margin: 0;
flex: 1; flex: 1;
} }
.category { .category {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border-radius: var(--border-radius-pill);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -285,7 +286,7 @@
} }
.description { .description {
color: #666; color: var(--text-secondary);
margin-bottom: 1rem; margin-bottom: 1rem;
flex-grow: 1; flex-grow: 1;
} }
@@ -295,8 +296,8 @@
gap: 2rem; gap: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem; padding: 0.75rem;
background-color: #f8f9fa; background-color: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
} }
.info-item { .info-item {
@@ -307,14 +308,14 @@
.label { .label {
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
} }
.value { .value {
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
font-weight: 500; font-weight: 500;
} }
@@ -325,15 +326,15 @@
.progress-bar { .progress-bar {
width: 100%; width: 100%;
height: 8px; height: 8px;
background-color: #e0e0e0; background-color: var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.progress-fill { .progress-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #4a90e2 0%, #357abd 100%); background: var(--gradient-primary);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
@@ -348,7 +349,7 @@
text-align: center; text-align: center;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
.worked, .worked,
@@ -357,20 +358,20 @@
} }
.worked { .worked {
color: #666; color: var(--text-secondary);
} }
.confirmed { .confirmed {
color: #4a90e2; color: var(--color-primary);
} }
.btn { .btn {
display: inline-block; display: inline-block;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -379,6 +380,6 @@
} }
.btn:hover { .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -899,22 +899,22 @@
.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: white;
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);
} }
.header-buttons { .header-buttons {
@@ -924,16 +924,16 @@
} }
.filters { .filters {
background: #f8f9fa; background: var(--bg-secondary);
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.filters h3 { .filters h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
} }
.filters-header { .filters-header {
@@ -947,11 +947,11 @@
.filter-count { .filter-count {
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: var(--text-secondary);
} }
.filter-count strong { .filter-count strong {
color: #4a90e2; color: var(--color-primary);
font-weight: 600; font-weight: 600;
} }
@@ -964,24 +964,28 @@
.filter-row select { .filter-row select {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input { .search-input {
flex: 1; flex: 1;
min-width: 200px; min-width: 200px;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input:focus { .search-input:focus {
outline: none; outline: none;
border-color: #4a90e2; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); box-shadow: var(--focus-ring);
} }
.checkbox-label { .checkbox-label {
@@ -994,7 +998,7 @@
.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;
@@ -1002,12 +1006,12 @@
} }
.btn-primary { .btn-primary {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.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 {
@@ -1016,21 +1020,21 @@
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.btn-danger { .btn-danger {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.btn-danger:disabled { .btn-danger:disabled {
@@ -1043,14 +1047,14 @@
font-size: 0.875rem; font-size: 0.875rem;
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
color: inherit; color: inherit;
} }
.alert { .alert {
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1058,21 +1062,21 @@
} }
.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);
} }
.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);
} }
.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);
} }
.alert h3 { .alert h3 {
@@ -1090,11 +1094,13 @@
.delete-input { .delete-input {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
width: 200px; width: 200px;
background: var(--bg-input);
color: var(--text-primary);
} }
.delete-buttons { .delete-buttons {
@@ -1105,8 +1111,8 @@
.qso-table-container { .qso-table-container {
overflow-x: auto; overflow-x: auto;
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
} }
.qso-table { .qso-table {
@@ -1118,39 +1124,39 @@
.qso-table td { .qso-table td {
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.qso-table th { .qso-table th {
background-color: #f8f9fa; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
} }
.qso-table tr:hover { .qso-table tr:hover {
background-color: #f8f9fa; background-color: var(--bg-secondary);
} }
.callsign { .callsign {
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
.badge { .badge {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
} }
.badge-success { .badge-success {
background-color: #d4edda; background-color: var(--color-success-bg);
color: #155724; color: var(--color-success);
} }
.badge-pending { .badge-pending {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
color: #856404; color: var(--badge-pending-text);
} }
.confirmation-list { .confirmation-list {
@@ -1168,29 +1174,29 @@
.service-type { .service-type {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.confirmation-date { .confirmation-date {
font-size: 0.875rem; font-size: 0.875rem;
color: #333; color: var(--text-primary);
} }
.loading, .error, .empty { .loading, .error, .empty {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: #666; color: var(--text-secondary);
} }
.error { .error {
color: #dc3545; color: var(--color-error);
} }
.showing { .showing {
text-align: center; text-align: center;
color: #666; color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
margin-top: 1rem; margin-top: 1rem;
} }
@@ -1201,14 +1207,14 @@
align-items: center; align-items: center;
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 1rem; padding: 1rem;
background: #f8f9fa; background: var(--bg-secondary);
border-radius: 8px; border-radius: var(--border-radius-lg);
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
} }
.pagination-info { .pagination-info {
color: #666; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -1226,7 +1232,7 @@
.page-ellipsis { .page-ellipsis {
padding: 0 0.5rem; padding: 0 0.5rem;
color: #666; color: var(--text-secondary);
} }
.btn-small { .btn-small {
@@ -1241,16 +1247,16 @@
} }
.import-log { .import-log {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
margin-top: 1rem; margin-top: 1rem;
} }
.import-log h3 { .import-log h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: var(--text-primary);
font-size: 1.25rem; font-size: 1.25rem;
} }
@@ -1264,15 +1270,15 @@
.log-section h4 { .log-section h4 {
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
color: #555; color: var(--text-secondary);
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
} }
.log-table-container { .log-table-container {
overflow-x: auto; overflow-x: auto;
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
} }
.log-table { .log-table {
@@ -1285,13 +1291,13 @@
.log-table td { .log-table td {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.log-table th { .log-table th {
background-color: #f8f9fa; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -1300,12 +1306,12 @@
} }
.log-table tr:hover { .log-table tr:hover {
background-color: #f8f9fa; background-color: var(--bg-secondary);
} }
.log-table .callsign { .log-table .callsign {
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
/* QSO Detail Modal Styles */ /* QSO Detail Modal Styles */
@@ -1315,11 +1321,11 @@
} }
.qso-row:hover { .qso-row:hover {
background-color: #f0f7ff !important; background-color: var(--color-primary-light) !important;
} }
.qso-row:focus { .qso-row:focus {
outline: 2px solid #4a90e2; outline: 2px solid var(--color-primary);
outline-offset: -2px; outline-offset: -2px;
} }
@@ -1340,13 +1346,13 @@
/* Modal Content */ /* Modal Content */
.modal-content { .modal-content {
background: white; background: var(--bg-card);
border-radius: 8px; border-radius: var(--border-radius-lg);
max-width: 700px; max-width: 700px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
/* Modal Header */ /* Modal Header */
@@ -1355,13 +1361,13 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5rem; padding: 1.5rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.modal-header h2 { .modal-header h2 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
} }
.modal-close { .modal-close {
@@ -1376,14 +1382,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #666; color: var(--text-secondary);
border-radius: 4px; border-radius: var(--border-radius);
transition: all 0.2s; transition: all 0.2s;
} }
.modal-close:hover { .modal-close:hover {
background-color: #f0f0f0; background-color: var(--bg-tertiary);
color: #333; color: var(--text-primary);
} }
/* Modal Body */ /* Modal Body */
@@ -1402,10 +1408,10 @@
.qso-detail-section h3 { .qso-detail-section h3 {
font-size: 1.1rem; font-size: 1.1rem;
color: #4a90e2; color: var(--color-primary);
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0; border-bottom: 2px solid var(--border-color);
} }
/* Detail Grid */ /* Detail Grid */
@@ -1423,14 +1429,14 @@
.detail-label { .detail-label {
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
} }
.detail-value { .detail-value {
font-size: 0.95rem; font-size: 0.95rem;
color: #333; color: var(--text-primary);
font-weight: 500; font-weight: 500;
} }
@@ -1443,10 +1449,10 @@
.confirmation-service h4 { .confirmation-service h4 {
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.confirmation-status-item { .confirmation-status-item {
@@ -1466,19 +1472,19 @@
} }
.status-badge.confirmed { .status-badge.confirmed {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.status-badge.not-confirmed, .status-badge.not-confirmed,
.status-badge.no-data { .status-badge.no-data {
background-color: #e0e0e0; background-color: var(--border-color);
color: #666; color: var(--text-secondary);
} }
.status-badge.unknown { .status-badge.unknown {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
color: #856404; color: var(--badge-pending-text);
} }
/* Meta Info */ /* Meta Info */
@@ -1486,18 +1492,18 @@
display: flex; display: flex;
gap: 2rem; gap: 2rem;
padding: 1rem; padding: 1rem;
background-color: #f8f9fa; background-color: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.8rem; font-size: 0.8rem;
} }
.meta-label { .meta-label {
color: #666; color: var(--text-secondary);
font-weight: 600; font-weight: 600;
} }
.meta-value { .meta-value {
color: #333; color: var(--text-primary);
font-family: monospace; font-family: monospace;
} }
@@ -1507,15 +1513,15 @@
} }
.modal-content::-webkit-scrollbar-track { .modal-content::-webkit-scrollbar-track {
background: #f1f1f1; background: var(--bg-secondary);
} }
.modal-content::-webkit-scrollbar-thumb { .modal-content::-webkit-scrollbar-thumb {
background: #888; background: var(--text-muted);
border-radius: 4px; border-radius: 4px;
} }
.modal-content::-webkit-scrollbar-thumb:hover { .modal-content::-webkit-scrollbar-thumb:hover {
background: #555; background: var(--text-secondary);
} }
</style> </style>

View File

@@ -32,9 +32,9 @@
} }
.stat-card { .stat-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
text-align: center; text-align: center;
} }
@@ -42,11 +42,11 @@
.stat-value { .stat-value {
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
color: #4a90e2; color: var(--color-primary);
} }
.stat-label { .stat-label {
color: #666; color: var(--text-secondary);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
</style> </style>

View File

@@ -23,11 +23,11 @@
<style> <style>
.lotw-btn { .lotw-btn {
background-color: #4a90e2; background-color: var(--color-primary);
} }
.lotw-btn:hover:not(:disabled) { .lotw-btn:hover:not(:disabled) {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.dcl-btn { .dcl-btn {

View File

@@ -1,6 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { authAPI } from '$lib/api.js'; import { browser } from '$app/environment';
import { authAPI, autoSyncAPI } from '$lib/api.js';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -16,9 +17,23 @@
let hasLoTWCredentials = false; let hasLoTWCredentials = false;
let hasDCLCredentials = false; let hasDCLCredentials = false;
// Auto-sync settings
let autoSyncSettings = {
lotwEnabled: false,
lotwIntervalHours: 24,
lotwNextSyncAt: null,
dclEnabled: false,
dclIntervalHours: 24,
dclNextSyncAt: null,
};
let loadingAutoSync = false;
let savingAutoSync = false;
let successAutoSync = false;
onMount(async () => { onMount(async () => {
// Load user profile to check if credentials exist // Load user profile to check if credentials exist
await loadProfile(); await loadProfile();
await loadAutoSyncSettings();
}); });
async function loadProfile() { async function loadProfile() {
@@ -40,6 +55,21 @@
} }
} }
async function loadAutoSyncSettings() {
try {
loadingAutoSync = true;
const response = await autoSyncAPI.getSettings();
if (response.settings) {
autoSyncSettings = response.settings;
}
} catch (err) {
console.error('Failed to load auto-sync settings:', err);
// Don't show error for auto-sync, it's optional
} finally {
loadingAutoSync = false;
}
}
async function handleSaveLoTW(e) { async function handleSaveLoTW(e) {
e.preventDefault(); e.preventDefault();
@@ -91,9 +121,46 @@
} }
} }
async function handleSaveAutoSync(e) {
e.preventDefault();
try {
savingAutoSync = true;
error = null;
successAutoSync = false;
await autoSyncAPI.updateSettings({
lotwEnabled: autoSyncSettings.lotwEnabled,
lotwIntervalHours: parseInt(autoSyncSettings.lotwIntervalHours),
dclEnabled: autoSyncSettings.dclEnabled,
dclIntervalHours: parseInt(autoSyncSettings.dclIntervalHours),
});
console.log('Auto-sync settings saved successfully!');
// Reload settings to get updated next sync times
await loadAutoSyncSettings();
successAutoSync = true;
} catch (err) {
console.error('Auto-sync save failed:', err);
error = err.message;
} finally {
savingAutoSync = false;
}
}
function formatNextSyncTime(dateString) {
if (!dateString) return 'Not scheduled';
const date = new Date(dateString);
return date.toLocaleString();
}
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
goto('/auth/login'); // Use hard redirect to ensure proper navigation after logout
if (browser) {
window.location.href = '/auth/login';
}
} }
</script> </script>
@@ -237,6 +304,116 @@
</p> </p>
</div> </div>
</div> </div>
<div class="settings-section">
<h2>Automatic Sync Settings</h2>
<p class="help-text">
Configure automatic synchronization for LoTW and DCL. The server will automatically
sync your QSOs at the specified interval. Credentials must be configured above.
</p>
{#if !hasLoTWCredentials && !hasDCLCredentials}
<div class="alert alert-info">
<strong>Note:</strong> Configure LoTW or DCL credentials above to enable automatic sync.
</div>
{/if}
<form on:submit={handleSaveAutoSync} class="settings-form">
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if successAutoSync}
<div class="alert alert-success">
Auto-sync settings saved successfully!
</div>
{/if}
<h3>LoTW Auto-Sync</h3>
<div class="form-row">
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
bind:checked={autoSyncSettings.lotwEnabled}
disabled={!hasLoTWCredentials || savingAutoSync}
/>
Enable LoTW auto-sync
</label>
{#if !hasLoTWCredentials}
<p class="hint">Configure LoTW credentials above first</p>
{/if}
</div>
<div class="form-group">
<label for="lotwIntervalHours">Sync interval (hours)</label>
<input
id="lotwIntervalHours"
type="number"
min="1"
max="720"
bind:value={autoSyncSettings.lotwIntervalHours}
disabled={!autoSyncSettings.lotwEnabled || savingAutoSync}
/>
<p class="hint">
Minimum 1 hour, maximum 720 hours (30 days). Default: 24 hours.
</p>
</div>
</div>
{#if autoSyncSettings.lotwEnabled && autoSyncSettings.lotwNextSyncAt}
<p class="next-sync-info">
Next scheduled sync: <strong>{formatNextSyncTime(autoSyncSettings.lotwNextSyncAt)}</strong>
</p>
{/if}
<hr class="divider" />
<h3>DCL Auto-Sync</h3>
<div class="form-row">
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
bind:checked={autoSyncSettings.dclEnabled}
disabled={!hasDCLCredentials || savingAutoSync}
/>
Enable DCL auto-sync
</label>
{#if !hasDCLCredentials}
<p class="hint">Configure DCL credentials above first</p>
{/if}
</div>
<div class="form-group">
<label for="dclIntervalHours">Sync interval (hours)</label>
<input
id="dclIntervalHours"
type="number"
min="1"
max="720"
bind:value={autoSyncSettings.dclIntervalHours}
disabled={!autoSyncSettings.dclEnabled || savingAutoSync}
/>
<p class="hint">
Minimum 1 hour, maximum 720 hours (30 days). Default: 24 hours.
</p>
</div>
</div>
{#if autoSyncSettings.dclEnabled && autoSyncSettings.dclNextSyncAt}
<p class="next-sync-info">
Next scheduled sync: <strong>{formatNextSyncTime(autoSyncSettings.dclNextSyncAt)}</strong>
</p>
{/if}
<button type="submit" class="btn btn-primary" disabled={savingAutoSync}>
{savingAutoSync ? 'Saving...' : 'Save Auto-Sync Settings'}
</button>
</form>
</div>
</div> </div>
<style> <style>
@@ -438,4 +615,58 @@
.info-box a:hover { .info-box a:hover {
text-decoration: underline; text-decoration: underline;
} }
/* Auto-sync specific styles */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
.checkbox-group {
padding-top: 0.75rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.divider {
border: none;
border-top: 1px solid #e0e0e0;
margin: 2rem 0;
}
.next-sync-info {
padding: 0.75rem 1rem;
background-color: #e3f2fd;
border-left: 4px solid #4a90e2;
border-radius: 4px;
margin-top: 1rem;
font-size: 0.9rem;
color: #333;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
gap: 1rem;
}
}
</style> </style>