feat(cluster): "already worked only on the same slot" option
New DX Cluster setting (Settings → DX Cluster). Off (today's behaviour) a call worked anywhere reads as already worked; on, the WORKED-call flag needs the same band AND mode. It folds through the digital-mode grouping (Settings → General): grouped, a 20m FT8 contact also marks a 20m FT4 spot worked; ungrouped they are separate slots. Backend: qso.WorkedCallSlotKeys builds CALL|band and CALL|band|mode(grouped) keys; ClusterSpotStatuses uses them when the option is on (a spot with no inferable mode falls back to same-band).
This commit is contained in:
@@ -2400,6 +2400,19 @@ func (a *App) groupDigitalSlots() bool {
|
|||||||
return v == "1"
|
return v == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clusterWorkedSameSlot reports the "consider a call already worked only if
|
||||||
|
// worked on the SAME band+mode slot" cluster preference (Settings → DX Cluster).
|
||||||
|
// Off (default) → a call worked on any band/mode reads as already worked. On →
|
||||||
|
// the WORKED-call flag needs the same band and mode (digital-grouped when that
|
||||||
|
// option is also on).
|
||||||
|
func (a *App) clusterWorkedSameSlot() bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, "ui.opslog.clusterWorkedSameSlot")
|
||||||
|
return v == "1"
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) GetUIPref(key string) (string, error) {
|
func (a *App) GetUIPref(key string) (string, error) {
|
||||||
if a.settings == nil || !a.settingsScoped.Load() {
|
if a.settings == nil || !a.settingsScoped.Load() {
|
||||||
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
|
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
|
||||||
@@ -15925,6 +15938,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
// "I've already QSO'd this exact station" even when the band/mode
|
// "I've already QSO'd this exact station" even when the band/mode
|
||||||
// makes the entity check say "new-band" or "new-slot".
|
// makes the entity check say "new-band" or "new-slot".
|
||||||
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
|
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
|
||||||
|
// "Already worked only on the same slot" option (Settings → DX Cluster): the
|
||||||
|
// WORKED-call flag then needs the SAME band and mode (digital-grouped through
|
||||||
|
// the same normMode when that option is on) rather than the call anywhere.
|
||||||
|
sameSlot := a.clusterWorkedSameSlot()
|
||||||
|
var workedCallSlots map[string]struct{}
|
||||||
|
if sameSlot {
|
||||||
|
workedCallSlots, _ = a.qso.WorkedCallSlotKeys(a.ctx, normMode)
|
||||||
|
}
|
||||||
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
|
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
|
||||||
// lookup) and worked POTA parks. Both built once per batch.
|
// lookup) and worked POTA parks. Both built once per batch.
|
||||||
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
||||||
@@ -15945,7 +15966,21 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
Band: strings.ToLower(q.Band),
|
Band: strings.ToLower(q.Band),
|
||||||
Mode: strings.ToUpper(q.Mode),
|
Mode: strings.ToUpper(q.Mode),
|
||||||
}
|
}
|
||||||
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
|
if sameSlot {
|
||||||
|
// Already worked ONLY when this exact band+mode slot was worked. With no
|
||||||
|
// inferable mode, fall back to same-band (better than claiming the whole
|
||||||
|
// call is fresh). Digital grouping folds through the same normMode.
|
||||||
|
upCall := strings.ToUpper(q.Call)
|
||||||
|
if out[i].Mode == "" {
|
||||||
|
_, out[i].WorkedCall = workedCallSlots[upCall+"|"+out[i].Band]
|
||||||
|
} else {
|
||||||
|
cm := out[i].Mode
|
||||||
|
if normMode != nil {
|
||||||
|
cm = normMode(cm)
|
||||||
|
}
|
||||||
|
_, out[i].WorkedCall = workedCallSlots[upCall+"|"+out[i].Band+"|"+cm]
|
||||||
|
}
|
||||||
|
} else if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
|
||||||
out[i].WorkedCall = true
|
out[i].WorkedCall = true
|
||||||
}
|
}
|
||||||
// NEW PFX: the spot's CQ WPX prefix, never worked before.
|
// NEW PFX: the spot's CQ WPX prefix, never worked before.
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,8 @@
|
|||||||
"The Grid box no longer pops outside the QSO entry panel when the window is narrowed — it wraps to its own line instead.",
|
"The Grid box no longer pops outside the QSO entry panel when the window is narrowed — it wraps to its own line instead.",
|
||||||
"Selecting a QSO in the Recent QSOs list now shows that station in the Stats (F1) matrix, instead of leaving it blank.",
|
"Selecting a QSO in the Recent QSOs list now shows that station in the Stats (F1) matrix, instead of leaving it blank.",
|
||||||
"Stats (F1): click any coloured band/mode square to list the contacts behind it.",
|
"Stats (F1): click any coloured band/mode square to list the contacts behind it.",
|
||||||
"Awards: the DXCC list now shows each entity’s main prefix (XE, DL, F…) in its own sortable, searchable column."
|
"Awards: the DXCC list now shows each entity’s main prefix (XE, DL, F…) in its own sortable, searchable column.",
|
||||||
|
"DX Cluster: new option \"Already worked only on the same slot\" (Settings → DX Cluster). With it on, a spot reads \"worked\" only when you worked that callsign on the SAME band and mode — not just anywhere. It respects the digital-mode grouping (Settings → General): grouped, a 20m FT8 contact also marks a 20m FT4 spot as worked; ungrouped, FT8 and FT4 are separate slots."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Visionneuse de log : la fenêtre conserve deux fois plus d'historique (512 Ko au lieu de 256 Ko, ~3200 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde.",
|
"Visionneuse de log : la fenêtre conserve deux fois plus d'historique (512 Ko au lieu de 256 Ko, ~3200 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde.",
|
||||||
@@ -28,7 +29,8 @@
|
|||||||
"Le champ Locator ne déborde plus du panneau de saisie quand la fenêtre est rétrécie — il passe à la ligne.",
|
"Le champ Locator ne déborde plus du panneau de saisie quand la fenêtre est rétrécie — il passe à la ligne.",
|
||||||
"Sélectionner un QSO dans la liste des QSO récents affiche désormais cette station dans la matrice Stats (F1), au lieu de la laisser vide.",
|
"Sélectionner un QSO dans la liste des QSO récents affiche désormais cette station dans la matrice Stats (F1), au lieu de la laisser vide.",
|
||||||
"Stats (F1) : cliquer sur une case bande/mode colorée liste les contacts correspondants.",
|
"Stats (F1) : cliquer sur une case bande/mode colorée liste les contacts correspondants.",
|
||||||
"Diplômes : la liste DXCC affiche le préfixe principal de chaque entité (XE, DL, F…) dans une colonne triable et cherchable."
|
"Diplômes : la liste DXCC affiche le préfixe principal de chaque entité (XE, DL, F…) dans une colonne triable et cherchable.",
|
||||||
|
"DX Cluster : nouvelle option « Déjà contacté seulement sur le même slot » (Réglages → DX Cluster). Activée, un spot n'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode — pas juste n'importe où. Elle respecte le groupage des modes numériques (Réglages → Général) : groupé, un contact 20m FT8 marque aussi un spot 20m FT4 comme contacté ; dégroupé, FT8 et FT4 sont des slots distincts."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1246,6 +1246,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||||
|
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
// Password-encryption (secret vault) state.
|
// Password-encryption (secret vault) state.
|
||||||
@@ -4000,6 +4001,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
|
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
|
||||||
<span className="font-mono">w8avi.net:7300</span>.
|
<span className="font-mono">w8avi.net:7300</span>.
|
||||||
</p>
|
</p>
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||||
|
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
||||||
|
<span>{t('clu.workedSameSlot')} <span className="text-xs text-muted-foreground">{t('clu.workedSameSlotHint')}</span></span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingServer && (
|
{editingServer && (
|
||||||
|
|||||||
@@ -262,6 +262,8 @@ const en: Dict = {
|
|||||||
'clu.moveUp': 'Move up', 'clu.moveDown': 'Move down', 'clu.edit': 'Edit', 'clu.delete': 'Delete', 'clu.none': 'No cluster nodes saved yet.', 'clu.connect': 'Connect', 'clu.disconnect': 'Disconnect',
|
'clu.moveUp': 'Move up', 'clu.moveDown': 'Move down', 'clu.edit': 'Edit', 'clu.delete': 'Delete', 'clu.none': 'No cluster nodes saved yet.', 'clu.connect': 'Connect', 'clu.disconnect': 'Disconnect',
|
||||||
'clu.add': 'Add cluster', 'clu.connectAll': 'Connect all', 'clu.disconnectAll': 'Disconnect all', 'clu.autoConnect': 'Auto-connect all enabled on app start',
|
'clu.add': 'Add cluster', 'clu.connectAll': 'Connect all', 'clu.disconnectAll': 'Disconnect all', 'clu.autoConnect': 'Auto-connect all enabled on app start',
|
||||||
'clu.freeNodes': 'Free public nodes:',
|
'clu.freeNodes': 'Free public nodes:',
|
||||||
|
'clu.workedSameSlot': 'Already worked only on the same slot',
|
||||||
|
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.',
|
||||||
// Backup panel
|
// Backup panel
|
||||||
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
|
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
|
||||||
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
|
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
|
||||||
@@ -672,6 +674,8 @@ const fr: Dict = {
|
|||||||
'clu.moveUp': 'Monter', 'clu.moveDown': 'Descendre', 'clu.edit': 'Éditer', 'clu.delete': 'Supprimer', 'clu.none': 'Aucun nœud cluster enregistré.', 'clu.connect': 'Connecter', 'clu.disconnect': 'Déconnecter',
|
'clu.moveUp': 'Monter', 'clu.moveDown': 'Descendre', 'clu.edit': 'Éditer', 'clu.delete': 'Supprimer', 'clu.none': 'Aucun nœud cluster enregistré.', 'clu.connect': 'Connecter', 'clu.disconnect': 'Déconnecter',
|
||||||
'clu.add': 'Ajouter cluster', 'clu.connectAll': 'Tout connecter', 'clu.disconnectAll': 'Tout déconnecter', 'clu.autoConnect': 'Connexion auto de tous les activés au démarrage',
|
'clu.add': 'Ajouter cluster', 'clu.connectAll': 'Tout connecter', 'clu.disconnectAll': 'Tout déconnecter', 'clu.autoConnect': 'Connexion auto de tous les activés au démarrage',
|
||||||
'clu.freeNodes': 'Nœuds publics gratuits :',
|
'clu.freeNodes': 'Nœuds publics gratuits :',
|
||||||
|
'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
|
||||||
|
'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.',
|
||||||
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
|
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
|
||||||
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
|
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
|
||||||
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
|
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
|
||||||
|
|||||||
@@ -2163,6 +2163,36 @@ func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{},
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkedCallSlotKeys backs the cluster "already worked only on the same slot"
|
||||||
|
// option. It returns each worked slot keyed BOTH as "CALL|band" (mode-agnostic,
|
||||||
|
// for a spot whose mode couldn't be inferred) and "CALL|band|MODE" with the mode
|
||||||
|
// run through normMode — so when digital grouping is on, FT8/FT4/RTTY fold into
|
||||||
|
// one "DIG" and a call worked on 20m FT8 also matches a 20m FT4 spot. Call is
|
||||||
|
// upper-cased and band lower-cased to match the spot keys ClusterSpotStatuses
|
||||||
|
// builds. normMode may be nil (no grouping).
|
||||||
|
func (r *Repo) WorkedCallSlotKeys(ctx context.Context, normMode func(string) string) (map[string]struct{}, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT upper(callsign), lower(band), upper(mode) FROM qso
|
||||||
|
WHERE callsign != '' AND band IS NOT NULL AND band != '' AND mode IS NOT NULL AND mode != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]struct{}, 4096)
|
||||||
|
for rows.Next() {
|
||||||
|
var c, b, m string
|
||||||
|
if err := rows.Scan(&c, &b, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if normMode != nil {
|
||||||
|
m = normMode(m)
|
||||||
|
}
|
||||||
|
out[c+"|"+b] = struct{}{}
|
||||||
|
out[c+"|"+b+"|"+m] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// WorkedPOTARefs returns the set of POTA park references already worked
|
// WorkedPOTARefs returns the set of POTA park references already worked
|
||||||
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
|
||||||
// (an n-fer); each is added separately.
|
// (an n-fer); each is added separately.
|
||||||
|
|||||||
Reference in New Issue
Block a user