feat: day batch — upload results surfaced as toasts (silent LoTW failures from the right-click send looked like nothing was sent); live selection count + Select all/Unselect all toggle in the grid toolbar; active filters bypass the row limit (all matches shown, 10k safety cap); NET Control: same right-click menu on worked-before + drag&drop roster<->on-air (drop on roster logs the QSO); compact mode restores previous window size/position; advanced-filter field renamed Station callsign; optional DXCC-style digital-mode grouping (Settings->General) for matrix badges + cluster colouring; changelog 0.20.9 entries (version NOT bumped)
This commit is contained in:
@@ -530,6 +530,13 @@ type App struct {
|
|||||||
// must be allowed through.
|
// must be allowed through.
|
||||||
shuttingDown bool
|
shuttingDown bool
|
||||||
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
|
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
|
||||||
|
// Geometry captured when ENTERING compact mode, restored on the way back —
|
||||||
|
// otherwise leaving compact snapped the window to a hardcoded 1400×900 at
|
||||||
|
// wherever the compact strip had been dragged.
|
||||||
|
preCompactW, preCompactH int
|
||||||
|
preCompactX, preCompactY int
|
||||||
|
preCompactMax bool
|
||||||
|
preCompactValid bool
|
||||||
|
|
||||||
// Cached operator location used to compute distance/bearing for
|
// Cached operator location used to compute distance/bearing for
|
||||||
// cluster spots. Refreshed on profile activation; zero means
|
// cluster spots. Refreshed on profile activation; zero means
|
||||||
@@ -1811,6 +1818,17 @@ func (a *App) ResetDatabaseToDefault() error {
|
|||||||
// widths, sort…) in the DB settings table under a "ui." namespace, so they
|
// widths, sort…) in the DB settings table under a "ui." namespace, so they
|
||||||
// travel with the logbook and survive a reinstall — unlike the WebView's
|
// travel with the logbook and survive a reinstall — unlike the WebView's
|
||||||
// localStorage. Values are opaque JSON blobs owned by the frontend.
|
// localStorage. Values are opaque JSON blobs owned by the frontend.
|
||||||
|
// groupDigitalSlots reports the "digital modes count as one" preference
|
||||||
|
// (Settings → General; stored as a portable ui.* pref so the frontend matrix
|
||||||
|
// and this backend cluster logic read the same switch).
|
||||||
|
func (a *App) groupDigitalSlots() bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, "ui.opslog.groupDigitalSlots")
|
||||||
|
return v == "1"
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) GetUIPref(key string) (string, error) {
|
func (a *App) GetUIPref(key string) (string, error) {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -5204,6 +5222,19 @@ func (a *App) SetCompactMode(on bool) {
|
|||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if on && !a.compact {
|
||||||
|
// Capture the normal geometry BEFORE shrinking, so leaving compact puts
|
||||||
|
// the window back exactly where and how big it was.
|
||||||
|
a.preCompactMax = wruntime.WindowIsMaximised(a.ctx)
|
||||||
|
if a.preCompactMax {
|
||||||
|
// Unmaximise first: the size/position of a maximised window are the
|
||||||
|
// monitor's, not the user's chosen geometry.
|
||||||
|
wruntime.WindowUnmaximise(a.ctx)
|
||||||
|
}
|
||||||
|
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
|
||||||
|
a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
|
||||||
|
a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH
|
||||||
|
}
|
||||||
a.compact = on
|
a.compact = on
|
||||||
if on {
|
if on {
|
||||||
// Lock the window to the compact size by pinning min == max. Without
|
// Lock the window to the compact size by pinning min == max. Without
|
||||||
@@ -5218,7 +5249,16 @@ func (a *App) SetCompactMode(on bool) {
|
|||||||
// Release the lock first (raise the max) before growing back.
|
// Release the lock first (raise the max) before growing back.
|
||||||
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
|
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
|
||||||
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
||||||
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
if a.preCompactValid {
|
||||||
|
wruntime.WindowSetSize(a.ctx, a.preCompactW, a.preCompactH)
|
||||||
|
wruntime.WindowSetPosition(a.ctx, a.preCompactX, a.preCompactY)
|
||||||
|
if a.preCompactMax {
|
||||||
|
wruntime.WindowMaximise(a.ctx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Nothing captured (compact since launch) — fall back to the default.
|
||||||
|
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8160,6 +8200,12 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
msg = err.Error()
|
msg = err.Error()
|
||||||
}
|
}
|
||||||
emit("LoTW upload failed: " + msg)
|
emit("LoTW upload failed: " + msg)
|
||||||
|
// The qslmgr:log console is only visible in the QSL Manager — a failure
|
||||||
|
// triggered from the Recent QSOs right-click was completely silent, which
|
||||||
|
// read as "send to LoTW does nothing". Surface it as a toast too.
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", "LoTW upload failed: "+msg)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
a.markExtUploaded(svc, id, "")
|
a.markExtUploaded(svc, id, "")
|
||||||
@@ -8314,6 +8360,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
}
|
}
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
|
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
|
||||||
|
// Always end with a visible summary — the right-click "Send to …" runs in
|
||||||
|
// the background and the per-QSO log lives in the QSL Manager console.
|
||||||
|
label := map[extsvc.Service]string{
|
||||||
|
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
|
||||||
|
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
|
||||||
|
}[svc]
|
||||||
|
if label == "" {
|
||||||
|
label = string(svc)
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%s: %d/%d QSO uploaded", label, uploaded, len(ids)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13065,7 +13121,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor)
|
// Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/
|
||||||
|
// RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was
|
||||||
|
// worked shows "worked", not "new-slot" — DXCC-style mode classes.
|
||||||
|
var normMode func(string) string
|
||||||
|
if a.groupDigitalSlots() {
|
||||||
|
normMode = qso.GroupDigitalMode
|
||||||
|
}
|
||||||
|
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor, normMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -13133,12 +13196,17 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
}
|
}
|
||||||
// Band already worked. If this MODE was never worked on the entity (any
|
// Band already worked. If this MODE was never worked on the entity (any
|
||||||
// band) → new-mode. If the mode was worked elsewhere but not on THIS
|
// band) → new-mode. If the mode was worked elsewhere but not on THIS
|
||||||
// band+mode → new-slot. Otherwise → worked.
|
// band+mode → new-slot. Otherwise → worked. The check mode goes through
|
||||||
if _, m := e.Modes[out[i].Mode]; !m {
|
// the same normaliser as the slot map (digital grouping).
|
||||||
|
checkMode := out[i].Mode
|
||||||
|
if normMode != nil {
|
||||||
|
checkMode = normMode(checkMode)
|
||||||
|
}
|
||||||
|
if _, m := e.Modes[checkMode]; !m {
|
||||||
out[i].Status = "new-mode"
|
out[i].Status = "new-mode"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
|
if _, ok := e.Slots[out[i].Band][checkMode]; !ok {
|
||||||
out[i].Status = "new-slot"
|
out[i].Status = "new-slot"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,32 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.20.9",
|
||||||
|
"date": "2026-07-22",
|
||||||
|
"en": [
|
||||||
|
"The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.",
|
||||||
|
"Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.",
|
||||||
|
"The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.",
|
||||||
|
"NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).",
|
||||||
|
"Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).",
|
||||||
|
"The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).",
|
||||||
|
"Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).",
|
||||||
|
"New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.",
|
||||||
|
"NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).",
|
||||||
|
"New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.",
|
||||||
|
"Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.",
|
||||||
|
"Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.",
|
||||||
|
"NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).",
|
||||||
|
"Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).",
|
||||||
|
"Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).",
|
||||||
|
"Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).",
|
||||||
|
"Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».",
|
||||||
|
"NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).",
|
||||||
|
"Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.20.8",
|
"version": "0.20.8",
|
||||||
"date": "2026-07-21",
|
"date": "2026-07-21",
|
||||||
|
|||||||
+37
-12
@@ -116,6 +116,9 @@ type ModePreset = ModePresetForm;
|
|||||||
type WB = WorkedBeforeView;
|
type WB = WorkedBeforeView;
|
||||||
type CATState = Omit<catModels.RigState, 'convertValues'>;
|
type CATState = Omit<catModels.RigState, 'convertValues'>;
|
||||||
|
|
||||||
|
// Safety cap on how many rows an ACTIVE filter may load (the normal row
|
||||||
|
// threshold is bypassed while filtering — see buildActiveFilter).
|
||||||
|
const FILTERED_LIMIT_CAP = 10000;
|
||||||
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
||||||
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
||||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||||
@@ -1431,13 +1434,21 @@ export default function App() {
|
|||||||
|
|
||||||
// The full filter sent to the backend = the builder conditions + the quick
|
// The full filter sent to the backend = the builder conditions + the quick
|
||||||
// callsign search box (always ANDed) + the on-screen row threshold.
|
// callsign search box (always ANDed) + the on-screen row threshold.
|
||||||
const buildActiveFilter = useCallback((): QueryFilter => ({
|
// When a filter is ACTIVE the row threshold is lifted to a high safety cap:
|
||||||
quick_callsign: filterCallsign,
|
// the threshold exists to keep the UNFILTERED 30k-QSO log fast, but applying
|
||||||
conditions: activeFilter.conditions ?? [],
|
// it to a filter meant "200 matches, only the first 100 shown". A filtered
|
||||||
match: activeFilter.match ?? 'AND',
|
// result set is almost always small, and the cap protects against a
|
||||||
limit: qsoLimit,
|
// broad filter (e.g. band=20m) pulling half the log.
|
||||||
offset: 0,
|
const buildActiveFilter = useCallback((): QueryFilter => {
|
||||||
}), [filterCallsign, activeFilter, qsoLimit]);
|
const hasFilter = !!(filterCallsign || (activeFilter.conditions ?? []).length);
|
||||||
|
return {
|
||||||
|
quick_callsign: filterCallsign,
|
||||||
|
conditions: activeFilter.conditions ?? [],
|
||||||
|
match: activeFilter.match ?? 'AND',
|
||||||
|
limit: hasFilter ? FILTERED_LIMIT_CAP : qsoLimit,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
}, [filterCallsign, activeFilter, qsoLimit]);
|
||||||
|
|
||||||
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
|
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
|
||||||
// surface the error — used by the startup retry, since the logbook DB (a remote
|
// surface the error — used by the startup retry, since the logbook DB (a remote
|
||||||
@@ -3729,7 +3740,12 @@ export default function App() {
|
|||||||
case 'netcontrol':
|
case 'netcontrol':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
||||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||||
|
qsoMenuHandlers={{
|
||||||
|
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||||
|
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||||
|
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'recent':
|
case 'recent':
|
||||||
@@ -4796,9 +4812,13 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
{(activeFilter.conditions?.length || filterCallsign)
|
||||||
<span className="text-warning">Limit reached — raise Max to see more.</span>
|
? (matchCount != null && matchCount > FILTERED_LIMIT_CAP && (
|
||||||
)}
|
<span className="text-warning">{matchCount.toLocaleString('en-US')} matches — only {FILTERED_LIMIT_CAP.toLocaleString('en-US')} shown, narrow the filter (or use the filtered ADIF export).</span>
|
||||||
|
))
|
||||||
|
: (qsos.length >= qsoLimit && qsos.length < total && (
|
||||||
|
<span className="text-warning">Limit reached — raise Max to see more.</span>
|
||||||
|
))}
|
||||||
<Label className="text-[11px] text-muted-foreground">Max</Label>
|
<Label className="text-[11px] text-muted-foreground">Max</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -5052,7 +5072,12 @@ export default function App() {
|
|||||||
tune the rig. */}
|
tune the rig. */}
|
||||||
{netEnabled && (
|
{netEnabled && (
|
||||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||||
|
qsoMenuHandlers={{
|
||||||
|
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||||
|
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||||
|
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||||
|
}} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -109,14 +109,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
// Derived straight from the entity's real band_status (all bands it was
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
// worked on — not just the operator's configured column list).
|
// worked on — not just the operator's configured column list).
|
||||||
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
|
||||||
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
|
||||||
// lists every real (band, mode) the entity was worked on.
|
// DXCC-style grouping instead (Settings → General), where all digital modes
|
||||||
|
// count as ONE — then FT4 after FT8 is just "worked".
|
||||||
|
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
|
||||||
|
const normMode = (m: string): string => {
|
||||||
|
const u = (m || '').toUpperCase().trim();
|
||||||
|
if (!groupDigital) return u;
|
||||||
|
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
|
||||||
|
};
|
||||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||||
const curMode = (currentMode || '').toUpperCase().trim();
|
const curMode = normMode(currentMode);
|
||||||
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||||
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
|
||||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
|
||||||
// Mutually-exclusive badges, shown only when the entity is worked but this
|
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||||
// exact band+mode is NOT yet:
|
// exact band+mode is NOT yet:
|
||||||
// New Band & Mode = both the band AND the mode are new for this entity.
|
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||||
|
|||||||
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
|
|||||||
|
|
||||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||||
|
|
||||||
|
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||||
|
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||||
|
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||||
|
type QSOMenuHandlers = {
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onLogged?: () => void;
|
onLogged?: () => void;
|
||||||
countries?: string[];
|
countries?: string[];
|
||||||
bands?: string[];
|
bands?: string[];
|
||||||
modes?: string[];
|
modes?: string[];
|
||||||
|
qsoMenuHandlers?: QSOMenuHandlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [nets, setNets] = useState<Net[]>([]);
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
const [selId, setSelId] = useState<string>('');
|
const [selId, setSelId] = useState<string>('');
|
||||||
@@ -70,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||||
|
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||||
|
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||||
|
const onAirApi = useRef<any>(null);
|
||||||
|
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||||
|
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||||
|
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||||
|
const activateRef = useRef<(call: string) => void>(() => {});
|
||||||
|
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||||
|
const wireDropZones = useCallback(() => {
|
||||||
|
const rApi = rosterGrid.current?.api;
|
||||||
|
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||||
|
dropZonesDone.current.roster = true;
|
||||||
|
rApi.addRowDropZone({
|
||||||
|
getContainer: () => onAirWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||||
|
dropZonesDone.current.onair = true;
|
||||||
|
onAirApi.current.addRowDropZone({
|
||||||
|
getContainer: () => rosterWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
const [wbCall, setWbCall] = useState('');
|
const [wbCall, setWbCall] = useState('');
|
||||||
@@ -195,6 +237,10 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
}
|
}
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Keep the drop-zone closures pointed at the CURRENT handlers.
|
||||||
|
activateRef.current = activate;
|
||||||
|
deactivateRef.current = deactivate;
|
||||||
|
|
||||||
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||||
// exactly as the single-log button would.
|
// exactly as the single-log button would.
|
||||||
async function logAll() {
|
async function logAll() {
|
||||||
@@ -258,7 +304,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||||
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||||
@@ -307,12 +354,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.onAirActive')}
|
{t('ncp.onAirActive')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
rows={active}
|
rows={active}
|
||||||
total={active.length}
|
total={active.length}
|
||||||
storageKey="net.onair"
|
storageKey="net.onair"
|
||||||
selectRowSignal={selectRow}
|
selectRowSignal={selectRow}
|
||||||
|
rowDragCall
|
||||||
|
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
onRowSelected={setSelectedActiveIds}
|
onRowSelected={setSelectedActiveIds}
|
||||||
@@ -360,6 +409,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
rows={(wb.entries ?? []) as any}
|
rows={(wb.entries ?? []) as any}
|
||||||
total={wb.count}
|
total={wb.count}
|
||||||
storageKey="net.wb"
|
storageKey="net.wb"
|
||||||
|
{...qsoMenuHandlers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -373,7 +423,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.netUsersRoster')}
|
{t('ncp.netUsersRoster')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<AgGridReact<Station>
|
<AgGridReact<Station>
|
||||||
ref={rosterGrid}
|
ref={rosterGrid}
|
||||||
@@ -382,6 +432,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
columnDefs={rosterCols}
|
columnDefs={rosterCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
|
onGridReady={() => wireDropZones()}
|
||||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +33,11 @@ type Props = {
|
|||||||
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||||
// Control auto-advancing to the next on-air station after logging one).
|
// Control auto-advancing to the next on-air station after logging one).
|
||||||
selectRowSignal?: { id: number; seq: number };
|
selectRowSignal?: { id: number; seq: number };
|
||||||
|
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
||||||
|
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||||
|
// register external drop zones via api.addRowDropZone.
|
||||||
|
rowDragCall?: boolean;
|
||||||
|
onGridApi?: (api: any) => void;
|
||||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
// reuse this grid elsewhere with its OWN independent column config.
|
// reuse this grid elsewhere with its OWN independent column config.
|
||||||
@@ -252,7 +257,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -260,6 +265,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
|||||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
||||||
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||||
@@ -319,7 +326,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
|||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
||||||
|
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||||
|
col.rowDrag = true;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
});
|
});
|
||||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
@@ -333,7 +344,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
|||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t, awardShown]);
|
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
|
||||||
|
|
||||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
@@ -356,6 +367,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
|
onGridApi?.(e.api);
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||||
// Fall back to the portable DB copy when the local cache is empty
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
@@ -371,6 +383,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
|||||||
// null when no column filter is active, so "Showing X of Y" reflects them.
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
const api = e?.api ?? gridRef.current?.api;
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
|
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||||
if (!api || !onFilteredCountChange) return;
|
if (!api || !onFilteredCountChange) return;
|
||||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
}, [onFilteredCountChange]);
|
}, [onFilteredCountChange]);
|
||||||
@@ -400,7 +413,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
|||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
const api = gridRef.current?.api;
|
||||||
|
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
|
setSelCount(sel.length);
|
||||||
|
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||||
@@ -480,6 +496,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storage
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
{/* Live selection count — visible without opening the context menu. */}
|
||||||
|
{selCount > 0 && (
|
||||||
|
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
||||||
|
{t('rqg.selectedCount', { n: selCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Select every loaded row that passes the active column filters — so a
|
||||||
|
filtered view can be selected in one click (then send to LoTW, bulk
|
||||||
|
edit, export…). Once everything is selected the same button flips to
|
||||||
|
"Unselect all". */}
|
||||||
|
{(() => {
|
||||||
|
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
|
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
||||||
|
onClick={() => {
|
||||||
|
const api: any = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (allSelected) api.deselectAll();
|
||||||
|
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
||||||
|
else api.selectAll('filtered');
|
||||||
|
}}>
|
||||||
|
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title={t('rqg.clearFiltersTitle')}>
|
title={t('rqg.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||||
|
|||||||
@@ -1149,6 +1149,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
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 [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.
|
||||||
@@ -4619,6 +4620,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
||||||
|
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||||
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
|
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
// Cluster filter selections — restored on reopen.
|
// Cluster filter selections — restored on reopen.
|
||||||
|
|||||||
+19
-1
@@ -1371,6 +1371,17 @@ func modeClass(mode string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupDigitalMode collapses every digital mode into the single "DIG" bucket
|
||||||
|
// (FT8, FT4, RTTY, PSK… all become one mode) while leaving phone modes and CW
|
||||||
|
// untouched. Used as the optional slot normaliser when the operator prefers
|
||||||
|
// DXCC-style mode classes over per-mode slots (Settings → General).
|
||||||
|
func GroupDigitalMode(mode string) string {
|
||||||
|
if modeClass(mode) == "DIG" {
|
||||||
|
return "DIG"
|
||||||
|
}
|
||||||
|
return strings.ToUpper(mode)
|
||||||
|
}
|
||||||
|
|
||||||
// BandMode is a (band, mode) pair used for the NEW SLOT check.
|
// BandMode is a (band, mode) pair used for the NEW SLOT check.
|
||||||
type BandMode struct {
|
type BandMode struct {
|
||||||
Band string `json:"band"`
|
Band string `json:"band"`
|
||||||
@@ -1802,7 +1813,11 @@ type EntitySlot struct {
|
|||||||
// 0 (unresolvable) skips the QSO.
|
// 0 (unresolvable) skips the QSO.
|
||||||
//
|
//
|
||||||
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
||||||
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) {
|
//
|
||||||
|
// normMode (nil = identity) maps each QSO's mode before it is stored as a
|
||||||
|
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
|
||||||
|
// one bucket. Callers must normalise their lookup mode the same way.
|
||||||
|
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||||
WHERE band IS NOT NULL AND band != ''
|
WHERE band IS NOT NULL AND band != ''
|
||||||
@@ -1827,6 +1842,9 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
|
|||||||
if key == 0 {
|
if key == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if normMode != nil {
|
||||||
|
mode = normMode(mode)
|
||||||
|
}
|
||||||
e, ok := out[key]
|
e, ok := out[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
e = &EntitySlot{
|
e = &EntitySlot{
|
||||||
|
|||||||
Reference in New Issue
Block a user