Compare commits
12 Commits
695000e35c
...
8550b91255
| Author | SHA1 | Date | |
|---|---|---|---|
|
8550b91255
|
|||
|
a93d4ff85b
|
|||
|
f3ee1be651
|
|||
|
6c9aa1efe7
|
|||
|
14c7319c9e
|
|||
|
5792a98dca
|
|||
|
aa25d21c6b
|
|||
|
e14da11a93
|
|||
|
dc34fc20b1
|
|||
|
c75e55d130
|
|||
|
89edd07722
|
|||
|
dd3beef9af
|
50
CLAUDE.md
50
CLAUDE.md
@@ -754,3 +754,53 @@ AND (dclQslRstatus IS NULL OR dclQslRstatus != 'Y')
|
|||||||
- Tracks updated QSOs (restores previous state)
|
- Tracks updated QSOs (restores previous state)
|
||||||
- Only allows canceling failed jobs or stale running jobs (>1 hour)
|
- Only allows canceling failed jobs or stale running jobs (>1 hour)
|
||||||
- Server-side validation prevents unauthorized cancellations
|
- Server-side validation prevents unauthorized cancellations
|
||||||
|
|
||||||
|
### Award Detail View (January 2025)
|
||||||
|
|
||||||
|
**Overview**: The award detail page (`src/frontend/src/routes/awards/[id]/+page.svelte`) displays award progress in a pivot table format with entities as rows and band/mode combinations as columns.
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- **QSO Count per Slot**: Each table cell shows the count of confirmed QSOs for that (entity, band, mode) combination
|
||||||
|
- **Drill-Down**: Click a count to open a modal showing all QSOs for that slot
|
||||||
|
- **QSO Detail**: Click any QSO in the list to view full QSO details
|
||||||
|
- **Mode Filter**: Filter by specific mode or view "Mixed Mode" (aggregates all modes by band)
|
||||||
|
|
||||||
|
**Backend Changes** (`src/backend/services/awards.service.js`):
|
||||||
|
- `calculateDOKAwardProgress()`: Groups by (DOK, band, mode) slots, collects all confirmed QSOs in `qsos` array
|
||||||
|
- `calculatePointsAwardProgress()`: Updated for all count modes (perBandMode, perStation, perQso) with `qsos` array
|
||||||
|
- `getAwardEntityBreakdown()`: Groups by (entity, band, mode) slots for entity awards
|
||||||
|
|
||||||
|
**Response Structure**:
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
entity: "F03",
|
||||||
|
band: "80m",
|
||||||
|
mode: "CW",
|
||||||
|
worked: true,
|
||||||
|
confirmed: true,
|
||||||
|
qsos: [
|
||||||
|
{ qsoId: 123, callsign: "DK0MU", mode: "CW", qsoDate: "20250115", timeOn: "123456", confirmed: true },
|
||||||
|
{ qsoId: 456, callsign: "DL1ABC", mode: "CW", qsoDate: "20250120", timeOn: "234500", confirmed: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mode Filter**:
|
||||||
|
- **Mixed Mode (default)**: Shows bands as columns, aggregates all modes
|
||||||
|
- Example: Columns are "80m", "40m", "20m"
|
||||||
|
- Clicking a count shows all QSOs for that band across all modes
|
||||||
|
- **Specific Mode**: Shows (band, mode) combinations as columns
|
||||||
|
- Example: Columns are "80m CW", "80m SSB", "40m CW"
|
||||||
|
- Filters to only show QSOs with that mode
|
||||||
|
|
||||||
|
**Frontend Components**:
|
||||||
|
- **Mode Filter Dropdown**: Located between summary cards and table
|
||||||
|
- Dynamically populated with available modes from the data
|
||||||
|
- Clear button appears when specific mode is selected
|
||||||
|
- **Count Badges**: Blue clickable links showing QSO count (removed bubbles, kept links)
|
||||||
|
- **QSO List Modal**: Shows all QSOs for selected slot with columns: Callsign, Date, Time, Mode
|
||||||
|
- **QSO Detail Modal**: Full QSO information (existing feature)
|
||||||
|
|
||||||
|
**Files Modified**:
|
||||||
|
- `src/backend/services/awards.service.js` - Backend grouping and QSO collection
|
||||||
|
- `src/frontend/src/routes/awards/[id]/+page.svelte` - Frontend display and interaction
|
||||||
|
|||||||
@@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
award-definitions/dxcc-sat.json
Normal file
14
award-definitions/dxcc-sat.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,12 @@ function loadAwardDefinitions() {
|
|||||||
try {
|
try {
|
||||||
const files = [
|
const files = [
|
||||||
'dxcc.json',
|
'dxcc.json',
|
||||||
'dxcc-cw.json',
|
'dxcc-sat.json',
|
||||||
'was.json',
|
'was.json',
|
||||||
'vucc-sat.json',
|
'vucc-sat.json',
|
||||||
'sat-rs44.json',
|
'sat-rs44.json',
|
||||||
'special-stations.json',
|
'special-stations.json',
|
||||||
'dld.json',
|
'dld.json',
|
||||||
'dld-80m.json',
|
|
||||||
'dld-40m.json',
|
|
||||||
'dld-cw.json',
|
|
||||||
'dld-80m-cw.json',
|
|
||||||
'73-on-73.json',
|
'73-on-73.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -140,11 +136,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 +211,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 +224,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 +358,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 +372,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 +405,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 +417,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 +448,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 +458,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 +508,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 +524,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 +540,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,16 +721,34 @@ 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';
|
||||||
|
const slotKey = `${entity}/${band}/${mode}`;
|
||||||
|
|
||||||
|
// Determine what to display as the entity name (only on first create)
|
||||||
let displayName = String(entity);
|
let displayName = String(entity);
|
||||||
if (rules.displayField) {
|
if (rules.displayField) {
|
||||||
let rawValue = qso[rules.displayField];
|
let rawValue = qso[rules.displayField];
|
||||||
@@ -696,27 +760,38 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
|||||||
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
|
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
entityMap.set(entity, {
|
if (!slotMap.has(slotKey)) {
|
||||||
qsoId: qso.id,
|
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 +803,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,53 @@
|
|||||||
let loading = true;
|
let loading = true;
|
||||||
let error = null;
|
let error = null;
|
||||||
let groupedData = [];
|
let groupedData = [];
|
||||||
let bands = [];
|
let columns = []; // Array of {band, mode?} - mode is undefined for mixed mode
|
||||||
|
let selectedMode = 'Mixed Mode'; // Mode filter, default is all modes aggregated
|
||||||
|
|
||||||
// QSO detail modal state
|
// QSO detail modal state
|
||||||
let selectedQSO = null;
|
let selectedQSO = null;
|
||||||
let showQSODetailModal = false;
|
let showQSODetailModal = false;
|
||||||
let loadingQSO = false;
|
let loadingQSO = false;
|
||||||
|
|
||||||
|
// QSO list modal state
|
||||||
|
let showQSOListModal = false;
|
||||||
|
let selectedSlotQSOs = [];
|
||||||
|
let selectedSlotInfo = null; // { entityName, band, mode }
|
||||||
|
|
||||||
|
// Get available modes from entities
|
||||||
|
$: availableModes = ['Mixed Mode', ...new Set(entities.map(e => e.mode).filter(Boolean).sort())];
|
||||||
|
|
||||||
|
// Band order by wavelength (longest to shortest), SAT at the end
|
||||||
|
const bandOrder = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', 'SAT', '23cm', '13cm', '9cm', '6cm', '3cm'];
|
||||||
|
|
||||||
|
// Filter entities by selected mode for summary calculations
|
||||||
|
$: filteredEntities = selectedMode === 'Mixed Mode'
|
||||||
|
? entities
|
||||||
|
: entities.filter(e => e.mode === selectedMode);
|
||||||
|
|
||||||
|
// Calculate unique entity progress (for DXCC, DLD, etc.)
|
||||||
|
$: uniqueEntityProgress = (() => {
|
||||||
|
const uniqueEntities = new Map();
|
||||||
|
|
||||||
|
filteredEntities.forEach(e => {
|
||||||
|
const entityName = e.entityName || e.entity || 'Unknown';
|
||||||
|
|
||||||
|
if (!uniqueEntities.has(entityName)) {
|
||||||
|
uniqueEntities.set(entityName, { worked: false, confirmed: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = uniqueEntities.get(entityName);
|
||||||
|
if (e.worked) status.worked = true;
|
||||||
|
if (e.confirmed) status.confirmed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: uniqueEntities.size,
|
||||||
|
worked: Array.from(uniqueEntities.values()).filter(s => s.worked).length,
|
||||||
|
confirmed: Array.from(uniqueEntities.values()).filter(s => s.confirmed).length
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadAwardData();
|
await loadAwardData();
|
||||||
});
|
});
|
||||||
@@ -56,17 +96,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupDataForTable() {
|
function groupDataForTable() {
|
||||||
// Group by entity name, then create band columns
|
// Group by entity name, then create columns based on mode filter
|
||||||
const entityMap = new Map();
|
const entityMap = new Map();
|
||||||
const bandsSet = new Set();
|
const columnSet = new Set();
|
||||||
|
|
||||||
|
const isMixedMode = selectedMode === 'Mixed Mode';
|
||||||
|
|
||||||
entities.forEach((entity) => {
|
entities.forEach((entity) => {
|
||||||
|
// Skip if mode filter is set and entity doesn't match
|
||||||
|
if (!isMixedMode && entity.mode !== selectedMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const entityName = entity.entityName || entity.entity || 'Unknown';
|
const entityName = entity.entityName || entity.entity || 'Unknown';
|
||||||
|
|
||||||
if (!entityMap.has(entityName)) {
|
if (!entityMap.has(entityName)) {
|
||||||
entityMap.set(entityName, {
|
entityMap.set(entityName, {
|
||||||
entityName,
|
entityName,
|
||||||
bands: new Map(),
|
slots: new Map(),
|
||||||
worked: entity.worked,
|
worked: entity.worked,
|
||||||
confirmed: entity.confirmed,
|
confirmed: entity.confirmed,
|
||||||
});
|
});
|
||||||
@@ -74,27 +121,69 @@
|
|||||||
|
|
||||||
const entityData = entityMap.get(entityName);
|
const entityData = entityMap.get(entityName);
|
||||||
|
|
||||||
if (entity.band) {
|
// Check if this is a satellite QSO - use "SAT" instead of band
|
||||||
bandsSet.add(entity.band);
|
const isSatellite = entity.qsos && entity.qsos.some(qso => qso.satName);
|
||||||
|
const band = isSatellite ? 'SAT' : (entity.band || 'Unknown');
|
||||||
|
|
||||||
if (!entityData.bands.has(entity.band)) {
|
if (isMixedMode) {
|
||||||
entityData.bands.set(entity.band, []);
|
// Mixed Mode: aggregate by band only, collect all QSOs across modes
|
||||||
|
columnSet.add(band);
|
||||||
|
|
||||||
|
if (!entityData.slots.has(band)) {
|
||||||
|
entityData.slots.set(band, {
|
||||||
|
band,
|
||||||
|
mode: null, // No specific mode in mixed mode
|
||||||
|
qsos: [], // Will be aggregated
|
||||||
|
confirmed: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add QSO info to this band
|
const slot = entityData.slots.get(band);
|
||||||
entityData.bands.get(entity.band).push({
|
// Add QSOs from this entity to the aggregated slot
|
||||||
qsoId: entity.qsoId,
|
if (entity.qsos && entity.qsos.length > 0) {
|
||||||
callsign: entity.callsign,
|
slot.qsos.push(...entity.qsos);
|
||||||
mode: entity.mode,
|
if (entity.confirmed) slot.confirmed = true;
|
||||||
band: entity.band,
|
}
|
||||||
|
} else {
|
||||||
|
// Specific Mode: group by (band, mode)
|
||||||
|
const mode = entity.mode || 'Unknown';
|
||||||
|
const columnKey = `${band}/${mode}`;
|
||||||
|
columnSet.add(columnKey);
|
||||||
|
|
||||||
|
entityData.slots.set(columnKey, {
|
||||||
|
band,
|
||||||
|
mode,
|
||||||
|
qsos: entity.qsos || [],
|
||||||
confirmed: entity.confirmed,
|
confirmed: entity.confirmed,
|
||||||
qsoDate: entity.qsoDate,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert bands Set to sorted array
|
// Convert columnSet to sorted array of column objects
|
||||||
bands = Array.from(bandsSet).sort();
|
columns = Array.from(columnSet)
|
||||||
|
.map(key => {
|
||||||
|
if (isMixedMode) {
|
||||||
|
return { band: key, mode: null }; // key is just the band name
|
||||||
|
} else {
|
||||||
|
const [band, mode] = key.split('/');
|
||||||
|
return { band, mode };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by band order (by wavelength), then by mode
|
||||||
|
const aBandIndex = bandOrder.indexOf(a.band);
|
||||||
|
const bBandIndex = bandOrder.indexOf(b.band);
|
||||||
|
const aIndex = aBandIndex === -1 ? 999 : aBandIndex;
|
||||||
|
const bIndex = bBandIndex === -1 ? 999 : bBandIndex;
|
||||||
|
|
||||||
|
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||||
|
|
||||||
|
// Same band, sort by mode if present
|
||||||
|
if (a.mode !== undefined && b.mode !== undefined) {
|
||||||
|
return (a.mode || '').localeCompare(b.mode || '');
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
// Convert Map to array
|
// Convert Map to array
|
||||||
groupedData = Array.from(entityMap.values());
|
groupedData = Array.from(entityMap.values());
|
||||||
@@ -108,15 +197,22 @@
|
|||||||
const filteredEntities = getFilteredEntities();
|
const filteredEntities = getFilteredEntities();
|
||||||
|
|
||||||
const entityMap = new Map();
|
const entityMap = new Map();
|
||||||
const bandsSet = new Set();
|
const columnSet = new Set();
|
||||||
|
|
||||||
|
const isMixedMode = selectedMode === 'Mixed Mode';
|
||||||
|
|
||||||
filteredEntities.forEach((entity) => {
|
filteredEntities.forEach((entity) => {
|
||||||
|
// Skip if mode filter is set and entity doesn't match
|
||||||
|
if (!isMixedMode && entity.mode !== selectedMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const entityName = entity.entityName || entity.entity || 'Unknown';
|
const entityName = entity.entityName || entity.entity || 'Unknown';
|
||||||
|
|
||||||
if (!entityMap.has(entityName)) {
|
if (!entityMap.has(entityName)) {
|
||||||
entityMap.set(entityName, {
|
entityMap.set(entityName, {
|
||||||
entityName,
|
entityName,
|
||||||
bands: new Map(),
|
slots: new Map(),
|
||||||
worked: entity.worked,
|
worked: entity.worked,
|
||||||
confirmed: entity.confirmed,
|
confirmed: entity.confirmed,
|
||||||
});
|
});
|
||||||
@@ -124,25 +220,68 @@
|
|||||||
|
|
||||||
const entityData = entityMap.get(entityName);
|
const entityData = entityMap.get(entityName);
|
||||||
|
|
||||||
if (entity.band) {
|
// Check if this is a satellite QSO - use "SAT" instead of band
|
||||||
bandsSet.add(entity.band);
|
const isSatellite = entity.qsos && entity.qsos.some(qso => qso.satName);
|
||||||
|
const band = isSatellite ? 'SAT' : (entity.band || 'Unknown');
|
||||||
|
|
||||||
if (!entityData.bands.has(entity.band)) {
|
if (isMixedMode) {
|
||||||
entityData.bands.set(entity.band, []);
|
// Mixed Mode: aggregate by band only
|
||||||
|
columnSet.add(band);
|
||||||
|
|
||||||
|
if (!entityData.slots.has(band)) {
|
||||||
|
entityData.slots.set(band, {
|
||||||
|
band,
|
||||||
|
mode: null,
|
||||||
|
qsos: [],
|
||||||
|
confirmed: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
entityData.bands.get(entity.band).push({
|
const slot = entityData.slots.get(band);
|
||||||
qsoId: entity.qsoId,
|
if (entity.qsos && entity.qsos.length > 0) {
|
||||||
callsign: entity.callsign,
|
slot.qsos.push(...entity.qsos);
|
||||||
mode: entity.mode,
|
if (entity.confirmed) slot.confirmed = true;
|
||||||
band: entity.band,
|
}
|
||||||
|
} else {
|
||||||
|
// Specific Mode: group by (band, mode)
|
||||||
|
const mode = entity.mode || 'Unknown';
|
||||||
|
const columnKey = `${band}/${mode}`;
|
||||||
|
columnSet.add(columnKey);
|
||||||
|
|
||||||
|
entityData.slots.set(columnKey, {
|
||||||
|
band,
|
||||||
|
mode,
|
||||||
|
qsos: entity.qsos || [],
|
||||||
confirmed: entity.confirmed,
|
confirmed: entity.confirmed,
|
||||||
qsoDate: entity.qsoDate,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
bands = Array.from(bandsSet).sort();
|
columns = Array.from(columnSet)
|
||||||
|
.map(key => {
|
||||||
|
if (isMixedMode) {
|
||||||
|
return { band: key, mode: null };
|
||||||
|
} else {
|
||||||
|
const [band, mode] = key.split('/');
|
||||||
|
return { band, mode };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by band order (by wavelength), then by mode
|
||||||
|
const aBandIndex = bandOrder.indexOf(a.band);
|
||||||
|
const bBandIndex = bandOrder.indexOf(b.band);
|
||||||
|
const aIndex = aBandIndex === -1 ? 999 : aBandIndex;
|
||||||
|
const bIndex = bBandIndex === -1 ? 999 : bBandIndex;
|
||||||
|
|
||||||
|
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||||
|
|
||||||
|
// Same band, sort by mode if present
|
||||||
|
if (a.mode !== undefined && b.mode !== undefined) {
|
||||||
|
return (a.mode || '').localeCompare(b.mode || '');
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
groupedData = Array.from(entityMap.values());
|
groupedData = Array.from(entityMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,27 +298,69 @@
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-apply sort when entities or sort changes
|
// Re-apply sort when entities or mode changes
|
||||||
$: if (entities.length > 0) {
|
$: if (entities.length > 0 || selectedMode) {
|
||||||
applyFilter();
|
applyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate band sums
|
// Calculate column sums - counts unique entities per column (not QSO counts)
|
||||||
$: bandSums = (() => {
|
$: columnSums = (() => {
|
||||||
const sums = new Map();
|
const sums = new Map();
|
||||||
const hasPoints = entities.length > 0 && entities[0].points !== undefined;
|
const hasPoints = entities.length > 0 && entities[0].points !== undefined;
|
||||||
|
const isMixedMode = selectedMode === 'Mixed Mode';
|
||||||
|
|
||||||
bands.forEach(band => {
|
columns.forEach(({ band, mode }) => {
|
||||||
|
const key = isMixedMode ? band : `${band}/${mode}`;
|
||||||
if (hasPoints) {
|
if (hasPoints) {
|
||||||
// Sum points for confirmed QSOs in this band
|
// Sum points for confirmed QSOs in this column
|
||||||
|
if (isMixedMode) {
|
||||||
const sum = entities
|
const sum = entities
|
||||||
.filter(e => e.band === band && e.confirmed)
|
.filter(e => {
|
||||||
|
// For SAT column, check if entity has satellite QSOs
|
||||||
|
if (band === 'SAT') {
|
||||||
|
return e.qsos && e.qsos.some(qso => qso.satName) && e.confirmed;
|
||||||
|
}
|
||||||
|
return e.band === band && e.confirmed;
|
||||||
|
})
|
||||||
.reduce((total, e) => total + (e.points || 0), 0);
|
.reduce((total, e) => total + (e.points || 0), 0);
|
||||||
sums.set(band, sum);
|
sums.set(key, sum);
|
||||||
} else {
|
} else {
|
||||||
// Count confirmed QSOs in this band
|
const sum = entities
|
||||||
const count = entities.filter(e => e.band === band && e.confirmed).length;
|
.filter(e => {
|
||||||
sums.set(band, count);
|
// For SAT column, check if entity has satellite QSOs
|
||||||
|
if (band === 'SAT') {
|
||||||
|
return e.qsos && e.qsos.some(qso => qso.satName) && e.mode === mode && e.confirmed;
|
||||||
|
}
|
||||||
|
return e.band === band && e.mode === mode && e.confirmed;
|
||||||
|
})
|
||||||
|
.reduce((total, e) => total + (e.points || 0), 0);
|
||||||
|
sums.set(key, sum);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Count unique entities in this column (not QSO counts)
|
||||||
|
if (isMixedMode) {
|
||||||
|
const matchedEntities = entities.filter(e => {
|
||||||
|
// For SAT column, check if entity has satellite QSOs
|
||||||
|
if (band === 'SAT') {
|
||||||
|
return e.qsos && e.qsos.some(qso => qso.satName) && e.confirmed;
|
||||||
|
}
|
||||||
|
return e.band === band && e.confirmed;
|
||||||
|
});
|
||||||
|
// Count unique entity names
|
||||||
|
const uniqueEntities = new Set(matchedEntities.map(e => e.entityName || e.entity || 'Unknown'));
|
||||||
|
sums.set(key, uniqueEntities.size);
|
||||||
|
} else {
|
||||||
|
const matchedEntities = entities.filter(e => {
|
||||||
|
// For SAT column, check if entity has satellite QSOs
|
||||||
|
if (band === 'SAT') {
|
||||||
|
return e.qsos && e.qsos.some(qso => qso.satName) && e.mode === mode && e.confirmed;
|
||||||
|
}
|
||||||
|
return e.band === band && e.mode === mode && e.confirmed;
|
||||||
|
});
|
||||||
|
// Count unique entity names
|
||||||
|
const uniqueEntities = new Set(matchedEntities.map(e => e.entityName || e.entity || 'Unknown'));
|
||||||
|
sums.set(key, uniqueEntities.size);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,6 +410,23 @@
|
|||||||
showQSODetailModal = false;
|
showQSODetailModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QSO List Modal Functions
|
||||||
|
function openQSOListModal(slotData, entityName, band, mode) {
|
||||||
|
selectedSlotInfo = {
|
||||||
|
entityName,
|
||||||
|
band,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
selectedSlotQSOs = slotData.qsos || [];
|
||||||
|
showQSOListModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQSOListModal() {
|
||||||
|
selectedSlotInfo = null;
|
||||||
|
selectedSlotQSOs = [];
|
||||||
|
showQSOListModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
// ADIF format: YYYYMMDD
|
// ADIF format: YYYYMMDD
|
||||||
@@ -272,16 +470,16 @@
|
|||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
{#if entities.length > 0 && entities[0].points !== undefined}
|
{#if entities.length > 0 && entities[0].points !== undefined}
|
||||||
{@const earnedPoints = entities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
{@const earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
||||||
{@const targetPoints = award.target}
|
{@const targetPoints = award.target}
|
||||||
{@const neededPoints = Math.max(0, targetPoints - earnedPoints)}
|
{@const neededPoints = Math.max(0, targetPoints - earnedPoints)}
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
<span class="summary-label">Total Combinations:</span>
|
<span class="summary-label">Total Combinations:</span>
|
||||||
<span class="summary-value">{entities.length}</span>
|
<span class="summary-value">{filteredEntities.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card confirmed">
|
<div class="summary-card confirmed">
|
||||||
<span class="summary-label">Confirmed:</span>
|
<span class="summary-label">Confirmed:</span>
|
||||||
<span class="summary-value">{entities.filter((e) => e.confirmed).length}</span>
|
<span class="summary-value">{filteredEntities.filter((e) => e.confirmed).length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;">
|
<div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;">
|
||||||
<span class="summary-label">Points:</span>
|
<span class="summary-label">Points:</span>
|
||||||
@@ -296,20 +494,18 @@
|
|||||||
<span class="summary-value">{targetPoints}</span>
|
<span class="summary-value">{targetPoints}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@const workedCount = entities.filter((e) => e.worked).length}
|
{@const neededCount = award.target ? Math.max(0, award.target - uniqueEntityProgress.worked) : uniqueEntityProgress.total - uniqueEntityProgress.worked}
|
||||||
{@const confirmedCount = entities.filter((e) => e.confirmed).length}
|
|
||||||
{@const neededCount = award.target ? Math.max(0, award.target - workedCount) : entities.filter((e) => !e.worked).length}
|
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
<span class="summary-label">Total:</span>
|
<span class="summary-label">Total:</span>
|
||||||
<span class="summary-value">{entities.length}</span>
|
<span class="summary-value">{uniqueEntityProgress.total}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card confirmed">
|
<div class="summary-card confirmed">
|
||||||
<span class="summary-label">Confirmed:</span>
|
<span class="summary-label">Confirmed:</span>
|
||||||
<span class="summary-value">{confirmedCount}</span>
|
<span class="summary-value">{uniqueEntityProgress.confirmed}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card worked">
|
<div class="summary-card worked">
|
||||||
<span class="summary-label">Worked:</span>
|
<span class="summary-label">Worked:</span>
|
||||||
<span class="summary-value">{workedCount}</span>
|
<span class="summary-value">{uniqueEntityProgress.worked}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card unworked">
|
<div class="summary-card unworked">
|
||||||
<span class="summary-label">Needed:</span>
|
<span class="summary-label">Needed:</span>
|
||||||
@@ -318,6 +514,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mode-filter">
|
||||||
|
<label for="mode-select">Filter by mode:</label>
|
||||||
|
<select id="mode-select" bind:value={selectedMode}>
|
||||||
|
{#each availableModes as mode}
|
||||||
|
<option value={mode}>{mode}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if selectedMode !== 'Mixed Mode'}
|
||||||
|
<button class="clear-filter-btn" on:click={() => selectedMode = 'Mixed Mode'}>Clear</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
{#if groupedData.length === 0}
|
{#if groupedData.length === 0}
|
||||||
<div class="empty">No entities match the current filter.</div>
|
<div class="empty">No entities match the current filter.</div>
|
||||||
@@ -326,7 +534,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="entity-column">Entity</th>
|
<th class="entity-column">Entity</th>
|
||||||
{#each bands as band}
|
{#each columns as { band }}
|
||||||
<th class="band-column">{band}</th>
|
<th class="band-column">{band}</th>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -337,26 +545,23 @@
|
|||||||
<td class="entity-cell">
|
<td class="entity-cell">
|
||||||
<div class="entity-name">{row.entityName}</div>
|
<div class="entity-name">{row.entityName}</div>
|
||||||
</td>
|
</td>
|
||||||
{#each bands as band}
|
{#each columns as { band, mode }}
|
||||||
{@const qsos = row.bands.get(band) || []}
|
{@const columnKey = mode ? `${band}/${mode}` : band}
|
||||||
|
{@const slotData = row.slots.get(columnKey)}
|
||||||
<td class="band-cell">
|
<td class="band-cell">
|
||||||
{#if qsos.length > 0}
|
{#if slotData && slotData.qsos && slotData.qsos.length > 0}
|
||||||
<div class="qso-list">
|
<span
|
||||||
{#each qsos as qso}
|
class="qso-count-link"
|
||||||
<div
|
on:click={() => openQSOListModal(slotData, row.entityName, band, mode)}
|
||||||
class="qso-entry {qso.confirmed ? 'qso-confirmed' : 'qso-worked'}"
|
on:keydown={(e) => e.key === 'Enter' && openQSOListModal(slotData, row.entityName, band, mode)}
|
||||||
on:click={() => openQSODetailModal(qso)}
|
|
||||||
on:keydown={(e) => e.key === 'Enter' && openQSODetailModal(qso)}
|
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
title="{slotData.qsos.length} QSO{slotData.qsos.length === 1 ? '' : 's'}"
|
||||||
>
|
>
|
||||||
<span class="callsign">{qso.callsign}</span>
|
{slotData.qsos.length}
|
||||||
<span class="mode">{qso.mode}</span>
|
</span>
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="no-qso">-</div>
|
<span class="no-qso">-</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -368,8 +573,9 @@
|
|||||||
<td class="sum-label">
|
<td class="sum-label">
|
||||||
<strong>Sum</strong>
|
<strong>Sum</strong>
|
||||||
</td>
|
</td>
|
||||||
{#each bands as band}
|
{#each columns as { band, mode }}
|
||||||
{@const sum = bandSums.get(band) ?? 0}
|
{@const columnKey = mode ? `${band}/${mode}` : band}
|
||||||
|
{@const sum = columnSums.get(columnKey) ?? 0}
|
||||||
<td class="sum-cell">
|
<td class="sum-cell">
|
||||||
<strong>{sum}</strong>
|
<strong>{sum}</strong>
|
||||||
</td>
|
</td>
|
||||||
@@ -564,6 +770,51 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- QSO List Modal -->
|
||||||
|
{#if showQSOListModal && selectedSlotInfo}
|
||||||
|
<div class="modal-backdrop" on:click={closeQSOListModal} on:keydown={(e) => e.key === 'Escape' && closeQSOListModal()} role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-content qso-list-modal" on:click|stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>QSOs for {selectedSlotInfo.entityName} ({selectedSlotInfo.band}{#if selectedSlotInfo.mode} {selectedSlotInfo.mode}{/if})</h2>
|
||||||
|
<button class="modal-close" on:click={closeQSOListModal} aria-label="Close modal">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if selectedSlotQSOs.length === 0}
|
||||||
|
<div class="empty">No QSOs found for this slot.</div>
|
||||||
|
{:else}
|
||||||
|
<table class="qso-list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Callsign</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each selectedSlotQSOs as qso}
|
||||||
|
<tr
|
||||||
|
class="qso-list-row"
|
||||||
|
on:click={() => { openQSODetailModal(qso); closeQSOListModal(); }}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && (openQSODetailModal(qso), closeQSOListModal())}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<td class="callsign-cell">{qso.callsign}</td>
|
||||||
|
<td>{formatDate(qso.qsoDate)}</td>
|
||||||
|
<td>{formatTime(qso.timeOn)}</td>
|
||||||
|
<td>{qso.mode || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
@@ -1037,4 +1288,129 @@
|
|||||||
.modal-content::-webkit-scrollbar-thumb:hover {
|
.modal-content::-webkit-scrollbar-thumb:hover {
|
||||||
background: #555;
|
background: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mode Filter */
|
||||||
|
.mode-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-filter label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-filter select {
|
||||||
|
padding: 0.5rem 2rem 0.5rem 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-filter select:hover {
|
||||||
|
border-color: #4a90e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-filter select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4a90e2;
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filter-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filter-btn:hover {
|
||||||
|
background-color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QSO Count Link */
|
||||||
|
.qso-count-link {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #4a90e2;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qso-count-link:hover {
|
||||||
|
background-color: #f0f7ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qso-count-link:focus {
|
||||||
|
outline: 2px solid #4a90e2;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-qso {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QSO List Modal */
|
||||||
|
.qso-list-modal {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qso-list-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qso-list-table th,
|
||||||
|
.qso-list-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qso-list-table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qso-list-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qso-list-row:hover {
|
||||||
|
background-color: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qso-list-row:focus {
|
||||||
|
outline: 2px solid #4a90e2;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callsign-cell {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user