Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9846354bf9 | ||
|
|
3da1d71323 |
@@ -9121,6 +9121,49 @@ func (a *App) GetLogFilePath() string {
|
|||||||
return applog.Path()
|
return applog.Path()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TailLogFile returns the END of the diagnostic log — the last maxBytes, cut
|
||||||
|
// back to a line boundary so the first line is never a fragment.
|
||||||
|
//
|
||||||
|
// The tail, not the file: it rotates at 10 MB, and the live viewer polls this
|
||||||
|
// every second. Shipping megabytes across the Wails bridge on a timer would cost
|
||||||
|
// more than the whole rest of the UI.
|
||||||
|
func (a *App) TailLogFile(maxBytes int) (string, error) {
|
||||||
|
p := applog.Path()
|
||||||
|
if p == "" {
|
||||||
|
return "", fmt.Errorf("log file not initialised")
|
||||||
|
}
|
||||||
|
if maxBytes <= 0 || maxBytes > 4*1024*1024 {
|
||||||
|
maxBytes = 256 * 1024
|
||||||
|
}
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
start := int64(0)
|
||||||
|
if fi.Size() > int64(maxBytes) {
|
||||||
|
start = fi.Size() - int64(maxBytes)
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
buf, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s := string(buf)
|
||||||
|
if start > 0 {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
s = s[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UILog lets the frontend write to the same diagnostic log as the backend.
|
// UILog lets the frontend write to the same diagnostic log as the backend.
|
||||||
//
|
//
|
||||||
// Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the
|
// Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the
|
||||||
@@ -11377,13 +11420,18 @@ func (a *App) consumeUDPEvents() {
|
|||||||
// after the configured duration. De-duped per call in the Flex backend.
|
// after the configured duration. De-duped per call in the Flex backend.
|
||||||
if a.catFlexDecodeSpots && a.cat != nil {
|
if a.catFlexDecodeSpots && a.cat != nil {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if t, seen := lastDecodeSpot[ev.DecodeCall]; seen && now.Sub(t) < decodeSpotWindow {
|
// Key by call AND 100 kHz slot, not by call alone: with two
|
||||||
|
// receivers running (50.313 and 50.400, say) a station heard on
|
||||||
|
// both is two legitimate spots on two panadapters, and a
|
||||||
|
// call-only key threw the second away.
|
||||||
|
key := fmt.Sprintf("%s@%d", ev.DecodeCall, ev.DecodeFreqHz/100_000)
|
||||||
|
if t, seen := lastDecodeSpot[key]; seen && now.Sub(t) < decodeSpotWindow {
|
||||||
continue // spotted this call very recently — don't re-hammer the radio
|
continue // spotted this call very recently — don't re-hammer the radio
|
||||||
}
|
}
|
||||||
if len(lastDecodeSpot) > 4000 {
|
if len(lastDecodeSpot) > 4000 {
|
||||||
lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
|
lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
|
||||||
}
|
}
|
||||||
lastDecodeSpot[ev.DecodeCall] = now
|
lastDecodeSpot[key] = now
|
||||||
secs := a.catFlexDecodeSecs
|
secs := a.catFlexDecodeSecs
|
||||||
if secs <= 0 {
|
if secs <= 0 {
|
||||||
secs = 120
|
secs = 120
|
||||||
@@ -15476,6 +15524,12 @@ type SpotStatus struct {
|
|||||||
// when that database has been downloaded); POTA from the spot's tagged park.
|
// when that database has been downloaded); POTA from the spot's tagged park.
|
||||||
NewCounty bool `json:"new_county"`
|
NewCounty bool `json:"new_county"`
|
||||||
NewPOTA bool `json:"new_pota"`
|
NewPOTA bool `json:"new_pota"`
|
||||||
|
// NewPfx flags a CQ WPX prefix never worked before, and Pfx is that prefix.
|
||||||
|
// Also orthogonal: a common entity on a worked band can still carry a prefix
|
||||||
|
// that has never been in the log, which is exactly what a WPX chaser is
|
||||||
|
// scanning the cluster for.
|
||||||
|
NewPfx bool `json:"new_pfx"`
|
||||||
|
Pfx string `json:"pfx,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
||||||
@@ -15531,6 +15585,16 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
// 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)
|
||||||
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
|
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
|
||||||
|
// Worked WPX prefixes, derived from the callsigns we already loaded — no
|
||||||
|
// extra query. Derived rather than read from the stored PFX column: that
|
||||||
|
// column is only filled when an import supplied it, and deriving keeps this
|
||||||
|
// in step with the WPX award, which does the same thing.
|
||||||
|
workedPfx := make(map[string]struct{}, len(workedCalls))
|
||||||
|
for c := range workedCalls {
|
||||||
|
if p := award.WPXPrefix(c); p != "" {
|
||||||
|
workedPfx[p] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
for i, q := range spots {
|
for i, q := range spots {
|
||||||
out[i] = SpotStatus{
|
out[i] = SpotStatus{
|
||||||
Call: q.Call,
|
Call: q.Call,
|
||||||
@@ -15540,6 +15604,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
|
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.
|
||||||
|
if p := award.WPXPrefix(q.Call); p != "" {
|
||||||
|
out[i].Pfx = p
|
||||||
|
if _, done := workedPfx[p]; !done {
|
||||||
|
out[i].NewPfx = true
|
||||||
|
}
|
||||||
|
}
|
||||||
// NEW POTA: the spot's tagged park, never worked before.
|
// NEW POTA: the spot's tagged park, never worked before.
|
||||||
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
|
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
|
||||||
if _, done := workedPOTA[ref]; !done {
|
if _, done := workedPOTA[ref]; !done {
|
||||||
|
|||||||
+36
-2
@@ -1,4 +1,36 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.23.2",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Lookup: the locator now keeps whichever is more precise. A 4-character square from QRZ or HamQTH no longer overwrites the full 6-character locator confirmed on a previous contact.",
|
||||||
|
"LoTW badge: when ARRL's own user list has not been rebuilt for a while, the tooltip says so with its date — a station shown as \"uploaded 7 days ago\" may simply be sitting in a frozen list.",
|
||||||
|
"FlexRadio decode spots: with two WSJT-X instances on two slices, decodes are no longer placed on the wrong panadapter, and a station heard on both is now spotted on both.",
|
||||||
|
"DX cluster: a spot glued to the login prompt is no longer dropped. Clusters that print the prompt without a newline cost you the first spot after connecting.",
|
||||||
|
"Help → Diagnostic log: read the log live, without leaving OpsLog. Filter the lines, and follow the end or scroll back freely.",
|
||||||
|
"Entity stats: the DX station's sunrise and sunset (UTC) now show on the right, next to Most Wanted. Midnight sun and polar night are named as such."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Recherche : le locator garde désormais le plus précis des deux. Un carré en 4 caractères venant de QRZ ou HamQTH n'écrase plus le locator complet en 6 caractères confirmé lors d'un contact précédent.",
|
||||||
|
"Badge LoTW : quand la liste ARRL elle-même n'a pas été régénérée depuis un moment, l'infobulle le dit avec sa date — une station annoncée « dernier envoi il y a 7 jours » peut simplement figurer dans une liste figée.",
|
||||||
|
"Spots de décodes FlexRadio : avec deux instances WSJT-X sur deux slices, les décodes ne s'affichent plus sur le mauvais panadapter, et une station entendue sur les deux est désormais spottée sur les deux.",
|
||||||
|
"Cluster DX : un spot collé à l'invite de connexion n'est plus perdu. Les clusters qui affichent leur invite sans retour à la ligne coûtaient le premier spot après la connexion.",
|
||||||
|
"Aide → Journal de diagnostic : lire le journal en direct sans quitter OpsLog. Filtrage des lignes, et suivi de la fin qu'on peut relâcher pour remonter.",
|
||||||
|
"Statistiques d'entité : le lever et le coucher du soleil de la station DX (UTC) s'affichent à droite, à côté du Most Wanted. Soleil de minuit et nuit polaire sont nommés."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.1",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"DX cluster: NEW COUNTY is green, like NEW POTA.",
|
||||||
|
"CW: pressing ESC during a macro no longer turns the report from 599 into 59 when \"ESC clears the callsign too\" is on."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cluster DX : NOUVEAU COMTÉ passe en vert, comme NOUVEAU POTA.",
|
||||||
|
"CW : appuyer sur Échap pendant une macro ne fait plus passer le report de 599 à 59 quand l'option « Échap efface aussi l'indicatif » est cochée."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.23.0",
|
"version": "0.23.0",
|
||||||
"date": "",
|
"date": "",
|
||||||
@@ -6,13 +38,15 @@
|
|||||||
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
|
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
|
||||||
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
|
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
|
||||||
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
|
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
|
||||||
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on."
|
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on. (Thanks HB9HBY)",
|
||||||
|
"DX cluster: NEW PFX marks a spot whose CQ WPX prefix has never been worked, in the status column and as a filter."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
|
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
|
||||||
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
|
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
|
||||||
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
|
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
|
||||||
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours."
|
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours. (Merci HB9HBY)",
|
||||||
|
"Cluster DX : NOUVEAU PFX signale un spot dont le préfixe CQ WPX n'a jamais été contacté, dans la colonne de statut et en filtre."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+52
-13
@@ -120,6 +120,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
|
import { LogViewer } from '@/components/LogViewer';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
type ImportResult = adifModels.ImportResult;
|
type ImportResult = adifModels.ImportResult;
|
||||||
@@ -492,6 +493,14 @@ export default function App() {
|
|||||||
const [bandRx, setBandRx] = useState('20m');
|
const [bandRx, setBandRx] = useState('20m');
|
||||||
const [countries, setCountries] = useState<string[]>([]);
|
const [countries, setCountries] = useState<string[]>([]);
|
||||||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||||
|
// Refs for the report defaults. applyModePreset is called from long-lived
|
||||||
|
// closures — the global key handler is the one that bit us — whose dependency
|
||||||
|
// lists deliberately exclude the mode, so reading the state variables there
|
||||||
|
// gives whatever they were when that closure was built.
|
||||||
|
const modePresetsRef = useRef(modePresets);
|
||||||
|
useEffect(() => { modePresetsRef.current = modePresets; }, [modePresets]);
|
||||||
|
const rstListsRef = useRef(rstLists);
|
||||||
|
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
|
||||||
const [rstSent, setRstSent] = useState('59');
|
const [rstSent, setRstSent] = useState('59');
|
||||||
const [rstRcvd, setRstRcvd] = useState('59');
|
const [rstRcvd, setRstRcvd] = useState('59');
|
||||||
const [grid, setGrid] = useState('');
|
const [grid, setGrid] = useState('');
|
||||||
@@ -539,7 +548,7 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
|
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
|
||||||
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
|
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
|
||||||
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null);
|
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number; feed_date?: string; feed_stale?: boolean } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const c = callsign.trim();
|
const c = callsign.trim();
|
||||||
if (!c) { setLotwInfo(null); return; }
|
if (!c) { setLotwInfo(null); return; }
|
||||||
@@ -1382,7 +1391,7 @@ export default function App() {
|
|||||||
// county or a new park is not a DXCC state, it is another dimension of the
|
// county or a new park is not a DXCC state, it is another dimension of the
|
||||||
// same spot — which is why the grid shows them as separate badges. Filtering
|
// same spot — which is why the grid shows them as separate badges. Filtering
|
||||||
// is an OR across all of them, as it already was for a worked callsign.
|
// is an OR across all of them, as it already was for a worked callsign.
|
||||||
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county';
|
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx';
|
||||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
@@ -1478,7 +1487,7 @@ export default function App() {
|
|||||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||||
// different slots don't share the same colour.
|
// different slots don't share the same colour.
|
||||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
|
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
|
||||||
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||||
// still need resolving without re-subscribing the cluster:spot listener.
|
// still need resolving without re-subscribing the cluster:spot listener.
|
||||||
const spotStatusRef = useRef(spotStatus);
|
const spotStatusRef = useRef(spotStatus);
|
||||||
@@ -1539,6 +1548,7 @@ export default function App() {
|
|||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
// "What's new": the changelog for the version(s) since the operator last ran,
|
// "What's new": the changelog for the version(s) since the operator last ran,
|
||||||
// shown once on the first launch after an update (EN/FR per the UI language).
|
// shown once on the first launch after an update (EN/FR per the UI language).
|
||||||
|
const [showLogViewer, setShowLogViewer] = useState(false);
|
||||||
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
||||||
// Which language the What's-new modal shows — defaults to the UI language but
|
// Which language the What's-new modal shows — defaults to the UI language but
|
||||||
// toggleable, so a French operator running the English UI can still read it.
|
// toggleable, so a French operator running the English UI can still read it.
|
||||||
@@ -2187,8 +2197,9 @@ export default function App() {
|
|||||||
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
||||||
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
||||||
// so switching SSB→CW flips 59→599 even without a configured preset.
|
// so switching SSB→CW flips 59→599 even without a configured preset.
|
||||||
const p = modePresets.find((x) => x.name === m);
|
// Read through the refs, never the state: see their declaration.
|
||||||
const fallback = rstOptions(m, rstLists)[0] || '';
|
const p = modePresetsRef.current.find((x) => x.name === m);
|
||||||
|
const fallback = rstOptions(m, rstListsRef.current)[0] || '';
|
||||||
setRstSent(p?.default_rst_sent || fallback);
|
setRstSent(p?.default_rst_sent || fallback);
|
||||||
setRstRcvd(p?.default_rst_rcvd || fallback);
|
setRstRcvd(p?.default_rst_rcvd || fallback);
|
||||||
}
|
}
|
||||||
@@ -2461,6 +2472,8 @@ export default function App() {
|
|||||||
worked_call: !!(r as any).worked_call,
|
worked_call: !!(r as any).worked_call,
|
||||||
new_county: !!(r as any).new_county,
|
new_county: !!(r as any).new_county,
|
||||||
new_pota: !!(r as any).new_pota,
|
new_pota: !!(r as any).new_pota,
|
||||||
|
new_pfx: !!(r as any).new_pfx,
|
||||||
|
pfx: (r as any).pfx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -2916,6 +2929,8 @@ export default function App() {
|
|||||||
worked_call: !!(r as any).worked_call,
|
worked_call: !!(r as any).worked_call,
|
||||||
new_county: !!(r as any).new_county,
|
new_county: !!(r as any).new_county,
|
||||||
new_pota: !!(r as any).new_pota,
|
new_pota: !!(r as any).new_pota,
|
||||||
|
new_pfx: !!(r as any).new_pfx,
|
||||||
|
pfx: (r as any).pfx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -3024,7 +3039,11 @@ export default function App() {
|
|||||||
setWb(null); // clear the Worked-before grid for the just-cleared callsign
|
setWb(null); // clear the Worked-before grid for the just-cleared callsign
|
||||||
setLookupError('');
|
setLookupError('');
|
||||||
rstUserEditedRef.current = false;
|
rstUserEditedRef.current = false;
|
||||||
applyModePreset(mode);
|
// modeRef, not `mode`: resetEntry is reached from the global key handler,
|
||||||
|
// whose effect does not re-run on a mode change. Pressing ESC during a CW
|
||||||
|
// macro therefore reset the report using the mode as it was when that
|
||||||
|
// handler was built — 'SSB' — and turned 599 into 59.
|
||||||
|
applyModePreset(modeRef.current);
|
||||||
setDetails((d) => ({
|
setDetails((d) => ({
|
||||||
...d,
|
...d,
|
||||||
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
|
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
|
||||||
@@ -3276,10 +3295,16 @@ export default function App() {
|
|||||||
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
||||||
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
||||||
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
||||||
// Grid: a REAL provider grid always wins. Otherwise the last QSO's precise
|
// Grid: the provider (QRZ / HamQTH) leads, but only while it is at least as
|
||||||
// locator beats the coarse cty.dat entity centroid the provider block set — so
|
// PRECISE as what we already worked. It used to lead whenever it answered at
|
||||||
// a French call resolves to its real JNxx, not the country's JN16 centroid.
|
// all, so a 4-character square — which is what a provider returns when the
|
||||||
const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid;
|
// station only published a rough locator, and what the cty.dat entity
|
||||||
|
// centroid gives — overwrote the full 6-character locator confirmed on a
|
||||||
|
// previous contact. Comparing lengths keeps the finer of the two, whichever
|
||||||
|
// side it came from.
|
||||||
|
const provGrid = String(r?.grid ?? '').trim();
|
||||||
|
const lastGrid = String(last.grid ?? '').trim();
|
||||||
|
const adoptLastGrid = !ue.has('grid') && !!lastGrid && lastGrid.length > provGrid.length;
|
||||||
if (adoptLastGrid) setGrid(last.grid);
|
if (adoptLastGrid) setGrid(last.grid);
|
||||||
setDetails((d) => {
|
setDetails((d) => {
|
||||||
// When we adopt the last QSO's locator, take its coordinates too (derive them
|
// When we adopt the last QSO's locator, take its coordinates too (derive them
|
||||||
@@ -3601,6 +3626,8 @@ export default function App() {
|
|||||||
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
||||||
] : []),
|
] : []),
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{ type: 'item', label: t('logview.title'), action: 'help.log' },
|
||||||
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||||
@@ -3634,6 +3661,7 @@ export default function App() {
|
|||||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||||
case 'tools.downloadRefs': downloadRefs(); break;
|
case 'tools.downloadRefs': downloadRefs(); break;
|
||||||
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
||||||
|
case 'help.log': setShowLogViewer(true); break;
|
||||||
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||||
case 'help.sendlog': sendLogToDeveloper(); break;
|
case 'help.sendlog': sendLogToDeveloper(); break;
|
||||||
// Opens in the system browser, NOT the app WebView: a payment page must show
|
// Opens in the system browser, NOT the app WebView: a payment page must show
|
||||||
@@ -4042,8 +4070,14 @@ export default function App() {
|
|||||||
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
|
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
|
||||||
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||||
: 'border-danger text-danger bg-danger/15';
|
: 'border-danger text-danger bg-danger/15';
|
||||||
|
// ARRL regenerates lotw-user-activity.csv on its own schedule and has been
|
||||||
|
// seen frozen for a week (2026-08-02: newest line 2026-07-27). "Uploaded 7
|
||||||
|
// days ago" then says nothing about the station, so the tooltip names the
|
||||||
|
// day the feed itself was built whenever that day is not recent.
|
||||||
|
const tip = t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })
|
||||||
|
+ (lotwInfo.feed_stale && lotwInfo.feed_date ? ' — ' + t('lotw.feedStale', { date: lotwInfo.feed_date }) : '');
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
<div className="shrink-0" title={tip}>
|
||||||
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
||||||
LoTW
|
LoTW
|
||||||
</span>
|
</span>
|
||||||
@@ -4351,7 +4385,8 @@ export default function App() {
|
|||||||
const matches = clusterStatusFilter.has(st)
|
const matches = clusterStatusFilter.has(st)
|
||||||
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
||||||
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
||||||
|| (!!e?.new_county && clusterStatusFilter.has('new-county'));
|
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|
||||||
|
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'));
|
||||||
if (!matches) return false;
|
if (!matches) return false;
|
||||||
}
|
}
|
||||||
if (clusterHideWorked) {
|
if (clusterHideWorked) {
|
||||||
@@ -4470,7 +4505,8 @@ export default function App() {
|
|||||||
// Same colours as the badges in the grid — a filter that does not
|
// Same colours as the badges in the grid — a filter that does not
|
||||||
// look like what it selects has to be learned twice.
|
// look like what it selects has to be learned twice.
|
||||||
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
|
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||||
]).map((s) => {
|
]).map((s) => {
|
||||||
const on = clusterStatusFilter.has(s.k);
|
const on = clusterStatusFilter.has(s.k);
|
||||||
@@ -5229,6 +5265,9 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Live diagnostic log (Help → …). Polls only while open. */}
|
||||||
|
<LogViewer open={showLogViewer} onOpenChange={setShowLogViewer} />
|
||||||
|
|
||||||
{/* What's new — shown once on the first launch after an update. */}
|
{/* What's new — shown once on the first launch after an update. */}
|
||||||
{whatsNew && (
|
{whatsNew && (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Star, Radio } from 'lucide-react';
|
import { Star, Radio, Sunrise, Sunset } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { sunTimes } from '@/lib/sun';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -13,6 +14,10 @@ interface Props {
|
|||||||
currentMode: string;
|
currentMode: string;
|
||||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||||
|
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||||
|
// to an entity with no position at all, and the block simply does not appear.
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||||
@@ -84,7 +89,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
|||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon }: Props) {
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -151,6 +156,36 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
|
||||||
|
// about to open or close on their side, which is the reason to look at a DX
|
||||||
|
// station's grey line at all. Recomputed only when the position changes.
|
||||||
|
const sun = useMemo(
|
||||||
|
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||||
|
[lat, lon],
|
||||||
|
);
|
||||||
|
const sunBlock = sun ? (
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
||||||
|
title="Sunrise / sunset at the DX station (UTC)">
|
||||||
|
{sun.polarDay ? (
|
||||||
|
<span className="font-semibold text-warning">midnight sun</span>
|
||||||
|
) : sun.polarNight ? (
|
||||||
|
<span className="font-semibold text-info">polar night</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Sunrise className="size-3.5 text-warning" />
|
||||||
|
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Sunset className="size-3.5 text-info" />
|
||||||
|
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">UTC</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -158,7 +193,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-[220px]">
|
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
||||||
{newOne ? (
|
{newOne ? (
|
||||||
<>
|
<>
|
||||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||||
@@ -207,6 +242,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
Type a callsign to see entity stats
|
Type a callsign to see entity stats
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{sunBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export type SpotStatusEntry = {
|
|||||||
worked_call?: boolean;
|
worked_call?: boolean;
|
||||||
new_county?: boolean;
|
new_county?: boolean;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
pfx?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -123,9 +125,9 @@ function cellText(value: any, color: string | null): any {
|
|||||||
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusColor is the colour for a resolved status in the Status column. County
|
// statusColor is the colour for a resolved status in the Status column. County,
|
||||||
// and POTA keep their own tokens: they are orthogonal to the band/mode/DXCC
|
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
|
||||||
// story and an operator filters on them separately.
|
// DXCC story and an operator filters on them separately.
|
||||||
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||||
switch (s?.status) {
|
switch (s?.status) {
|
||||||
case 'new':
|
case 'new':
|
||||||
@@ -191,6 +193,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||||
|
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
},
|
},
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
@@ -205,8 +208,9 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
: t('clg2.wkdCall');
|
: t('clg2.wkdCall');
|
||||||
parts.push({ text: label, color: main });
|
parts.push({ text: label, color: main });
|
||||||
}
|
}
|
||||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--info)' });
|
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' });
|
||||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
|
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
|
||||||
|
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
|
||||||
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span style={{ whiteSpace: 'nowrap' }}>
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||||
|
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
|
||||||
|
// have one — it is what the operator can see and check — and the provider's
|
||||||
|
// raw coordinates cover entities that resolve through cty.dat with no grid.
|
||||||
|
const dxLL = useMemo(() => {
|
||||||
|
const byGrid = gridToLatLon(remoteGrid);
|
||||||
|
if (byGrid) return byGrid;
|
||||||
|
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
|
||||||
|
return null;
|
||||||
|
}, [remoteGrid, details.lat, details.lon]);
|
||||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||||
|
|
||||||
@@ -263,7 +272,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||||
{open === 'stats' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''}
|
||||||
|
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// LogViewer shows the tail of opslog.log and keeps it current while open.
|
||||||
|
//
|
||||||
|
// This exists because diagnosing a problem used to mean asking the operator to
|
||||||
|
// find a file in their data directory, open it in a text editor, and re-open it
|
||||||
|
// after every attempt. Half the round-trips in a bug report were spent on that.
|
||||||
|
//
|
||||||
|
// Three behaviours matter more than they look:
|
||||||
|
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
|
||||||
|
// moment they scroll up — otherwise the line they are reading is yanked away
|
||||||
|
// a second later, which defeats the one thing this window is for. Scrolling
|
||||||
|
// back to the bottom re-arms it, so following is never a dead end.
|
||||||
|
// • The filter keeps only matching LINES rather than highlighting inside a
|
||||||
|
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||||
|
// the subsystem trace you would have grepped for.
|
||||||
|
// • It polls only while open. A closed dialog costs nothing.
|
||||||
|
const TAIL_BYTES = 256 * 1024;
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
|
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [path, setPath] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
// Shown next to the checkbox so a QUIET log still proves it is being polled —
|
||||||
|
// without it there is no way to tell 'nothing new' from 'window is dead'.
|
||||||
|
const [lastPull, setLastPull] = useState('');
|
||||||
|
const boxRef = useRef<HTMLPreElement>(null);
|
||||||
|
const autoRef = useRef(true);
|
||||||
|
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
|
||||||
|
|
||||||
|
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
|
||||||
|
// straight away is not enough while the dialog is still animating in: the box
|
||||||
|
// has no height yet, scrollHeight is wrong, and the scroll silently does
|
||||||
|
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
|
||||||
|
// at lines from hours ago, which looks exactly like a frozen window.
|
||||||
|
const stick = useCallback(() => {
|
||||||
|
if (!autoRef.current) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pull = useCallback(() => {
|
||||||
|
TailLogFile(TAIL_BYTES).then((s: any) => {
|
||||||
|
setText(String(s ?? ''));
|
||||||
|
setLastPull(new Date().toLocaleTimeString());
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setAutoScroll(true);
|
||||||
|
autoRef.current = true;
|
||||||
|
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
|
||||||
|
pull();
|
||||||
|
// Re-assert the bottom after the open animation has settled. A quiet log
|
||||||
|
// never changes, so the content-driven scroll below would never fire again
|
||||||
|
// and the first attempt would be the only one.
|
||||||
|
const t1 = window.setTimeout(stick, 120);
|
||||||
|
const t2 = window.setTimeout(stick, 450);
|
||||||
|
const id = window.setInterval(pull, POLL_MS);
|
||||||
|
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
|
||||||
|
}, [open, pull, stick]);
|
||||||
|
|
||||||
|
const all = useMemo(() => text.split('\n'), [text]);
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return all;
|
||||||
|
return all.filter((l) => l.toLowerCase().includes(q));
|
||||||
|
}, [all, query]);
|
||||||
|
|
||||||
|
// Stick to the bottom after each refresh, but only while auto-scroll is on.
|
||||||
|
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
|
||||||
|
|
||||||
|
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
|
||||||
|
function onScroll() {
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = shown.join('\n');
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('logview.title')}</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
|
||||||
|
onChange={(e) => setQuery(e.target.value)} />
|
||||||
|
{query.trim() !== '' && (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{t('logview.matches', { n: shown.length, total: all.length })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre ref={boxRef} onScroll={onScroll}
|
||||||
|
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
|
||||||
|
{body.trim() === '' ? t('logview.empty') : body}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
|
||||||
|
const on = !!c;
|
||||||
|
setAutoScroll(on);
|
||||||
|
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
|
||||||
|
}} />
|
||||||
|
{t('logview.autoScroll')}
|
||||||
|
</label>
|
||||||
|
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
|
||||||
|
{t('logview.copy')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
|
||||||
|
{t('logview.toEnd')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,83 @@
|
|||||||
|
// Sunrise / sunset for a station, in UTC.
|
||||||
|
//
|
||||||
|
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
||||||
|
// the solar equations: the map's terminator and the entry strip's sunrise must
|
||||||
|
// never disagree, and there is a whole comment block over there about a sign
|
||||||
|
// error that produced six months of plausible-looking wrong answers. One source
|
||||||
|
// of astronomy, one place to be wrong.
|
||||||
|
//
|
||||||
|
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
||||||
|
// time would raise the question "local to whom — me or the DX?".
|
||||||
|
import { sunPosition } from './greyline';
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
||||||
|
// the horizon plus atmospheric refraction.
|
||||||
|
const H0 = -0.833;
|
||||||
|
|
||||||
|
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
||||||
|
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
||||||
|
const { dec, gha } = sunPosition(date);
|
||||||
|
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||||
|
const d = dec * DEG;
|
||||||
|
const lat = latDeg * DEG;
|
||||||
|
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SunTimes = {
|
||||||
|
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
||||||
|
set: string;
|
||||||
|
polarDay: boolean; // Sun stays up all day
|
||||||
|
polarNight: boolean; // Sun never comes up
|
||||||
|
};
|
||||||
|
|
||||||
|
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
||||||
|
// crosses the rise/set altitude.
|
||||||
|
//
|
||||||
|
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
||||||
|
// special-casing for the polar cases and for days where the declination shifts
|
||||||
|
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
||||||
|
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
||||||
|
// crossing found means no sunrise, which is a real answer above the Arctic
|
||||||
|
// circle. The crossing minute is then refined by linear interpolation, so the
|
||||||
|
// result is accurate to well under a minute despite the coarse scan.
|
||||||
|
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
||||||
|
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
||||||
|
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
||||||
|
|
||||||
|
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||||
|
const minute = 60000;
|
||||||
|
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
||||||
|
const anyUp = prev > H0;
|
||||||
|
let anyDown = prev <= H0;
|
||||||
|
let sawUp = anyUp;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 1440; i++) {
|
||||||
|
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
||||||
|
if (alt > H0) sawUp = true; else anyDown = true;
|
||||||
|
if (prev <= H0 && alt > H0 && !out.rise) {
|
||||||
|
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||||
|
} else if (prev > H0 && alt <= H0 && !out.set) {
|
||||||
|
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||||
|
}
|
||||||
|
prev = alt;
|
||||||
|
}
|
||||||
|
if (!out.rise && !out.set) {
|
||||||
|
out.polarDay = sawUp && !anyDown;
|
||||||
|
out.polarNight = !sawUp;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// frac is where between two samples the altitude crosses H0.
|
||||||
|
function frac(a: number, b: number): number {
|
||||||
|
const d = b - a;
|
||||||
|
return d === 0 ? 0 : (H0 - a) / d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.23.0';
|
export const APP_VERSION = '0.23.2';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+2
@@ -989,6 +989,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
|
export function TailLogFile(arg1:number):Promise<string>;
|
||||||
|
|
||||||
export function TestCloudlogUpload():Promise<string>;
|
export function TestCloudlogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|||||||
@@ -1926,6 +1926,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TailLogFile(arg1) {
|
||||||
|
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestCloudlogUpload() {
|
export function TestCloudlogUpload() {
|
||||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1442,6 +1442,8 @@ export namespace lotwusers {
|
|||||||
is_user: boolean;
|
is_user: boolean;
|
||||||
last_upload?: string;
|
last_upload?: string;
|
||||||
days_ago: number;
|
days_ago: number;
|
||||||
|
feed_date?: string;
|
||||||
|
feed_stale: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Info(source);
|
return new Info(source);
|
||||||
@@ -1452,6 +1454,8 @@ export namespace lotwusers {
|
|||||||
this.is_user = source["is_user"];
|
this.is_user = source["is_user"];
|
||||||
this.last_upload = source["last_upload"];
|
this.last_upload = source["last_upload"];
|
||||||
this.days_ago = source["days_ago"];
|
this.days_ago = source["days_ago"];
|
||||||
|
this.feed_date = source["feed_date"];
|
||||||
|
this.feed_stale = source["feed_stale"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2904,6 +2908,8 @@ export namespace main {
|
|||||||
worked_call: boolean;
|
worked_call: boolean;
|
||||||
new_county: boolean;
|
new_county: boolean;
|
||||||
new_pota: boolean;
|
new_pota: boolean;
|
||||||
|
new_pfx: boolean;
|
||||||
|
pfx?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SpotStatus(source);
|
return new SpotStatus(source);
|
||||||
@@ -2920,6 +2926,8 @@ export namespace main {
|
|||||||
this.worked_call = source["worked_call"];
|
this.worked_call = source["worked_call"];
|
||||||
this.new_county = source["new_county"];
|
this.new_county = source["new_county"];
|
||||||
this.new_pota = source["new_pota"];
|
this.new_pota = source["new_pota"];
|
||||||
|
this.new_pfx = source["new_pfx"];
|
||||||
|
this.pfx = source["pfx"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StartupStatus {
|
export class StartupStatus {
|
||||||
|
|||||||
@@ -648,6 +648,18 @@ func parseShowDX(line string) (Spot, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseSpot(line string) (Spot, bool) {
|
func parseSpot(line string) (Spot, bool) {
|
||||||
|
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
|
||||||
|
// to arrive gets glued onto it and the line reads
|
||||||
|
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
|
||||||
|
// and the spot is dropped — silently, apart from an UNPARSED log line.
|
||||||
|
//
|
||||||
|
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
|
||||||
|
// an un-anchored match would also accept a talk/announce line that merely
|
||||||
|
// QUOTES a spot, and start injecting other people's conversations as real
|
||||||
|
// spots.
|
||||||
|
if i := strings.Index(line, "DX de"); i > 0 {
|
||||||
|
line = line[i:]
|
||||||
|
}
|
||||||
m := spotRE.FindStringSubmatch(line)
|
m := spotRE.FindStringSubmatch(line)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return Spot{}, false
|
return Spot{}, false
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) {
|
|||||||
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
||||||
"W1AW", 14195.5, "20m", "",
|
"W1AW", 14195.5, "20m", "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The cluster prints its prompt with no trailing newline, so the
|
||||||
|
// first spot arrives glued to it. Seen live on 2026-08-02.
|
||||||
|
`login: DX de YO2CK-#: 21074.0 PY4PTO FT8 -16 dB 1733Z`,
|
||||||
|
"PY4PTO", 21074.0, "15m", "",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
||||||
"3DA0RU", 7074.0, "40m", "JN18",
|
"3DA0RU", 7074.0, "40m", "JN18",
|
||||||
|
|||||||
@@ -86,7 +86,13 @@ type Server struct {
|
|||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
// WSJT: dial frequency from the last Status, added to Decode audio offsets —
|
||||||
|
// keyed by the WSJT-X instance id, NOT one value per listener. Two instances
|
||||||
|
// share one multicast group, so a single value was overwritten by whichever
|
||||||
|
// sent Status last: decodes from the 50.313 receiver were placed on the
|
||||||
|
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
||||||
|
// id is distinct whenever there is more than one.
|
||||||
|
dialHz map[string]int64
|
||||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
// offsets can be turned into RF frequencies for the panadapter.
|
// offsets can be turned into RF frequencies for the panadapter.
|
||||||
if w.FreqHz > 0 && !w.IsDecode {
|
if w.FreqHz > 0 && !w.IsDecode {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.lastDialHz = w.FreqHz
|
if s.dialHz == nil {
|
||||||
|
s.dialHz = map[string]int64{}
|
||||||
|
}
|
||||||
|
s.dialHz[w.ProgramID] = w.FreqHz
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
if w.IsDecode {
|
if w.IsDecode {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
dial := s.lastDialHz
|
dial := s.dialHz[w.ProgramID]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if dial <= 0 {
|
if dial <= 0 {
|
||||||
return // no dial freq yet (Status not seen) → can't place the spot
|
// No Status from THIS instance yet. Guessing with another
|
||||||
|
// instance's dial is what produced the wrong-panadapter spots,
|
||||||
|
// so drop the decode and wait — Status arrives every second.
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ev.DecodeCall = w.DecodeCall
|
ev.DecodeCall = w.DecodeCall
|
||||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# QRZ.com XML API — what a free account returns, and what a subscription adds
|
||||||
|
|
||||||
|
Captured 2026-08-02 on F4BPO's two accounts, same callsign looked up twice
|
||||||
|
within a minute. Kept because the difference is the answer to a question that
|
||||||
|
comes back regularly: *"someone told me their logger gets the 6-character
|
||||||
|
locator from QRZ without paying"*. It does not.
|
||||||
|
|
||||||
|
## Free account — `SubExp: non-subscriber`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Callsign>
|
||||||
|
<call>F4BPO</call>
|
||||||
|
<fname>Gregory</fname>
|
||||||
|
<name>Salaun</name>
|
||||||
|
<addr2>Veigy-Foncenex</addr2>
|
||||||
|
<country>France</country>
|
||||||
|
</Callsign>
|
||||||
|
<Session>
|
||||||
|
<SubExp>non-subscriber</SubExp>
|
||||||
|
<Message>A subscription is required to access the complete record.</Message>
|
||||||
|
</Session>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Five fields.** No `grid`, no `lat`/`lon`, no `dxcc`, no zones, no email, no
|
||||||
|
street, no image.
|
||||||
|
|
||||||
|
## Subscriber — `SubExp: Sat Jul 3 21:45:38 2027`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Callsign>
|
||||||
|
<call>F4BPO</call> <dxcc>227</dxcc> <nickname>Greg</nickname>
|
||||||
|
<fname>Gregory</fname> <name>Salaun</name>
|
||||||
|
<addr2>Veigy-Foncenex</addr2><zip>74140</zip> <country>France</country>
|
||||||
|
<lat>46.103333</lat> <lon>6.290000</lon> <grid>JN36dc</grid>
|
||||||
|
<ccode>97</ccode> <land>France</land> <class>2</class>
|
||||||
|
<codes>E</codes> <qslmgr>Lotw, Buro, Direct</qslmgr>
|
||||||
|
<email>…</email> <u_views>56966</u_views>
|
||||||
|
<bio>4414</bio> <biodate>2026-07-08 15:30:58</biodate>
|
||||||
|
<image>https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg</image>
|
||||||
|
<imageinfo>800:1200:221041</imageinfo>
|
||||||
|
<moddate>2025-08-24 19:55:31</moddate>
|
||||||
|
<eqsl>0</eqsl> <mqsl>1</mqsl> <lotw>1</lotw>
|
||||||
|
<cqzone>14</cqzone> <ituzone>27</ituzone>
|
||||||
|
<geoloc>user</geoloc> <name_fmt>Gregory "Greg" Salaun</name_fmt>
|
||||||
|
<serial>1000134</serial>
|
||||||
|
</Callsign>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
||||||
|
or the e-mail.** If someone reports a logger doing it on a free account, they
|
||||||
|
are either using HamQTH (free, returns a 6-character `grid`) or they hold an
|
||||||
|
XML subscription without realising it — the QRZ.com account and the "XML
|
||||||
|
Logbook Data" subscription are sold separately. `SubExp` in the session block
|
||||||
|
settles it in one request.
|
||||||
|
- **Profile edits take a few minutes to reach the XML feed.** Changing the grid
|
||||||
|
on the website and looking the callsign up immediately returns the OLD value,
|
||||||
|
which looks exactly like a stale cache. Re-test after a few minutes before
|
||||||
|
suspecting the logger. (This cost an hour on 2026-08-02.)
|
||||||
|
|
||||||
|
## Fields OpsLog does not use yet
|
||||||
|
|
||||||
|
`qrz.go` maps call, name, addr1/2, zip, state, county, country, grid, lat, lon,
|
||||||
|
dxcc, cqzone, ituzone, cont, email, qslmgr and image. Left on the table:
|
||||||
|
|
||||||
|
- **`geoloc`** — `user` when the operator entered the position by hand, versus a
|
||||||
|
geocoded value derived from the postal address. This is a direct statement of
|
||||||
|
how much the coordinates and the locator can be trusted, and it is exactly
|
||||||
|
the question the grid-precision rule in App.tsx tries to answer by comparing
|
||||||
|
string lengths. Worth wiring if the locator ever misbehaves again.
|
||||||
|
- **`eqsl` / `mqsl` / `lotw`** — the station's QSL preferences as flags, more
|
||||||
|
reliable than parsing the free-text `qslmgr`.
|
||||||
|
- `nickname` / `name_fmt`, `class`, `moddate`, `bio`.
|
||||||
@@ -29,6 +29,13 @@ type Info struct {
|
|||||||
IsUser bool `json:"is_user"`
|
IsUser bool `json:"is_user"`
|
||||||
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
||||||
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
||||||
|
// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
|
||||||
|
// last regenerated it, and FeedStale says that date is more than two days old.
|
||||||
|
// Without them "last uploaded 7 days ago" is unreadable: it may mean the
|
||||||
|
// station went quiet, or that the FEED has been frozen for a week — which is
|
||||||
|
// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
|
||||||
|
FeedDate string `json:"feed_date,omitempty"`
|
||||||
|
FeedStale bool `json:"feed_stale"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager holds the parsed list + cache location.
|
// Manager holds the parsed list + cache location.
|
||||||
@@ -36,6 +43,7 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
||||||
updated time.Time // when the cache was last refreshed
|
updated time.Time // when the cache was last refreshed
|
||||||
|
newest time.Time // newest upload date in the file = when ARRL built it
|
||||||
dir string
|
dir string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
@@ -100,6 +108,7 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
|||||||
// parse loads the CSV bytes into the map and returns the count.
|
// parse loads the CSV bytes into the map and returns the count.
|
||||||
func (m *Manager) parse(data []byte) int {
|
func (m *Manager) parse(data []byte) int {
|
||||||
users := make(map[string]time.Time, 1<<20)
|
users := make(map[string]time.Time, 1<<20)
|
||||||
|
var newest time.Time
|
||||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
@@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
users[call] = t
|
users[call] = t
|
||||||
|
if t.After(newest) {
|
||||||
|
newest = t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.users = users
|
m.users = users
|
||||||
|
m.newest = newest
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
return len(users)
|
return len(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// feedInfo fills the feed-age fields shared by every Lookup result.
|
||||||
|
func (m *Manager) feedInfo(i Info) Info {
|
||||||
|
if !m.newest.IsZero() {
|
||||||
|
i.FeedDate = m.newest.Format("2006-01-02")
|
||||||
|
i.FeedStale = time.Since(m.newest) > 48*time.Hour
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
||||||
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
||||||
// the longest slash-separated segment — the base call LoTW is keyed on.
|
// the longest slash-separated segment — the base call LoTW is keyed on.
|
||||||
@@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info {
|
|||||||
t, ok = m.users[base]
|
t, ok = m.users[base]
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return Info{DaysAgo: -1}
|
return m.feedInfo(Info{DaysAgo: -1})
|
||||||
}
|
}
|
||||||
days := int(time.Since(t).Hours() / 24)
|
days := int(time.Since(t).Hours() / 24)
|
||||||
if days < 0 {
|
if days < 0 {
|
||||||
days = 0
|
days = 0
|
||||||
}
|
}
|
||||||
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
|
return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns how many callsigns are loaded.
|
// Count returns how many callsigns are loaded.
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.23.0"
|
appVersion = "0.23.2"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user