Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd3b5b688 | ||
|
|
f582bbab3a | ||
|
|
b40dc8a597 | ||
|
|
14bb92fac0 | ||
|
|
9887b2aa78 | ||
|
|
2adf252942 | ||
|
|
660653ce14 | ||
|
|
f3b607b15a | ||
|
|
ae5aa7b547 | ||
|
|
e8dfa0eaaf | ||
|
|
b53c56d508 | ||
|
|
67c85be23c | ||
|
|
e41a3a4c1c | ||
|
|
e4276ff66e | ||
|
|
1e87081058 | ||
|
|
12b0a861e0 | ||
|
|
712d83a012 | ||
|
|
bd540ef18b | ||
|
|
2f2fced15c | ||
|
|
97ae05d688 | ||
|
|
df1d2767cd | ||
|
|
ed099a660e | ||
|
|
6536d140ba | ||
|
|
b2b93e2839 | ||
|
|
f9113b6ad3 | ||
|
|
e168ab4148 | ||
|
|
e4412955f0 | ||
|
|
78eb766789 | ||
|
|
d8064cc426 | ||
|
|
bd4f1b2325 | ||
|
|
b38b9030f0 | ||
|
|
ea302966d5 | ||
|
|
ea5c2a56cf | ||
|
|
6b0fc48389 | ||
|
|
a27fa08e4e | ||
|
|
27d0952d01 | ||
|
|
4e9b1eebe9 | ||
|
|
0848c275d2 | ||
|
|
ea0789b191 | ||
|
|
1f94d0ef08 | ||
|
|
dd77e7f467 | ||
|
|
60dad353ce | ||
|
|
1b63483596 | ||
|
|
56c103140c | ||
|
|
b60e8285a4 | ||
|
|
0f1424335e | ||
|
|
506a1e6e4c | ||
|
|
8b7756beb4 | ||
|
|
dee4ffce2a | ||
|
|
fbf3cd116b | ||
|
|
b2165173cf | ||
|
|
912d727d7a | ||
|
|
32036a93ed | ||
|
|
7f4b0b78c0 | ||
|
|
775daa233e | ||
|
|
4fb013701b | ||
|
|
2dd68da284 | ||
|
|
65991be09d | ||
|
|
1c4697ecb2 | ||
|
|
ab89611eca | ||
|
|
b952e074f4 | ||
|
|
e01fc39abc | ||
|
|
deabb74393 | ||
|
|
803f4dc4ed | ||
|
|
20784c2fb1 | ||
|
|
83dbdf0539 | ||
|
|
c9d46cfab9 | ||
|
|
c7140fd639 | ||
|
|
c887b77b5f | ||
|
|
b3e398b8d6 | ||
|
|
f4a72da118 | ||
|
|
086581851b | ||
|
|
f8fdfab031 | ||
|
|
3f82da3c18 | ||
|
|
cd557acf81 | ||
|
|
036dc53fe8 | ||
|
|
73806cbd70 | ||
|
|
ee148732dd | ||
|
|
3014796c1d | ||
|
|
405b0e24cf | ||
|
|
778db0542e | ||
|
|
f50806fbd9 | ||
|
|
bcfb3bfd37 | ||
|
|
cb430a22ee | ||
|
|
abfed4afa2 | ||
|
|
d48420485f | ||
|
|
0cd243fe00 | ||
|
|
c49faf9a14 | ||
|
|
d01c288454 | ||
|
|
c31ab05c80 | ||
|
|
f45952d7fb | ||
|
|
e6889caae9 | ||
|
|
e3b7a35e2c | ||
|
|
1e507225dd | ||
|
|
47992b5f03 | ||
|
|
453e0df27b | ||
|
|
6ed38014ed | ||
|
|
f832d1ba07 | ||
|
|
f0e00c63a3 | ||
|
|
40ecfb9fa9 | ||
|
|
8c68a7d711 | ||
|
|
628d1e8490 | ||
|
|
0881c72c0f | ||
|
|
6da30f91c4 | ||
|
|
f833ff6d04 | ||
|
|
fba7e79a1c | ||
|
|
d829726679 | ||
|
|
c48edd7898 | ||
|
|
ffaf6fc869 | ||
|
|
4f77d51ffe | ||
|
|
a197d124dc | ||
|
|
9599c3e0b9 | ||
|
|
a81125eab1 | ||
|
|
bbe1b3ce80 | ||
|
|
5e80c27f61 | ||
|
|
7be6f64596 | ||
|
|
0bab7f05b9 | ||
|
|
dc898ce2af | ||
|
|
e5c9ca1a7d | ||
|
|
21a0d560de | ||
|
|
2941121f4b | ||
|
|
6c75ff345b | ||
|
|
d5e25244ee | ||
|
|
4095455e66 | ||
|
|
3f95763ca6 | ||
|
|
72ec3cbb97 | ||
|
|
a8bca8c316 | ||
|
|
8b75166dbe | ||
|
|
d9e03d9de3 | ||
|
|
491546c7f4 | ||
|
|
91b21a4a36 | ||
|
|
eab11db766 | ||
|
|
0c64bdcaf9 | ||
|
|
f0d7a07ed6 | ||
|
|
2aceb99948 | ||
|
|
c48294b0ca | ||
|
|
747c2b9105 | ||
|
|
724e68b38d | ||
|
|
7d664bd1de | ||
|
|
93732ee563 | ||
|
|
5293bb18c5 | ||
|
|
14ac73028e | ||
|
|
b5a88ee5a2 | ||
|
|
e81500f809 | ||
|
|
37805fe3ed | ||
|
|
7578e49573 | ||
|
|
564d97b692 | ||
|
|
99599458ff | ||
|
|
191e92ad62 | ||
|
|
f405e71d7d | ||
|
|
0924062ced | ||
|
|
8683a450a7 | ||
|
|
9f8e3c73d9 | ||
|
|
423cf1f998 | ||
|
|
c0dc1bfcc4 | ||
|
|
46d6531cf5 | ||
|
|
3ed2d957e5 | ||
|
|
5770c40d89 | ||
|
|
5e2c452753 | ||
|
|
f3f966f5b7 | ||
|
|
38c838d232 | ||
|
|
7eb734d86f | ||
|
|
b49599150c | ||
|
|
331db58705 | ||
|
|
62e20de69c | ||
|
|
480d24384b | ||
|
|
c2ff0f714a | ||
|
|
fe2affc72f | ||
|
|
be0326c862 | ||
|
|
8113557015 | ||
|
|
3def789742 | ||
|
|
92ea98bcfa | ||
|
|
15649a4e92 | ||
|
|
a6842382b2 | ||
|
|
5bf7eb45d7 | ||
|
|
09f3ddaacb | ||
|
|
ca8617c891 | ||
|
|
e2c442552b | ||
|
|
bb75c6f23f | ||
|
|
5c958572dc | ||
|
|
26b8dade20 | ||
|
|
ffd6bcf54b | ||
|
|
6c2c1f106a | ||
|
|
03d5376d01 | ||
|
|
dc14990b44 | ||
|
|
d95c998081 | ||
|
|
c5c9d355ec | ||
|
|
7470ebf47e | ||
|
|
2cd1274975 | ||
|
|
61006d8155 | ||
|
|
94cba2592e | ||
|
|
9b1faada38 | ||
|
|
e67f57fee7 | ||
|
|
a9e3a159d9 | ||
|
|
7c781bf793 | ||
|
|
4f9a366884 | ||
|
|
db87ef39c0 | ||
|
|
3ce930e9cc | ||
|
|
aca4dc5678 | ||
|
|
c495dced0f | ||
|
|
1b736272c9 | ||
|
|
05fc8ad80c | ||
|
|
4e88bdfaa7 | ||
|
|
30143b01bf | ||
|
|
fbd62fda30 | ||
|
|
ab0db0ef3a | ||
|
|
380472bda8 | ||
|
|
4e2a5877d6 | ||
|
|
d44a971acf | ||
|
|
fabd1becce | ||
|
|
345be94c65 | ||
|
|
d0c6e420d2 | ||
|
|
f0026b8bd3 | ||
|
|
2007479da6 | ||
|
|
4ba5569ca8 | ||
|
|
dc0b00b474 | ||
|
|
2695747db6 | ||
|
|
eba54344b0 | ||
|
|
93879a0ce1 | ||
|
|
8fccfa29b1 | ||
|
|
784d809290 | ||
|
|
700b688fc0 | ||
|
|
03598cfaff | ||
|
|
ed517eb8d3 | ||
|
|
1cfba19ed2 | ||
|
|
b18f5c491e | ||
|
|
741364bf99 | ||
|
|
d0f06d9670 | ||
|
|
8e49d37cbd | ||
|
|
b2382a6135 | ||
|
|
f8fb57210f | ||
|
|
8762131248 | ||
|
|
e5f79b18c2 | ||
|
|
1605e30f60 | ||
|
|
6aff322be6 | ||
|
|
ed3c132f70 | ||
|
|
385e8c4c86 | ||
|
|
61e5736f1e | ||
|
|
6ca808a6c9 | ||
|
|
f698096f53 | ||
|
|
ef628e066a | ||
|
|
4ffdfc2548 | ||
|
|
83e7727aab | ||
|
|
1348dc2177 | ||
|
|
57ac382f91 | ||
|
|
858c04d267 | ||
|
|
2484cd2515 | ||
|
|
62256942a1 | ||
|
|
3c8aadf41f | ||
|
|
2f5fd63afd | ||
|
|
4f3c3edaee | ||
|
|
7d33379fe1 | ||
|
|
82a49150d2 | ||
|
|
1ef9e553f7 | ||
|
|
39fab68bd4 | ||
|
|
bbddba6e74 | ||
|
|
b659f6f16d | ||
|
|
b4ad12bdc6 | ||
|
|
5dd3532074 | ||
|
|
dd7b63c059 | ||
|
|
8fc6673611 | ||
|
|
d33291b521 | ||
|
|
1b32b1ddec | ||
|
|
65bbaa85f3 | ||
|
|
99d903eb44 | ||
|
|
13515c58c0 | ||
|
|
e4014e11d2 | ||
|
|
025472820b | ||
|
|
8b66030c89 | ||
|
|
d4f23a52af | ||
|
|
b4b9674d8c | ||
|
|
f7d2de0777 | ||
|
|
344e8b2091 | ||
|
|
c5e0ec9033 | ||
|
|
210a99983e | ||
|
|
2a6e09a1d7 | ||
|
|
1b7f8ec9c1 | ||
|
|
75a2f73992 | ||
|
|
deee8c4618 | ||
|
|
92a5f30ac0 | ||
|
|
d3a405f4f6 | ||
|
|
f7b9bfd0bc | ||
|
|
102097c5c4 | ||
|
|
3b90ef6b7a | ||
|
|
839cf0aafd | ||
|
|
9fd801ea69 | ||
|
|
298c2fde44 | ||
|
|
78e99afc63 | ||
|
|
740f007395 | ||
|
|
43095a1d89 | ||
|
|
fa01968207 | ||
|
|
91452cefc0 | ||
|
|
904b451951 | ||
|
|
c4d90e6880 | ||
|
|
1ac00c101e | ||
|
|
e107b0b741 | ||
|
|
24668d5981 | ||
|
|
8aa0e39aff | ||
|
|
96c99f0ae6 | ||
|
|
0550ecdac3 | ||
|
|
9c33feecfa | ||
|
|
8afba2c4e8 | ||
|
|
2d3bfa704e | ||
|
|
29bdea1e43 | ||
|
|
b93c67f3cb | ||
|
|
735dfe69db | ||
|
|
67fe5bcff0 | ||
|
|
d36d431d9a | ||
|
|
0c8d79e2fa | ||
|
|
796a1d8e7d | ||
|
|
9531a54ac1 | ||
|
|
d2d64706f5 | ||
|
|
cfdd24d52e | ||
|
|
8052bd2935 | ||
|
|
55879809f2 | ||
|
|
6969560efb | ||
|
|
7eff19f6e2 | ||
|
|
e0dfa53f54 | ||
|
|
4f87cedc2c | ||
|
|
d273d21f1a | ||
|
|
bde136b98b | ||
|
|
ae7472a67d | ||
|
|
0d48dbfd17 | ||
|
|
d6ed9f03eb | ||
|
|
c95a1137fc | ||
|
|
d41352a3a5 | ||
|
|
9b2115be8f | ||
|
|
0a8c1ac45f | ||
|
|
6497568813 | ||
|
|
9f62808392 | ||
|
|
9d8b69d804 | ||
|
|
6825af135a | ||
|
|
ca42fd90f9 | ||
|
|
18a583901c | ||
|
|
2cb1add3db | ||
|
|
a3815c24a1 | ||
|
|
f6f5235a8b | ||
|
|
86a644863a | ||
|
|
dd9fddbc5b | ||
|
|
74acf88976 | ||
|
|
da9c76e161 | ||
|
|
fb78b1c052 | ||
|
|
e51abd262e | ||
|
|
0ec855d95a | ||
|
|
ae06495f91 | ||
|
|
4dd2c3b997 | ||
|
|
ec71dd1661 | ||
|
|
31f9bdfc98 | ||
|
|
57e98139ab | ||
|
|
b10a867125 | ||
|
|
17819ea673 | ||
|
|
a8fac52400 | ||
|
|
2bf98a9801 | ||
|
|
80dac64b56 | ||
|
|
79427ccd18 | ||
|
|
4352b9aec5 | ||
|
|
a0f7f2abf0 | ||
|
|
40f5960c76 | ||
|
|
59135d55ab | ||
|
|
44cf5954fd | ||
|
|
f1b7a7e477 | ||
|
|
19ae00124f | ||
|
|
642ed358c2 | ||
|
|
1d8cd25205 | ||
|
|
1f55cfe9fa | ||
|
|
90c6458af0 | ||
|
|
18f44a5aa3 | ||
|
|
4d13cf7d15 | ||
|
|
366d9df634 | ||
|
|
f4b7c9dcf8 | ||
|
|
48c448e6f3 | ||
|
|
9c757e5175 | ||
|
|
626edca8ba | ||
|
|
b202d98ae5 | ||
|
|
949cc17ed1 | ||
|
|
e9feeffdc3 | ||
|
|
7d7d1042c0 | ||
|
|
9cbfd39da0 | ||
|
|
5338ad0b29 | ||
|
|
970127cc16 | ||
|
|
3704dbdd99 | ||
|
|
3cdf16b501 | ||
|
|
9a72afd467 | ||
|
|
e2bfe73bdb | ||
|
|
6e2e2cc3aa | ||
|
|
75d11a4069 | ||
|
|
cfe5d6f2a7 | ||
|
|
78c289d681 | ||
|
|
761f554ac0 | ||
|
|
4c1b4fd1a8 | ||
|
|
f2cc4f11cd | ||
|
|
faf084ddfc | ||
|
|
01dcd91253 | ||
|
|
3d603d34aa | ||
|
|
3d1602c4bd | ||
|
|
a10fb2413a | ||
|
|
bd8ca2e9de | ||
|
|
df56391eb8 | ||
|
|
82ce3353c4 | ||
|
|
20dc3e83a6 | ||
|
|
62cdabe114 | ||
|
|
9bfb44925b | ||
|
|
fb1af9b6a2 | ||
|
|
57a9bed145 | ||
|
|
fbd323af26 | ||
|
|
689d1cc902 | ||
|
|
67602fd485 | ||
|
|
2de5bce998 | ||
|
|
f0959c796d | ||
|
|
16e64fd7b7 | ||
|
|
7e696a72bb | ||
|
|
9af2732314 | ||
|
|
f82d92565f | ||
|
|
d0f25aea9b | ||
|
|
f91b83ee12 | ||
|
|
a554257d5f | ||
|
|
a20d9a0b76 | ||
|
|
92ee8aadde | ||
|
|
61c6666a4c | ||
|
|
9f5c482667 | ||
|
|
b9ea077a64 | ||
|
|
a48fa0b2e7 | ||
|
|
821883dfd3 | ||
|
|
ef087492cc | ||
|
|
98f11ee3d0 | ||
|
|
058f164ab6 | ||
|
|
6c47c27775 | ||
|
|
7410fa4825 | ||
|
|
246d9d1b6a | ||
|
|
d12cfe94cd | ||
|
|
ede81d3926 | ||
|
|
f27faa81a6 | ||
|
|
69e6c20b97 | ||
|
|
d93c40ebb3 | ||
|
|
dfe3afbf2d | ||
|
|
bb3542c920 | ||
|
|
a966abd9df | ||
|
|
95482ec1d8 | ||
|
|
500fe8bfec | ||
|
|
87ff0a9e16 | ||
|
|
18f9b915c5 | ||
|
|
b3fdd0b7fa | ||
|
|
9846354bf9 | ||
|
|
3da1d71323 | ||
|
|
72f692aa04 |
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The coupling is a SET, not a global switch.
|
||||||
|
//
|
||||||
|
// A station can run a combiner pair AND a third amplifier that has nothing to
|
||||||
|
// do with it — two SPE on the combiner, a PowerGenius on another antenna. A
|
||||||
|
// global flag would send that third one into OPERATE alongside them.
|
||||||
|
func TestAmpTargetsFollowTheGroup(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.ampInsts = map[string]*ampInst{"spe1": {}, "spe2": {}, "pgxl": {}}
|
||||||
|
group := []string{"spe1", "spe2"}
|
||||||
|
|
||||||
|
// A member commands the whole group, itself first.
|
||||||
|
got := a.ampTargets("spe2", group)
|
||||||
|
if len(got) != 2 || got[0] != "spe2" || got[1] != "spe1" {
|
||||||
|
t.Errorf("member = %v, want [spe2 spe1] — the clicked one first", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The amplifier OUTSIDE the group keeps its buttons to itself.
|
||||||
|
if got := a.ampTargets("pgxl", group); len(got) != 1 || got[0] != "pgxl" {
|
||||||
|
t.Errorf("outsider = %v, want just [pgxl]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No group at all: everyone is on their own.
|
||||||
|
if got := a.ampTargets("spe1", nil); len(got) != 1 || got[0] != "spe1" {
|
||||||
|
t.Errorf("no group = %v, want just [spe1]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group remembering an amplifier that is gone must not carry it: it would
|
||||||
|
// fail the command for a member that no longer exists.
|
||||||
|
if got := a.ampTargets("spe1", []string{"spe1", "deleted"}); len(got) != 1 || got[0] != "spe1" {
|
||||||
|
t.Errorf("stale member = %v, want it dropped", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One amplifier coupled to itself is not a group — storing it would make every
|
||||||
|
// command fan out to a single member for ever, which is just noise.
|
||||||
|
func TestLinkedAmpsNeedsTwo(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{[]string{"a", "b"}, 2},
|
||||||
|
{[]string{"a"}, 0},
|
||||||
|
{[]string{" ", "a"}, 0}, // blanks are not members
|
||||||
|
{nil, 0},
|
||||||
|
} {
|
||||||
|
clean := make([]string, 0, len(tc.in))
|
||||||
|
for _, id := range tc.in {
|
||||||
|
if id != "" && id != " " {
|
||||||
|
clean = append(clean, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clean) < 2 {
|
||||||
|
clean = nil
|
||||||
|
}
|
||||||
|
if len(clean) != tc.want {
|
||||||
|
t.Errorf("SetLinkedAmps(%v) would keep %d, want %d", tc.in, len(clean), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two combined amplifiers must be commanded AT THE SAME TIME, not one after the
|
||||||
|
// other.
|
||||||
|
//
|
||||||
|
// Sequentially, the second was commanded only once the first had answered — and
|
||||||
|
// an SPE answers over its own link, in its own time. The combiner heard power
|
||||||
|
// appear on one input before the other and beeped about it, on every OFF and
|
||||||
|
// every ON. This is what an operator hears, so it is worth a test that would
|
||||||
|
// hear it too.
|
||||||
|
func TestLinkedAmpCommandsLeaveTogether(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
const slow = 150 * time.Millisecond
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
starts := map[string]time.Time{}
|
||||||
|
err := a.ampFanOut([]string{"one", "two"}, func(id string) error {
|
||||||
|
mu.Lock()
|
||||||
|
starts[id] = time.Now()
|
||||||
|
mu.Unlock()
|
||||||
|
time.Sleep(slow) // an amplifier taking its time to answer
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fan-out: %v", err)
|
||||||
|
}
|
||||||
|
if len(starts) != 2 {
|
||||||
|
t.Fatalf("%d amplifiers were commanded, want both", len(starts))
|
||||||
|
}
|
||||||
|
// Both goroutines wait on one channel and are released by closing it, so the
|
||||||
|
// gap is scheduling noise. Sequential execution would put a full command
|
||||||
|
// between them.
|
||||||
|
gap := starts["one"].Sub(starts["two"])
|
||||||
|
if gap < 0 {
|
||||||
|
gap = -gap
|
||||||
|
}
|
||||||
|
if gap > slow/3 {
|
||||||
|
t.Errorf("the two amplifiers were commanded %v apart — the combiner hears that as one input arriving late", gap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The amplifier the operator clicked comes first, and its failure is the one
|
||||||
|
// reported: "the amp I pressed did not respond" beats the same message about
|
||||||
|
// its silent partner.
|
||||||
|
func TestLinkedAmpErrorNamesTheOneClicked(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
clicked := errors.New("the one clicked")
|
||||||
|
other := errors.New("the other one")
|
||||||
|
err := a.ampFanOut([]string{"clicked", "other"}, func(id string) error {
|
||||||
|
if id == "clicked" {
|
||||||
|
return clicked
|
||||||
|
}
|
||||||
|
return other
|
||||||
|
})
|
||||||
|
if !errors.Is(err, clicked) {
|
||||||
|
t.Errorf("fan-out reported %v, want the amplifier the operator pressed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One amplifier is the ordinary case and must not change: run inline, no
|
||||||
|
// goroutine, no barrier, and the error straight back.
|
||||||
|
func TestSingleAmpRunsInline(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
boom := errors.New("not running")
|
||||||
|
if err := a.ampFanOut([]string{"solo"}, func(string) error { return boom }); !errors.Is(err, boom) {
|
||||||
|
t.Errorf("a single amplifier reported %v, want the error itself", err)
|
||||||
|
}
|
||||||
|
if err := a.ampFanOut(nil, func(string) error { return boom }); err != nil {
|
||||||
|
t.Errorf("an empty group reported %v, want nothing to do", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// MY_ANTENNA from the Antenna Genius.
|
||||||
|
//
|
||||||
|
// A station with a switch has a truth the log does not: which antenna is
|
||||||
|
// actually connected right now. The working conditions hold what was PLANNED
|
||||||
|
// for the band — the default antenna ticked for 20 m — and that is right until
|
||||||
|
// the operator throws the switch, at which point the log quietly keeps claiming
|
||||||
|
// the other antenna for the rest of the session.
|
||||||
|
//
|
||||||
|
// So, as an option: when the Antenna Genius knows which antenna is selected,
|
||||||
|
// its name wins. The name is the one configured on the device, because that is
|
||||||
|
// the name the operator gave it and the one they will look for in the log.
|
||||||
|
//
|
||||||
|
// WHICH PORT is the whole difficulty. An Antenna Genius has two, A and B, and a
|
||||||
|
// FlexRadio has two transmit antenna jacks, ANT1 and ANT2. The QSO was made on
|
||||||
|
// exactly one of them, and stamping the wrong port's antenna would be worse
|
||||||
|
// than stamping the band default — a wrong answer that looks authoritative. The
|
||||||
|
// radio's own TX antenna selection is what decides.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/antgenius"
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyAntGeniusMyAnt = "antgenius.my_antenna" // use the selected antenna as MY_ANTENNA
|
||||||
|
keyAntGeniusPortForA1 = "antgenius.port_for_ant1" // which AG port ANT1 is wired to (1=A, 2=B)
|
||||||
|
)
|
||||||
|
|
||||||
|
// antGeniusPortFor maps the radio's transmit antenna jack to an Antenna Genius
|
||||||
|
// port.
|
||||||
|
//
|
||||||
|
// The wiring is the station's, not something that can be read from either
|
||||||
|
// device: ANT1 usually goes to port A and ANT2 to port B, which is what the
|
||||||
|
// setting defaults to, but a station wired the other way round would otherwise
|
||||||
|
// log every QSO with the other antenna's name.
|
||||||
|
func (a *App) antGeniusPortFor(txAnt string) int {
|
||||||
|
ant1Port := 1
|
||||||
|
if a.settingOr(keyAntGeniusPortForA1, "1") == "2" {
|
||||||
|
ant1Port = 2
|
||||||
|
}
|
||||||
|
other := 3 - ant1Port
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(txAnt)) {
|
||||||
|
case "ANT1", "ANT 1", "1", "A":
|
||||||
|
return ant1Port
|
||||||
|
case "ANT2", "ANT 2", "2", "B":
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
return 0 // XVTR, a rig with one jack, or nothing reported: no port to name
|
||||||
|
}
|
||||||
|
|
||||||
|
// antGeniusAntennaName returns the name of the antenna currently selected on
|
||||||
|
// the port the radio is transmitting through, or "" when it cannot be told.
|
||||||
|
//
|
||||||
|
// Deliberately silent rather than approximate. Every "" here means the log
|
||||||
|
// keeps the band default it would have had anyway, which is a defensible
|
||||||
|
// answer; a guessed port is not.
|
||||||
|
func (a *App) antGeniusAntennaName() string {
|
||||||
|
if a.antgenius == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
st := a.antgenius.GetStatus()
|
||||||
|
if !st.Connected {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// The jack in use comes from the FlexRadio's own state — TXAnt lives on the
|
||||||
|
// Flex panel state, not on the backend-agnostic RigState, because only a
|
||||||
|
// Flex has named antenna jacks to report.
|
||||||
|
txAnt := ""
|
||||||
|
if a.cat != nil {
|
||||||
|
if fs, ok := a.cat.FlexState(); ok {
|
||||||
|
txAnt = fs.TXAnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
port := a.antGeniusPortFor(txAnt)
|
||||||
|
if port == 0 {
|
||||||
|
// One switch port in use and no ambiguity about which: a station whose
|
||||||
|
// radio has a single jack still deserves the name. Two ports carrying
|
||||||
|
// different antennas with nothing to choose between them does not.
|
||||||
|
if st.PortA > 0 && st.PortB == 0 {
|
||||||
|
port = 1
|
||||||
|
} else if st.PortB > 0 && st.PortA == 0 {
|
||||||
|
port = 2
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx := st.PortA
|
||||||
|
if port == 2 {
|
||||||
|
idx = st.PortB
|
||||||
|
}
|
||||||
|
if idx <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return antGeniusNameOf(st, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// antGeniusNameOf looks an antenna index up in the device's own list.
|
||||||
|
func antGeniusNameOf(st antgenius.Status, idx int) string {
|
||||||
|
for _, ant := range st.Antennas {
|
||||||
|
if ant.Index == idx {
|
||||||
|
return strings.TrimSpace(ant.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAntGeniusAntenna overrides MY_ANTENNA with the switch's selection, when
|
||||||
|
// the option is on.
|
||||||
|
//
|
||||||
|
// Called at log time rather than while the entry form is open: an operator who
|
||||||
|
// changes antenna mid-QSO is telling us what the contact was actually made on,
|
||||||
|
// and the value that matters is the one at the moment it is logged.
|
||||||
|
func (a *App) applyAntGeniusAntenna(myAntenna *string) {
|
||||||
|
if myAntenna == nil || a.settingOr(keyAntGeniusMyAnt, "") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := a.antGeniusAntennaName()
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *myAntenna != name {
|
||||||
|
applog.Printf("antgenius: MY_ANTENNA %q → %q (the antenna selected on the switch)", *myAntenna, name)
|
||||||
|
}
|
||||||
|
*myAntenna = name
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/antgenius"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The switch has two ports and the radio two antenna jacks; a QSO was made
|
||||||
|
// through exactly one of them. Naming the wrong port's antenna would be worse
|
||||||
|
// than naming the band default — it is a wrong answer that looks authoritative.
|
||||||
|
func TestAntennaNameComesFromTheDeviceList(t *testing.T) {
|
||||||
|
st := antgenius.Status{
|
||||||
|
Connected: true,
|
||||||
|
PortA: 2,
|
||||||
|
PortB: 5,
|
||||||
|
Antennas: []antgenius.Antenna{
|
||||||
|
{Index: 2, Name: "OB11-5 20/15/10"},
|
||||||
|
{Index: 5, Name: "Vertical 80m"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := antGeniusNameOf(st, 2); got != "OB11-5 20/15/10" {
|
||||||
|
t.Fatalf("port A antenna = %q", got)
|
||||||
|
}
|
||||||
|
if got := antGeniusNameOf(st, 5); got != "Vertical 80m" {
|
||||||
|
t.Fatalf("port B antenna = %q", got)
|
||||||
|
}
|
||||||
|
// An index the device never described has no name to give, and inventing
|
||||||
|
// one ("Antenna 7") would put a label in the log that exists nowhere else.
|
||||||
|
if got := antGeniusNameOf(st, 7); got != "" {
|
||||||
|
t.Fatalf("unknown index produced %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The jack-to-port wiring is the station's own. Defaulting ANT1 to port A is a
|
||||||
|
// convention, not a fact, so it is a setting — and the mapping has to hold both
|
||||||
|
// ways round.
|
||||||
|
func TestJackToPortMapping(t *testing.T) {
|
||||||
|
app := &App{}
|
||||||
|
// No settings store: settingOr falls back, which is ANT1 → port A.
|
||||||
|
if got := app.antGeniusPortFor("ANT1"); got != 1 {
|
||||||
|
t.Fatalf("ANT1 mapped to port %d, want A(1)", got)
|
||||||
|
}
|
||||||
|
if got := app.antGeniusPortFor("ANT2"); got != 2 {
|
||||||
|
t.Fatalf("ANT2 mapped to port %d, want B(2)", got)
|
||||||
|
}
|
||||||
|
// A jack that is neither — a transverter port, or a rig that reports
|
||||||
|
// nothing — has no port to name, and saying so beats guessing.
|
||||||
|
if got := app.antGeniusPortFor("XVTR"); got != 0 {
|
||||||
|
t.Fatalf("XVTR mapped to port %d, want none", got)
|
||||||
|
}
|
||||||
|
if got := app.antGeniusPortFor(""); got != 0 {
|
||||||
|
t.Fatalf("an unreported jack mapped to port %d, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyAwardsTracked holds the PER-PROFILE list of award codes the operator wants
|
||||||
|
// to follow (JSON array of award.Def.Code). Award definitions themselves are
|
||||||
|
// global (keyAwardDefs, shared across profiles), but WHICH awards a station
|
||||||
|
// tracks is a per-profile choice — a DX profile follows DXCC/WPX, a POTA profile
|
||||||
|
// follows POTA/WWFF. An empty/unset list means "track them all" so the Awards
|
||||||
|
// tab is never blank before the operator has picked anything.
|
||||||
|
const keyAwardsTracked = "awards.tracked"
|
||||||
|
|
||||||
|
// GetTrackedAwards returns the active profile's followed award codes. An empty
|
||||||
|
// slice means the operator has not narrowed the list — the Awards tab then shows
|
||||||
|
// every award.
|
||||||
|
func (a *App) GetTrackedAwards() ([]string, error) {
|
||||||
|
out := []string{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
s, _ := a.settings.Get(a.ctx, keyAwardsTracked)
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTrackedAwards persists the followed award codes for the active profile and
|
||||||
|
// notifies the Awards tab to re-filter its list. Codes are stored verbatim; the
|
||||||
|
// Awards tab intersects them with the live award definitions, so a code that no
|
||||||
|
// longer exists is simply ignored (not an error).
|
||||||
|
func (a *App) SaveTrackedAwards(codes []string) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if codes == nil {
|
||||||
|
codes = []string{}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(codes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyAwardsTracked, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "awards:tracked-changed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deniedCallHashes lists station callsigns not permitted to run this build,
|
||||||
|
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
|
||||||
|
// in the source or the compiled binary. Enforcement is best-effort by design —
|
||||||
|
// the call is entered by the operator and can be changed — so this only turns
|
||||||
|
// away straightforward use, not a determined one.
|
||||||
|
var deniedCallHashes = map[string]struct{}{
|
||||||
|
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
|
||||||
|
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
|
||||||
|
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
|
||||||
|
"d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {},
|
||||||
|
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
||||||
|
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
|
||||||
|
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||||
|
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
|
||||||
|
// same way extsvc does, so F4XYZ/P matches F4XYZ.
|
||||||
|
func callDenied(call string) bool {
|
||||||
|
base := denyBaseCall(call)
|
||||||
|
if base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(base))
|
||||||
|
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
|
||||||
|
// longest token (the real call), otherwise the call itself, upper-cased.
|
||||||
|
func denyBaseCall(s string) string {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
for _, part := range strings.Split(s, "/") {
|
||||||
|
if len(part) > len(best) {
|
||||||
|
best = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceCallGate exits the process silently when any of the supplied callsigns
|
||||||
|
// is denied. Called once at startup, after the profiles are loaded: the first
|
||||||
|
// launch is where the operator enters and saves the call, so a denied build
|
||||||
|
// simply does not come back up on the next launch. No window, no message.
|
||||||
|
func enforceCallGate(calls ...string) {
|
||||||
|
for _, c := range calls {
|
||||||
|
if callDenied(c) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Elecraft K3/K4 panel bindings.
|
||||||
|
//
|
||||||
|
// The controls the operator asked for and nothing else: power, volume, the
|
||||||
|
// S-meter and SWR, MOX, and an ATU tune. A K3 exposes dozens of commands, but a
|
||||||
|
// panel earns its place by covering what is reached for during a QSO — and each
|
||||||
|
// one added without a radio to test it against is a control that may or may not
|
||||||
|
// do what its label says. See internal/cat/kenwood_panel.go.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetKenwoodState returns the K3/K4 panel snapshot. Zero value when the active
|
||||||
|
// CAT backend is something else, which is how the UI knows to hide the panel.
|
||||||
|
func (a *App) GetKenwoodState() cat.KenwoodTXState {
|
||||||
|
if a.cat == nil {
|
||||||
|
return cat.KenwoodTXState{}
|
||||||
|
}
|
||||||
|
st, _ := a.cat.KenwoodState()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodPower(w int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodAFGain(p int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAFGain(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodRFGain(p int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRFGain(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodMicGain(p int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodMicGain(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodSquelch(p int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodSquelch(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodPreamp(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPreamp(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodAtt(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAtt(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodNB(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodNB(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodNR(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodNR(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodAGC(name string) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAGC(name) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodFilter(hz int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodFilter(hz) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodAntenna(n int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAntenna(n) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodRIT(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRIT(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SetKenwoodXIT(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodXIT(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// NudgeKenwoodRIT moves the RIT/XIT offset by delta Hz.
|
||||||
|
func (a *App) NudgeKenwoodRIT(delta int) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.NudgeKenwoodRIT(delta) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearKenwoodRIT zeroes the RIT and XIT offsets together, which is what the
|
||||||
|
// radio's own RC does and what an operator means by "clear it".
|
||||||
|
func (a *App) ClearKenwoodRIT() error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.ClearKenwoodRIT() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKenwoodTX is the panel's MOX.
|
||||||
|
func (a *App) SetKenwoodTX(on bool) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodTX(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleKenwoodATU puts the tuner in line or bypasses it (the HOLD of the same
|
||||||
|
// front-panel switch the tune uses).
|
||||||
|
func (a *App) ToggleKenwoodATU() error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.ToggleKenwoodATU() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) TuneKenwoodATU() error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.TuneKenwoodATU() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshKenwood re-reads the settings on the next poll — for when a knob was
|
||||||
|
// turned on the radio itself.
|
||||||
|
func (a *App) RefreshKenwood() error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.RefreshKenwood() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) kenwoodPanelDo(fn func(cat.KenwoodPanelController) error) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.KenwoodPanelDo(fn)
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/psu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Bench power supply (Modbus RTU) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A programmable supply feeding the shack, switched on and off from OpsLog so
|
||||||
|
// the station comes up and goes down with the logbook rather than by reaching
|
||||||
|
// behind the desk.
|
||||||
|
//
|
||||||
|
// OpsLog READS the supply's measurements and its set points, and WRITES exactly
|
||||||
|
// one thing: the output on/off. The register map has the voltage and current
|
||||||
|
// set points and the three protection trip levels as writable too, and none of
|
||||||
|
// them belong to a logbook — a wrong value there is 30 V where a radio expected
|
||||||
|
// 13.8. See internal/psu for the map and where it comes from.
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyPSUEnabled = "psu.enabled"
|
||||||
|
keyPSUPort = "psu.com_port"
|
||||||
|
keyPSUBaud = "psu.baud"
|
||||||
|
keyPSUAddress = "psu.address" // Modbus slave address, 1…15 on this family
|
||||||
|
)
|
||||||
|
|
||||||
|
// PSUSettings is the JSON shape for the Hardware → Power supply panel.
|
||||||
|
type PSUSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ComPort string `json:"com_port"`
|
||||||
|
Baud int `json:"baud"` // 9600 from the factory
|
||||||
|
Address int `json:"address"` // 1 from the factory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSUSettings returns the persisted supply config.
|
||||||
|
func (a *App) GetPSUSettings() (PSUSettings, error) {
|
||||||
|
out := PSUSettings{Baud: 9600, Address: 1}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx, keyPSUEnabled, keyPSUPort, keyPSUBaud, keyPSUAddress)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Enabled = m[keyPSUEnabled] == "1"
|
||||||
|
out.ComPort = m[keyPSUPort]
|
||||||
|
if v, e := strconv.Atoi(m[keyPSUBaud]); e == nil && v > 0 {
|
||||||
|
out.Baud = v
|
||||||
|
}
|
||||||
|
if v, e := strconv.Atoi(m[keyPSUAddress]); e == nil && v >= 1 && v <= 250 {
|
||||||
|
out.Address = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePSUSettings persists the config and (re)starts or stops the client.
|
||||||
|
func (a *App) SavePSUSettings(s PSUSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if s.Baud <= 0 {
|
||||||
|
s.Baud = 9600
|
||||||
|
}
|
||||||
|
// The manual gives 1…15 for the address field and 1…250 for the address
|
||||||
|
// SETTING register. Clamped to the wider range and defaulted to 1: an
|
||||||
|
// address of 0 is the Modbus broadcast, which never answers, so accepting it
|
||||||
|
// would give a supply that is present and permanently "not responding".
|
||||||
|
if s.Address < 1 || s.Address > 250 {
|
||||||
|
s.Address = 1
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keyPSUEnabled: boolStr(s.Enabled),
|
||||||
|
keyPSUPort: strings.TrimSpace(s.ComPort),
|
||||||
|
keyPSUBaud: strconv.Itoa(s.Baud),
|
||||||
|
keyPSUAddress: strconv.Itoa(s.Address),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.restartAsync("psu", a.startPSU)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPSU stops any running client and starts a fresh one if the supply is
|
||||||
|
// enabled and has a port. Safe to call repeatedly (startup, settings save,
|
||||||
|
// profile switch).
|
||||||
|
func (a *App) startPSU() {
|
||||||
|
if a.psu != nil {
|
||||||
|
go a.psu.Stop()
|
||||||
|
a.psu = nil
|
||||||
|
}
|
||||||
|
s, err := a.GetPSUSettings()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("psu: not started — settings unavailable: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.ComPort) == "" {
|
||||||
|
applog.Printf("psu: not started — no serial port configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("psu: starting on %s @ %d baud, Modbus address %d", s.ComPort, s.Baud, s.Address)
|
||||||
|
a.psu = psu.New(psu.Config{ComPort: s.ComPort, Baud: s.Baud, Address: byte(s.Address)})
|
||||||
|
_ = a.psu.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSUStatus returns the supply's last polled state for the UI.
|
||||||
|
func (a *App) GetPSUStatus() psu.Status {
|
||||||
|
if a.psu == nil {
|
||||||
|
return psu.Status{}
|
||||||
|
}
|
||||||
|
return a.psu.GetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPSUOutput switches the supply's output. The only write OpsLog makes to it.
|
||||||
|
func (a *App) SetPSUOutput(on bool) error {
|
||||||
|
if a.psu == nil {
|
||||||
|
return fmt.Errorf("the power supply is not enabled in Settings")
|
||||||
|
}
|
||||||
|
return a.psu.SetOutput(on)
|
||||||
|
}
|
||||||
+245
-10
@@ -31,16 +31,34 @@ import (
|
|||||||
const (
|
const (
|
||||||
keyQSLEmailSubject = "qsl.email_subject"
|
keyQSLEmailSubject = "qsl.email_subject"
|
||||||
keyQSLEmailBody = "qsl.email_body"
|
keyQSLEmailBody = "qsl.email_body"
|
||||||
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||||
|
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
|
||||||
)
|
)
|
||||||
|
|
||||||
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
|
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
|
||||||
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
|
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
|
||||||
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
||||||
|
|
||||||
|
// appQSLCardRcvdField marks that a QSL was RECEIVED for this QSO (set by the
|
||||||
|
// operator, e.g. when a card arrives by e-mail). It drives the PSE/TNX stamp on
|
||||||
|
// the card: received → "TNX QSL" (thanks for your card), not received → "PSE QSL"
|
||||||
|
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
|
||||||
|
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
|
||||||
|
|
||||||
|
// qslCredit closes the default QSL e-mail: who made the card, and where the
|
||||||
|
// recipient can get the same program.
|
||||||
|
//
|
||||||
|
// A DEFAULT, not a signature. It lives in the body template like every other
|
||||||
|
// line, so an operator who does not want it deletes it once and it is gone —
|
||||||
|
// and one who has already written their own body never sees it appear, because
|
||||||
|
// a stored template is returned verbatim and the default is only the fallback.
|
||||||
|
// Appending it at send time instead would have made it unremovable, which is
|
||||||
|
// not a thing to do to someone's outgoing mail.
|
||||||
|
const qslCredit = "--\nDesigned & sent by OpsLog — " + releasesPageURL
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
||||||
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
|
||||||
)
|
)
|
||||||
|
|
||||||
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
||||||
@@ -453,11 +471,37 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
|
// qslDefaultMessage returns the operator's default QSL message (Settings), used
|
||||||
|
// on the card when a QSO has no QSLMSG of its own.
|
||||||
|
func (a *App) qslDefaultMessage() string {
|
||||||
|
if a.settings == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, _ := a.settings.Get(a.ctx, keyQSLDefaultMsg)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOpsLogQSLReceived marks (or clears) that a QSL was received for a QSO. This
|
||||||
|
// flips the card's PSE/TNX stamp. Stored as a timestamp in the QSO extras (a
|
||||||
|
// single-key UPDATE, like the sent marker) so it survives concurrent uploads.
|
||||||
|
func (a *App) SetOpsLogQSLReceived(qsoID int64, on bool) error {
|
||||||
|
if a.qso == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
v := ""
|
||||||
|
if on {
|
||||||
|
v = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return a.qso.SetExtra(a.ctx, qsoID, appQSLCardRcvdField, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLEmailTemplates is the eQSL e-mail subject/body, the auto-send toggle, and
|
||||||
|
// the default QSL message printed on the card when a QSO has none of its own.
|
||||||
type QSLEmailTemplates struct {
|
type QSLEmailTemplates struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
AutoSend bool `json:"auto_send"`
|
AutoSend bool `json:"auto_send"`
|
||||||
|
DefaultMessage string `json:"default_message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
||||||
@@ -466,7 +510,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
|
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend, keyQSLDefaultMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -477,6 +521,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
|||||||
out.Body = b
|
out.Body = b
|
||||||
}
|
}
|
||||||
out.AutoSend = m[keyQSLAutoSend] == "1"
|
out.AutoSend = m[keyQSLAutoSend] == "1"
|
||||||
|
out.DefaultMessage = m[keyQSLDefaultMsg]
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +540,10 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
|||||||
if t.AutoSend {
|
if t.AutoSend {
|
||||||
v = "1"
|
v = "1"
|
||||||
}
|
}
|
||||||
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
|
if err := a.settings.Set(a.ctx, keyQSLAutoSend, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, keyQSLDefaultMsg, t.DefaultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
|
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
|
||||||
@@ -634,6 +682,18 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
|||||||
}
|
}
|
||||||
return strconv.Itoa(z)
|
return strconv.Itoa(z)
|
||||||
}
|
}
|
||||||
|
// The QSL message on the card: the QSO's own QSLMSG wins; when it's empty the
|
||||||
|
// operator's default message (Settings) is used instead.
|
||||||
|
msg := q.QSLMsg
|
||||||
|
if strings.TrimSpace(msg) == "" {
|
||||||
|
msg = a.qslDefaultMessage()
|
||||||
|
}
|
||||||
|
// PSE/TNX stamp, chosen automatically by whether a QSL was received for this
|
||||||
|
// QSO (appQSLCardRcvdField): received → thank them, otherwise ask for a card.
|
||||||
|
pseTnx := "PSE QSL"
|
||||||
|
if q.Extras != nil && strings.TrimSpace(q.Extras[appQSLCardRcvdField]) != "" {
|
||||||
|
pseTnx = "TNX QSL"
|
||||||
|
}
|
||||||
vars := map[string]string{
|
vars := map[string]string{
|
||||||
"profile.callsign": info.Callsign,
|
"profile.callsign": info.Callsign,
|
||||||
"profile.operator_name": info.Operator,
|
"profile.operator_name": info.Operator,
|
||||||
@@ -650,12 +710,16 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
|||||||
"qso.mode": q.Mode,
|
"qso.mode": q.Mode,
|
||||||
"qso.submode": q.Submode,
|
"qso.submode": q.Submode,
|
||||||
"qso.rst_sent": q.RSTSent,
|
"qso.rst_sent": q.RSTSent,
|
||||||
"qso.qsl_msg": q.QSLMsg,
|
"qso.qsl_msg": msg,
|
||||||
|
"qso.pse_tnx": pseTnx,
|
||||||
"qso.name": q.Name,
|
"qso.name": q.Name,
|
||||||
}
|
}
|
||||||
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
||||||
if q.FreqHz != nil {
|
if q.FreqHz != nil {
|
||||||
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
|
// Megahertz, without the unit. The column is labelled FREQ and the box is
|
||||||
|
// narrow — "10.136 MHz" ran into the MODE column beside it, and a card that
|
||||||
|
// says FREQ 10.136 has never confused anybody.
|
||||||
|
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
|
||||||
}
|
}
|
||||||
return vars, a.qslCountryInfo(), nil
|
return vars, a.qslCountryInfo(), nil
|
||||||
}
|
}
|
||||||
@@ -688,13 +752,184 @@ func (a *App) qslCountryInfo() qslcard.CountryInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sampleQSO is the canned contact used by the editor's live preview.
|
// sampleQSO is the canned contact used by the editor's live preview.
|
||||||
|
//
|
||||||
|
// Every field the QSO box can show must be FILLED here, and filled with a value
|
||||||
|
// of realistic width. The preview is the only place an operator can see whether
|
||||||
|
// a column fits before printing a card — a blank one silently promises room it
|
||||||
|
// will not have. The frequency is the widest of them, hence a real 20 m SSB one
|
||||||
|
// rather than a round number.
|
||||||
func sampleQSO() qso.QSO {
|
func sampleQSO() qso.QSO {
|
||||||
|
hz := int64(14285000)
|
||||||
return qso.QSO{
|
return qso.QSO{
|
||||||
Callsign: "DL1ABC",
|
Callsign: "DL1ABC",
|
||||||
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
|
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
|
||||||
Band: "20m",
|
Band: "20m",
|
||||||
Mode: "SSB",
|
Mode: "SSB",
|
||||||
|
Submode: "USB",
|
||||||
|
FreqHz: &hz,
|
||||||
RSTSent: "59",
|
RSTSent: "59",
|
||||||
QSLMsg: "TNX FB QSO — 73!",
|
QSLMsg: "TNX FB QSO — 73!",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QSLForeignTemplate is one card design belonging to ANOTHER profile, offered
|
||||||
|
// for copying into the active one.
|
||||||
|
type QSLForeignTemplate struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProfileID int64 `json:"profile_id"`
|
||||||
|
ProfileName string `json:"profile_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLListOtherProfileTemplates lists the designs the ACTIVE profile cannot see.
|
||||||
|
//
|
||||||
|
// A second profile is usually the same operator with a different rig or a
|
||||||
|
// different locator — same callsign, same cards. Redrawing a design because the
|
||||||
|
// antenna changed is work nobody should have to do, and the designs a profile
|
||||||
|
// already sees (its own, and the shared ones) are deliberately left out: they
|
||||||
|
// are not something to copy.
|
||||||
|
func (a *App) QSLListOtherProfileTemplates() ([]QSLForeignTemplate, error) {
|
||||||
|
if a.qslTemplates == nil || a.profiles == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
active := int64(-1)
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
active = p.ID
|
||||||
|
}
|
||||||
|
names := map[int64]string{}
|
||||||
|
if list, err := a.ListProfiles(); err == nil {
|
||||||
|
for _, p := range list {
|
||||||
|
names[p.ID] = p.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all, err := a.qslTemplates.List(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []QSLForeignTemplate{}
|
||||||
|
for _, r := range all {
|
||||||
|
// Shared designs (no profile) already appear in every profile's list.
|
||||||
|
if r.ProfileID == nil || *r.ProfileID == active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, QSLForeignTemplate{
|
||||||
|
ID: r.ID, Name: r.Name, ProfileID: *r.ProfileID,
|
||||||
|
ProfileName: firstNonEmptyStr(names[*r.ProfileID], fmt.Sprintf("profile %d", *r.ProfileID)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLCopyTemplateToActiveProfile duplicates another profile's design into the
|
||||||
|
// active one and returns the new id.
|
||||||
|
//
|
||||||
|
// A COPY, not a move or a share. The original profile keeps its design
|
||||||
|
// untouched, and the copy is free to diverge — a second profile usually exists
|
||||||
|
// because something differs, and that something often ends up on the card.
|
||||||
|
func (a *App) QSLCopyTemplateToActiveProfile(id int64) (int64, error) {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
src, err := a.qslTemplates.Get(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// The stored document references photos by NAME, relative to the template's
|
||||||
|
// own asset folder — so copying the JSON alone would point the new design at
|
||||||
|
// files that are not in its folder, and the save would fail validation.
|
||||||
|
//
|
||||||
|
// The files are copied too, giving the duplicate its own folder. Sharing the
|
||||||
|
// source's would mean deleting one design silently emptied the other.
|
||||||
|
rec := qslcard.Record{Name: src.Name + " (copy)", JSON: src.JSON}
|
||||||
|
if p, e := a.profiles.Active(a.ctx); e == nil {
|
||||||
|
rec.ProfileID = &p.ID
|
||||||
|
}
|
||||||
|
// Saved first: a new template needs its id before it can own a folder.
|
||||||
|
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
srcDir := qslcard.TemplateDir(a.qslDir(), id)
|
||||||
|
dstDir := qslcard.TemplateDir(a.qslDir(), rec.ID)
|
||||||
|
// A design with no pictures at all has no folder, and that is not a failure.
|
||||||
|
// Anything else IS: this used to be written as "ignore os.IsNotExist", which
|
||||||
|
// also swallowed every ENOENT raised while copying — so a failed copy looked
|
||||||
|
// like a design that had nothing to copy, and the operator got "hero photo
|
||||||
|
// not found" with no clue why.
|
||||||
|
if _, statErr := os.Stat(srcDir); statErr == nil {
|
||||||
|
if err := copyDirContents(srcDir, dstDir); err != nil {
|
||||||
|
// Roll back rather than leave a design whose pictures are missing.
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||||
|
return 0, fmt.Errorf("copy template assets: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every failure from here rolls back BOTH halves: deleting only the row would
|
||||||
|
// leave the copied pictures on disk under an id nothing refers to.
|
||||||
|
rollback := func() {
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||||
|
}
|
||||||
|
t, err := qslcard.Parse([]byte(rec.JSON))
|
||||||
|
if err != nil {
|
||||||
|
rollback()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dstDir)); err != nil {
|
||||||
|
rollback()
|
||||||
|
return 0, fmt.Errorf("copied design is incomplete: %w", err)
|
||||||
|
}
|
||||||
|
applog.Printf("qsl: copied template %q (id %d) into the active profile as %q (id %d)",
|
||||||
|
src.Name, id, rec.Name, rec.ID)
|
||||||
|
return rec.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyQSLTemplatesToProfile duplicates one profile's QSL designs into another,
|
||||||
|
// PICTURES INCLUDED.
|
||||||
|
//
|
||||||
|
// This was an INSERT … SELECT, which cloned the rows and nothing else. A design
|
||||||
|
// references its photos by name, relative to its own asset folder, so every
|
||||||
|
// duplicated design pointed at a folder that did not exist: no thumbnail in the
|
||||||
|
// designer, and nothing to print. The rows looked right, which is why it went
|
||||||
|
// unnoticed — the damage is entirely on disk.
|
||||||
|
//
|
||||||
|
// Row by row rather than in one statement, because each new design needs its id
|
||||||
|
// before it can own a folder. A design whose files cannot be copied is dropped
|
||||||
|
// rather than kept empty: an operator who sees the design listed will believe
|
||||||
|
// it works.
|
||||||
|
func (a *App) copyQSLTemplatesToProfile(fromProfile, toProfile int64) error {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
all, err := a.qslTemplates.List(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, src := range all {
|
||||||
|
if src.ProfileID == nil || *src.ProfileID != fromProfile {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rec := qslcard.Record{Name: src.Name, JSON: src.JSON, IsDefault: src.IsDefault}
|
||||||
|
to := toProfile
|
||||||
|
rec.ProfileID = &to
|
||||||
|
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srcDir := qslcard.TemplateDir(a.qslDir(), src.ID)
|
||||||
|
if _, statErr := os.Stat(srcDir); statErr != nil {
|
||||||
|
continue // a design with no pictures at all — nothing to carry over
|
||||||
|
}
|
||||||
|
if err := copyDirContents(srcDir, qslcard.TemplateDir(a.qslDir(), rec.ID)); err != nil {
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||||
|
return fmt.Errorf("copy assets of %q: %w", src.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStr(a, b string) string {
|
||||||
|
if strings.TrimSpace(a) != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
keyExtEQSLPassword: true,
|
keyExtEQSLPassword: true,
|
||||||
keyExtCloudlogAPIKey: true,
|
keyExtCloudlogAPIKey: true,
|
||||||
|
// The web-publish config is one JSON blob and the FTP password lives inside
|
||||||
|
// it, so the whole blob is encrypted. That costs nothing — it is read and
|
||||||
|
// written as a unit anyway — and beats storing a server password in clear
|
||||||
|
// next to the rest.
|
||||||
|
keyWebPublish: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||||
|
|||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Row colouring for the log grid, by QSL / LoTW status — the thing Logger32 does
|
||||||
|
// and the reason an operator can tell at a glance what still needs sending.
|
||||||
|
//
|
||||||
|
// Rules are ORDERED and the first match wins, because a contact is usually
|
||||||
|
// several things at once: one confirmed on LoTW and by card is confirmed, not
|
||||||
|
// "sent, awaiting reply". Putting the order in the data rather than in a chain
|
||||||
|
// of ifs is what lets the settings panel show it in the same order it applies.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RowColorRule is one status and the colour it paints.
|
||||||
|
type RowColorRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz, hamlog.
|
||||||
|
//
|
||||||
|
// Empty means ALL of them — what an operator expects from a rule they have
|
||||||
|
// not narrowed, and what keeps a config written before this field meaningful.
|
||||||
|
Channels []string `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The QSL channels a rule can be scoped to.
|
||||||
|
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz", "hamlog"}
|
||||||
|
|
||||||
|
// RowColorSettings is the whole appearance block.
|
||||||
|
type RowColorSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Style is how the colour is shown: "bar" paints a stripe down the left edge,
|
||||||
|
// "tint" washes the row, "both" does each.
|
||||||
|
//
|
||||||
|
// A bar is the default because a filled row is a poor signal in a log where
|
||||||
|
// nearly every contact has SOME QSL state: colour that is always present
|
||||||
|
// stops being information and becomes a striped background, with the data
|
||||||
|
// behind it. The stripe says the same thing and costs nothing to read.
|
||||||
|
Style string `json:"style"`
|
||||||
|
// Intensity is the tint strength in percent. Only used by "tint"/"both".
|
||||||
|
Intensity int `json:"intensity"`
|
||||||
|
// BandMapLotw marks stations that upload to LoTW on the band map, with the
|
||||||
|
// same "L" badge the cluster list uses — one visual vocabulary across the two
|
||||||
|
// views rather than a second invention.
|
||||||
|
BandMapLotw bool `json:"bandmap_lotw"`
|
||||||
|
// Configured distinguishes "saved with this off" from "saved before the
|
||||||
|
// option existed", so a new marker can default ON without silently turning
|
||||||
|
// itself back on for an operator who switched it off.
|
||||||
|
// RecentZebra decides whether the Recent QSOs grid alternates its row
|
||||||
|
// background. "" (or "on") keeps the banding, which is what the grid has
|
||||||
|
// always done; "off" makes every row the same colour.
|
||||||
|
//
|
||||||
|
// A string with an empty default rather than a bool: a bool would have to
|
||||||
|
// mean "true when absent", and every reader would have to remember that.
|
||||||
|
RecentZebra string `json:"recent_zebra,omitempty"`
|
||||||
|
// RecentZebraColor is the alternate row's colour. Empty follows the theme,
|
||||||
|
// which is the only answer that stays right across all four of them.
|
||||||
|
RecentZebraColor string `json:"recent_zebra_color,omitempty"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
Rules []RowColorRule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule ids, in priority order. The frontend matches on these and holds the
|
||||||
|
// labels, so a translated name never has to travel through the settings.
|
||||||
|
// The four categories, in the order they are tested — first match wins.
|
||||||
|
//
|
||||||
|
// "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and
|
||||||
|
// still owe a paper card, and the colour an operator scans for is the one that
|
||||||
|
// means "there is something left to do here". Put after "confirmed", that row
|
||||||
|
// would go green and the card would never be printed.
|
||||||
|
//
|
||||||
|
// "Worked" is the catch-all: a contact with nothing sent, nothing asked for and
|
||||||
|
// nothing back. Off by default — with it on, every remaining row is painted.
|
||||||
|
var rowColorOrder = []string{
|
||||||
|
"to_send", // requested or queued on a watched channel, not gone out
|
||||||
|
"confirmed", // a watched channel has a confirmation back
|
||||||
|
"sent", // gone out on a watched channel, nothing back yet
|
||||||
|
"worked", // none of the above
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults: green for done, amber for waiting, blue for owed. Deliberately
|
||||||
|
// muted — they are composited at low opacity over a dark grid, and a saturated
|
||||||
|
// value there reads as an error state rather than a status.
|
||||||
|
var rowColorDefaults = map[string]string{
|
||||||
|
"to_send": "#a855f7",
|
||||||
|
"confirmed": "#16a34a",
|
||||||
|
"sent": "#f59e0b",
|
||||||
|
"worked": "#64748b",
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexColor guards what reaches the stylesheet. The value is interpolated into a
|
||||||
|
// CSS color-mix() by the grid, so anything that is not plainly a hex colour is
|
||||||
|
// refused rather than passed through.
|
||||||
|
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
|
|
||||||
|
func normRowColors(s RowColorSettings) RowColorSettings {
|
||||||
|
byID := map[string]RowColorRule{}
|
||||||
|
for _, r := range s.Rules {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
out := RowColorSettings{
|
||||||
|
Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity,
|
||||||
|
BandMapLotw: s.BandMapLotw, Configured: true,
|
||||||
|
RecentZebra: strings.ToLower(strings.TrimSpace(s.RecentZebra)),
|
||||||
|
}
|
||||||
|
if out.RecentZebra != "off" {
|
||||||
|
out.RecentZebra = "" // anything unrecognised means the default banding
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(s.RecentZebraColor); hexColor.MatchString(c) {
|
||||||
|
out.RecentZebraColor = c
|
||||||
|
}
|
||||||
|
if !s.Configured {
|
||||||
|
out.BandMapLotw = true // new option, on unless the operator says otherwise
|
||||||
|
}
|
||||||
|
switch out.Style {
|
||||||
|
case "bar", "tint", "both":
|
||||||
|
default:
|
||||||
|
out.Style = "bar"
|
||||||
|
}
|
||||||
|
// Clamped rather than rejected: the value only shapes a colour-mix, and a
|
||||||
|
// number outside the range is a slider that got away, not a fault worth
|
||||||
|
// resetting the operator's whole choice for.
|
||||||
|
if out.Intensity < 5 {
|
||||||
|
out.Intensity = 12
|
||||||
|
} else if out.Intensity > 45 {
|
||||||
|
out.Intensity = 45
|
||||||
|
}
|
||||||
|
ok := map[string]bool{}
|
||||||
|
for _, c := range rowColorChannels {
|
||||||
|
ok[c] = true
|
||||||
|
}
|
||||||
|
for _, id := range rowColorOrder {
|
||||||
|
r := byID[id]
|
||||||
|
r.ID = id
|
||||||
|
if !hexColor.MatchString(strings.TrimSpace(r.Color)) {
|
||||||
|
r.Color = rowColorDefaults[id]
|
||||||
|
}
|
||||||
|
// Keep only channels we know, in the canonical order, de-duplicated.
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, c := range r.Channels {
|
||||||
|
want[strings.ToLower(strings.TrimSpace(c))] = true
|
||||||
|
}
|
||||||
|
chans := []string{}
|
||||||
|
for _, c := range rowColorChannels {
|
||||||
|
if want[c] {
|
||||||
|
chans = append(chans, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Channels = chans
|
||||||
|
out.Rules = append(out.Rules, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatrixColors recolours the band/mode matrix — the PH/CW/DIG grid in the Stats
|
||||||
|
// panel, whose five fills and current-entry ring are the fastest read in the
|
||||||
|
// whole app and the one an operator is most likely to want in their own colours.
|
||||||
|
//
|
||||||
|
// Every colour is OPTIONAL and an empty one keeps whatever the active theme
|
||||||
|
// paints. That is why this stores OVERRIDES rather than a palette: each of the
|
||||||
|
// twelve themes ships a matrix ramp tuned to its own background, and an operator
|
||||||
|
// who only wants a different green must not thereby freeze the other four to the
|
||||||
|
// theme they happened to be using the day they picked it.
|
||||||
|
type MatrixColors struct {
|
||||||
|
// Enabled off leaves the theme's own ramp untouched, so switching it off is a
|
||||||
|
// genuine revert and not "some other set of colours".
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CallConfirmed string `json:"call_confirmed"`
|
||||||
|
CallWorked string `json:"call_worked"`
|
||||||
|
EntityConfirmed string `json:"entity_confirmed"`
|
||||||
|
EntityWorked string `json:"entity_worked"`
|
||||||
|
NotWorked string `json:"not_worked"`
|
||||||
|
CurrentEntry string `json:"current_entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
|
||||||
|
// "use the theme's" — because these are written straight into a CSS custom
|
||||||
|
// property, and the same rule as hexColor's own comment applies: what cannot be
|
||||||
|
// trusted into a stylesheet is refused rather than passed through.
|
||||||
|
func normMatrixColors(c MatrixColors) MatrixColors {
|
||||||
|
clean := func(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if hexColor.MatchString(s) {
|
||||||
|
return strings.ToLower(s)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return MatrixColors{
|
||||||
|
Enabled: c.Enabled,
|
||||||
|
CallConfirmed: clean(c.CallConfirmed),
|
||||||
|
CallWorked: clean(c.CallWorked),
|
||||||
|
EntityConfirmed: clean(c.EntityConfirmed),
|
||||||
|
EntityWorked: clean(c.EntityWorked),
|
||||||
|
NotWorked: clean(c.NotWorked),
|
||||||
|
CurrentEntry: clean(c.CurrentEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatrixColors returns the operator's matrix palette overrides. All-empty
|
||||||
|
// (the default) means "whatever the theme says".
|
||||||
|
func (a *App) GetMatrixColors() MatrixColors {
|
||||||
|
var c MatrixColors
|
||||||
|
if raw := a.settingOr(keyMatrixColors, ""); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &c)
|
||||||
|
}
|
||||||
|
return normMatrixColors(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMatrixColors persists them.
|
||||||
|
func (a *App) SaveMatrixColors(c MatrixColors) error {
|
||||||
|
b, err := json.Marshal(normMatrixColors(c))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyMatrixColors, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRowColors returns the row-colouring configuration, defaults included so the
|
||||||
|
// panel never has to invent one.
|
||||||
|
func (a *App) GetRowColors() RowColorSettings {
|
||||||
|
var s RowColorSettings
|
||||||
|
if raw := a.settingOr(keyRowColors, ""); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &s)
|
||||||
|
}
|
||||||
|
return normRowColors(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRowColors persists it.
|
||||||
|
func (a *App) SaveRowColors(s RowColorSettings) error {
|
||||||
|
b, err := json.Marshal(normRowColors(s))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyRowColors, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The colour is interpolated into a CSS color-mix() by the grid, so anything
|
||||||
|
// that is not plainly a hex colour has to be refused rather than passed on.
|
||||||
|
func TestRowColorsRefuseAnythingButHex(t *testing.T) {
|
||||||
|
in := RowColorSettings{Enabled: true, Rules: []RowColorRule{
|
||||||
|
{ID: "confirmed", Color: "#123abc", Enabled: true},
|
||||||
|
{ID: "sent", Color: "red; background:url(x)", Enabled: true},
|
||||||
|
{ID: "to_send", Color: "", Enabled: true},
|
||||||
|
}}
|
||||||
|
got := normRowColors(in)
|
||||||
|
|
||||||
|
byID := map[string]RowColorRule{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
if byID["confirmed"].Color != "#123abc" {
|
||||||
|
t.Errorf("a valid colour was rewritten: %q", byID["confirmed"].Color)
|
||||||
|
}
|
||||||
|
if byID["sent"].Color != rowColorDefaults["sent"] {
|
||||||
|
t.Errorf("an injection attempt survived: %q", byID["sent"].Color)
|
||||||
|
}
|
||||||
|
if byID["to_send"].Color != rowColorDefaults["to_send"] {
|
||||||
|
t.Errorf("an empty colour was kept: %q", byID["to_send"].Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority lives in the data, not in a chain of ifs: a contact confirmed on
|
||||||
|
// LoTW AND by card is confirmed, not "sent, awaiting reply". The panel shows
|
||||||
|
// the rules in the order they apply, so that order must survive a round trip.
|
||||||
|
func TestRowColorsKeepPriorityOrder(t *testing.T) {
|
||||||
|
// Saved in a jumbled order, as a hand-edited settings row could be.
|
||||||
|
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||||
|
{ID: "to_send", Color: "#111111"},
|
||||||
|
{ID: "confirmed", Color: "#222222"},
|
||||||
|
}})
|
||||||
|
if len(got.Rules) != len(rowColorOrder) {
|
||||||
|
t.Fatalf("got %d rules, want every one present", len(got.Rules))
|
||||||
|
}
|
||||||
|
for i, id := range rowColorOrder {
|
||||||
|
if got.Rules[i].ID != id {
|
||||||
|
t.Errorf("rule %d is %q, want %q", i, got.Rules[i].ID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The saved colours survived the reordering: each stayed with its own rule.
|
||||||
|
byID := map[string]string{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r.Color
|
||||||
|
}
|
||||||
|
if byID["to_send"] != "#111111" || byID["confirmed"] != "#222222" {
|
||||||
|
t.Errorf("colours moved between rules: %v", byID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style and strength are normalised, not rejected: they only shape a colour, and
|
||||||
|
// a value that got away is a slider, not a fault worth resetting the operator's
|
||||||
|
// whole choice for.
|
||||||
|
func TestRowColorsNormaliseStyleAndIntensity(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
inStyle string
|
||||||
|
inPct int
|
||||||
|
wantStyle string
|
||||||
|
wantPct int
|
||||||
|
}{
|
||||||
|
{"bar", 12, "bar", 12},
|
||||||
|
{"tint", 30, "tint", 30},
|
||||||
|
{"both", 45, "both", 45},
|
||||||
|
{"", 0, "bar", 12}, // never configured
|
||||||
|
{"wallpaper", 12, "bar", 12}, // not a style we draw
|
||||||
|
{"tint", 900, "tint", 45}, // clamped, not reset
|
||||||
|
{"tint", -5, "tint", 12},
|
||||||
|
} {
|
||||||
|
got := normRowColors(RowColorSettings{Style: tc.inStyle, Intensity: tc.inPct})
|
||||||
|
if got.Style != tc.wantStyle || got.Intensity != tc.wantPct {
|
||||||
|
t.Errorf("(%q,%d) -> (%q,%d), want (%q,%d)",
|
||||||
|
tc.inStyle, tc.inPct, got.Style, got.Intensity, tc.wantStyle, tc.wantPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels are normalised to the canonical order, and anything unknown is
|
||||||
|
// dropped. Empty stays empty, because empty means "every channel" — a rule the
|
||||||
|
// operator has not narrowed must not silently become a rule about nothing.
|
||||||
|
func TestRowColorChannelsNormalise(t *testing.T) {
|
||||||
|
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||||
|
{ID: "confirmed", Color: "#111111", Channels: []string{"LOTW", "pigeon", "qsl", "lotw"}},
|
||||||
|
{ID: "sent", Color: "#222222"},
|
||||||
|
}})
|
||||||
|
byID := map[string][]string{}
|
||||||
|
for _, r := range got.Rules {
|
||||||
|
byID[r.ID] = r.Channels
|
||||||
|
}
|
||||||
|
if len(byID["confirmed"]) != 2 || byID["confirmed"][0] != "qsl" || byID["confirmed"][1] != "lotw" {
|
||||||
|
t.Errorf("confirmed channels = %v, want [qsl lotw] — canonical order, deduped, unknown dropped", byID["confirmed"])
|
||||||
|
}
|
||||||
|
if len(byID["sent"]) != 0 {
|
||||||
|
t.Errorf("sent channels = %v, want empty (meaning every channel)", byID["sent"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order IS the priority, and "to send" leads. A contact confirmed on LoTW
|
||||||
|
// that still owes a paper card must read as owing the card — put after
|
||||||
|
// "confirmed" it would go green and never be printed.
|
||||||
|
func TestToSendOutranksConfirmed(t *testing.T) {
|
||||||
|
got := normRowColors(RowColorSettings{})
|
||||||
|
if got.Rules[0].ID != "to_send" {
|
||||||
|
t.Errorf("first rule is %q, want to_send", got.Rules[0].ID)
|
||||||
|
}
|
||||||
|
if got.Rules[len(got.Rules)-1].ID != "worked" {
|
||||||
|
t.Errorf("last rule is %q, want worked as the catch-all", got.Rules[len(got.Rules)-1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
@@ -26,6 +28,12 @@ type AutostartProgram struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Args string `json:"args"`
|
Args string `json:"args"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
// CloseOnExit asks for this program to be closed when OpsLog closes.
|
||||||
|
//
|
||||||
|
// Per program and not one switch for the list, because the answer differs
|
||||||
|
// inside one station: an operator wants WSJT-X gone with the logger and the
|
||||||
|
// rotator controller left running.
|
||||||
|
CloseOnExit bool `json:"close_on_exit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutostartLaunchResult reports what happened for one program when launching.
|
// AutostartLaunchResult reports what happened for one program when launching.
|
||||||
@@ -106,6 +114,52 @@ func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
|
|||||||
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
|
return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// launched remembers the process id of every program OPSLOG started, keyed by
|
||||||
|
// program id.
|
||||||
|
//
|
||||||
|
// Only what we started is ever closed. A copy of WSJT-X the operator opened
|
||||||
|
// themselves — before OpsLog, for something else entirely — is theirs, and
|
||||||
|
// closing it because a logger happened to quit would be taking a decision that
|
||||||
|
// was never asked for. That is also why "already running" stores nothing.
|
||||||
|
var launched = struct {
|
||||||
|
sync.Mutex
|
||||||
|
pid map[string]int
|
||||||
|
}{pid: map[string]int{}}
|
||||||
|
|
||||||
|
// CloseAutostartPrograms closes the programs marked "close with OpsLog" — the
|
||||||
|
// ones OpsLog itself launched this session.
|
||||||
|
//
|
||||||
|
// A polite close, never a kill: taskkill without /F posts WM_CLOSE, so WSJT-X
|
||||||
|
// writes its settings and its log the way it would if the operator had clicked
|
||||||
|
// the cross. Forcing it would lose exactly the state an operator cares about,
|
||||||
|
// and a program that ignores a close request is entitled to.
|
||||||
|
func (a *App) CloseAutostartPrograms() {
|
||||||
|
progs, _ := a.GetAutostartPrograms()
|
||||||
|
for _, p := range progs {
|
||||||
|
if !p.CloseOnExit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
launched.Lock()
|
||||||
|
pid, ok := launched.pid[p.ID]
|
||||||
|
delete(launched.pid, p.ID)
|
||||||
|
launched.Unlock()
|
||||||
|
if !ok || pid <= 0 {
|
||||||
|
continue // not started by us this session — not ours to close
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(p.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = filepath.Base(p.Path)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid))
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
applog.Printf("autostart: could not close %s (pid %d): %v — %s", name, pid, err, strings.TrimSpace(string(out)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applog.Printf("autostart: asked %s (pid %d) to close", name, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// launchProgram starts one program unless its executable is already running.
|
// launchProgram starts one program unless its executable is already running.
|
||||||
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
|
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
|
||||||
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
|
res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
|
||||||
@@ -131,6 +185,13 @@ func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchR
|
|||||||
res.Status, res.Message = "error", err.Error()
|
res.Status, res.Message = "error", err.Error()
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
// Remembered so it can be closed again on exit, if asked. Recorded BEFORE
|
||||||
|
// the wait goroutine, which releases the handle.
|
||||||
|
if cmd.Process != nil {
|
||||||
|
launched.Lock()
|
||||||
|
launched.pid[p.ID] = cmd.Process.Pid
|
||||||
|
launched.Unlock()
|
||||||
|
}
|
||||||
// Don't wait on the child — it runs independently of OpsLog. Release the
|
// Don't wait on the child — it runs independently of OpsLog. Release the
|
||||||
// handle so we don't accumulate zombies.
|
// handle so we don't accumulate zombies.
|
||||||
go func() { _ = cmd.Wait() }()
|
go func() { _ = cmd.Wait() }()
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// "Have I worked this reference before?" — the NEW badge on a QSO's award
|
||||||
|
// references.
|
||||||
|
//
|
||||||
|
// # Where the answer comes from, and where it must NOT come from
|
||||||
|
//
|
||||||
|
// The obvious source is the materialised award_refs column: it is already on
|
||||||
|
// every row and one query reads it. It is also the wrong answer twice over.
|
||||||
|
// That column holds DISPLAY LABELS, not references — depending on the award's
|
||||||
|
// RefDisplay it can read "Krasnogvardeysky" or "ST-25 — Krasnogvardeysky", and
|
||||||
|
// DXCC stores a country name — and it is a derived cache that lags: a QSO whose
|
||||||
|
// references were back-filled (see BackfillRDA) counts on the Awards grid long
|
||||||
|
// before anything recomputes its column. The first build of this badge used it
|
||||||
|
// and marked a district worked on five bands as NEW.
|
||||||
|
//
|
||||||
|
// So the answer comes from the same place the Awards grid's answer comes from:
|
||||||
|
// award.MatchQSO over the logbook. What is engineered here is the COST of that,
|
||||||
|
// because the question is asked while the operator is logging:
|
||||||
|
//
|
||||||
|
// - the set is per award CODE, and built only for codes actually asked about;
|
||||||
|
// - it reuses the award snapshot when one is already in memory, which it
|
||||||
|
// usually is (the Awards tab, the cluster status index);
|
||||||
|
// - otherwise it STREAMS the logbook rather than materialising a second copy
|
||||||
|
// of it — the references of one award are a few thousand short strings;
|
||||||
|
// - and it is built in the BACKGROUND. A caller that asks before the set is
|
||||||
|
// ready is told so and gets no badge, never a wait and never a wrong badge.
|
||||||
|
//
|
||||||
|
// A freshly logged QSO's references are folded into the sets, so a normal
|
||||||
|
// evening of logging never rebuilds anything.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// workedRefIndex is the per-code set of references already in the log, plus the
|
||||||
|
// logbook revision it describes.
|
||||||
|
type workedRefIndex struct {
|
||||||
|
rev string
|
||||||
|
byCode map[string]map[string]struct{}
|
||||||
|
building map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwardRefNewResult answers one badge request.
|
||||||
|
type AwardRefNewResult struct {
|
||||||
|
// New maps "CODE@REF" to "never worked". An entry whose award set is still
|
||||||
|
// building is ABSENT rather than false: "not worked" and "not known yet"
|
||||||
|
// must not look the same to the badge.
|
||||||
|
New map[string]bool `json:"new"`
|
||||||
|
// Pending is true when at least one award set is still being built, and the
|
||||||
|
// caller should ask again shortly.
|
||||||
|
Pending bool `json:"pending"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwardRefsNew reports which of the given "CODE@REF" entries have never been
|
||||||
|
// worked. Returns immediately, always.
|
||||||
|
func (a *App) AwardRefsNew(entries []string) AwardRefNewResult {
|
||||||
|
res := AwardRefNewResult{New: map[string]bool{}}
|
||||||
|
if a.qso == nil || len(entries) == 0 {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
rev, err := a.qso.Revision(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res // logbook unreachable: no badge is the honest answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group the request by award code, so one code is looked up once however
|
||||||
|
// many of its references are on the QSO.
|
||||||
|
want := map[string][]string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
code, _, ok := splitRefEntry(e)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want[code] = append(want[code], e)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
if a.workedRef.rev != rev || a.workedRef.byCode == nil {
|
||||||
|
// The logbook moved on: every set describes a log that no longer exists.
|
||||||
|
a.workedRef = workedRefIndex{rev: rev, byCode: map[string]map[string]struct{}{}, building: map[string]bool{}}
|
||||||
|
}
|
||||||
|
var build []string
|
||||||
|
for code := range want {
|
||||||
|
set, ready := a.workedRef.byCode[code]
|
||||||
|
if !ready {
|
||||||
|
if !a.workedRef.building[code] {
|
||||||
|
a.workedRef.building[code] = true
|
||||||
|
build = append(build, code)
|
||||||
|
}
|
||||||
|
res.Pending = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range want[code] {
|
||||||
|
_, ref, _ := splitRefEntry(e)
|
||||||
|
_, worked := set[ref]
|
||||||
|
res.New[e] = !worked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.workedRefMu.Unlock()
|
||||||
|
|
||||||
|
for _, code := range build {
|
||||||
|
go a.buildWorkedRefs(code, rev)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitRefEntry splits "RDA@ST-25" into its uppercased code and reference.
|
||||||
|
func splitRefEntry(e string) (code, ref string, ok bool) {
|
||||||
|
at := strings.Index(e, "@")
|
||||||
|
if at <= 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(e[:at]))
|
||||||
|
ref = strings.ToUpper(strings.TrimSpace(e[at+1:]))
|
||||||
|
return code, ref, code != "" && ref != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWorkedRefs collects every reference of one award already in the log.
|
||||||
|
func (a *App) buildWorkedRefs(code string, rev string) {
|
||||||
|
set := map[string]struct{}{}
|
||||||
|
defer func() {
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
// Publish only against the revision we were asked for: a QSO logged while
|
||||||
|
// this ran has already reset the index, and a set built from the older log
|
||||||
|
// would claim to describe the newer one.
|
||||||
|
if a.workedRef.rev == rev && a.workedRef.byCode != nil {
|
||||||
|
a.workedRef.byCode[code] = set
|
||||||
|
}
|
||||||
|
delete(a.workedRef.building, code)
|
||||||
|
a.workedRefMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var def *award.Def
|
||||||
|
for _, d := range a.awardDefs() {
|
||||||
|
if strings.EqualFold(d.Code, code) {
|
||||||
|
dd := d
|
||||||
|
def = &dd
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def == nil {
|
||||||
|
return // unknown award: an empty set, so nothing is ever badged NEW
|
||||||
|
}
|
||||||
|
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
|
||||||
|
collect := func(q *qso.QSO) {
|
||||||
|
for _, r := range award.MatchQSO(*def, metas, q) {
|
||||||
|
set[strings.ToUpper(strings.TrimSpace(r))] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The snapshot is already enriched and already in memory whenever the Awards
|
||||||
|
// tab has been open — by far the common case, and free.
|
||||||
|
if snap := a.awardSnapshotIfLoaded(); snap != nil {
|
||||||
|
for i := range snap {
|
||||||
|
collect(&snap[i])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Otherwise stream. One pass, nothing retained but the reference strings.
|
||||||
|
_ = a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
|
||||||
|
a.enrichQSOForAwards(&q)
|
||||||
|
collect(&q)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// awardSnapshotIfLoaded returns the award snapshot ONLY if one is already built
|
||||||
|
// and current. Never builds one: the caller is a background convenience, and
|
||||||
|
// reading the whole logbook for it is the cost this file exists to avoid.
|
||||||
|
func (a *App) awardSnapshotIfLoaded() []qso.QSO {
|
||||||
|
rev, err := a.qso.Revision(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a.awardSnapMu.Lock()
|
||||||
|
defer a.awardSnapMu.Unlock()
|
||||||
|
if a.awardSnap != nil && a.awardSnapRev == rev {
|
||||||
|
return a.awardSnap
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteWorkedQSO folds a just-logged contact's references into the built sets, so
|
||||||
|
// logging does not throw away work already done.
|
||||||
|
func (a *App) noteWorkedQSO(q qso.QSO) {
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
codes := make(map[string]bool, len(a.workedRef.byCode))
|
||||||
|
for c := range a.workedRef.byCode {
|
||||||
|
codes[c] = true
|
||||||
|
}
|
||||||
|
a.workedRefMu.Unlock()
|
||||||
|
if len(codes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rev, err := a.qso.Revision(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.enrichQSOForAwards(&q)
|
||||||
|
found := map[string][]string{}
|
||||||
|
for _, d := range a.awardDefs() {
|
||||||
|
code := strings.ToUpper(d.Code)
|
||||||
|
if !codes[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metas := a.awardRefMetas([]award.Def{d})[code]
|
||||||
|
for _, r := range award.MatchQSO(d, metas, &q) {
|
||||||
|
found[code] = append(found[code], strings.ToUpper(strings.TrimSpace(r)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
defer a.workedRefMu.Unlock()
|
||||||
|
// Carry the sets forward to the new revision rather than dropping them: they
|
||||||
|
// were correct one contact ago, and that contact is what is being added.
|
||||||
|
a.workedRef.rev = rev
|
||||||
|
for code, refs := range found {
|
||||||
|
if set := a.workedRef.byCode[code]; set != nil {
|
||||||
|
for _, r := range refs {
|
||||||
|
set[r] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateWorkedRefs drops every set. For the changes a revision cannot see —
|
||||||
|
// award definitions edited, reference lists updated, QSLs confirmed.
|
||||||
|
func (a *App) invalidateWorkedRefs() {
|
||||||
|
a.workedRefMu.Lock()
|
||||||
|
a.workedRef = workedRefIndex{}
|
||||||
|
a.workedRefMu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSplitRefEntry(t *testing.T) {
|
||||||
|
code, ref, ok := splitRefEntry("rda@st-25")
|
||||||
|
if !ok || code != "RDA" || ref != "ST-25" {
|
||||||
|
t.Fatalf("got %q %q %v", code, ref, ok)
|
||||||
|
}
|
||||||
|
// The picker writes what the operator typed, spacing included.
|
||||||
|
if _, ref, _ = splitRefEntry("POTA@ fr-1234 "); ref != "FR-1234" {
|
||||||
|
t.Fatalf("not trimmed: %q", ref)
|
||||||
|
}
|
||||||
|
// A reference can contain the separator characters of a label; only the
|
||||||
|
// FIRST @ splits, so "IOTA@EU-064" survives and a stray one does not create
|
||||||
|
// an award out of nothing.
|
||||||
|
for _, bad := range []string{"", "RDA", "@ST-25", "RDA@", "RDA@ "} {
|
||||||
|
if _, _, ok := splitRefEntry(bad); ok {
|
||||||
|
t.Fatalf("%q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relabelOnly decides whether a save can take the cheap path. A wrong "yes"
|
||||||
|
// leaves stale references in the log, so every case that is not purely a
|
||||||
|
// display change must fall through to the full recompute.
|
||||||
|
func TestRelabelOnly(t *testing.T) {
|
||||||
|
base := []award.Def{
|
||||||
|
{Code: "DDFM", Field: "note", RefDisplay: "ref", Valid: true},
|
||||||
|
{Code: "IOTA", Field: "iota", RefDisplay: "", Valid: true},
|
||||||
|
}
|
||||||
|
cp := func() []award.Def { out := make([]award.Def, len(base)); copy(out, base); return out }
|
||||||
|
|
||||||
|
// The case this exists for: one award's column switches to the description.
|
||||||
|
next := cp()
|
||||||
|
next[0].RefDisplay = "name"
|
||||||
|
codes, ok := relabelOnly(base, next)
|
||||||
|
if !ok || len(codes) != 1 || codes[0] != "DDFM" {
|
||||||
|
t.Errorf("display-only change = (%v, %v), want ([DDFM], true)", codes, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two at once is still a relabel.
|
||||||
|
next = cp()
|
||||||
|
next[0].RefDisplay = "both"
|
||||||
|
next[1].RefDisplay = "name"
|
||||||
|
if codes, ok := relabelOnly(base, next); !ok || len(codes) != 2 {
|
||||||
|
t.Errorf("two display changes = (%v, %v), want two codes", codes, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing changed: no relabel to do, and no full recompute claimed either.
|
||||||
|
if codes, ok := relabelOnly(base, cp()); ok || codes != nil {
|
||||||
|
t.Errorf("identical definitions must not report a relabel, got (%v, %v)", codes, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything that can change WHICH QSOs match must fail the test.
|
||||||
|
for name, mutate := range map[string]func([]award.Def){
|
||||||
|
"field": func(d []award.Def) { d[0].Field = "comment" },
|
||||||
|
"pattern": func(d []award.Def) { d[0].Pattern = `\d{2}` },
|
||||||
|
"exact": func(d []award.Def) { d[0].ExactMatch = true },
|
||||||
|
"validity": func(d []award.Def) { d[0].ValidFrom = "2020-01-01" },
|
||||||
|
"dxcc": func(d []award.Def) { d[0].DXCCFilter = []int{227} },
|
||||||
|
"disabled": func(d []award.Def) { d[0].Valid = false },
|
||||||
|
"or rule": func(d []award.Def) { d[0].OrRules = []award.OrRule{{Field: "qth"}} },
|
||||||
|
"with disp": func(d []award.Def) { d[0].RefDisplay = "name"; d[0].Field = "comment" },
|
||||||
|
} {
|
||||||
|
n := cp()
|
||||||
|
mutate(n)
|
||||||
|
if _, ok := relabelOnly(base, n); ok {
|
||||||
|
t.Errorf("%s: took the relabel path — stale references would stay in the log", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An award added or removed changes the set, never a relabel.
|
||||||
|
if _, ok := relabelOnly(base, append(cp(), award.Def{Code: "WWFF"})); ok {
|
||||||
|
t.Errorf("an added award must force a full recompute")
|
||||||
|
}
|
||||||
|
if _, ok := relabelOnly(base, cp()[:1]); ok {
|
||||||
|
t.Errorf("a removed award must force a full recompute")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The award snapshot must be built ONCE however many callers ask at once.
|
||||||
|
//
|
||||||
|
// The cache lock is released before the logbook is read, so every caller that
|
||||||
|
// arrives during a build used to miss the cache and start its own. Opening the
|
||||||
|
// Awards panel does exactly that: a field log showed three pulls of the same
|
||||||
|
// 123 615 QSOs inside ten seconds, and three copies alive together took the Go
|
||||||
|
// heap from 725 MB to 2.5 GB.
|
||||||
|
//
|
||||||
|
// This models the same shape — a cheap cache check, a slow build, a shared
|
||||||
|
// result — against the pattern awardSnapshot now uses, so the invariant is
|
||||||
|
// pinned without needing a logbook.
|
||||||
|
func TestSnapshotBuildsOncePerRevision(t *testing.T) {
|
||||||
|
var (
|
||||||
|
cacheMu sync.Mutex
|
||||||
|
buildMu sync.Mutex
|
||||||
|
cached []int
|
||||||
|
rev = "r1"
|
||||||
|
gotRev string
|
||||||
|
builds int
|
||||||
|
)
|
||||||
|
|
||||||
|
get := func() []int {
|
||||||
|
cacheMu.Lock()
|
||||||
|
if cached != nil && gotRev == rev {
|
||||||
|
defer cacheMu.Unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheMu.Unlock()
|
||||||
|
|
||||||
|
buildMu.Lock()
|
||||||
|
defer buildMu.Unlock()
|
||||||
|
// Re-check: whoever held the build lock has just finished.
|
||||||
|
cacheMu.Lock()
|
||||||
|
if cached != nil && gotRev == rev {
|
||||||
|
defer cacheMu.Unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheMu.Unlock()
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond) // the logbook read
|
||||||
|
out := []int{1, 2, 3}
|
||||||
|
|
||||||
|
cacheMu.Lock()
|
||||||
|
builds++
|
||||||
|
cached, gotRev = out, rev
|
||||||
|
cacheMu.Unlock()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([][]int, 8)
|
||||||
|
for i := range results {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) { defer wg.Done(); results[i] = get() }(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if builds != 1 {
|
||||||
|
t.Errorf("%d builds for one revision — each concurrent caller pulled the whole logbook again", builds)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if len(r) != 3 {
|
||||||
|
t.Errorf("caller %d got %v", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new revision must rebuild: the guard is against duplicate work, not
|
||||||
|
// against a logbook that changed.
|
||||||
|
cacheMu.Lock()
|
||||||
|
rev = "r2"
|
||||||
|
cacheMu.Unlock()
|
||||||
|
get()
|
||||||
|
if builds != 2 {
|
||||||
|
t.Errorf("builds = %d after the revision moved, want 2 — a changed logbook must be re-read", builds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A band change now has two sources: the rig, and the entry strip on a station
|
||||||
|
// whose rig OpsLog does not control. They share one memory of the last band, so
|
||||||
|
// a station that has both commands its antenna switch once per QSY rather than
|
||||||
|
// twice — the entry selector pushes a QSY, the rig reports the same band back,
|
||||||
|
// and only the first of the two is a change.
|
||||||
|
func TestBandChangeIsNotedOncePerBand(t *testing.T) {
|
||||||
|
lastTriggerBandMu.Lock()
|
||||||
|
lastTriggerBand = ""
|
||||||
|
lastTriggerBandMu.Unlock()
|
||||||
|
|
||||||
|
if b, changed := noteBandChange("20m"); !changed || b != "20m" {
|
||||||
|
t.Fatalf("first 20m = (%q,%v), want (\"20m\",true)", b, changed)
|
||||||
|
}
|
||||||
|
// The same band from the other source — the rig echoing the QSY back.
|
||||||
|
if _, changed := noteBandChange("20M"); changed {
|
||||||
|
t.Error("the rig echoing the band back counted as a second change — the switch would be commanded twice")
|
||||||
|
}
|
||||||
|
if _, changed := noteBandChange(" 20m "); changed {
|
||||||
|
t.Error("whitespace made the same band look new")
|
||||||
|
}
|
||||||
|
if b, changed := noteBandChange("40m"); !changed || b != "40m" {
|
||||||
|
t.Errorf("40m = (%q,%v), want (\"40m\",true)", b, changed)
|
||||||
|
}
|
||||||
|
// An unknown band must not be recorded, or the next real one would look
|
||||||
|
// unchanged against it.
|
||||||
|
if _, changed := noteBandChange(""); changed {
|
||||||
|
t.Error("an empty band was treated as a change")
|
||||||
|
}
|
||||||
|
if _, changed := noteBandChange("40m"); changed {
|
||||||
|
t.Error("the empty band overwrote the last one")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/pskr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The receiver radius is what makes an opening report evidence about the
|
||||||
|
// OPERATOR's path rather than someone else's. Both ends of the range destroy
|
||||||
|
// that: too small and no receiver is ever near enough, too large and the
|
||||||
|
// reports are about a different part of the world. A stored value that would do
|
||||||
|
// either has to fall back rather than be honoured.
|
||||||
|
func TestBandOpenNearKmClamps(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unset falls back", "", pskr.DefaultNearKm},
|
||||||
|
{"not a number falls back", "soon", pskr.DefaultNearKm},
|
||||||
|
{"zero falls back", "0", pskr.DefaultNearKm},
|
||||||
|
{"negative falls back", "-100", pskr.DefaultNearKm},
|
||||||
|
{"absurdly tight falls back", "5", pskr.DefaultNearKm},
|
||||||
|
{"absurdly wide falls back", "5000", pskr.DefaultNearKm},
|
||||||
|
{"a real choice is kept", "100", 100},
|
||||||
|
{"the lower bound is kept", "25", 25},
|
||||||
|
{"the upper bound is kept", "1000", 1000},
|
||||||
|
{"surrounding space is ignored", " 150 ", 150},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := bandOpenNearKm(tc.raw); got != tc.want {
|
||||||
|
t.Errorf("bandOpenNearKm(%q) = %d, want %d", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The data sources band-opening detection depends on, and the settings that
|
||||||
|
// switch them on.
|
||||||
|
//
|
||||||
|
// The detection shipped reading whatever the operator's cluster nodes happened
|
||||||
|
// to carry. That was a design mistake of the worst kind: the feature depended
|
||||||
|
// on RBN feeds and a PSK Reporter subscription that nobody could know were
|
||||||
|
// needed, so it looked broken rather than unconfigured. Nexus showed a 6 m
|
||||||
|
// opening with 869 stations while OpsLog, on the same PC, showed nothing.
|
||||||
|
//
|
||||||
|
// So enabling the watch ARRANGES ITS OWN SOURCES: it adds the two RBN nodes if
|
||||||
|
// they are missing and brings the PSK Reporter feed up. Turning it off leaves
|
||||||
|
// the nodes alone — they may have been wanted for their own sake, and silently
|
||||||
|
// removing a cluster node an operator is using would be worse than leaving one
|
||||||
|
// they no longer need.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/bandopen"
|
||||||
|
"hamlog/internal/cluster"
|
||||||
|
"hamlog/internal/geo"
|
||||||
|
"hamlog/internal/gridcache"
|
||||||
|
"hamlog/internal/pskr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyBandOpenEnabled = "bandopen.enabled"
|
||||||
|
keyBandOpenBands = "bandopen.bands" // comma-separated; empty = the default set
|
||||||
|
keyBandOpenNearKm = "bandopen.nearkm" // receiver radius; empty = pskr.DefaultNearKm
|
||||||
|
)
|
||||||
|
|
||||||
|
// rbnNodes are the two Reverse Beacon Network endpoints the watch wants: CW and
|
||||||
|
// digital are separate ports and carry different skimmers.
|
||||||
|
var rbnNodes = []cluster.ServerConfig{
|
||||||
|
{Name: "RBN CW", Host: "telnet.reversebeacon.net", Port: 7000, Enabled: true},
|
||||||
|
{Name: "RBN FTx", Host: "telnet.reversebeacon.net", Port: 7001, Enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// BandOpenSettings is the panel's shape.
|
||||||
|
type BandOpenSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
// Available is every band that can be watched, so the UI does not carry its
|
||||||
|
// own copy of a list that belongs to the detector.
|
||||||
|
Available []string `json:"available"`
|
||||||
|
// NearKm is how close a RECEIVER has to be for its report to count as
|
||||||
|
// evidence about the operator's own path. An opening on these bands reaches
|
||||||
|
// an AREA, and the right radius is a judgement about how big that area is —
|
||||||
|
// a 2 m tropo duct is not a 10 m Es footprint — so it is the operator's to
|
||||||
|
// make rather than a constant.
|
||||||
|
//
|
||||||
|
// Tightening it costs nothing in traffic: the broker subscription is one
|
||||||
|
// ring of squares whatever this says, and this only decides what survives
|
||||||
|
// once the messages are here. It costs SENSITIVITY — too small a radius in a
|
||||||
|
// thinly-populated region leaves no receivers at all, and a watch with
|
||||||
|
// nothing to look at reports nothing while appearing to work.
|
||||||
|
NearKm int `json:"near_km"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetBandOpenSettings() BandOpenSettings {
|
||||||
|
s := BandOpenSettings{
|
||||||
|
Enabled: a.settingOr(keyBandOpenEnabled, "") == "1",
|
||||||
|
Bands: splitCSV(a.settingOr(keyBandOpenBands, "")),
|
||||||
|
Available: pskr.Bands,
|
||||||
|
NearKm: bandOpenNearKm(a.settingOr(keyBandOpenNearKm, "")),
|
||||||
|
}
|
||||||
|
// Keep only bands that are still offered. A saved selection outlives the code
|
||||||
|
// that made it: 12 m was dropped from the watched set, but every operator who
|
||||||
|
// had already enabled the watch kept subscribing to it — paying for a firehose
|
||||||
|
// whose messages the detector then threw away.
|
||||||
|
s.Bands = keepKnownBands(s.Bands)
|
||||||
|
if len(s.Bands) == 0 {
|
||||||
|
s.Bands = append(s.Bands, pskr.Bands...)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func keepKnownBands(want []string) []string {
|
||||||
|
ok := make(map[string]bool, len(pskr.Bands))
|
||||||
|
for _, b := range pskr.Bands {
|
||||||
|
ok[b] = true
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(want))
|
||||||
|
for _, b := range want {
|
||||||
|
if ok[strings.ToLower(strings.TrimSpace(b))] {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandOpenNearKm reads the stored receiver radius, falling back to the default
|
||||||
|
// for anything unset or out of range. The bounds are the two ways to make the
|
||||||
|
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
|
||||||
|
// and past 1000 km the reports stop being about the operator's own path — which
|
||||||
|
// is the entire premise of measuring from a receiver rather than a transmitter.
|
||||||
|
func bandOpenNearKm(raw string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil || n < 25 || n > 1000 {
|
||||||
|
return pskr.DefaultNearKm
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveBandOpenSettings(s BandOpenSettings) error {
|
||||||
|
a.setSetting(keyBandOpenEnabled, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
||||||
|
a.setSetting(keyBandOpenBands, strings.Join(s.Bands, ","))
|
||||||
|
a.setSetting(keyBandOpenNearKm, strconv.Itoa(bandOpenNearKm(strconv.Itoa(s.NearKm))))
|
||||||
|
if s.Enabled {
|
||||||
|
a.ensureRBNNodes()
|
||||||
|
}
|
||||||
|
a.startBandOpenFeed()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureRBNNodes adds the RBN endpoints when they are absent.
|
||||||
|
//
|
||||||
|
// Matched on host AND port rather than on name: an operator who renamed theirs
|
||||||
|
// "Skimmers CW" has the node, and adding a second one pointed at the same
|
||||||
|
// server would give them every spot twice — which the detector would read as
|
||||||
|
// twice as many stations, i.e. an opening that is not there.
|
||||||
|
func (a *App) ensureRBNNodes() {
|
||||||
|
have, err := a.ListClusterServers()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("bandopen: cannot read the cluster nodes (%v) — not adding RBN", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, want := range rbnNodes {
|
||||||
|
found := false
|
||||||
|
for _, h := range have {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(h.Host), want.Host) && h.Port == want.Port {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := a.SaveClusterServer(want); err != nil {
|
||||||
|
applog.Printf("bandopen: could not add %s: %v", want.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applog.Printf("bandopen: added cluster node %s (%s:%d) — the watch needs it",
|
||||||
|
want.Name, want.Host, want.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startBandOpenFeed brings the PSK Reporter subscription up or down to match
|
||||||
|
// the setting. Called at startup and whenever the setting is saved.
|
||||||
|
func (a *App) startBandOpenFeed() {
|
||||||
|
if a.pskr != nil {
|
||||||
|
a.pskr.Stop()
|
||||||
|
a.pskr = nil
|
||||||
|
}
|
||||||
|
s := a.GetBandOpenSettings()
|
||||||
|
a.bandOpen.on.Store(s.Enabled)
|
||||||
|
a.setBandOpenBands(s.Bands)
|
||||||
|
if !s.Enabled {
|
||||||
|
// Put out whatever is currently lit. Leaving the badges up would keep
|
||||||
|
// announcing an opening from a watch that is now off, and they only fade
|
||||||
|
// on a timer fed by spots this path no longer looks at — so they would
|
||||||
|
// hang there until the app restarted.
|
||||||
|
a.clearBandOpenings()
|
||||||
|
}
|
||||||
|
chaseGrids := a.gridStore != nil
|
||||||
|
chaseNew := a.chaseNewEnabled()
|
||||||
|
// Three consumers, one feed. Any one of them is reason enough to bring it up,
|
||||||
|
// and turning one off must not cut the others loose.
|
||||||
|
if !s.Enabled && !chaseGrids && !chaseNew {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Every spot is measured from the operator's position. Without one there is
|
||||||
|
// nothing to measure, and a detector fed unmeasurable spots reports nothing
|
||||||
|
// while looking like it is working.
|
||||||
|
if !a.opSet {
|
||||||
|
applog.Printf("pskr: no station grid set — the feed needs one to measure a path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid chasing and new-chasing want every band; the opening watch wants its
|
||||||
|
// four. "+" is the MQTT single-level wildcard, so one subscription per square
|
||||||
|
// covers the lot.
|
||||||
|
bands := s.Bands
|
||||||
|
if chaseGrids || chaseNew {
|
||||||
|
bands = []string{"+"}
|
||||||
|
}
|
||||||
|
// Filter at the BROKER on the receiver's square rather than receiving the
|
||||||
|
// world and discarding it here. Measured on the live feed: the four opening
|
||||||
|
// bands unfiltered are 83 messages a second, of which roughly one in a
|
||||||
|
// hundred survived the NearKm test below. One ring of squares — about the
|
||||||
|
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
||||||
|
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
||||||
|
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
||||||
|
|
||||||
|
var onGrid func(call, grid string)
|
||||||
|
if chaseGrids {
|
||||||
|
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
|
||||||
|
}
|
||||||
|
var onSpot func(pskr.Spot)
|
||||||
|
switch {
|
||||||
|
case s.Enabled && chaseNew:
|
||||||
|
onSpot = func(sp pskr.Spot) { a.feedBandOpen(sp); a.feedChaseNew(sp) }
|
||||||
|
case s.Enabled:
|
||||||
|
onSpot = a.feedBandOpen
|
||||||
|
case chaseNew:
|
||||||
|
onSpot = a.feedChaseNew
|
||||||
|
}
|
||||||
|
|
||||||
|
a.pskr = pskr.New(pskr.Config{
|
||||||
|
Bands: bands,
|
||||||
|
RxGrids: rxGrids,
|
||||||
|
NearKm: s.NearKm,
|
||||||
|
OpLat: a.opLat, OpLon: a.opLon,
|
||||||
|
Geo: func(grid string) (int, int, bool) {
|
||||||
|
lat, lon, ok := gridToLatLon(grid)
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
// The same arithmetic the cluster path uses, so one spot cannot be
|
||||||
|
// 2000 km away down one road and 2100 km down the other.
|
||||||
|
d := int(haversineKm(a.opLat, a.opLon, lat, lon) + 0.5)
|
||||||
|
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
|
||||||
|
return d, b, true
|
||||||
|
},
|
||||||
|
OnSpot: onSpot,
|
||||||
|
OnGrid: onGrid,
|
||||||
|
Logf: applog.Printf,
|
||||||
|
})
|
||||||
|
if err := a.pskr.Start(); err != nil {
|
||||||
|
applog.Printf("pskr: feed did not start: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v, chase-new=%v)",
|
||||||
|
bands, len(rxGrids), s.Enabled, chaseGrids, chaseNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedBandOpen hands one PSK Reporter decode to the detector.
|
||||||
|
//
|
||||||
|
// Called from the MQTT goroutine at up to thousands a minute when 6 m is open,
|
||||||
|
// so it does the least possible: the detector's own window and de-duplication
|
||||||
|
// by callsign are what turn that flood into one announcement.
|
||||||
|
func (a *App) feedBandOpen(s pskr.Spot) {
|
||||||
|
if !a.bandOpenWanted(s.Band) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
if a.bandOpen.det == nil {
|
||||||
|
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
||||||
|
}
|
||||||
|
op := a.bandOpen.det.Add(bandopen.Spot{
|
||||||
|
Call: s.Call, Band: s.Band, DistKm: s.DistKm, Bearing: s.Bearing, At: s.At,
|
||||||
|
}, a.opLat)
|
||||||
|
if op != nil {
|
||||||
|
a.rememberOpening(*op)
|
||||||
|
}
|
||||||
|
if s.DistKm >= bandopen.DefaultConfig().MinKm {
|
||||||
|
a.markBandAlive(s.Band, s.At)
|
||||||
|
}
|
||||||
|
a.bandOpen.mu.Unlock()
|
||||||
|
if op != nil {
|
||||||
|
a.announceOpening(*op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSKReporterStatus is what the settings panel polls.
|
||||||
|
func (a *App) GetPSKReporterStatus() pskr.Status {
|
||||||
|
if a.pskr == nil {
|
||||||
|
return pskr.Status{Bands: pskr.Bands}
|
||||||
|
}
|
||||||
|
return a.pskr.Status()
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingOr reads one key, falling back when the store is not up yet or the
|
||||||
|
// value is blank. The settings store is a plain string key/value and every
|
||||||
|
// caller does this by hand; two of them here earn the helper.
|
||||||
|
func (a *App) settingOr(key, def string) string {
|
||||||
|
if a.settings == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, key)
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Band-opening announcements — the app-side glue for internal/bandopen.
|
||||||
|
//
|
||||||
|
// The detector needs nothing OpsLog does not already compute: the cluster event
|
||||||
|
// worker enriches every spot with the great-circle distance and bearing from
|
||||||
|
// the operator's grid before this is called. So watching for sporadic E costs
|
||||||
|
// one function call per spot and no new data source.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/bandopen"
|
||||||
|
"hamlog/internal/cluster"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bandOpenState struct {
|
||||||
|
// on mirrors the "Watch for band openings" setting.
|
||||||
|
//
|
||||||
|
// Cached rather than read per spot: this is the cluster hot path, where a
|
||||||
|
// settings query per spot is exactly what the rest of this file avoids.
|
||||||
|
// startBandOpenFeed owns it — it runs at startup and again on every save, so
|
||||||
|
// the switch takes effect without a restart.
|
||||||
|
on atomic.Bool
|
||||||
|
// bands is the operator's SELECTED set, held as map[string]bool.
|
||||||
|
//
|
||||||
|
// bandopen.Watched only says which bands the detector is capable of; it
|
||||||
|
// knows nothing about the chips in Settings. Nothing checked the selection
|
||||||
|
// on either feed path, so unticking 10 m and 2 m changed the subscription and
|
||||||
|
// left the cluster path announcing them anyway.
|
||||||
|
bands atomic.Value
|
||||||
|
mu sync.Mutex
|
||||||
|
det *bandopen.Detector
|
||||||
|
last []bandopen.Opening // most recent first, for the UI
|
||||||
|
// live holds the announced openings that are still going, keyed by band, and
|
||||||
|
// aliveUntil says when each stops counting as current.
|
||||||
|
//
|
||||||
|
// The detector announces an opening ONCE and then goes quiet for 45 minutes,
|
||||||
|
// which is right for a message but useless for a badge that has to stay lit
|
||||||
|
// while the band is open and go out when it closes. Nothing tells us an
|
||||||
|
// opening ended, so it is inferred: every qualifying spot on that band pushes
|
||||||
|
// the deadline out, and when they stop arriving the badge fades by itself.
|
||||||
|
live map[string]bandopen.Opening
|
||||||
|
aliveUntil map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// openingIdle is how long a band may go without a qualifying spot before its
|
||||||
|
// badge goes out. Longer than the detector's own 12-minute window, so a quiet
|
||||||
|
// couple of minutes mid-opening does not blink the badge off and on again.
|
||||||
|
const openingIdle = 15 * time.Minute
|
||||||
|
|
||||||
|
const maxRememberedOpenings = 20
|
||||||
|
|
||||||
|
// detectBandOpening feeds one spot to the detector and announces a hit.
|
||||||
|
func (a *App) detectBandOpening(s cluster.Spot) {
|
||||||
|
// The watch has to be switched on.
|
||||||
|
//
|
||||||
|
// It was not checked here at all: the setting only ever governed the extra
|
||||||
|
// DATA SOURCES (the RBN nodes and the PSK Reporter feed), while the detector
|
||||||
|
// itself ran on every ordinary cluster spot. So an operator who had never
|
||||||
|
// enabled the watch still got opening banners, from a feature they had
|
||||||
|
// deliberately left off.
|
||||||
|
if !a.bandOpen.on.Load() || !a.bandOpenWanted(s.Band) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No operator grid = no distance and no bearing on the spot, and the whole
|
||||||
|
// detection rests on those two. Say nothing rather than guess.
|
||||||
|
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
if a.bandOpen.det == nil {
|
||||||
|
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
||||||
|
}
|
||||||
|
op := a.bandOpen.det.Add(bandopen.Spot{
|
||||||
|
Call: s.DXCall, Band: s.Band, DistKm: s.DistanceKm,
|
||||||
|
Bearing: s.ShortPath, At: s.ReceivedAt,
|
||||||
|
}, a.opLat)
|
||||||
|
if op != nil {
|
||||||
|
a.rememberOpening(*op)
|
||||||
|
}
|
||||||
|
// Keeps a lit badge lit. Gated on the same floor the detector uses, or a
|
||||||
|
// band busy with short-range tropo would hold an Es badge on for ever.
|
||||||
|
if s.DistanceKm >= bandopen.DefaultConfig().MinKm {
|
||||||
|
a.markBandAlive(s.Band, s.ReceivedAt)
|
||||||
|
}
|
||||||
|
a.bandOpen.mu.Unlock()
|
||||||
|
if op != nil {
|
||||||
|
a.announceOpening(*op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markBandAlive pushes a band's badge deadline out. Called for every spot the
|
||||||
|
// detector accepted, from either feed. Cheap on purpose: this runs on the MQTT
|
||||||
|
// goroutine at thousands a minute when 6 m is open.
|
||||||
|
//
|
||||||
|
// Caller holds bandOpen.mu.
|
||||||
|
func (a *App) markBandAlive(band string, at time.Time) {
|
||||||
|
if _, lit := a.bandOpen.live[strings.ToLower(band)]; !lit {
|
||||||
|
return // nothing announced for this band, nothing to keep alive
|
||||||
|
}
|
||||||
|
if a.bandOpen.aliveUntil == nil {
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
a.bandOpen.aliveUntil[strings.ToLower(band)] = at.Add(openingIdle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rememberOpening files a detection and lights its badge. Caller holds the mutex.
|
||||||
|
func (a *App) rememberOpening(op bandopen.Opening) {
|
||||||
|
a.bandOpen.last = append([]bandopen.Opening{op}, a.bandOpen.last...)
|
||||||
|
if len(a.bandOpen.last) > maxRememberedOpenings {
|
||||||
|
a.bandOpen.last = a.bandOpen.last[:maxRememberedOpenings]
|
||||||
|
}
|
||||||
|
if a.bandOpen.live == nil {
|
||||||
|
a.bandOpen.live = map[string]bandopen.Opening{}
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
b := strings.ToLower(op.Band)
|
||||||
|
a.bandOpen.live[b] = op
|
||||||
|
a.bandOpen.aliveUntil[b] = op.At.Add(openingIdle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLiveOpenings returns the openings still under way, for the status-bar
|
||||||
|
// badge. Expired ones are dropped as they are noticed — there is no janitor for
|
||||||
|
// something that holds at most five entries.
|
||||||
|
func (a *App) GetLiveOpenings() []bandopen.Opening {
|
||||||
|
now := time.Now()
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
out := make([]bandopen.Opening, 0, len(a.bandOpen.live))
|
||||||
|
for b, op := range a.bandOpen.live {
|
||||||
|
if until, ok := a.bandOpen.aliveUntil[b]; !ok || now.After(until) {
|
||||||
|
delete(a.bandOpen.live, b)
|
||||||
|
delete(a.bandOpen.aliveUntil, b)
|
||||||
|
applog.Printf("bandopen: %s opening has gone quiet", strings.ToUpper(b))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, op)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandOpenWanted reports whether the operator has this band ticked. Falls back
|
||||||
|
// to the detector's own set until a selection has been stored, so a band is
|
||||||
|
// never silently dropped before the settings have been read.
|
||||||
|
func (a *App) bandOpenWanted(band string) bool {
|
||||||
|
if !bandopen.Watched(band) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sel, ok := a.bandOpen.bands.Load().(map[string]bool)
|
||||||
|
if !ok || len(sel) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return sel[strings.ToLower(strings.TrimSpace(band))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// setBandOpenBands records the selection and puts out badges for bands that
|
||||||
|
// have just been unticked — they fade on a timer fed by spots the detector no
|
||||||
|
// longer looks at, so they would otherwise stay lit until the next restart.
|
||||||
|
func (a *App) setBandOpenBands(bands []string) {
|
||||||
|
sel := make(map[string]bool, len(bands))
|
||||||
|
for _, b := range bands {
|
||||||
|
sel[strings.ToLower(strings.TrimSpace(b))] = true
|
||||||
|
}
|
||||||
|
a.bandOpen.bands.Store(sel)
|
||||||
|
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
for b := range a.bandOpen.live {
|
||||||
|
if len(sel) > 0 && !sel[b] {
|
||||||
|
delete(a.bandOpen.live, b)
|
||||||
|
delete(a.bandOpen.aliveUntil, b)
|
||||||
|
applog.Printf("bandopen: %s is no longer watched — badge cleared", strings.ToUpper(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearBandOpenings puts out every lit badge and forgets the detector's window.
|
||||||
|
// Called when the watch is switched off: the badges fade on a timer fed by
|
||||||
|
// spots the detector no longer looks at, so without this they would stay up
|
||||||
|
// until the next restart. The remembered list is left alone — those openings
|
||||||
|
// really did happen, and the operator may still want to see what he missed.
|
||||||
|
func (a *App) clearBandOpenings() {
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
a.bandOpen.live = nil
|
||||||
|
a.bandOpen.aliveUntil = nil
|
||||||
|
// Drop the accumulated spot window too, so switching the watch back on starts
|
||||||
|
// from what is on the air now rather than from an hour-old burst.
|
||||||
|
a.bandOpen.det = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
||||||
|
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
||||||
|
// opening reads the same however it was noticed.
|
||||||
|
func (a *App) announceOpening(op bandopen.Opening) {
|
||||||
|
applog.Printf("bandopen: %s opening — %d stations, ~%d km, %s%s (%s)",
|
||||||
|
op.Band, op.Calls, op.MedianKm, op.Sector(),
|
||||||
|
map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason],
|
||||||
|
strings.Join(op.Examples, " "))
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "bandopen:detected", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBandOpenings returns the openings seen this session, newest first. The UI
|
||||||
|
// polls this so a detection is still visible after its toast has gone.
|
||||||
|
func (a *App) GetBandOpenings() []bandopen.Opening {
|
||||||
|
a.bandOpen.mu.Lock()
|
||||||
|
defer a.bandOpen.mu.Unlock()
|
||||||
|
out := make([]bandopen.Opening, len(a.bandOpen.last))
|
||||||
|
copy(out, a.bandOpen.last)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BandOpeningSummary is the one-line form used in the toast and the log.
|
||||||
|
func BandOpeningSummary(o bandopen.Opening) string {
|
||||||
|
s := fmt.Sprintf("%s open — %d stations ~%d km, %s", strings.ToUpper(o.Band), o.Calls, o.MedianKm, o.Sector())
|
||||||
|
if !o.InSeason {
|
||||||
|
s += " (unusual for the season)"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/bandopen"
|
||||||
|
"hamlog/internal/cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The "Watch for band openings" switch has to gate the DETECTOR, not just the
|
||||||
|
// extra data sources.
|
||||||
|
//
|
||||||
|
// It originally governed only the RBN nodes and the PSK Reporter feed, while
|
||||||
|
// the detector itself ran on every ordinary cluster spot — so an operator who
|
||||||
|
// had never enabled the watch still got opening banners for a feature he had
|
||||||
|
// deliberately left off. That is what this pins.
|
||||||
|
func TestBandOpenWatchGatesTheDetector(t *testing.T) {
|
||||||
|
spot := func() cluster.Spot {
|
||||||
|
return cluster.Spot{
|
||||||
|
DXCall: "EA1ABC", Band: "6m", DistanceKm: 1400, ShortPath: 210,
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched off: the spot must not even reach the detector.
|
||||||
|
off := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||||
|
off.detectBandOpening(spot())
|
||||||
|
if off.bandOpen.det != nil {
|
||||||
|
t.Error("the detector ran with the watch switched off")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switched on: the same spot is accepted (one spot is not an opening, so
|
||||||
|
// nothing is announced — but the detector now exists and is collecting).
|
||||||
|
on := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||||
|
on.bandOpen.on.Store(true)
|
||||||
|
on.detectBandOpening(spot())
|
||||||
|
if on.bandOpen.det == nil {
|
||||||
|
t.Error("the detector did not run with the watch switched on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching the watch off must put the badges out. They fade on a timer fed by
|
||||||
|
// spots the detector no longer looks at, so left alone they would stay lit
|
||||||
|
// until the next restart.
|
||||||
|
func TestClearBandOpeningsPutsTheBadgesOut(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.bandOpen.live = map[string]bandopen.Opening{"6m": {Band: "6m", Calls: 9}}
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{"6m": time.Now().Add(time.Hour)}
|
||||||
|
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
||||||
|
a.bandOpen.last = []bandopen.Opening{{Band: "6m", Calls: 9}}
|
||||||
|
|
||||||
|
a.clearBandOpenings()
|
||||||
|
|
||||||
|
if got := a.GetLiveOpenings(); len(got) != 0 {
|
||||||
|
t.Errorf("a badge stayed lit after the watch was switched off: %v", got)
|
||||||
|
}
|
||||||
|
if a.bandOpen.det != nil {
|
||||||
|
t.Error("the accumulated spot window survived — switching back on would start from a stale burst")
|
||||||
|
}
|
||||||
|
// The history is NOT cleared: those openings really happened.
|
||||||
|
if len(a.GetBandOpenings()) != 1 {
|
||||||
|
t.Error("the remembered openings were thrown away")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unticking a band in Settings must actually stop its announcements.
|
||||||
|
//
|
||||||
|
// Nothing checked the operator's selection on either feed path: bandopen.Watched
|
||||||
|
// only says which bands the detector is CAPABLE of. So a station with just 6 m
|
||||||
|
// ticked still got 10 m and 2 m badges, from the cluster path and — once grid
|
||||||
|
// chasing widened the subscription to every band — from PSK Reporter too.
|
||||||
|
func TestBandOpenHonoursTheSelectedBands(t *testing.T) {
|
||||||
|
a := &App{opSet: true, opLat: 48, opLon: 2}
|
||||||
|
a.bandOpen.on.Store(true)
|
||||||
|
a.setBandOpenBands([]string{"6m"})
|
||||||
|
|
||||||
|
if !a.bandOpenWanted("6m") {
|
||||||
|
t.Error("6m is ticked and was refused")
|
||||||
|
}
|
||||||
|
for _, b := range []string{"10m", "2m", "4m"} {
|
||||||
|
if a.bandOpenWanted(b) {
|
||||||
|
t.Errorf("%s is not ticked and was accepted", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A band the detector cannot watch at all stays out whatever is stored.
|
||||||
|
if a.bandOpenWanted("20m") {
|
||||||
|
t.Error("20m is not a watched band and was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the cluster path must obey it too.
|
||||||
|
a.detectBandOpening(cluster.Spot{
|
||||||
|
DXCall: "K1ABC", Band: "10m", DistanceKm: 6000, ShortPath: 280, ReceivedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if a.bandOpen.det != nil {
|
||||||
|
t.Error("a spot on an unticked band reached the detector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unticking a band puts its badge out. They fade on a timer fed by spots the
|
||||||
|
// detector no longer looks at, so it would otherwise stay lit until a restart.
|
||||||
|
func TestUntickingABandClearsItsBadge(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.bandOpen.live = map[string]bandopen.Opening{
|
||||||
|
"6m": {Band: "6m", Calls: 9},
|
||||||
|
"10m": {Band: "10m", Calls: 5},
|
||||||
|
}
|
||||||
|
a.bandOpen.aliveUntil = map[string]time.Time{
|
||||||
|
"6m": time.Now().Add(time.Hour),
|
||||||
|
"10m": time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
a.setBandOpenBands([]string{"6m"})
|
||||||
|
|
||||||
|
live := a.GetLiveOpenings()
|
||||||
|
if len(live) != 1 || live[0].Band != "6m" {
|
||||||
|
t.Errorf("live openings = %v, want only 6m", live)
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The first breadcrumbs, written before anything else can fail.
|
||||||
|
//
|
||||||
|
// OpsLog keeps its log in the data folder, which lives beside the executable —
|
||||||
|
// so every fault that happens BEFORE that folder exists is invisible. Reported
|
||||||
|
// from a Windows 10 machine: "the process appears, no data folder is created,
|
||||||
|
// nothing starts", with no file anywhere to say why. There was nothing to read
|
||||||
|
// because the only place we write to had not been created yet.
|
||||||
|
//
|
||||||
|
// This writes to %LOCALAPPDATA%\OpsLog\startup.log instead: a folder Windows
|
||||||
|
// guarantees is writable for the user, whatever OpsLog itself was installed
|
||||||
|
// into. It records the handful of milestones between the process starting and
|
||||||
|
// the window appearing, and nothing else — it is not a second log, it is the
|
||||||
|
// answer to "it does not start".
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootLogPath is the file, or "" when even LOCALAPPDATA is unavailable.
|
||||||
|
func bootLogPath() string {
|
||||||
|
dir := os.Getenv("LOCALAPPDATA")
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "OpsLog")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "startup.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootLog appends one line. Never fails loudly: it exists to explain a failure,
|
||||||
|
// so it must not become one.
|
||||||
|
func bootLog(format string, args ...any) {
|
||||||
|
p := bootLogPath()
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
// Trimmed when it gets long. A startup log that grows for two years is a
|
||||||
|
// file nobody opens, and the interesting launch is always the last one.
|
||||||
|
if fi, err := f.Stat(); err == nil && fi.Size() > 256*1024 {
|
||||||
|
f.Close()
|
||||||
|
_ = os.Remove(p)
|
||||||
|
f, err = os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05.000"), fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootLogLaunch records what was launched and from where.
|
||||||
|
func bootLogLaunch() {
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
bootLog("launch: %s %v", exe, os.Args[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDataDirWritable makes sure the folder OpsLog keeps everything in can
|
||||||
|
// actually be created and written to, and says exactly what failed if not.
|
||||||
|
//
|
||||||
|
// The data folder sits beside the executable, which is fine on a stick or in a
|
||||||
|
// home directory and refused outright under Program Files — where Windows
|
||||||
|
// silently denies the write to anything not elevated. That refusal used to end
|
||||||
|
// the launch with no window and no message.
|
||||||
|
func checkDataDirWritable() error {
|
||||||
|
dir, err := userDataDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot work out where to keep the data: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("cannot create the data folder %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
probe := filepath.Join(dir, ".writetest")
|
||||||
|
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("the data folder %s cannot be written to: %w", dir, err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(probe)
|
||||||
|
bootLog("data dir ok: %s", dir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startupReached flips as soon as OnStartup runs — the first moment OpsLog's
|
||||||
|
// own code is executing inside the window's lifecycle.
|
||||||
|
var startupReached atomic.Bool
|
||||||
|
|
||||||
|
// startupStuckMessage is what an operator sees when the window never starts.
|
||||||
|
//
|
||||||
|
// It names the two causes and what to do about each, because "OpsLog is not
|
||||||
|
// responding" sends people to reinstall OpsLog, which is the one thing that
|
||||||
|
// cannot help: the part that has not started is not ours.
|
||||||
|
const startupStuckMessage = "OpsLog started but its window never opened.\n\n" +
|
||||||
|
"This is the WebView2 runtime failing to start, and it is almost always one of two things:\n\n" +
|
||||||
|
"• The Microsoft Edge WebView2 Runtime is missing — install it from Microsoft, then start OpsLog again.\n" +
|
||||||
|
"• An antivirus is blocking msedgewebview2.exe — add OpsLog's folder to its exceptions.\n\n" +
|
||||||
|
"The details are in the OpsLog folder under LOCALAPPDATA (startup.log)."
|
||||||
|
|
||||||
|
// webviewDataPath is where WebView2 keeps its profile.
|
||||||
|
//
|
||||||
|
// Named rather than left to the default, which lands in the ROAMING profile: on
|
||||||
|
// a managed account that folder can be redirected to a network share, and a
|
||||||
|
// WebView2 profile on a share that is slow or unreachable does not fail — it
|
||||||
|
// hangs, which is indistinguishable from a runtime that will not start.
|
||||||
|
//
|
||||||
|
// It also gives the folder a name someone can be told to delete: a corrupt
|
||||||
|
// profile is a real cause of this, and "delete this folder and try again" is
|
||||||
|
// only advice if the folder can be pointed at.
|
||||||
|
func webviewDataPath() string {
|
||||||
|
dir := os.Getenv("LOCALAPPDATA")
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
return "" // let Wails decide; there is nothing better to offer
|
||||||
|
}
|
||||||
|
p := filepath.Join(dir, "OpsLog", "WebView2")
|
||||||
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||||
|
bootLog("WebView2 profile folder %s could not be created: %v — leaving it to Wails", p, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// stuckMarkerPath is written before the window is attempted and removed once it
|
||||||
|
// opens, so the NEXT launch can tell that the last one never got there.
|
||||||
|
func stuckMarkerPath() string {
|
||||||
|
dir := os.Getenv("LOCALAPPDATA")
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "OpsLog", ".launching")
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastLaunchHung is set at startup from the marker left by the previous run.
|
||||||
|
var lastLaunchHung bool
|
||||||
|
|
||||||
|
// noteLaunchAttempt records that a window is about to be attempted, and reports
|
||||||
|
// whether the previous attempt ever finished.
|
||||||
|
func noteLaunchAttempt() {
|
||||||
|
p := stuckMarkerPath()
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
lastLaunchHung = true
|
||||||
|
bootLog("the previous launch never opened its window — trying again with GPU acceleration off")
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||||
|
_ = os.WriteFile(p, []byte(time.Now().Format(time.RFC3339)), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLaunchMarker is called once the window is up: the attempt finished.
|
||||||
|
func clearLaunchMarker() { _ = os.Remove(stuckMarkerPath()) }
|
||||||
|
|
||||||
|
// fixedWebView2Path finds a FIXED-VERSION WebView2 runtime shipped beside
|
||||||
|
// OpsLog, or "" when there is none.
|
||||||
|
//
|
||||||
|
// Microsoft publishes the runtime in two forms. Evergreen installs itself into
|
||||||
|
// Windows and updates itself — the normal case, and the one this looks for
|
||||||
|
// first. FIXED VERSION is a folder of files an application carries with it and
|
||||||
|
// points at, needing no installation and no administrator: it exists precisely
|
||||||
|
// for machines where Evergreen cannot be installed, which is not a rare
|
||||||
|
// situation on a managed, offline or otherwise locked-down Windows.
|
||||||
|
//
|
||||||
|
// So: drop the extracted folder next to OpsLog.exe and it is used. Nothing to
|
||||||
|
// configure — a setting for this would be a question asked of the one operator
|
||||||
|
// least able to answer it, on a machine where nothing starts.
|
||||||
|
func fixedWebView2Path() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base := filepath.Dir(exe)
|
||||||
|
for _, name := range []string{"WebView2", "WebView2Runtime", "webview2"} {
|
||||||
|
dir := filepath.Join(base, name)
|
||||||
|
if p := findWebView2Binary(dir); p != "" {
|
||||||
|
bootLog("using the fixed-version WebView2 runtime in %s", p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWebView2Binary returns dir (or its single versioned subfolder) when it
|
||||||
|
// holds msedgewebview2.exe — the fixed-version download unpacks with the
|
||||||
|
// version as a folder level, and asking someone to flatten it by hand is one
|
||||||
|
// more step to get wrong.
|
||||||
|
func findWebView2Binary(dir string) string {
|
||||||
|
if fileExists(filepath.Join(dir, "msedgewebview2.exe")) {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sub := filepath.Join(dir, e.Name())
|
||||||
|
if fileExists(filepath.Join(sub, "msedgewebview2.exe")) {
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// edgeBlockedMessage is shown when an Edge blocker is found in the way.
|
||||||
|
//
|
||||||
|
// It names the tool rather than the symptom: OpsLog draws its whole interface
|
||||||
|
// with the WebView2 engine, which is Edge's, so a machine where Edge is blocked
|
||||||
|
// cannot run it — and no amount of reinstalling OpsLog will change that.
|
||||||
|
const edgeBlockedMessage = "OpsLog cannot start because Edge is blocked on this PC.\n\n" +
|
||||||
|
"A blocker tool (or a policy) is stopping msedgewebview2.exe from running. " +
|
||||||
|
"OpsLog draws its whole interface with that engine, so it cannot open its window.\n\n" +
|
||||||
|
"Unblock Edge — in the tool that blocked it — and start OpsLog again. " +
|
||||||
|
"The details are in the OpsLog folder under LOCALAPPDATA (startup.log)."
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A bulk-editable field passes through THREE tables: the field list in the UI,
|
||||||
|
// bulkFieldColumns here, and bulkEditableCols in internal/qso. Mode and RST were
|
||||||
|
// added to the first and the third and not the second, so the dialog offered
|
||||||
|
// them and the save failed with "unknown field" — reported from the field.
|
||||||
|
//
|
||||||
|
// Nothing warns about that: each table is valid on its own. This is the check.
|
||||||
|
func TestEveryMappedBulkFieldIsWhitelisted(t *testing.T) {
|
||||||
|
for id, col := range bulkFieldColumns {
|
||||||
|
if !qso.BulkEditable(col) {
|
||||||
|
t.Errorf("bulk field %q maps to column %q, which internal/qso refuses to write", id, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the reverse: a column the qso layer allows but nothing maps to is dead
|
||||||
|
// weight — it looks supported from the inside and cannot be reached from outside.
|
||||||
|
func TestNoUnreachableBulkColumn(t *testing.T) {
|
||||||
|
mapped := map[string]bool{}
|
||||||
|
for _, col := range bulkFieldColumns {
|
||||||
|
mapped[col] = true
|
||||||
|
}
|
||||||
|
for _, col := range qso.BulkEditableColumns() {
|
||||||
|
if !mapped[col] {
|
||||||
|
t.Errorf("column %q is bulk-writable but no field maps to it — unreachable", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// How a Kenwood-dialect radio is reached.
|
||||||
|
//
|
||||||
|
// Three transports, and they are genuinely different things rather than three
|
||||||
|
// spellings of one: a COM port, the same CAT bytes carried over TCP by a serial
|
||||||
|
// bridge, and the radio's own network protocol — which is a session with its own
|
||||||
|
// framing and authentication, and is not implemented here.
|
||||||
|
//
|
||||||
|
// Kept as an explicit setting rather than inferred from which field an operator
|
||||||
|
// happened to fill in. The old code preferred a network address whenever one was
|
||||||
|
// present, which is invisible from the settings page: someone who typed a host
|
||||||
|
// months ago, then set a COM port, had a radio that never answered and nothing
|
||||||
|
// on screen to explain it.
|
||||||
|
|
||||||
|
const (
|
||||||
|
kenwoodLinkUSB = "usb" // a COM port
|
||||||
|
kenwoodLinkBridge = "bridge" // RS-232 to Ethernet: the same CAT bytes over TCP
|
||||||
|
kenwoodLinkNative = "native" // the radio's own network protocol — not supported
|
||||||
|
)
|
||||||
|
|
||||||
|
// kenwoodLinkOr resolves the stored choice, falling back to what an older
|
||||||
|
// install can be read as: a configured host meant the bridge, because that is
|
||||||
|
// what the previous code used it for.
|
||||||
|
func kenwoodLinkOr(link, host string) string {
|
||||||
|
switch link {
|
||||||
|
case kenwoodLinkUSB, kenwoodLinkBridge, kenwoodLinkNative:
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
return kenwoodLinkBridge
|
||||||
|
}
|
||||||
|
return kenwoodLinkUSB
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The transport is a stored choice now, but installs exist that predate it and
|
||||||
|
// hold only a host. Reading those as "bridge" is what keeps a working station
|
||||||
|
// working across the upgrade — the old code used a host for exactly that.
|
||||||
|
func TestTheLinkIsReadFromAnOlderInstall(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
link, host, want string
|
||||||
|
}{
|
||||||
|
{"", "", kenwoodLinkUSB}, // nothing configured
|
||||||
|
{"", "192.168.1.50:4999", kenwoodLinkBridge}, // upgraded: host only
|
||||||
|
{kenwoodLinkUSB, "192.168.1.50:4999", kenwoodLinkUSB}, // chose USB, host left behind
|
||||||
|
{kenwoodLinkBridge, "", kenwoodLinkBridge},
|
||||||
|
{kenwoodLinkNative, "", kenwoodLinkNative},
|
||||||
|
{"nonsense", "", kenwoodLinkUSB}, // a value nobody wrote: fall back, don't guess
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := kenwoodLinkOr(c.link, c.host); got != c.want {
|
||||||
|
t.Errorf("link=%q host=%q → %q, want %q", c.link, c.host, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one that matters after the fact: an operator who picks USB while an old
|
||||||
|
// host is still stored must get the COM port. The previous code preferred the
|
||||||
|
// host whenever it was non-empty, which is invisible from the settings page.
|
||||||
|
func TestChoosingUSBBeatsALeftoverHost(t *testing.T) {
|
||||||
|
if got := kenwoodLinkOr(kenwoodLinkUSB, "10.0.0.9:4999"); got != kenwoodLinkUSB {
|
||||||
|
t.Fatalf("a leftover host overrode the operator's choice: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The whole point of the signature is that pressing Save in Preferences must
|
||||||
|
// not drop the rig — and with it every WSJT-X/JTDX client of the shared CAT
|
||||||
|
// server, mid-QSO, with an error box. Only a change to the LINK may reconnect.
|
||||||
|
func TestCatLinkSigIgnoresNonLinkSettings(t *testing.T) {
|
||||||
|
base := CATSettings{
|
||||||
|
Enabled: true, Backend: "flex", FlexHost: "192.168.1.20", FlexPort: 4992,
|
||||||
|
PollMs: 250, DigitalDefault: "FT8",
|
||||||
|
}
|
||||||
|
same := base
|
||||||
|
// Everything here is applied to the RUNNING manager; none of it justifies a
|
||||||
|
// reconnection, and a spot option in particular must not wipe the panadapter
|
||||||
|
// (a Flex reconnect clears every spot on it).
|
||||||
|
same.PollMs = 500
|
||||||
|
same.DelayMs = 20
|
||||||
|
same.OffsetOn = true
|
||||||
|
same.OffsetHz = 116_000_000
|
||||||
|
same.FlexDecodeSpots = !base.FlexDecodeSpots
|
||||||
|
same.FlexDecodeSecs = 300
|
||||||
|
same.FlexDVKDax = !base.FlexDVKDax
|
||||||
|
same.PTTHotkeyEnabled = true
|
||||||
|
same.PTTHotkey = "Pause"
|
||||||
|
if catLinkSig(base) != catLinkSig(same) {
|
||||||
|
t.Errorf("non-link settings changed the signature:\n%s\n%s", catLinkSig(base), catLinkSig(same))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the ones that DO describe the connection must still force a restart.
|
||||||
|
for name, mutate := range map[string]func(*CATSettings){
|
||||||
|
"backend": func(c *CATSettings) { c.Backend = "kenwood" },
|
||||||
|
"flex host": func(c *CATSettings) { c.FlexHost = "192.168.1.21" },
|
||||||
|
"flex port": func(c *CATSettings) { c.FlexPort = 4993 },
|
||||||
|
"serial port": func(c *CATSettings) { c.Backend = "yaesu"; c.YaesuPort = "COM7" },
|
||||||
|
"baud": func(c *CATSettings) { c.Backend = "yaesu"; c.YaesuBaud = 19200 },
|
||||||
|
"civ address": func(c *CATSettings) { c.Backend = "icom"; c.IcomAddr = 152 },
|
||||||
|
"disabled": func(c *CATSettings) { c.Enabled = false },
|
||||||
|
} {
|
||||||
|
c := base
|
||||||
|
mutate(&c)
|
||||||
|
if catLinkSig(c) == catLinkSig(base) {
|
||||||
|
t.Errorf("%s did not change the signature — the rig would never reconnect", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The share server is the thing WSJT-X actually holds a socket to: restarting
|
||||||
|
// it is what produced the error box, so it has a signature of its own.
|
||||||
|
func TestCatShareSig(t *testing.T) {
|
||||||
|
base := CATSettings{Enabled: true, ShareEnabled: true, ShareProto: "rigctl", SharePort: 4532}
|
||||||
|
other := base
|
||||||
|
other.FlexHost = "10.0.0.5" // a rig-link change must not restart the server
|
||||||
|
if catShareSig(base) != catShareSig(other) {
|
||||||
|
t.Errorf("a rig-link change restarted the share server")
|
||||||
|
}
|
||||||
|
moved := base
|
||||||
|
moved.SharePort = 4533
|
||||||
|
if catShareSig(base) == catShareSig(moved) {
|
||||||
|
t.Errorf("a new port must restart the server")
|
||||||
|
}
|
||||||
|
off := base
|
||||||
|
off.ShareEnabled = false
|
||||||
|
if catShareSig(off) != "off" || catShareSig(off) == catShareSig(base) {
|
||||||
|
t.Errorf("switching sharing off must be a change")
|
||||||
|
}
|
||||||
|
// CAT off means the server has nothing to serve, whatever the share fields say.
|
||||||
|
catOff := base
|
||||||
|
catOff.Enabled = false
|
||||||
|
if catShareSig(catOff) != "off" {
|
||||||
|
t.Errorf("share signature with CAT off = %q, want \"off\"", catShareSig(catOff))
|
||||||
|
}
|
||||||
|
}
|
||||||
+1114
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"hamlog/internal/award"
|
"hamlog/internal/award"
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,15 @@ func main() {
|
|||||||
sc.Scan() // header
|
sc.Scan() // header
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
line := sc.Text()
|
line := sc.Text()
|
||||||
|
// The FIPS CSV is not reliably UTF-8, and the one county whose name is
|
||||||
|
// not ASCII (Doña Ana, NM) came through mangled once already — it landed
|
||||||
|
// in the reference as "NM/DO̱AANA", a code no log could ever match,
|
||||||
|
// silently costing that county for every operator. Refuse the row rather
|
||||||
|
// than emit a broken one.
|
||||||
|
if !utf8.ValidString(line) {
|
||||||
|
fmt.Fprintf(os.Stderr, "cntygen: skipping non-UTF-8 line: %q\n", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
parts := strings.SplitN(line, ",", 3)
|
parts := strings.SplitN(line, ",", 3)
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// One-shot generator: emits internal/uls/ctcounty_gen.go, the Connecticut
|
||||||
|
// ZIP → legal county table. Not part of the build.
|
||||||
|
//
|
||||||
|
// Why it has to exist
|
||||||
|
// -------------------
|
||||||
|
// internal/uls resolves a US callsign to a county through GeoNames' ZIP table.
|
||||||
|
// The Census replaced Connecticut's eight counties with nine PLANNING REGIONS
|
||||||
|
// as county-equivalents in 2022, and GeoNames followed: 418 of Connecticut's
|
||||||
|
// 429 ZIPs now report "Capitol Region", "Lower Connecticut River Valley" and
|
||||||
|
// so on. CQ's USA-CA award did not follow, callbooks did not follow, and no
|
||||||
|
// operator's log did either — so every Connecticut station resolved to a name
|
||||||
|
// nothing could match, showed as a new county for ever, and counted for no
|
||||||
|
// award. Roughly 15 000 US callsigns.
|
||||||
|
//
|
||||||
|
// A planning region is NOT a renamed county — it is built from towns drawn
|
||||||
|
// from several different counties — so there is no name-to-name mapping to be
|
||||||
|
// had. The ZIP is the only handle, hence this table.
|
||||||
|
//
|
||||||
|
// Sources (both public, both free)
|
||||||
|
// --------------------------------
|
||||||
|
// geonames US.txt from https://download.geonames.org/export/zip/US.zip
|
||||||
|
// — the ZIP list itself, and each ZIP's centroid.
|
||||||
|
// crosswalk https://www2.census.gov/geo/docs/maps-data/data/rel2020/
|
||||||
|
// zcta520/tab20_zcta520_county20_natl.txt
|
||||||
|
// — the 2020 ZCTA↔county relationship file, which predates the
|
||||||
|
// change and therefore still carries the eight real counties.
|
||||||
|
//
|
||||||
|
// The crosswalk covers 278 of the 418 affected ZIPs. The rest are PO-box-only
|
||||||
|
// ZIPs, which have no ZCTA at all; each is given the county of the nearest
|
||||||
|
// resolved ZIP centroid. That is sound here because a PO-box ZIP sits inside
|
||||||
|
// the town it serves, and Connecticut's counties are tens of kilometres across
|
||||||
|
// — the fallback can only err on a ZIP that straddles a county line, which the
|
||||||
|
// ZIP-to-county approach is already documented as accepting (~98%).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/ctzipgen US.txt tab20_zcta520_county20_natl.txt > internal/uls/ctcounty_gen.go
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type zipPt struct {
|
||||||
|
zip string
|
||||||
|
lat, lon float64
|
||||||
|
county string // from the crosswalk, "" if the ZIP has no ZCTA
|
||||||
|
geoName string // what GeoNames says today
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: ctzipgen US.txt tab20_zcta520_county20_natl.txt")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Every Connecticut ZIP, with its centroid, from GeoNames.
|
||||||
|
var pts []*zipPt
|
||||||
|
byZip := map[string]*zipPt{}
|
||||||
|
f, err := os.Open(os.Args[1])
|
||||||
|
must(err)
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
c := strings.Split(sc.Text(), "\t")
|
||||||
|
if len(c) < 11 || strings.ToUpper(strings.TrimSpace(c[4])) != "CT" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
z := strings.TrimSpace(c[1])
|
||||||
|
if z == "" || byZip[z] != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := &zipPt{zip: z, lat: atof(c[9]), lon: atof(c[10]), geoName: strings.TrimSpace(c[5])}
|
||||||
|
byZip[z] = p
|
||||||
|
pts = append(pts, p)
|
||||||
|
}
|
||||||
|
must(sc.Err())
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// 2. The 2020 county for each ZCTA. A ZCTA can straddle a county line, so
|
||||||
|
// keep the county holding the largest share of its land area.
|
||||||
|
best := map[string]float64{}
|
||||||
|
f2, err := os.Open(os.Args[2])
|
||||||
|
must(err)
|
||||||
|
sc2 := bufio.NewScanner(f2)
|
||||||
|
sc2.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||||
|
sc2.Scan() // header
|
||||||
|
for sc2.Scan() {
|
||||||
|
c := strings.Split(sc2.Text(), "|")
|
||||||
|
if len(c) < 18 || !strings.HasPrefix(c[9], "09") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := byZip[strings.TrimSpace(c[1])]
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
area := atof(c[16])
|
||||||
|
if p.county != "" && area <= best[p.zip] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best[p.zip] = area
|
||||||
|
p.county = strings.TrimSuffix(strings.TrimSpace(c[10]), " County")
|
||||||
|
}
|
||||||
|
must(sc2.Err())
|
||||||
|
f2.Close()
|
||||||
|
|
||||||
|
// 3. Fill the PO-box-only ZIPs from the nearest ZIP the crosswalk resolved.
|
||||||
|
var anchors []*zipPt
|
||||||
|
for _, p := range pts {
|
||||||
|
if p.county != "" {
|
||||||
|
anchors = append(anchors, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(anchors) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "ctzipgen: crosswalk resolved nothing — wrong file?")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
filled := 0
|
||||||
|
for _, p := range pts {
|
||||||
|
if p.county != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nearest, bestD := "", math.MaxFloat64
|
||||||
|
for _, a := range anchors {
|
||||||
|
if d := haversine(p.lat, p.lon, a.lat, a.lon); d < bestD {
|
||||||
|
bestD, nearest = d, a.county
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.county = nearest
|
||||||
|
filled++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Self-check: the ZIPs GeoNames still labels with a real county must
|
||||||
|
// agree with what we derived, or the derivation is wrong.
|
||||||
|
real := map[string]bool{
|
||||||
|
"Fairfield": true, "Hartford": true, "Litchfield": true, "Middlesex": true,
|
||||||
|
"New Haven": true, "New London": true, "Tolland": true, "Windham": true,
|
||||||
|
}
|
||||||
|
checked, bad := 0, 0
|
||||||
|
for _, p := range pts {
|
||||||
|
if !real[p.geoName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checked++
|
||||||
|
if p.geoName != p.county {
|
||||||
|
bad++
|
||||||
|
fmt.Fprintf(os.Stderr, "MISMATCH %s: geonames %q, derived %q\n", p.zip, p.geoName, p.county)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "ctzipgen: %d zips, %d from crosswalk, %d by nearest; cross-check %d/%d agree\n",
|
||||||
|
len(pts), len(pts)-filled, filled, checked-bad, checked)
|
||||||
|
if bad > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(pts, func(i, j int) bool { return pts[i].zip < pts[j].zip })
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("// Code generated by cmd/ctzipgen. DO NOT EDIT.\n\n")
|
||||||
|
b.WriteString("package uls\n\n")
|
||||||
|
b.WriteString("// ctCounty maps a Connecticut ZIP to its legal county.\n")
|
||||||
|
b.WriteString("//\n")
|
||||||
|
b.WriteString("// GeoNames reports Connecticut's 2022 planning regions instead, which no\n")
|
||||||
|
b.WriteString("// award, callbook or log uses. See cmd/ctzipgen for how this was built and\n")
|
||||||
|
b.WriteString("// why a name-to-name mapping cannot work.\n")
|
||||||
|
fmt.Fprintf(&b, "var ctCounty = map[string]string{\n")
|
||||||
|
for _, p := range pts {
|
||||||
|
fmt.Fprintf(&b, "\t%q: %q,\n", p.zip, p.county)
|
||||||
|
}
|
||||||
|
b.WriteString("}\n")
|
||||||
|
fmt.Print(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func atof(s string) float64 {
|
||||||
|
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// haversine returns the great-circle distance in km. Connecticut is small
|
||||||
|
// enough that a flat approximation would do, but this costs nothing and cannot
|
||||||
|
// be wrong near the state's edges.
|
||||||
|
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
|
const r = 6371.0
|
||||||
|
dLat := (lat2 - lat1) * math.Pi / 180
|
||||||
|
dLon := (lon2 - lon1) * math.Pi / 180
|
||||||
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||||
|
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||||
|
return 2 * r * math.Asin(math.Sqrt(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(err error) {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "ctzipgen:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -109,9 +109,9 @@ func probeWB(conn *sql.DB, call string, dxcc int) {
|
|||||||
SELECT band, mode,
|
SELECT band, mode,
|
||||||
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||||
MAX(CASE WHEN callsign = ?
|
MAX(CASE WHEN callsign = ?
|
||||||
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
|
AND (lotw_rcvd IN ('Y','V') OR qsl_rcvd IN ('Y','V') OR eqsl_rcvd IN ('Y','V'))
|
||||||
THEN 1 ELSE 0 END),
|
THEN 1 ELSE 0 END),
|
||||||
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
|
MAX(CASE WHEN lotw_rcvd IN ('Y','V') OR qsl_rcvd IN ('Y','V') OR eqsl_rcvd IN ('Y','V')
|
||||||
THEN 1 ELSE 0 END)
|
THEN 1 ELSE 0 END)
|
||||||
FROM qso WHERE dxcc = ?
|
FROM qso WHERE dxcc = ?
|
||||||
GROUP BY band, mode
|
GROUP BY band, mode
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Compacting a database.
|
||||||
|
//
|
||||||
|
// SQLite never shrinks a file on its own: deleting rows — or dropping a table,
|
||||||
|
// which is what the schema-role cleanup does — frees pages INSIDE the file and
|
||||||
|
// leaves the file the size it always was. A logbook that held 200 000 imported
|
||||||
|
// QSOs for a day is still a 200 MB file the day after they are gone, and the
|
||||||
|
// only thing that reclaims the space is a VACUUM, which rewrites the database
|
||||||
|
// from scratch.
|
||||||
|
//
|
||||||
|
// Offered as a button rather than done automatically: a VACUUM rewrites the
|
||||||
|
// whole file, needs room for a second copy of it while it runs, and takes real
|
||||||
|
// time on a large logbook. That is a decision for the operator, at a moment they
|
||||||
|
// choose — not something to spring on them during startup.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompactResult is what one compaction did.
|
||||||
|
type CompactResult struct {
|
||||||
|
// Path of the file compacted, empty for MySQL.
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Backend is "sqlite" or "mysql" — a shared server is optimised, not vacuumed.
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
// Before and After are file sizes in bytes; both 0 when there is no file.
|
||||||
|
Before int64 `json:"before"`
|
||||||
|
After int64 `json:"after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactDatabase reclaims the unused space in one of the two databases.
|
||||||
|
//
|
||||||
|
// target is "settings" or "logbook". They are separate on purpose: they are
|
||||||
|
// different files, of very different sizes, and an operator compacting a 400 MB
|
||||||
|
// logbook has no reason to wait on a 2 MB settings file as well.
|
||||||
|
func (a *App) CompactDatabase(target string) (CompactResult, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(target)) {
|
||||||
|
case "settings":
|
||||||
|
if a.db == nil {
|
||||||
|
return CompactResult{}, fmt.Errorf("the settings database is not open")
|
||||||
|
}
|
||||||
|
return a.vacuumSQLite(a.db, a.dbPath)
|
||||||
|
case "logbook":
|
||||||
|
if a.logDb == nil {
|
||||||
|
return CompactResult{}, fmt.Errorf("the logbook is not open")
|
||||||
|
}
|
||||||
|
if a.dbBackend == "mysql" {
|
||||||
|
// A shared server's storage is the admin's business, and OPTIMIZE TABLE
|
||||||
|
// locks the table for the length of a rebuild — every other operator
|
||||||
|
// waits. Still offered, because a logbook that has had a large import
|
||||||
|
// deleted benefits from it just as much; it simply reports no sizes,
|
||||||
|
// which the server alone knows.
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx, "OPTIMIZE TABLE qso"); err != nil {
|
||||||
|
return CompactResult{}, fmt.Errorf("optimize qso: %w", err)
|
||||||
|
}
|
||||||
|
applog.Printf("compact: OPTIMIZE TABLE qso on the shared MySQL logbook")
|
||||||
|
return CompactResult{Backend: "mysql"}, nil
|
||||||
|
}
|
||||||
|
// No separate file: the settings database is serving as the logbook, and
|
||||||
|
// compacting it is the same operation.
|
||||||
|
if a.logDbPath == "" {
|
||||||
|
return a.vacuumSQLite(a.db, a.dbPath)
|
||||||
|
}
|
||||||
|
return a.vacuumSQLite(a.logDb, a.logDbPath)
|
||||||
|
}
|
||||||
|
return CompactResult{}, fmt.Errorf("unknown database %q", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vacuumSQLite checkpoints the write-ahead log, then rewrites the file.
|
||||||
|
func (a *App) vacuumSQLite(conn *sql.DB, path string) (CompactResult, error) {
|
||||||
|
res := CompactResult{Path: path, Backend: "sqlite", Before: fileSizeOf(path)}
|
||||||
|
// Fold the WAL back into the main file first. Without it the pages freed by a
|
||||||
|
// recent DELETE can still be sitting in the -wal, and the vacuum reports a
|
||||||
|
// saving the file on disk does not show.
|
||||||
|
if _, err := conn.ExecContext(a.ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||||
|
applog.Printf("compact: wal checkpoint on %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if _, err := conn.ExecContext(a.ctx, "VACUUM"); err != nil {
|
||||||
|
return res, fmt.Errorf("vacuum: %w", err)
|
||||||
|
}
|
||||||
|
res.After = fileSizeOf(path)
|
||||||
|
applog.Printf("compact: %s %d → %d bytes", path, res.Before, res.After)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSizeOf returns a file's size, or 0 if it cannot be read.
|
||||||
|
func fileSizeOf(path string) int64 {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fi.Size()
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCopyDirContentsCreatesDestination pins the bug that broke copying a QSL
|
||||||
|
// design between profiles.
|
||||||
|
//
|
||||||
|
// copyDirContents wrote straight into dst without creating it, so the first
|
||||||
|
// os.Create failed with ENOENT. The caller was written as "ignore
|
||||||
|
// os.IsNotExist", meaning "the source has no assets, that is fine" — and ENOENT
|
||||||
|
// from the failed write matched it exactly. Nothing was copied, no error was
|
||||||
|
// reported, and the operator saw "copied design is incomplete: hero photo file
|
||||||
|
// … not found" from the validation two lines later.
|
||||||
|
func TestCopyDirContentsCreatesDestination(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
src := filepath.Join(base, "src")
|
||||||
|
if err := os.MkdirAll(filepath.Join(src, "sub"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "img_25c06bda.jpg"), []byte("hero"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "sub", "back.png"), []byte("back"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dst does not exist — the case of every first copy.
|
||||||
|
dst := filepath.Join(base, "dst")
|
||||||
|
if err := copyDirContents(src, dst); err != nil {
|
||||||
|
t.Fatalf("copyDirContents: %v", err)
|
||||||
|
}
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"img_25c06bda.jpg": "hero",
|
||||||
|
"sub/back.png": "back",
|
||||||
|
} {
|
||||||
|
got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("%s = %q, want %q", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing SOURCE is still an error from copyDirContents itself — it is the
|
||||||
|
// caller that decides whether a design with no pictures is acceptable, and it
|
||||||
|
// can only decide that if this does not quietly succeed.
|
||||||
|
func TestCopyDirContentsMissingSource(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
if err := copyDirContents(filepath.Join(base, "nope"), filepath.Join(base, "dst")); err == nil {
|
||||||
|
t.Fatal("copying a missing source returned no error")
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("want a not-exist error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/gridcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The grid cache used to drop EVERYTHING at its ceiling. That was survivable
|
||||||
|
// while the only feed was this station's own decodes, which never reached it.
|
||||||
|
// Fed by anything larger it is a cliff: every locator in the cluster list
|
||||||
|
// disappears at once, periodically, and the operator sees the column empty
|
||||||
|
// itself for no reason.
|
||||||
|
//
|
||||||
|
// Rotation keeps the previous generation, so a full cache costs the older half
|
||||||
|
// and nothing more.
|
||||||
|
func TestDecodeGridRotationKeepsThePreviousGeneration(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
|
||||||
|
// Fill exactly one generation.
|
||||||
|
for i := 0; i < decodeGridsCap; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Fatalf("first entry lost before any rotation: %q", got)
|
||||||
|
}
|
||||||
|
if a.decodeGridsOld != nil {
|
||||||
|
t.Fatal("rotated early — the cap is the ceiling of ONE generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One more entry rotates.
|
||||||
|
a.rememberDecodeGrid("NEWCALL", "IO91", gridcache.SourceDecode)
|
||||||
|
if a.decodeGridsOld == nil {
|
||||||
|
t.Fatal("did not rotate at the cap")
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||||
|
t.Errorf("the entry that caused the rotation was lost: %q", got)
|
||||||
|
}
|
||||||
|
// The whole previous generation is still readable — this is the point.
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Errorf("a locator from the previous generation was dropped: %q — that is the cliff again", got)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL099999"); got != "JN36" {
|
||||||
|
t.Errorf("previous generation incomplete: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second rotation is what finally retires the oldest half.
|
||||||
|
for i := 0; i < decodeGridsCap; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("SECOND%06d", i), "KP20", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "" {
|
||||||
|
t.Errorf("the cache is unbounded: %q survived two rotations", got)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||||
|
t.Errorf("an entry one generation old was retired too early: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callsigns are normalised on the way in AND on the way out, or a spot for
|
||||||
|
// "f4bpo" would miss a grid learnt as "F4BPO".
|
||||||
|
func TestDecodeGridCaseAndBlanks(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.rememberDecodeGrid(" f4bpo ", "JN36", gridcache.SourceDecode)
|
||||||
|
if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" {
|
||||||
|
t.Errorf("lookup of the upper-case form failed: %q", got)
|
||||||
|
}
|
||||||
|
a.rememberDecodeGrid("", "JN36", gridcache.SourceDecode)
|
||||||
|
a.rememberDecodeGrid("K1ABC", "", gridcache.SourceDecode)
|
||||||
|
if got := a.lookupDecodeGrid("K1ABC"); got != "" {
|
||||||
|
t.Errorf("stored an empty grid: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The write path runs on the decode goroutine while the cluster status builder
|
||||||
|
// reads. Rotation swaps the map headers, so an unguarded read is a data race
|
||||||
|
// rather than a stale value.
|
||||||
|
//
|
||||||
|
// This build is CGO-free, so -race is not available here; the test exercises the
|
||||||
|
// interleaving and would fault on a concurrent map access even without it.
|
||||||
|
func TestDecodeGridConcurrentAccess(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("W%05d", i), "FN31", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
_ = a.lookupDecodeGrid(fmt.Sprintf("W%05d", i))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a CHANGE may reach the write batch.
|
||||||
|
//
|
||||||
|
// The feeds repeat themselves — the same station is reported by dozens of
|
||||||
|
// receivers a minute — so queueing every report would put the whole stream in
|
||||||
|
// the batch instead of the news in it, and turn a cache into a write amplifier.
|
||||||
|
func TestOnlyChangesAreQueued(t *testing.T) {
|
||||||
|
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
a := &App{gridStore: st}
|
||||||
|
|
||||||
|
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||||
|
if n := st.Pending(); n != 1 {
|
||||||
|
t.Fatalf("a new locator queued %d writes, want 1", n)
|
||||||
|
}
|
||||||
|
if err := st.Flush(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same report, a hundred times over, is not news.
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if n := st.Pending(); n != 0 {
|
||||||
|
t.Errorf("unchanged reports queued %d writes — the batch would carry the whole feed", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A station that moved is.
|
||||||
|
a.rememberDecodeGrid("F4BPO", "KP30", gridcache.SourceDecode)
|
||||||
|
if n := st.Pending(); n != 1 {
|
||||||
|
t.Errorf("a changed locator queued %d writes, want 1", n)
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("F4BPO"); got != "KP30" {
|
||||||
|
t.Errorf("the map kept the old locator: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation must be OFF while a store is attached: it would discard callsigns the
|
||||||
|
// database still holds, and the lookup would then miss something we know.
|
||||||
|
func TestNoRotationWhilePersisting(t *testing.T) {
|
||||||
|
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
a := &App{gridStore: st}
|
||||||
|
|
||||||
|
for i := 0; i < decodeGridsCap+10; i++ {
|
||||||
|
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||||
|
}
|
||||||
|
if a.decodeGridsOld != nil {
|
||||||
|
t.Error("rotated while a store was attached — locators the database holds would go missing")
|
||||||
|
}
|
||||||
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||||
|
t.Errorf("the first entry was dropped: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// fatalBox is Windows-only; elsewhere the terminal carries the message.
|
||||||
|
func fatalBox(title, text string) { println(title + ": " + text) }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fatalBox shows a message box and returns.
|
||||||
|
//
|
||||||
|
// A GUI-subsystem program has no console: println goes nowhere, and a launch
|
||||||
|
// that ends before the window exists ends in complete silence. Every silent exit
|
||||||
|
// in main now says something here first — an operator who is told "another
|
||||||
|
// OpsLog is already running" can act on it; one who sees nothing files "it does
|
||||||
|
// not start", which is the report nobody can answer.
|
||||||
|
func fatalBox(title, text string) {
|
||||||
|
user32 := syscall.NewLazyDLL("user32.dll")
|
||||||
|
proc := user32.NewProc("MessageBoxW")
|
||||||
|
t, err1 := syscall.UTF16PtrFromString(text)
|
||||||
|
ti, err2 := syscall.UTF16PtrFromString(title)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mbIconError = 0x00000010
|
||||||
|
proc.Call(0, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(ti)), mbIconError)
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Chasing a split pile-up by the report the DX just sent.
|
||||||
|
//
|
||||||
|
// Working a DXpedition in split means guessing where it is listening. The DX
|
||||||
|
// answers one station, sends "5NN", and moves on; the useful information is
|
||||||
|
// therefore not the callsign it answered but the FREQUENCY that callsign was
|
||||||
|
// transmitting on, because the DX's receiver was there a second ago.
|
||||||
|
//
|
||||||
|
// A CW skimmer already knows this. SDC (Software Defined Connector) decodes the
|
||||||
|
// whole pile-up and marks each report on the panadapter as a spot — the marker
|
||||||
|
// TEXT is configured in SDC by the operator ("599" for a fresh report, "X" for
|
||||||
|
// an older one, by default). Those spots reach OpsLog already: it subscribes to
|
||||||
|
// the radio's spot feed, and every spot another program posts arrives on
|
||||||
|
// Flex.OnForeignSpot.
|
||||||
|
//
|
||||||
|
// So the whole feature is: recognise the marker, move the TRANSMIT slice there,
|
||||||
|
// leave the receive slice on the DX. Nothing here decodes anything.
|
||||||
|
//
|
||||||
|
// Three deliberate choices:
|
||||||
|
//
|
||||||
|
// - the marker text is a SETTING, not a constant. It is chosen in SDC, and
|
||||||
|
// any guess made here would be wrong for the operator who chose otherwise.
|
||||||
|
// - only the transmit slice moves, never the receive slice. Losing the DX is
|
||||||
|
// a worse outcome than a missed call.
|
||||||
|
// - the offset is signed and in Hz, because working "up a bit" from where the
|
||||||
|
// last station was answered is exactly how a pile-up is chased.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyFlexRSTChase = "flex.rst_chase"
|
||||||
|
|
||||||
|
// FlexRSTChase is the whole configuration.
|
||||||
|
type FlexRSTChase struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Markers are the skimmer's marker texts, comma-separated ("599,5NN").
|
||||||
|
// Matched against the spot's callsign field, case-insensitively and whole:
|
||||||
|
// a spot IS the marker or it is an ordinary callsign, and a substring rule
|
||||||
|
// would drag in any station whose call happens to contain the digits.
|
||||||
|
Markers string `json:"markers"`
|
||||||
|
// OffsetHz is added to the marker's frequency. Signed: chasing upward from
|
||||||
|
// the last station worked is the usual tactic, downward happens too.
|
||||||
|
OffsetHz int `json:"offset_hz"`
|
||||||
|
// SplitOnly refuses to act when the radio is not in split. On by default:
|
||||||
|
// out of split the transmit slice IS the receive slice, so "move the TX
|
||||||
|
// slice" would take the operator off the DX they are listening to.
|
||||||
|
SplitOnly bool `json:"split_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultFlexRSTChase = FlexRSTChase{Enabled: false, Markers: "599", OffsetHz: 0, SplitOnly: true}
|
||||||
|
|
||||||
|
// rstChaseMinGap throttles the moves. A skimmer marks every report it decodes,
|
||||||
|
// and a busy pile-up produces several a second; without a floor the transmit
|
||||||
|
// slice would twitch continuously and never be anywhere long enough to call.
|
||||||
|
const rstChaseMinGap = 700 * time.Millisecond
|
||||||
|
|
||||||
|
// rstChaseMinStep ignores a marker that lands where the slice already is.
|
||||||
|
// Re-sending the same frequency is not free: it is a command to the radio and a
|
||||||
|
// slice status back, several times a second, for no change at all.
|
||||||
|
const rstChaseMinStep = 20 // Hz
|
||||||
|
|
||||||
|
var (
|
||||||
|
rstChaseMu sync.Mutex
|
||||||
|
rstChaseLast time.Time
|
||||||
|
rstChaseFreq int64
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetFlexRSTChase returns the stored configuration (defaults when unset).
|
||||||
|
func (a *App) GetFlexRSTChase() FlexRSTChase {
|
||||||
|
s := defaultFlexRSTChase
|
||||||
|
if a.settings == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// settingOr, NOT settings.Get with profileScope(): the store already applies
|
||||||
|
// the active profile's prefix, so scoping the key here wrote p3.flex.rst_chase
|
||||||
|
// and read p3.p3.flex.rst_chase — the switch could never be read back on, and
|
||||||
|
// the feature did nothing at all with no sign of why.
|
||||||
|
if v := a.settingOr(keyFlexRSTChase, ""); strings.TrimSpace(v) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(v), &s)
|
||||||
|
}
|
||||||
|
return normRSTChase(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFlexRSTChase stores the configuration.
|
||||||
|
func (a *App) SaveFlexRSTChase(s FlexRSTChase) error {
|
||||||
|
b, err := json.Marshal(normRSTChase(s))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyFlexRSTChase, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFlexRSTChaseEnabled flips the switch alone.
|
||||||
|
//
|
||||||
|
// Its own binding because this belongs on a button in the panel, not in a
|
||||||
|
// settings page: it is turned on when a DXpedition appears and off when it is
|
||||||
|
// worked, which is a thing done mid-QSO with one hand.
|
||||||
|
func (a *App) SetFlexRSTChaseEnabled(on bool) error {
|
||||||
|
s := a.GetFlexRSTChase()
|
||||||
|
s.Enabled = on
|
||||||
|
return a.SaveFlexRSTChase(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normRSTChase(s FlexRSTChase) FlexRSTChase {
|
||||||
|
if strings.TrimSpace(s.Markers) == "" {
|
||||||
|
s.Markers = defaultFlexRSTChase.Markers
|
||||||
|
}
|
||||||
|
// A pile-up is a few kHz wide. Anything past that is a typo (Hz entered as
|
||||||
|
// if it were kHz), and honouring it would transmit far outside the segment.
|
||||||
|
if s.OffsetHz > 10000 {
|
||||||
|
s.OffsetHz = 10000
|
||||||
|
}
|
||||||
|
if s.OffsetHz < -10000 {
|
||||||
|
s.OffsetHz = -10000
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// rstChaseMarkerSet splits the configured markers into a comparison set.
|
||||||
|
func rstChaseMarkerSet(markers string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, m := range strings.FieldsFunc(markers, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
||||||
|
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
|
||||||
|
out[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleForeignSpot is what a skimmer's spot arrives at.
|
||||||
|
func (a *App) handleForeignSpot(callsign string, freqHz int64) {
|
||||||
|
cfg := a.GetFlexRSTChase()
|
||||||
|
if !cfg.Enabled || a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !rstChaseMarkerSet(cfg.Markers)[strings.ToUpper(strings.TrimSpace(callsign))] {
|
||||||
|
return // an ordinary spot: another station's callsign, not a report
|
||||||
|
}
|
||||||
|
// From here on every refusal is logged. A marker WAS recognised, so the
|
||||||
|
// operator is entitled to know why the slice stayed where it was — silence
|
||||||
|
// at this point is indistinguishable from a feature that does not work.
|
||||||
|
if cfg.SplitOnly && !a.cat.State().Split {
|
||||||
|
applog.Printf("rst chase: %s marked at %s, but the radio is not in split — ignored",
|
||||||
|
strings.ToUpper(callsign), hzText(freqHz))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := freqHz + int64(cfg.OffsetHz)
|
||||||
|
if target <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
rstChaseMu.Lock()
|
||||||
|
if now.Sub(rstChaseLast) < rstChaseMinGap {
|
||||||
|
rstChaseMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rstChaseFreq != 0 && absInt64(target-rstChaseFreq) < rstChaseMinStep {
|
||||||
|
rstChaseMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rstChaseLast, rstChaseFreq = now, target
|
||||||
|
rstChaseMu.Unlock()
|
||||||
|
|
||||||
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
return fc.SetTXSliceFrequency(target)
|
||||||
|
}); err != nil {
|
||||||
|
applog.Printf("rst chase: moving the TX slice to %s failed: %v", hzText(target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("rst chase: %s marked at %s → TX slice to %s (offset %+d Hz)",
|
||||||
|
strings.ToUpper(callsign), hzText(freqHz), hzText(target), cfg.OffsetHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hzText renders a frequency the way an operator reads one on a dial.
|
||||||
|
func hzText(hz int64) string {
|
||||||
|
return strconv.FormatFloat(float64(hz)/1000, 'f', 3, 64) + " kHz"
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Panadapter auto-zoom on a spot click.
|
||||||
|
//
|
||||||
|
// A CW signal is 100 Hz wide, an FT8 slot 50 Hz in a 3 kHz sub-band, an SSB
|
||||||
|
// station 2.7 kHz. One panadapter width cannot serve all three: at 200 kHz the
|
||||||
|
// CW station you clicked is a hair, and at 25 kHz an FT8 window shows one
|
||||||
|
// eighth of the traffic. So the width follows the MODE of the spot, which is
|
||||||
|
// the only thing that determines how much spectrum is worth looking at.
|
||||||
|
//
|
||||||
|
// The command is SmartSDR's own: "display pan s <pan> bandwidth=<MHz>", with an
|
||||||
|
// optional re-centre. Same approach as DXHunter, which is where the defaults
|
||||||
|
// below come from.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyFlexZoom = "flex.spot_zoom"
|
||||||
|
|
||||||
|
// FlexZoomSettings is the per-mode-group visible width, in kHz.
|
||||||
|
//
|
||||||
|
// kHz and not MHz: an operator thinks "25 kHz of CW", and a form asking for
|
||||||
|
// 0.025 invites a zero too many — which on a panadapter is not an error message
|
||||||
|
// but a display that has silently gone somewhere else.
|
||||||
|
type FlexZoomSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CWkHz int `json:"cw_khz"`
|
||||||
|
SSBkHz int `json:"ssb_khz"`
|
||||||
|
DigikHz int `json:"digi_khz"`
|
||||||
|
// Centre re-centres the panadapter on EVERY spot, not just the ones that
|
||||||
|
// would otherwise fall outside the new window — the display always follows.
|
||||||
|
// Off by default: an operator who has arranged their window around a run
|
||||||
|
// frequency does not necessarily want it moved every time they look at
|
||||||
|
// someone else, and a spot already on screen stays where it is either way.
|
||||||
|
Centre bool `json:"centre"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultFlexZoom — the widths DXHunter settled on, in kHz.
|
||||||
|
var defaultFlexZoom = FlexZoomSettings{Enabled: false, CWkHz: 25, SSBkHz: 200, DigikHz: 50, Centre: false}
|
||||||
|
|
||||||
|
// flexZoomGroup maps a spot's mode to the three widths worth distinguishing.
|
||||||
|
//
|
||||||
|
// The mode arrives as whatever the cluster comment implied — "CW", "FT8",
|
||||||
|
// "USB", "RTTY" — so this takes the ADIF-ish name, not the Flex one. Anything
|
||||||
|
// unrecognised is digital, which is the safe end: a 50 kHz window is readable
|
||||||
|
// for every mode, where 25 kHz is not.
|
||||||
|
func flexZoomGroup(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW", "CWR", "CWL", "CWU":
|
||||||
|
return "cw"
|
||||||
|
case "SSB", "USB", "LSB", "AM", "FM", "NFM", "DFM", "SAM", "PHONE", "DV":
|
||||||
|
return "ssb"
|
||||||
|
}
|
||||||
|
return "digi"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFlexZoom returns the settings, defaults included.
|
||||||
|
func (a *App) GetFlexZoom() FlexZoomSettings {
|
||||||
|
out := defaultFlexZoom
|
||||||
|
if raw := a.settingOr(keyFlexZoom, ""); raw != "" {
|
||||||
|
var s FlexZoomSettings
|
||||||
|
if err := json.Unmarshal([]byte(raw), &s); err == nil {
|
||||||
|
out = normFlexZoom(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normFlexZoom clamps the widths to something a panadapter can actually show.
|
||||||
|
// 1 kHz is narrower than most filters; 14 MHz is a whole HF spectrum.
|
||||||
|
func normFlexZoom(s FlexZoomSettings) FlexZoomSettings {
|
||||||
|
clamp := func(v, def int) int {
|
||||||
|
if v < 1 || v > 14000 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
s.CWkHz = clamp(s.CWkHz, defaultFlexZoom.CWkHz)
|
||||||
|
s.SSBkHz = clamp(s.SSBkHz, defaultFlexZoom.SSBkHz)
|
||||||
|
s.DigikHz = clamp(s.DigikHz, defaultFlexZoom.DigikHz)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFlexZoom persists the settings.
|
||||||
|
func (a *App) SaveFlexZoom(s FlexZoomSettings) error {
|
||||||
|
b, err := json.Marshal(normFlexZoom(s))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyFlexZoom, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexZoomForSpot resizes the panadapter for a spot that was just clicked.
|
||||||
|
//
|
||||||
|
// Called from the click handler, AFTER the rig has been tuned: the zoom is a
|
||||||
|
// view change and must never delay the frequency change an operator is waiting
|
||||||
|
// for. Does nothing at all unless the option is on and the backend is a Flex —
|
||||||
|
// no other radio has a panadapter to zoom.
|
||||||
|
func (a *App) FlexZoomForSpot(mode string, freqHz int64) {
|
||||||
|
if a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
z := a.GetFlexZoom()
|
||||||
|
if !z.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
khz := z.DigikHz
|
||||||
|
switch flexZoomGroup(mode) {
|
||||||
|
case "cw":
|
||||||
|
khz = z.CWkHz
|
||||||
|
case "ssb":
|
||||||
|
khz = z.SSBkHz
|
||||||
|
}
|
||||||
|
_ = a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
return fc.ZoomPan(float64(khz)/1000, float64(freqHz)/1e6, z.Centre)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The width follows the mode, and an unrecognised mode must land on the widest
|
||||||
|
// of the three: a 50 kHz window is readable for everything, where 25 kHz shows
|
||||||
|
// one eighth of an FT8 sub-band.
|
||||||
|
func TestFlexZoomGroup(t *testing.T) {
|
||||||
|
for mode, want := range map[string]string{
|
||||||
|
"CW": "cw", "cw": "cw", "CWR": "cw",
|
||||||
|
"SSB": "ssb", "USB": "ssb", "LSB": "ssb", "FM": "ssb", "AM": "ssb",
|
||||||
|
"FT8": "digi", "FT4": "digi", "RTTY": "digi", "JS8": "digi", "PSK31": "digi",
|
||||||
|
"": "digi", "something new": "digi",
|
||||||
|
} {
|
||||||
|
if got := flexZoomGroup(mode); got != want {
|
||||||
|
t.Errorf("flexZoomGroup(%q) = %q, want %q", mode, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A width of zero would command "bandwidth=0.000000" and take the panadapter
|
||||||
|
// somewhere the operator cannot get back from by eye.
|
||||||
|
func TestNormFlexZoomClamps(t *testing.T) {
|
||||||
|
got := normFlexZoom(FlexZoomSettings{CWkHz: 0, SSBkHz: -5, DigikHz: 99999})
|
||||||
|
if got.CWkHz != defaultFlexZoom.CWkHz || got.SSBkHz != defaultFlexZoom.SSBkHz || got.DigikHz != defaultFlexZoom.DigikHz {
|
||||||
|
t.Errorf("out-of-range widths not replaced by the defaults: %+v", got)
|
||||||
|
}
|
||||||
|
ok := normFlexZoom(FlexZoomSettings{CWkHz: 10, SSBkHz: 150, DigikHz: 40})
|
||||||
|
if ok.CWkHz != 10 || ok.SSBkHz != 150 || ok.DigikHz != 40 {
|
||||||
|
t.Errorf("valid widths must be kept as they are: %+v", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2439
-372
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
@@ -102,17 +102,40 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
return alerts.Rule.createFrom({ ...d, [key]: next });
|
return alerts.Rule.createFrom({ ...d, [key]: next });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Saving used to be silent: the dialog stays open and the rule looks exactly
|
||||||
|
// as it did a second earlier, so there was nothing to tell an operator whether
|
||||||
|
// the click had registered. A line that says so, and clears itself.
|
||||||
|
const [savedMsg, setSavedMsg] = useState('');
|
||||||
|
const savedTimer = useRef(0);
|
||||||
|
function flashSaved(text: string) {
|
||||||
|
setSavedMsg(text);
|
||||||
|
window.clearTimeout(savedTimer.current);
|
||||||
|
savedTimer.current = window.setTimeout(() => setSavedMsg(''), 3000);
|
||||||
|
}
|
||||||
|
useEffect(() => () => window.clearTimeout(savedTimer.current), []);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||||
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
|
try {
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
const saved = await SaveAlertRule(draft);
|
||||||
|
await refresh();
|
||||||
|
loadDraft(saved as Rule);
|
||||||
|
setErr('');
|
||||||
|
flashSaved(t('altm.saved', { name: draft.name.trim() }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function del() {
|
async function del() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!draft.id) { loadDraft(null); return; }
|
if (!draft.id) { loadDraft(null); return; }
|
||||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||||
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
|
try {
|
||||||
|
const name = draft.name;
|
||||||
|
await DeleteAlertRule(draft.id);
|
||||||
|
loadDraft(null);
|
||||||
|
await refresh();
|
||||||
|
flashSaved(t('altm.deleted', { name }));
|
||||||
|
}
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +280,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
{/* Editor actions */}
|
{/* Editor actions */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||||
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
||||||
|
{!err && savedMsg && <span className="text-[11px] text-success flex-1 truncate">{savedMsg}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||||
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
||||||
|
|||||||
@@ -174,6 +174,45 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||||
const connected = !!pg.connected || viaFlex;
|
const connected = !!pg.connected || viaFlex;
|
||||||
const fault = flex?.amp_fault;
|
const fault = flex?.amp_fault;
|
||||||
|
|
||||||
|
// Meters built from the amplifier's own GSCP status frame, for when the radio
|
||||||
|
// is not feeding a meter stream.
|
||||||
|
//
|
||||||
|
// Whether there is power is the amp's state field; how much is the plain
|
||||||
|
// forward figure. NOT "peakfwd" — that is a latched maximum which is never
|
||||||
|
// reset and survives in the last-known status after the amp disconnects, so it
|
||||||
|
// once claimed 1350 W from an old transmission while 10 W was going out. Same
|
||||||
|
// reason peak_id is left alone. Both readings are gated on transmit so they
|
||||||
|
// fall back to zero between overs instead of freezing on the last one.
|
||||||
|
const pgxlMeters = () => {
|
||||||
|
if (!pg.connected) return null;
|
||||||
|
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : /TRANSMIT/i.test(pg.state || '');
|
||||||
|
const fwdW = peakHold('pgfwd', txing ? Number(pg.fwd_w) || 0 : 0);
|
||||||
|
const idA = peakHold('pgid', txing ? Number(pg.id) || 0 : 0);
|
||||||
|
const swr = peakHold('pgswr', txing ? Number(pg.vswr) || 0 : 0);
|
||||||
|
const tempC = Number(pg.temperature) || 0;
|
||||||
|
// Two columns, not four. The card sits beside a tall neighbour in Station
|
||||||
|
// Control, so a single row of four leaves the height empty and squeezes each
|
||||||
|
// bar into a quarter width — two rows of two use the room that is already
|
||||||
|
// there and give every bar twice the resolution.
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||||
|
display={`${Math.round(fwdW)} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
<MeterBar label={t('ampw.id')} value={idA} lo={0} hi={25} display={`${idA.toFixed(1)} A`} accent="#16a34a" />
|
||||||
|
{/* Below 1:1 the reading is meaningless, so an idle amp shows a flat bar
|
||||||
|
rather than a zero that looks like a perfect match. */}
|
||||||
|
<MeterBar label={t('ampw.swr')} value={swr >= 1 ? swr : 1} lo={1} hi={3}
|
||||||
|
display={swr >= 1 ? swr.toFixed(1) : '—'}
|
||||||
|
segColor={(f) => (f > 0.75 ? '#dc2626' : f > 0.4 ? '#f59e0b' : '#16a34a')} />
|
||||||
|
<MeterBar label={t('ampw.temp')} value={tempC} unit="°C" lo={0} hi={100}
|
||||||
|
display={tempC > 0 ? `${Math.round(tempC)} °C` : '—'}
|
||||||
|
segColor={(f) => (f > 0.8 ? '#dc2626' : f > 0.6 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -204,13 +243,20 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
{/* Amplifier meters (FWD / ID / TEMP …).
|
||||||
{viaFlex && (() => {
|
The FlexRadio UDP stream is the preferred source — it is fast and reads
|
||||||
|
the same as SmartSDR. When there is no Flex, or it is not streaming,
|
||||||
|
the amplifier's OWN link carries the same figures; falling back to them
|
||||||
|
is what the docked widget already does. Without that fallback this card
|
||||||
|
showed an operator on a Kenwood nothing but OPERATE and the fan mode,
|
||||||
|
while the amplifier was reporting power, current and temperature all
|
||||||
|
along. */}
|
||||||
|
{(() => {
|
||||||
const meters = (flex?.meters as any[]) || [];
|
const meters = (flex?.meters as any[]) || [];
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (amps.length === 0) return null;
|
if (!viaFlex || amps.length === 0) return pgxlMeters();
|
||||||
// Power comes from the radio's meter stream and nothing else. The
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||||
// over: it is a latched maximum that is never reset, and it survives in
|
// over: it is a latched maximum that is never reset, and it survives in
|
||||||
|
|||||||
@@ -189,13 +189,20 @@ function AmpBlock({ amp, flex, showName, t }: {
|
|||||||
const txing = typeof flex?.transmitting === 'boolean'
|
const txing = typeof flex?.transmitting === 'boolean'
|
||||||
? flex.transmitting
|
? flex.transmitting
|
||||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||||
|
// Prefer the radio's VITA meter stream when it is actually flowing (local or
|
||||||
|
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
|
||||||
|
// link only when the radio isn't streaming its meters (a remote link without
|
||||||
|
// the VITA path), so the reading survives but is naturally slower.
|
||||||
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
||||||
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||||
const id = meter(/^ID$|current/i);
|
const id = meter(/^ID$|current/i);
|
||||||
// Live drain current, for the same reason: peak_id latches like peak_w.
|
// Live drain current, same source preference. peak_id latches like peak_w, so
|
||||||
|
// use the instantaneous 'id' and gate it on the radio's transmit flag.
|
||||||
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
||||||
const temp = meter(/temp/i);
|
const temp = meter(/temp/i);
|
||||||
const tempC = pg.temperature > 0 ? pg.temperature : (temp ? temp.value : undefined);
|
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
|
||||||
|
// that can read ~10°C higher); fall back to the direct temp when no VITA.
|
||||||
|
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-1.5">
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { RowColorSettings } from '@/lib/rowColors';
|
||||||
|
import {
|
||||||
|
MATRIX_VARS, applyMatrixColors, effectiveMatrixColor, emptyMatrixColors,
|
||||||
|
type MatrixColors,
|
||||||
|
} from '@/lib/matrixColors';
|
||||||
|
|
||||||
|
// A fixed palette plus a free picker. Muted values on purpose: they are
|
||||||
|
// composited at low opacity over a dark grid, where a saturated colour reads as
|
||||||
|
// an error state rather than a status.
|
||||||
|
const PALETTE = [
|
||||||
|
'#16a34a', '#0ea5e9', '#f59e0b', '#a855f7',
|
||||||
|
'#dc2626', '#14b8a6', '#eab308', '#ec4899',
|
||||||
|
'#64748b', '#84cc16', '#6366f1', '#f97316',
|
||||||
|
];
|
||||||
|
|
||||||
|
// The rule ids the backend orders; the labels live here so a translation never
|
||||||
|
// travels through the settings row.
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
to_send: 'appr.ruleToSend',
|
||||||
|
confirmed: 'appr.ruleConfirmed',
|
||||||
|
sent: 'appr.ruleSent',
|
||||||
|
worked: 'appr.ruleWorked',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The channels a rule can be scoped to. "worked" is the catch-all — it means
|
||||||
|
// nothing on ANY channel — so narrowing it would say nothing.
|
||||||
|
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const;
|
||||||
|
const CHANNEL_LABELS: Record<string, string> = {
|
||||||
|
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com', hamlog: 'HAMLOG.online',
|
||||||
|
};
|
||||||
|
|
||||||
|
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
|
||||||
|
// Stats panel.
|
||||||
|
//
|
||||||
|
// The pickers are seeded from what the matrix is painting RIGHT NOW (the active
|
||||||
|
// theme's ramp, or an existing override), not from a fixed palette: the operator
|
||||||
|
// starts from the colours in front of them and moves one, instead of being
|
||||||
|
// handed six values that may belong to a theme they stopped using. Every change
|
||||||
|
// is applied to the live document at once, so the sample row below is the real
|
||||||
|
// thing rather than a mock-up of it.
|
||||||
|
function MatrixColorsSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<MatrixColors | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setCfg((await GetMatrixColors()) as any);
|
||||||
|
} catch {
|
||||||
|
setCfg(emptyMatrixColors());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = (next: MatrixColors) => {
|
||||||
|
setCfg(next);
|
||||||
|
applyMatrixColors(next); // live, before the round trip — the panel must not lag the choice
|
||||||
|
SaveMatrixColors(next as any).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Turning it ON with nothing stored would change nothing at all and read as a
|
||||||
|
// broken switch, so the empty slots are filled from the theme's current ramp:
|
||||||
|
// the operator sees six swatches that match the grid and edits from there.
|
||||||
|
const enable = (on: boolean) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
if (!on) {
|
||||||
|
save({ ...cfg, enabled: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seeded = { ...cfg, enabled: true };
|
||||||
|
for (const { key, cssVar } of MATRIX_VARS) {
|
||||||
|
if (!String(seeded[key] ?? '').trim()) seeded[key] = effectiveMatrixColor(cssVar);
|
||||||
|
}
|
||||||
|
save(seeded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset clears the overrides but keeps the section switched on, then re-seeds
|
||||||
|
// from the theme — "back to the theme's colours", which is what an operator
|
||||||
|
// means by reset here, rather than "switch the whole feature off".
|
||||||
|
const reset = () => {
|
||||||
|
if (!cfg) return;
|
||||||
|
applyMatrixColors({ ...emptyMatrixColors(), enabled: false });
|
||||||
|
const seeded = { ...emptyMatrixColors(), enabled: true };
|
||||||
|
for (const { key, cssVar } of MATRIX_VARS) seeded[key] = effectiveMatrixColor(cssVar);
|
||||||
|
save(seeded);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} className="mt-0.5" onCheckedChange={(c) => enable(!!c)} />
|
||||||
|
<span>
|
||||||
|
{t('appr.matrixEnable')}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground">{t('appr.matrixHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
{MATRIX_VARS.map(({ key, cssVar, label }) => (
|
||||||
|
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={String(cfg[key] || '').trim() || effectiveMatrixColor(cssVar)}
|
||||||
|
onChange={(e) => save({ ...cfg, [key]: e.target.value })}
|
||||||
|
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
|
{t(label)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The matrix as it will actually look: same tokens, same shapes. */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground w-10 shrink-0">{t('appr.matrixSample')}</span>
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-call-conf" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-call-work" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-dx-conf" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={reset}
|
||||||
|
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground">
|
||||||
|
{t('appr.matrixReset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppearancePanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try { setCfg((await GetRowColors()) as any); } catch { /* defaults on the backend */ }
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = (next: RowColorSettings) => {
|
||||||
|
setCfg(next);
|
||||||
|
SaveRowColors(next as any).catch(() => {});
|
||||||
|
};
|
||||||
|
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// The rule card shows the row exactly as the grid will draw it, so the choice
|
||||||
|
// is made by looking rather than by imagining.
|
||||||
|
const preview = (color: string): Record<string, string> => {
|
||||||
|
const st = cfg?.style ?? 'bar';
|
||||||
|
const pct = cfg?.intensity ?? 12;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (st === 'tint' || st === 'both') out.backgroundColor = `color-mix(in srgb, ${color} ${pct}%, transparent)`;
|
||||||
|
if (st === 'bar' || st === 'both') out.boxShadow = `inset 3px 0 0 ${color}`;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg) return <div className="p-1 text-sm text-muted-foreground">…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!cfg.bandmap_lotw} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => save({ ...cfg, bandmap_lotw: !!c } as any)} />
|
||||||
|
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Banding is its own thing, above the QSL rules and outside them: it says
|
||||||
|
nothing about a contact, it only helps the eye keep its line across a
|
||||||
|
wide table. Turning the status colours off must not take it away. */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={(cfg as any).recent_zebra !== 'off'} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => save({ ...cfg, recent_zebra: c ? '' : 'off' } as any)} />
|
||||||
|
<span>{t('appr.zebra')} <span className="text-xs text-muted-foreground">{t('appr.zebraHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
{(cfg as any).recent_zebra !== 'off' && (
|
||||||
|
<div className="flex items-center gap-2 pl-6">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('appr.zebraColor')}</span>
|
||||||
|
<input type="color" className="size-7 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test((cfg as any).recent_zebra_color ?? '') ? (cfg as any).recent_zebra_color : '#808080'}
|
||||||
|
onChange={(e) => save({ ...cfg, recent_zebra_color: e.target.value } as any)} />
|
||||||
|
{/* Empty means "follow the theme", and there has to be a way back to
|
||||||
|
it — a colour chosen under one theme is wrong under the other. */}
|
||||||
|
{(cfg as any).recent_zebra_color && (
|
||||||
|
<button type="button" className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => save({ ...cfg, recent_zebra_color: '' } as any)}>{t('appr.zebraAuto')}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||||||
|
<span>{t('appr.enable')} <span className="text-xs text-muted-foreground">{t('appr.enableHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Style first: it decides whether the colours below are a signal or a
|
||||||
|
wallpaper, which matters more than which hue they are. */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-sm">{t('appr.style')}</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||||
|
{(['bar', 'tint', 'both'] as const).map((v) => (
|
||||||
|
<button key={v} type="button" onClick={() => save({ ...cfg, style: v })}
|
||||||
|
className={cn('px-3 py-1.5 font-medium', (cfg.style ?? 'bar') === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('appr.style' + v[0].toUpperCase() + v.slice(1))}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(cfg.style ?? 'bar') !== 'bar' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
{t('appr.intensity')}
|
||||||
|
<input type="range" min={5} max={45} step={1} value={cfg.intensity ?? 12}
|
||||||
|
onChange={(e) => save({ ...cfg, intensity: parseInt(e.target.value, 10) })}
|
||||||
|
className="w-32 accent-[var(--primary)]" />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground w-8">{cfg.intensity ?? 12}%</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Order matters and is shown: a contact is usually several of these at
|
||||||
|
once, and the first match wins. */}
|
||||||
|
<p className="text-xs text-muted-foreground">{t('appr.orderHint')}</p>
|
||||||
|
{cfg.rules.map((r, i) => (
|
||||||
|
<div key={r.id} className="rounded-lg border border-border/60 p-2.5 space-y-2"
|
||||||
|
style={r.enabled ? preview(r.color) : undefined}>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={r.enabled} onCheckedChange={(c) => patchRule(r.id, { enabled: !!c })} />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
|
||||||
|
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
|
||||||
|
</label>
|
||||||
|
{/* Which channels this category looks at. None ticked = all of
|
||||||
|
them, which is what an unnarrowed rule should mean. */}
|
||||||
|
{r.enabled && r.id !== 'worked' && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
|
||||||
|
{CHANNELS.map((c) => {
|
||||||
|
const on = !r.channels?.length || r.channels.includes(c);
|
||||||
|
return (
|
||||||
|
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Checkbox checked={on} onCheckedChange={(v) => {
|
||||||
|
const cur = r.channels?.length ? r.channels : [...CHANNELS];
|
||||||
|
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
|
||||||
|
patchRule(r.id, { channels: next });
|
||||||
|
}} />
|
||||||
|
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.enabled && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap pl-6">
|
||||||
|
{PALETTE.map((c) => (
|
||||||
|
<button key={c} type="button" title={c}
|
||||||
|
onClick={() => patchRule(r.id, { color: c })}
|
||||||
|
className={cn('size-6 rounded-md border-2 transition-transform hover:scale-110',
|
||||||
|
r.color.toLowerCase() === c ? 'border-foreground' : 'border-transparent')}
|
||||||
|
style={{ backgroundColor: c }} />
|
||||||
|
))}
|
||||||
|
<input type="color" value={r.color} title={t('appr.custom')}
|
||||||
|
onChange={(e) => patchRule(r.id, { color: e.target.value })}
|
||||||
|
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MatrixColorsSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle, Share2 } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -14,12 +14,12 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import {
|
import {
|
||||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||||
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
|
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference, RenameAwardReference,
|
||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
ExportAwardForCatalog,
|
ExportAwardForCatalog, ExportAward,
|
||||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -34,10 +34,13 @@ export type AwardDef = {
|
|||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
ref_display?: string; // grid column shows: ref | name | both
|
ref_display?: string; // grid column shows: ref | name | both
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
leading_str?: string; trailing_str?: string; prefix?: string; dynamic?: boolean;
|
||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||||
|
// The "custom" confirmation source: the field it reads and, optionally, the
|
||||||
|
// value(s) that count. Empty value = any non-empty value confirms.
|
||||||
|
confirm_field?: string; confirm_value?: string;
|
||||||
total: number; builtin?: boolean; version?: number;
|
total: number; builtin?: boolean; version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,9 +62,14 @@ type Preset = { key: string; name: string; field: string; dxcc: number; refs: Aw
|
|||||||
// organizational only; matching is driven by the field/pattern/dynamic options,
|
// organizational only; matching is driven by the field/pattern/dynamic options,
|
||||||
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
|
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
|
||||||
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
|
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
|
||||||
|
// Sentinel for "no QSO field at all". The definition stores an empty field, and
|
||||||
|
// the matcher already treats that as "nothing to scan" — this only gives the
|
||||||
|
// operator a way to ASK for it.
|
||||||
|
const NO_FIELD = '__none__';
|
||||||
const CONFIRM_SRC = [
|
const CONFIRM_SRC = [
|
||||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'hamlog', label: 'HAMLOG.online' },
|
||||||
|
{ id: 'custom', label: 'Custom' },
|
||||||
];
|
];
|
||||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||||
@@ -94,6 +102,13 @@ function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onTog
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Awards use a far-future date as "no end" (RDA carries 9999-12-31). Printing it
|
||||||
|
// back at the operator reads like a real deadline, so show it as open-ended.
|
||||||
|
function openEnded(d?: string): string {
|
||||||
|
if (!d) return '—';
|
||||||
|
return /^9\d{3}-/.test(d) ? '—' : d;
|
||||||
|
}
|
||||||
|
|
||||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||||
@@ -267,6 +282,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}, [testRows]);
|
}, [testRows]);
|
||||||
|
const noField = !String(cur?.field ?? '').trim();
|
||||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
const arr = ((cur?.[key] as string[]) ?? []);
|
const arr = ((cur?.[key] as string[]) ?? []);
|
||||||
@@ -304,6 +320,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export the SELECTED award on its own — the unit you actually share. The
|
||||||
|
// bundle above is a backup: sending it to someone hands over your whole
|
||||||
|
// catalogue when they asked for one award.
|
||||||
|
//
|
||||||
|
// Distinct from the catalog publish below, which is for shipping an award INTO
|
||||||
|
// OpsLog: that one stamps a version and clears user_edited. This one is a plain
|
||||||
|
// share and leaves both alone, so the recipient's copy is correctly marked as
|
||||||
|
// someone else's work rather than as a pristine built-in.
|
||||||
|
async function exportOne() {
|
||||||
|
setErr('');
|
||||||
|
if (!cur) return;
|
||||||
|
try {
|
||||||
|
const code = cur.code.trim().toUpperCase();
|
||||||
|
const p = await ExportAward(code);
|
||||||
|
if (p) setErr(t('awed.exportedOneTo', { code, path: p }));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||||
@@ -539,11 +572,30 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||||
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
|
||||||
<Field2 label={t('awed.searchInField')}>
|
<Field2 label={t('awed.searchInField')}>
|
||||||
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
|
{/* NO_FIELD is a sentinel because the stored value is the
|
||||||
|
empty string and a Select cannot carry one — an empty
|
||||||
|
item value means "show the placeholder" to Radix. */}
|
||||||
|
{/* Picking "no field" clears the fallbacks too: it promises that
|
||||||
|
nothing is matched automatically, and an OR rule left behind goes
|
||||||
|
on scanning its own field — worse than a visible one, because the
|
||||||
|
panel stops showing it. */}
|
||||||
|
<Select value={cur.field || NO_FIELD}
|
||||||
|
onValueChange={(v) => patch(v === NO_FIELD ? { field: '', or_rules: [] } : { field: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
<SelectContent className="max-h-72">
|
||||||
|
<SelectItem value={NO_FIELD}>{t('awed.fieldNone')}</SelectItem>
|
||||||
|
{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field2>
|
</Field2>
|
||||||
|
{/* An award whose references live in no ADIF field at all —
|
||||||
|
WWBOTA has no column anywhere — has nothing to match on,
|
||||||
|
and every control below would be a question with no
|
||||||
|
answer. Hiding them is the point: the references are the
|
||||||
|
ones the operator assigns to a QSO by hand. */}
|
||||||
|
{noField ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground pl-[128px]">{t('awed.fieldNoneHint')}</p>
|
||||||
|
) : (<>
|
||||||
<Field2 label={t('awed.matchBy')}>
|
<Field2 label={t('awed.matchBy')}>
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
{['code', 'description', 'pattern'].map((m) => (
|
{['code', 'description', 'pattern'].map((m) => (
|
||||||
@@ -556,15 +608,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
|
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
|
||||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||||
|
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</>)}
|
||||||
{/* Fallback searches: tried in order, only while nothing
|
{/* Fallback searches: tried in order, only while nothing
|
||||||
has matched yet — the first that hits wins (short-circuit),
|
has matched yet — the first that hits wins (short-circuit),
|
||||||
so a value already resolved by the primary rule isn't
|
so a value already resolved by the primary rule isn't
|
||||||
re-derived differently by a later one. */}
|
re-derived differently by a later one.
|
||||||
|
|
||||||
|
Shown even with no field when rules survive from an
|
||||||
|
imported definition: a rule that still runs must still
|
||||||
|
be visible, or the award finds references the panel
|
||||||
|
says it cannot. */}
|
||||||
|
{(!noField || (cur.or_rules?.length ?? 0) > 0) && (
|
||||||
<div className="border-t pt-2.5 space-y-2">
|
<div className="border-t pt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||||
@@ -602,6 +662,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -621,6 +682,30 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* "Custom" is only meaningful once it names a field, so the
|
||||||
|
inputs appear as soon as it is ticked in either column —
|
||||||
|
and a custom source with no field confirms nothing. */}
|
||||||
|
{((cur.confirm ?? []).includes('custom') || (cur.validate ?? []).includes('custom')) && (
|
||||||
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
|
<Label className="text-xs font-semibold">{t('awed.customLabel')}</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">{t('awed.customField')}</Label>
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="APP_OPSLOG_QSL_RCVD"
|
||||||
|
value={(cur as any).confirm_field ?? ''}
|
||||||
|
onChange={(e) => patch({ confirm_field: e.target.value.trim().toUpperCase() } as any)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">{t('awed.customValue')}</Label>
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="Y,V"
|
||||||
|
value={(cur as any).confirm_value ?? ''}
|
||||||
|
onChange={(e) => patch({ confirm_value: e.target.value } as any)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('awed.customHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* "Grant codes" and "export credit_granted" used to live here. No
|
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||||
ADIF export has ever written CREDIT_GRANTED, so both controls
|
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||||
did nothing at all. The stored values are kept (see award.Def);
|
did nothing at all. The stored values are kept (see award.Def);
|
||||||
@@ -733,6 +818,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<TabsContent value="refs" className="mt-0">
|
<TabsContent value="refs" className="mt-0">
|
||||||
<ReferencesPanel
|
<ReferencesPanel
|
||||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||||
|
awardValidFrom={cur.valid_from} awardValidTo={cur.valid_to}
|
||||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||||
onChanged={loadMeta} setErr={setErr}
|
onChanged={loadMeta} setErr={setErr}
|
||||||
/>
|
/>
|
||||||
@@ -752,6 +838,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Share just the selected one. Sitting next to the whole-catalogue
|
||||||
|
export because that is where an operator looks for it, and labelled
|
||||||
|
with the code so the two are never confused at a glance. */}
|
||||||
|
{cur && (
|
||||||
|
<Button variant="outline" onClick={exportOne} title={t('awed.exportOneTitle')}>
|
||||||
|
<Share2 className="size-3.5 mr-1" /> {t('awed.exportOne', { code: cur.code.trim().toUpperCase() })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -846,14 +940,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||||
// the online updater (POTA/SOTA/WWFF).
|
// the online updater (POTA/SOTA/WWFF).
|
||||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, onUpdateOnline, updating, onChanged, setErr }: {
|
||||||
code: string; presets: Preset[]; meta?: RefMeta;
|
code: string; presets: Preset[]; meta?: RefMeta; awardValidFrom?: string; awardValidTo?: string;
|
||||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [selCode, setSelCode] = useState<string | null>(null);
|
const [selCode, setSelCode] = useState<string | null>(null);
|
||||||
|
// The code as TYPED. The list and every patch key off selCode, so editing the
|
||||||
|
// code in place would make the editor lose the reference mid-edit; the draft
|
||||||
|
// is applied as a rename when the operator saves.
|
||||||
|
const [codeDraft, setCodeDraft] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [bulk, setBulk] = useState('');
|
const [bulk, setBulk] = useState('');
|
||||||
const [showBulk, setShowBulk] = useState(false);
|
const [showBulk, setShowBulk] = useState(false);
|
||||||
@@ -891,6 +989,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sel = refs.find((r) => r.code === selCode) || null;
|
const sel = refs.find((r) => r.code === selCode) || null;
|
||||||
|
useEffect(() => { setCodeDraft(selCode ?? ''); }, [selCode]);
|
||||||
// Large lists are already filtered by the server; small lists filter locally.
|
// Large lists are already filtered by the server; small lists filter locally.
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (large) return refs;
|
if (large) return refs;
|
||||||
@@ -904,6 +1003,27 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Save the selected reference, renumbering it first when the code was edited.
|
||||||
|
//
|
||||||
|
// The rename has to come first and has to be a rename: saving under the new
|
||||||
|
// code would simply create a second reference and leave the old one behind,
|
||||||
|
// which is how a list quietly grows duplicates.
|
||||||
|
async function saveSelected(r: AwardRef) {
|
||||||
|
const next = codeDraft.trim().toUpperCase();
|
||||||
|
if (!next) { setErr(t('awed.refCodeEmpty')); return; }
|
||||||
|
if (next !== r.code) {
|
||||||
|
try {
|
||||||
|
await RenameAwardReference(code, r.code, next);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Most often the number is already taken by another reference. Said
|
||||||
|
// here rather than swallowed: the save has NOT happened.
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelCode(next);
|
||||||
|
}
|
||||||
|
await saveRef({ ...r, code: next });
|
||||||
|
}
|
||||||
async function addRef() {
|
async function addRef() {
|
||||||
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
@@ -985,7 +1105,13 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
|
{/* Editable, because a shipped list can be wrong about it: WAJA
|
||||||
|
went out carrying Japan's civil prefecture numbers instead of
|
||||||
|
the JARL's, and correcting that meant deleting all 47
|
||||||
|
references and importing a new list. */}
|
||||||
|
<Input className="h-8 w-28 font-mono font-semibold" value={codeDraft}
|
||||||
|
title={t('awed.refCodeTip')}
|
||||||
|
onChange={(e) => setCodeDraft(e.target.value)} />
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||||
@@ -1003,7 +1129,27 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
third-party list may carry the values — but they are not offered
|
third-party list may carry the values — but they are not offered
|
||||||
for editing until something actually reads them. */}
|
for editing until something actually reads them. */}
|
||||||
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
{/* This reference's own validity window. A reference is not forever:
|
||||||
|
a park is delisted, a district merged. A QSO made while it
|
||||||
|
existed still counts — it was a valid contact on the day — and
|
||||||
|
one made afterwards does not.
|
||||||
|
Left empty the award's own dates govern, which is why they show
|
||||||
|
as the placeholder: the operator can see what "empty" inherits
|
||||||
|
instead of having to remember. */}
|
||||||
|
<Field2 label={t('awed.refValidFrom')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_from ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_from: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<Field2 label={t('awed.refValidTo')}>
|
||||||
|
<Input type="date" className="h-8 w-44" value={sel.valid_to ?? ''}
|
||||||
|
onChange={(e) => patchSel({ valid_to: e.target.value })} />
|
||||||
|
</Field2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(awardValidFrom || awardValidTo)
|
||||||
|
? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
|
||||||
|
: t('awed.refValidHint')}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveSelected(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { X, Plus, Loader2 } from 'lucide-react';
|
import { X, Plus, Loader2 } from 'lucide-react';
|
||||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../../wailsjs/go/main/App';
|
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta, AwardRefsNew, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean; valid?: boolean };
|
||||||
type Meta = { code: string; count: number; can_update: boolean };
|
type Meta = { code: string; count: number; can_update: boolean };
|
||||||
|
|
||||||
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
||||||
@@ -54,6 +54,34 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
const entries = value ? value.split(';').filter(Boolean) : [];
|
const entries = value ? value.split(';').filter(Boolean) : [];
|
||||||
|
|
||||||
|
// Which of the picked references have never been worked. Asked of the backend
|
||||||
|
// only when the list of references actually changes — a handful of times per
|
||||||
|
// QSO — and it answers from an in-memory set (see awardnew.go).
|
||||||
|
//
|
||||||
|
// The first question about an award may arrive before its set is built, in
|
||||||
|
// which case the answer is "pending" and carries no verdict for it: we ask
|
||||||
|
// again rather than badge on a guess. Attempts are capped so a logbook that
|
||||||
|
// never finishes building cannot leave a timer running for the session.
|
||||||
|
const [isNew, setIsNew] = useState<Record<string, boolean>>({});
|
||||||
|
const entriesKey = entries.join(';');
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entriesKey) { setIsNew({}); return; }
|
||||||
|
let alive = true;
|
||||||
|
let timer: number | undefined;
|
||||||
|
let tries = 0;
|
||||||
|
const ask = () => {
|
||||||
|
AwardRefsNew(entriesKey.split(';'))
|
||||||
|
.then((r: any) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setIsNew(r?.new ?? {});
|
||||||
|
if (r?.pending && ++tries < 10) timer = window.setTimeout(ask, 700);
|
||||||
|
})
|
||||||
|
.catch(() => { if (alive) setIsNew({}); }); // no badge beats a wrong one
|
||||||
|
};
|
||||||
|
ask();
|
||||||
|
return () => { alive = false; if (timer) window.clearTimeout(timer); };
|
||||||
|
}, [entriesKey]);
|
||||||
|
|
||||||
// When the reference set is cleared (a QSO was logged or the call was reset),
|
// When the reference set is cleared (a QSO was logged or the call was reset),
|
||||||
// also blank the picker's own state — the search box and the highlighted /
|
// also blank the picker's own state — the search box and the highlighted /
|
||||||
// selected reference — so the Awards tab starts fresh for the next contact.
|
// selected reference — so the Awards tab starts fresh for the next contact.
|
||||||
@@ -66,11 +94,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// The awards the operator FOLLOWS, exactly as the Awards tab reads them.
|
||||||
|
// This panel used to offer every award that existed, so a station chasing
|
||||||
|
// three of them was picking references out of a list of twenty.
|
||||||
|
const [tracked, setTracked] = useState<Set<string>>(new Set());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
Promise.all([GetAwardDefs(), GetAwardReferenceMeta(), GetTrackedAwards()])
|
||||||
.then(([d, m]) => {
|
.then(([d, m, tr]) => {
|
||||||
setDefs((d ?? []) as any);
|
setDefs((d ?? []) as any);
|
||||||
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
||||||
|
setTracked(new Set(((tr ?? []) as string[]).map((c) => String(c).toUpperCase())));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -83,6 +116,12 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
return defs.filter((d) => {
|
return defs.filter((d) => {
|
||||||
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||||
|
// Switched off in the award editor: not a candidate for anything.
|
||||||
|
if (d.valid === false) return false;
|
||||||
|
// Followed awards only, when a selection has been made. An empty
|
||||||
|
// selection means "all of them" — the same rule the Awards tab uses, so
|
||||||
|
// the two lists cannot disagree about what this station chases.
|
||||||
|
if (tracked.size > 0 && !tracked.has(String(d.code).toUpperCase())) return false;
|
||||||
const scope = d.dxcc_filter ?? [];
|
const scope = d.dxcc_filter ?? [];
|
||||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||||
// Offer the award even when its reference list isn't loaded yet: a custom
|
// Offer the award even when its reference list isn't loaded yet: a custom
|
||||||
@@ -94,7 +133,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
return true;
|
return true;
|
||||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
}, [defs, metas, dxcc]);
|
}, [defs, metas, dxcc, tracked]);
|
||||||
|
|
||||||
// Keep the selected award valid as the offered list changes with the call.
|
// Keep the selected award valid as the offered list changes with the call.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -256,7 +295,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
|
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
|
||||||
>
|
>
|
||||||
<span className="font-mono font-semibold flex-1 truncate">{entry}</span>
|
<span className="font-mono font-semibold truncate">{entry}</span>
|
||||||
|
{isNew[entry] && (
|
||||||
|
<span
|
||||||
|
title={t('awrs.newRefHint')}
|
||||||
|
className="shrink-0 px-1 rounded-sm text-[9px] leading-[14px] font-semibold tracking-wide bg-success-muted text-success border border-success-border"
|
||||||
|
>
|
||||||
|
{t('awrs.new')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
<button
|
<button
|
||||||
className="shrink-0 hover:text-destructive"
|
className="shrink-0 hover:text-destructive"
|
||||||
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
|
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
|
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import { AwardEditor } from '@/components/AwardEditor';
|
import { AwardEditor } from '@/components/AwardEditor';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
@@ -62,9 +64,15 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
// scoped: the award is limited to one or more DXCC entities. Missing-reference
|
||||||
|
// detection only means anything for those — see the Missing refs button.
|
||||||
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[]; scoped?: boolean };
|
||||||
|
|
||||||
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||||
|
onEditQSO?: (id: number) => void;
|
||||||
|
onAwardsChanged?: () => void;
|
||||||
|
onPaperQSL?: (call: string) => void;
|
||||||
|
} = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
// Computed results are cached per award code — each award is scanned only the
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
@@ -137,13 +145,20 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the award list (no QSO scan), then compute only the first award.
|
// Load the award list (no QSO scan), then compute only the first award. The
|
||||||
|
// list is narrowed to the awards the operator follows (Settings → Awards); an
|
||||||
|
// empty follow-set means "show them all" so the tab is never blank.
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
try {
|
try {
|
||||||
const defs = ((await GetAwardDefs()) ?? []) as any[];
|
const [defs, tracked] = await Promise.all([
|
||||||
const list: AwardListItem[] = defs
|
GetAwardDefs().then((d) => (d ?? []) as any[]),
|
||||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
|
||||||
|
]);
|
||||||
|
const follow = new Set(tracked);
|
||||||
|
let list: AwardListItem[] = defs
|
||||||
|
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [], scoped: (d.dxcc_filter ?? []).length > 0 }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
|
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
||||||
setAwardList(list);
|
setAwardList(list);
|
||||||
const first = list.find((a) => a.code === selected) ?? list[0];
|
const first = list.find((a) => a.code === selected) ?? list[0];
|
||||||
if (first) compute(first.code);
|
if (first) compute(first.code);
|
||||||
@@ -152,6 +167,12 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => { loadList(); }, []);
|
useEffect(() => { loadList(); }, []);
|
||||||
|
// Re-filter when the operator changes their followed awards in Settings.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('awards:tracked-changed', () => { loadList(); });
|
||||||
|
return () => { off(); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const current = byCode[`${selected}|${modeFilter}`];
|
const current = byCode[`${selected}|${modeFilter}`];
|
||||||
// Recompute when the mode class changes: the bands, counts and confirmations
|
// Recompute when the mode class changes: the bands, counts and confirmations
|
||||||
@@ -211,11 +232,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
// Alicante" — which reference order scatters across the whole table.
|
// Alicante" — which reference order scatters across the whole table.
|
||||||
// Remembered through writeUiPref rather than localStorage, so the choice
|
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||||
// travels with a copied data/ folder like every other UI preference here.
|
// travels with a copied data/ folder like every other UI preference here.
|
||||||
const [refSort, setRefSort] = useState<'ref' | 'name'>(
|
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
|
||||||
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
|
() => {
|
||||||
|
const v = localStorage.getItem('opslog.awardRefSort');
|
||||||
|
return v === 'name' || v === 'group' ? v : 'ref';
|
||||||
|
});
|
||||||
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||||
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||||
const toggleRefSort = (k: 'ref' | 'name') => {
|
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
|
||||||
if (k === refSort) {
|
if (k === refSort) {
|
||||||
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||||
setRefSortDir(d);
|
setRefSortDir(d);
|
||||||
@@ -258,10 +282,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
if (c !== 0) return c * dir;
|
if (c !== 0) return c * dir;
|
||||||
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
|
||||||
}
|
}
|
||||||
|
if (refSort === 'group') {
|
||||||
|
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
|
||||||
|
// entities. Blank prefixes sink to the bottom regardless of direction,
|
||||||
|
// so an unresolved entity never heads the list.
|
||||||
|
const ag = a.group ?? '', bg = b.group ?? '';
|
||||||
|
if (!ag !== !bg) return ag ? -1 : 1;
|
||||||
|
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
|
||||||
|
if (c !== 0) return c * dir;
|
||||||
|
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||||
|
}
|
||||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||||
});
|
});
|
||||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||||
|
|
||||||
|
// The group column earns its width only when the list actually carries one
|
||||||
|
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
||||||
|
// value IS the primary prefix, so it is labelled as such.
|
||||||
|
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
|
||||||
|
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
{/* Award list */}
|
{/* Award list */}
|
||||||
@@ -393,13 +433,21 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||||
<button
|
{/* Only for an award scoped to a DXCC entity. "In this award's
|
||||||
onClick={() => setShowMissing(true)}
|
scope but with no reference" needs a scope to be in: on a
|
||||||
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||||
title={t('awp.missingRefsTitle')}
|
contact anywhere would qualify, so the answer is always none.
|
||||||
>
|
The button used to be there regardless and could only ever
|
||||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
open a window saying it had found nothing. */}
|
||||||
</button>
|
{awardList.find((a) => a.code === selected)?.scoped && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMissing(true)}
|
||||||
|
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||||
|
title={t('awp.missingRefsTitle')}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
@@ -459,6 +507,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
|
{hasGroup && (
|
||||||
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{groupColLabel}
|
||||||
|
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
|
||||||
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
{t('awp.description')}
|
{t('awp.description')}
|
||||||
@@ -474,9 +530,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
{filteredRefs.map((r) => (
|
{filteredRefs.map((r) => (
|
||||||
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
|
||||||
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
|
||||||
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
|
{hasGroup && (
|
||||||
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
|
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
|
||||||
</td>
|
)}
|
||||||
|
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
|
||||||
{gridBands.map((b) => {
|
{gridBands.map((b) => {
|
||||||
const s = cellStatus(r, b);
|
const s = cellStatus(r, b);
|
||||||
return (
|
return (
|
||||||
@@ -513,7 +570,12 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
<th className="py-1.5 pr-2 font-medium w-40">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{groupColLabel}
|
||||||
|
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
|
||||||
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -546,7 +608,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cell && current && (
|
{cell && current && (
|
||||||
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
|
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} onPaperQSL={onPaperQSL} />
|
||||||
)}
|
)}
|
||||||
{showMissing && current && (
|
{showMissing && current && (
|
||||||
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||||
@@ -560,6 +622,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
// the missing reference (e.g. a department for DDFM).
|
// the missing reference (e.g. a department for DDFM).
|
||||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||||
|
|
||||||
|
// How many references the assign dropdown will put in the DOM at once. Above
|
||||||
|
// this a search box appears beside it and the rest are held back, counted.
|
||||||
|
const REF_MENU_MAX = 300;
|
||||||
|
|
||||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
@@ -568,6 +634,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
||||||
|
const [refSearch, setRefSearch] = useState('');
|
||||||
const [assignRef, setAssignRef] = useState('');
|
const [assignRef, setAssignRef] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
@@ -581,12 +648,38 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, [code]);
|
useEffect(() => { load(); }, [code]);
|
||||||
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
|
// The award's reference list drives the "assign" dropdown (e.g. China
|
||||||
|
// provinces) — fetched ONLY once there is something to assign it to.
|
||||||
|
//
|
||||||
|
// Missing-reference detection needs a DXCC scope, so a worldwide award like
|
||||||
|
// POTA always has zero rows here and says so. It was still loading every
|
||||||
|
// reference behind that message: on a log with the full POTA park list
|
||||||
|
// imported, tens of thousands of them, feeding a dropdown that could not be
|
||||||
|
// used for anything. That is the window freezing on a screen with nothing to
|
||||||
|
// offer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loading || qsos.length === 0) { setRefs([]); return; }
|
||||||
ListAwardReferences(code)
|
ListAwardReferences(code)
|
||||||
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
||||||
.catch(() => setRefs([]));
|
.catch(() => setRefs([]));
|
||||||
}, [code]);
|
}, [code, loading, qsos.length]);
|
||||||
|
|
||||||
|
// What the assign dropdown actually renders. Bounded, and it says how many it
|
||||||
|
// is holding back rather than silently showing the first few hundred.
|
||||||
|
const shownRefs = useMemo(() => {
|
||||||
|
const q = refSearch.trim().toUpperCase();
|
||||||
|
const rows = q
|
||||||
|
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q))
|
||||||
|
: refs;
|
||||||
|
return rows.slice(0, REF_MENU_MAX);
|
||||||
|
}, [refs, refSearch]);
|
||||||
|
const hiddenRefs = useMemo(() => {
|
||||||
|
const q = refSearch.trim().toUpperCase();
|
||||||
|
const total = q
|
||||||
|
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q)).length
|
||||||
|
: refs.length;
|
||||||
|
return Math.max(0, total - shownRefs.length);
|
||||||
|
}, [refs, refSearch, shownRefs.length]);
|
||||||
|
|
||||||
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
@@ -663,14 +756,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
{/* Bulk-assign toolbar */}
|
{/* Bulk-assign toolbar */}
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||||
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
||||||
|
{/* A search box beside the dropdown, and a bounded list inside it.
|
||||||
|
A reference list can hold tens of thousands of entries (POTA
|
||||||
|
parks, Russian districts); every one of them as a menu item is
|
||||||
|
hundreds of thousands of DOM nodes and a window that stops
|
||||||
|
answering. Nobody scrolls to K-4521 anyway — they type it. */}
|
||||||
|
{refs.length > REF_MENU_MAX && (
|
||||||
|
<Input className="h-7 w-40 text-xs font-mono" value={refSearch}
|
||||||
|
placeholder={t('awp.filterReferences')}
|
||||||
|
onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
|
)}
|
||||||
<Select value={assignRef} onValueChange={setAssignRef}>
|
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">
|
<SelectContent className="max-h-72">
|
||||||
{refs.map((r) => (
|
{shownRefs.map((r) => (
|
||||||
<SelectItem key={r.code} value={r.code}>
|
<SelectItem key={r.code} value={r.code}>
|
||||||
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{hiddenRefs > 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{t('awp.refsNarrow', { n: hiddenRefs })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||||
@@ -731,7 +839,14 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||||
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
|
function CellQSOModal({ code, cell, modeClass, onClose, onPaperQSL }: {
|
||||||
|
code: string; cell: { ref: string; band: string; name?: string }; modeClass: string;
|
||||||
|
onClose: () => void;
|
||||||
|
// Send this callsign to the QSL Manager's Paper QSL view. Chasing a
|
||||||
|
// confirmation starts here — an unconfirmed entity, the contact behind it —
|
||||||
|
// and used to continue by retyping the callsign into another tab.
|
||||||
|
onPaperQSL?: (call: string) => void;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -768,10 +883,18 @@ function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell:
|
|||||||
{qsos.map((q, i) => (
|
{qsos.map((q, i) => (
|
||||||
<tr key={q.id ?? i} className="border-b border-border/30">
|
<tr key={q.id ?? i} className="border-b border-border/30">
|
||||||
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
|
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
|
||||||
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
|
<td className="py-1 pr-2 font-mono font-semibold">
|
||||||
|
{onPaperQSL ? (
|
||||||
|
<button type="button" className="hover:underline text-primary"
|
||||||
|
title={t('awp.paperQslTip')}
|
||||||
|
onClick={() => { onPaperQSL(String(q.callsign ?? '')); onClose(); }}>
|
||||||
|
{q.callsign}
|
||||||
|
</button>
|
||||||
|
) : q.callsign}
|
||||||
|
</td>
|
||||||
<td className="py-1 pr-2">{q.band}</td>
|
<td className="py-1 pr-2">{q.band}</td>
|
||||||
<td className="py-1 pr-2">{q.mode}</td>
|
<td className="py-1 pr-2">{q.mode}</td>
|
||||||
<td className="py-1 pr-3 text-muted-foreground">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}</td>
|
<td className="py-1 pr-3 text-muted-foreground">{[isQSLConfirmed(q.lotw_rcvd) && 'LoTW', isQSLConfirmed(q.qsl_rcvd) && 'QSL', isQSLConfirmed(q.eqsl_rcvd) && 'eQSL'].filter(Boolean).join(', ')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
|
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||||
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||||
@@ -22,9 +25,58 @@ interface Spot {
|
|||||||
band?: string;
|
band?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
spotter?: string;
|
spotter?: string;
|
||||||
|
// The park reference the POTA poller tagged this spot with. The map never
|
||||||
|
// reads it — the click handler upstream turns it into the QSO's POTA award
|
||||||
|
// reference — but it is declared so it cannot be lost by accident.
|
||||||
|
//
|
||||||
|
// It travelled only because the spot objects are passed through by reference
|
||||||
|
// and the type was erased. The first refactor to copy a spot field by field
|
||||||
|
// would have dropped the park silently, and the only symptom would be an
|
||||||
|
// activation logged without its reference. Same mistake as the status entry
|
||||||
|
// below, whose extra markers were arriving and undeclared.
|
||||||
|
pota_ref?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpotStatusEntry = { status: string; country?: string };
|
// The FULL status entry, not the two fields the map used to declare. The extra
|
||||||
|
// markers were already arriving in this object — the local type simply never
|
||||||
|
// mentioned them, so they could not be drawn.
|
||||||
|
type SpotStatusEntry = {
|
||||||
|
status?: string;
|
||||||
|
country?: string;
|
||||||
|
worked_call?: boolean;
|
||||||
|
// worked_slot: this exact callsign already worked on THIS band and mode.
|
||||||
|
worked_slot?: boolean;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
// Whether the station uploads to LoTW. Not a status — it says nothing about
|
||||||
|
// whether the spot is worth chasing — so it is drawn as a badge, never a colour.
|
||||||
|
lotw?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||||||
|
// entity AND a new park. The cluster list stacks them as separate badges instead
|
||||||
|
// of letting one replace another, so the map stacks them too — as segments of
|
||||||
|
// the pill's left accent bar, which until now only repeated the pill's own
|
||||||
|
// colour and carried no information of its own.
|
||||||
|
//
|
||||||
|
// Their colours come from lib/spotMarkers, shared with the cluster list: the
|
||||||
|
// same fact must not be violet in one panel and green in the next.
|
||||||
|
// The map shows three of the five. A new PREFIX and a new GRID are left to the
|
||||||
|
// cluster list, which has the width to name them: the
|
||||||
|
// pill is 22 px tall, and a fourth segment turns the strip into a colour code
|
||||||
|
// nobody can read at a glance. Add it here the day the strip earns more room.
|
||||||
|
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
|
||||||
|
const markersFor = (e: SpotStatusEntry | undefined) =>
|
||||||
|
activeMarkers(e).filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
|
||||||
|
|
||||||
|
// The legend spells the markers out; the cluster list's badges are abbreviated
|
||||||
|
// ("NEW CTY") because they sit in a narrow cell, and there is room here.
|
||||||
|
const BMP_MARKER_LABEL: Record<string, string> = {
|
||||||
|
new_pota: 'bmp.legendNewPota',
|
||||||
|
new_county: 'bmp.legendNewCounty',
|
||||||
|
worked_call: 'bmp.legendWorkedCall',
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
band: string;
|
band: string;
|
||||||
@@ -41,6 +93,14 @@ interface Props {
|
|||||||
// globally from the band-map tab toolbar.
|
// globally from the band-map tab toolbar.
|
||||||
hideDigital?: boolean;
|
hideDigital?: boolean;
|
||||||
fitToBand?: boolean;
|
fitToBand?: boolean;
|
||||||
|
// onToggleFit and onToggleHideDigital add the two switches to this map's own
|
||||||
|
// header. Only the docked map passes them: the Band Map tab has both in its
|
||||||
|
// toolbar, above maps that all obey them at once, and a switch inside each
|
||||||
|
// card as well would be four ways to change one setting.
|
||||||
|
onToggleFit?: () => void;
|
||||||
|
onToggleHideDigital?: () => void;
|
||||||
|
// Mark stations that upload to LoTW (Settings → Appearance).
|
||||||
|
showLotw?: boolean;
|
||||||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||||
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||||
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||||
@@ -115,20 +175,39 @@ function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; labe
|
|||||||
function statusLabel(s: string, t: (k: string) => string): string {
|
function statusLabel(s: string, t: (k: string) => string): string {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return t('bmp.statusNew');
|
case 'new': return t('bmp.statusNew');
|
||||||
|
case 'new-band-mode': return t('bmp.statusNewBandMode');
|
||||||
case 'new-band': return t('bmp.statusNewBand');
|
case 'new-band': return t('bmp.statusNewBand');
|
||||||
|
case 'new-mode': return t('bmp.statusNewMode');
|
||||||
case 'new-slot': return t('bmp.statusNewSlot');
|
case 'new-slot': return t('bmp.statusNewSlot');
|
||||||
|
case 'new-call': return t('bmp.statusNewCall');
|
||||||
case 'worked': return t('bmp.statusWorked');
|
case 'worked': return t('bmp.statusWorked');
|
||||||
|
// An empty status means the entity could not be resolved. Nothing else
|
||||||
|
// empties it: the mute option leaves the status alone and takes only the
|
||||||
|
// already-worked-callsign mark.
|
||||||
default: return t('bmp.statusUnresolved');
|
default: return t('bmp.statusUnresolved');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QUIET: no status colour at all. The pill keeps the card background and the
|
||||||
|
// accent drops to a plain border grey, so the spot is present but says nothing.
|
||||||
|
const QUIET_STYLE = {
|
||||||
|
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
||||||
|
bar: 'bg-muted-foreground/30',
|
||||||
|
line: 'stroke-border',
|
||||||
|
dot: 'fill-border',
|
||||||
|
};
|
||||||
|
|
||||||
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
|
||||||
// pill = full pill background+text+border
|
// pill = full pill background+text+border
|
||||||
// bar = thick left accent inside the pill
|
// bar = thick left accent inside the pill
|
||||||
// line = SVG leader stroke (visible on hover)
|
// line = SVG leader stroke (visible on hover)
|
||||||
// dot = small marker on the freq scale
|
// dot = small marker on the freq scale
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return {
|
// NEW and NEW BAND+MODE share the strongest colour: both mean "there is
|
||||||
|
// something here you have never had", and a sixth shade in a palette read at
|
||||||
|
// a glance is a shade nobody can name.
|
||||||
|
case 'new':
|
||||||
|
case 'new-band-mode': return {
|
||||||
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||||||
bar: 'bg-danger',
|
bar: 'bg-danger',
|
||||||
line: 'stroke-danger',
|
line: 'stroke-danger',
|
||||||
@@ -140,18 +219,14 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
|||||||
line: 'stroke-warning',
|
line: 'stroke-warning',
|
||||||
dot: 'fill-warning',
|
dot: 'fill-warning',
|
||||||
};
|
};
|
||||||
|
case 'new-call':
|
||||||
case 'new-slot': return {
|
case 'new-slot': return {
|
||||||
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
||||||
bar: 'bg-caution',
|
bar: 'bg-caution',
|
||||||
line: 'stroke-caution',
|
line: 'stroke-caution',
|
||||||
dot: 'fill-caution',
|
dot: 'fill-caution',
|
||||||
};
|
};
|
||||||
case 'worked': return {
|
case 'worked': return QUIET_STYLE;
|
||||||
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
|
||||||
bar: 'bg-muted-foreground/30',
|
|
||||||
line: 'stroke-border',
|
|
||||||
dot: 'fill-border',
|
|
||||||
};
|
|
||||||
default: return {
|
default: return {
|
||||||
pill: 'bg-card text-foreground border-border hover:bg-accent/40',
|
pill: 'bg-card text-foreground border-border hover:bg-accent/40',
|
||||||
bar: 'bg-primary/60',
|
bar: 'bg-primary/60',
|
||||||
@@ -164,7 +239,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
|||||||
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
|
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
|
||||||
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
||||||
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
||||||
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
|
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
|
||||||
|
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
|
||||||
|
// 2 px/kHz, which scrolls in one screen on most displays.
|
||||||
|
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
|
||||||
|
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
|
||||||
|
|
||||||
|
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
|
||||||
|
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
|
||||||
|
// and thirteen entries there to say the same thing would be thirteen chances to
|
||||||
|
// forget one.
|
||||||
|
const ZOOM_KEY = 'opslog.bandMapZoom';
|
||||||
|
|
||||||
|
function readZoomMap(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
|
||||||
|
return m && typeof m === 'object' ? m : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readZoom(band: string): number {
|
||||||
|
const n = readZoomMap()[band];
|
||||||
|
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeZoom(band: string, idx: number): void {
|
||||||
|
const m = readZoomMap();
|
||||||
|
m[band] = idx;
|
||||||
|
writeUiPref(ZOOM_KEY, JSON.stringify(m));
|
||||||
|
}
|
||||||
const SCALE_W = 56;
|
const SCALE_W = 56;
|
||||||
const PILL_H = 22; // px — height of each callsign pill
|
const PILL_H = 22; // px — height of each callsign pill
|
||||||
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
||||||
@@ -179,11 +284,42 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
|||||||
// last; ties broken by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, onToggleFit, onToggleHideDigital, keyNav = false, showLotw = false }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// The two display options are applied ONCE here, on the whole map, so the
|
||||||
|
// leader lines, the dots, the pills and the badges can never disagree — and so
|
||||||
|
// the map and the cluster list share the single rule set in lib/spotDisplay.
|
||||||
|
// Re-derived whenever the poll delivers a new status map, which is also when a
|
||||||
|
// just-changed option takes effect.
|
||||||
|
// Read at render time, not inside the memo: the options must be part of the
|
||||||
|
// dependencies. Keyed only on spotStatusRaw, toggling an option changed
|
||||||
|
// nothing until the next poll happened to hand over a fresh object.
|
||||||
|
const dispOpts = readSpotDisplayOptions();
|
||||||
|
const spotStatus = useMemo(() => {
|
||||||
|
if (!dispOpts.muteWorked && !dispOpts.slotHighlight) return spotStatusRaw;
|
||||||
|
const out: Record<string, SpotStatusEntry> = {};
|
||||||
|
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
||||||
|
return out;
|
||||||
|
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
|
// remembered zoom.
|
||||||
|
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||||||
|
// Stored from the interaction, not from an effect on zoomIdx: an effect would
|
||||||
|
// also fire on the band change above, and in the same commit it would still be
|
||||||
|
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
|
||||||
|
// where the operator actually turns the wheel keeps the two apart. (Running
|
||||||
|
// twice under StrictMode is harmless: the same value is stored.)
|
||||||
|
const changeZoom = useCallback((delta: number) => {
|
||||||
|
setZoomIdx((z) => {
|
||||||
|
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
|
||||||
|
if (n !== z) writeZoom(band, n);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, [band]);
|
||||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerH, setContainerH] = useState(400);
|
const [containerH, setContainerH] = useState(400);
|
||||||
|
|
||||||
@@ -246,8 +382,10 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
switch (spotStatus[k]?.status ?? '') {
|
switch (spotStatus[k]?.status ?? '') {
|
||||||
case 'new': return 0;
|
case 'new': return 0;
|
||||||
|
case 'new-band-mode': return 0;
|
||||||
case 'new-band': return 1;
|
case 'new-band': return 1;
|
||||||
case 'new-slot': return 2;
|
case 'new-slot': return 2;
|
||||||
|
case 'new-call': return 2;
|
||||||
case 'worked': return 4;
|
case 'worked': return 4;
|
||||||
default: return 3;
|
default: return 3;
|
||||||
}
|
}
|
||||||
@@ -351,46 +489,21 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
|
changeZoom(e.deltaY > 0 ? -1 : 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
}, [range]);
|
}, [range]);
|
||||||
|
|
||||||
if (!range) {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
|
||||||
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
|
|
||||||
{t('bmp.notConfigured', { band: band || '—' })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tick step (where small marks land) and label step (where the kHz
|
|
||||||
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
|
||||||
// labels per viewport regardless of zoom — at base 8 px/kHz we want
|
|
||||||
// labels every 25 kHz, not every 250.
|
|
||||||
let tickStep = 50;
|
|
||||||
let labelStep = 100;
|
|
||||||
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
|
||||||
if (pxPerKHz >= 8) { tickStep = 5; labelStep = 25; }
|
|
||||||
if (pxPerKHz >= 16) { tickStep = 2; labelStep = 10; }
|
|
||||||
if (pxPerKHz >= 32) { tickStep = 1; labelStep = 5; }
|
|
||||||
if (pxPerKHz >= 64) { tickStep = 1; labelStep = 2; }
|
|
||||||
if (pxPerKHz >= 128) { tickStep = 1; labelStep = 1; }
|
|
||||||
const ticks: number[] = [];
|
|
||||||
for (let t = Math.ceil(lo / tickStep) * tickStep; t <= hi; t += tickStep) ticks.push(t);
|
|
||||||
|
|
||||||
function recenterOnRig() {
|
|
||||||
if (!scrollerRef.current || currentFreqHz <= 0) return;
|
|
||||||
const y = freqToY(currentFreqHz / 1000);
|
|
||||||
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||||
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
||||||
|
// MUST stay ABOVE the `if (!range)` early return below: on an unknown band
|
||||||
|
// (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
|
||||||
|
// hook, so the hook count changed between renders and React crashed with #300
|
||||||
|
// ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
|
||||||
|
// back to [0,1] when there's no range), so it's safe to run here.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!keyNav) return;
|
if (!keyNav) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -420,6 +533,38 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||||||
|
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
|
||||||
|
{t('bmp.notConfigured', { band: band || '—' })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick step (where small marks land) and label step (where the kHz
|
||||||
|
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
||||||
|
// labels per viewport regardless of zoom — at 8 px/kHz we want labels
|
||||||
|
// every 25 kHz, not every 250. The bare values below are the floor, and
|
||||||
|
// they are what the 2 px/kHz step lands on: a whole band in one screen
|
||||||
|
// wants a number every 100 kHz, not every 25.
|
||||||
|
let tickStep = 50;
|
||||||
|
let labelStep = 100;
|
||||||
|
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
||||||
|
if (pxPerKHz >= 8) { tickStep = 5; labelStep = 25; }
|
||||||
|
if (pxPerKHz >= 16) { tickStep = 2; labelStep = 10; }
|
||||||
|
if (pxPerKHz >= 32) { tickStep = 1; labelStep = 5; }
|
||||||
|
if (pxPerKHz >= 64) { tickStep = 1; labelStep = 2; }
|
||||||
|
if (pxPerKHz >= 128) { tickStep = 1; labelStep = 1; }
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let t = Math.ceil(lo / tickStep) * tickStep; t <= hi; t += tickStep) ticks.push(t);
|
||||||
|
|
||||||
|
function recenterOnRig() {
|
||||||
|
if (!scrollerRef.current || currentFreqHz <= 0) return;
|
||||||
|
const y = freqToY(currentFreqHz / 1000);
|
||||||
|
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
||||||
|
}
|
||||||
|
|
||||||
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
||||||
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
||||||
const rigY = freqToY(currentKHz);
|
const rigY = freqToY(currentKHz);
|
||||||
@@ -427,18 +572,48 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
||||||
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
what the panel obviously is, and with four band maps side by side it
|
||||||
|
was four times the same word. */}
|
||||||
|
<span className="flex-1 min-w-0 truncate">{band}</span>
|
||||||
|
<button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
|
||||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomOut')}>
|
title={t('bmp.zoomOut')}>
|
||||||
<Minus className="size-3" />
|
<Minus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
{/* A readout, not a switch.
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
|
||||||
|
It WAS the switch for a day: clicking the px/kHz value toggled
|
||||||
|
fit-to-band, and in fit it read "FIT". Nobody found it — a control
|
||||||
|
whose label is a measurement does not look like a control, and once
|
||||||
|
switched off there was no longer anything on screen saying the option
|
||||||
|
existed. The switches below say what they are. */}
|
||||||
|
{/* In fit mode the readout has nothing left to say — the lit FIT button
|
||||||
|
says it, and printing the word twice side by side is just noise. The
|
||||||
|
Band Map tab's cards have no button, so there the readout keeps
|
||||||
|
reporting both states. */}
|
||||||
|
{(!fitToBand || !onToggleFit) && (
|
||||||
|
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomIn')}>
|
title={t('bmp.zoomIn')}>
|
||||||
<Plus className="size-3" />
|
<Plus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
{onToggleFit && (
|
||||||
|
<button type="button" onClick={onToggleFit} title={t('bmp.fitTitle')}
|
||||||
|
className={cn('shrink-0 text-[10px] font-semibold normal-case tracking-normal px-1 rounded border transition-colors',
|
||||||
|
fitToBand ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('bmp.fit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onToggleHideDigital && (
|
||||||
|
<button type="button" onClick={onToggleHideDigital} title={t('bmp.hideFtTitle')}
|
||||||
|
className={cn('shrink-0 text-[10px] font-semibold normal-case tracking-normal px-1 rounded border transition-colors',
|
||||||
|
hideDigital ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('bmp.ftx')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" onClick={recenterOnRig}
|
<button type="button" onClick={recenterOnRig}
|
||||||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||||||
title={t('bmp.scrollToRig')}>
|
title={t('bmp.scrollToRig')}>
|
||||||
@@ -562,12 +737,32 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
'hover:translate-x-0.5 hover:shadow',
|
'hover:translate-x-0.5 hover:shadow',
|
||||||
style.pill,
|
style.pill,
|
||||||
)}
|
)}
|
||||||
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||||||
>
|
>
|
||||||
{/* Status accent strip on the left */}
|
{/* Left accent strip. With no extra marker it repeats the status
|
||||||
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
|
colour, exactly as before; otherwise it splits into one
|
||||||
|
segment per marker, so "worked entity + new park" shows both
|
||||||
|
instead of one hiding the other. */}
|
||||||
|
{(() => {
|
||||||
|
const marks = markersFor(entry);
|
||||||
|
if (marks.length === 0) return <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />;
|
||||||
|
return (
|
||||||
|
<span className="w-1 shrink-0 flex flex-col" aria-hidden>
|
||||||
|
{marks.map((m) => <span key={m.key} className="flex-1" style={{ background: m.colour }} />)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||||
<span>{p.spot.dx_call}</span>
|
<span>{p.spot.dx_call}</span>
|
||||||
|
{/* Same badge the cluster list draws, for the same reason: the
|
||||||
|
muted-blue of a confirmation, never a status colour —
|
||||||
|
whether a station uploads to LoTW says nothing about
|
||||||
|
whether the spot is worth chasing. A glyph rather than a
|
||||||
|
colour also keeps it off the channel the statuses use. */}
|
||||||
|
{showLotw && entry?.lotw && (
|
||||||
|
<span title={t('clu.lotwBadge')} className="text-[9px] font-normal rounded px-1 py-px"
|
||||||
|
style={{ background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)' }}>L</span>
|
||||||
|
)}
|
||||||
{mode && (
|
{mode && (
|
||||||
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
||||||
{mode}
|
{mode}
|
||||||
@@ -585,6 +780,11 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||||
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
|
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
|
||||||
|
{/* The stacked markers, straight from the shared table so the legend
|
||||||
|
cannot drift from what the pills actually draw. */}
|
||||||
|
{BMP_MARKERS.map((m) => (
|
||||||
|
<LegendDot key={m.key} colour={m.colour} label={t(BMP_MARKER_LABEL[m.key])} />
|
||||||
|
))}
|
||||||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||||
<span className="mx-0.5 opacity-40">|</span>
|
<span className="mx-0.5 opacity-40">|</span>
|
||||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Star, Radio } from 'lucide-react';
|
import { Star, Radio, Sunrise, Sunset, X, Loader2 } 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 { isQSLConfirmed } from '@/lib/qsl';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -13,6 +17,16 @@ 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;
|
||||||
|
// Set when the matrix is showing a QSO picked in the log grid rather than the
|
||||||
|
// entry form — labelled so the two can never be confused.
|
||||||
|
forCall?: string;
|
||||||
|
// Open the QSO editor for a contact — makes callsigns in the cell drill-down
|
||||||
|
// clickable. Threaded down from App.openEdit.
|
||||||
|
onEditQso?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -63,28 +77,33 @@ const STATUS_CLASSES: Record<string, string> = {
|
|||||||
dxcc_w: 'bg-mx-dx-work',
|
dxcc_w: 'bg-mx-dx-work',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Legend entries, in the same colour order as the cells. swatch = the
|
// Legend entries, in the same colour order as the cells — and the same order and
|
||||||
// background class (or a special ring marker for the current-entry cell).
|
// i18n keys the Appearance panel's colour pickers use, so the two can never
|
||||||
|
// disagree about which green is which. swatch = the background class (or a
|
||||||
|
// special ring marker for the current-entry cell).
|
||||||
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
||||||
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
|
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
|
||||||
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
|
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
|
||||||
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
|
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
|
||||||
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
|
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
|
||||||
{ swatch: 'bg-mx-none', label: 'Not worked' },
|
{ swatch: 'bg-mx-none', label: 'mx.none' },
|
||||||
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
|
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string {
|
||||||
const desc =
|
const desc =
|
||||||
status === 'call_c' ? 'This callsign confirmed' :
|
status === 'call_c' ? t('mx.tipCallConf') :
|
||||||
status === 'call_w' ? 'This callsign worked (not confirmed)' :
|
status === 'call_w' ? t('mx.tipCallWork') :
|
||||||
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
|
status === 'dxcc_c' ? t('mx.tipDxConf') :
|
||||||
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
|
status === 'dxcc_w' ? t('mx.tipDxWork') :
|
||||||
'Never worked';
|
t('mx.tipNone');
|
||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
// Cell drill-down: which band+class the operator clicked, or null.
|
||||||
|
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||||
// 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 +170,44 @@ 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],
|
||||||
|
);
|
||||||
|
// Stacked, not side by side. Laid out in a row this cost about 150 px of a
|
||||||
|
// header that has to hold the callsign, the badges and the band grid, and it
|
||||||
|
// was what pushed the whole row onto a second line. Two short times one above
|
||||||
|
// the other take a fraction of that and no extra height: the row is already
|
||||||
|
// taller than one line of text.
|
||||||
|
//
|
||||||
|
// "UTC" moves into the tooltip with them — the times are monospaced and always
|
||||||
|
// UTC everywhere in OpsLog, so the label was spending width to repeat a
|
||||||
|
// convention the operator already lives by.
|
||||||
|
const sunBlock = sun ? (
|
||||||
|
<div className="ml-auto flex flex-col items-end leading-tight 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 text-warning" />
|
||||||
|
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Sunset className="size-3 text-info" />
|
||||||
|
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -158,7 +215,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]">
|
||||||
@@ -173,6 +230,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</>
|
</>
|
||||||
) : hasDxcc ? (
|
) : hasDxcc ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Says WHOSE stats these are when they come from a row picked in
|
||||||
|
the log rather than from what's being typed. */}
|
||||||
|
{forCall && (
|
||||||
|
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
|
||||||
|
title="Stats for the QSO selected in the log">
|
||||||
|
{forCall}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||||
{dxccName || `DXCC #${dxcc}`}
|
{dxccName || `DXCC #${dxcc}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -207,6 +272,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">
|
||||||
@@ -246,11 +312,15 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')}
|
||||||
|
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
// Only a filled cell has anything to show — an empty one
|
||||||
|
// stays inert rather than opening a "no QSOs" dialog.
|
||||||
|
st && 'cursor-pointer hover:brightness-110',
|
||||||
|
isCurrent && 'ring-2 ring-mx-cur ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -269,14 +339,127 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-block size-3 rounded shrink-0',
|
'inline-block size-3 rounded shrink-0',
|
||||||
l.swatch,
|
l.swatch,
|
||||||
l.ring && 'ring-2 ring-warning ring-inset',
|
l.ring && 'ring-2 ring-mx-cur ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{l.label}
|
{t(l.label)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{slot && (
|
||||||
|
<SlotQSOModal
|
||||||
|
call={wb?.callsign ?? ''}
|
||||||
|
dxcc={dxcc}
|
||||||
|
entity={dxccName}
|
||||||
|
band={slot.band}
|
||||||
|
cls={slot.cls}
|
||||||
|
onClose={() => setSlot(null)}
|
||||||
|
onEdit={onEditQso}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cell drill-down ──────────────────────────────────────────────────────────
|
||||||
|
// The contacts behind one band+class cell: this exact callsign AND anyone else
|
||||||
|
// in the entity, because that is the pair of facts the cell's colour encodes.
|
||||||
|
// The call's own QSOs are marked so the two never blur together.
|
||||||
|
|
||||||
|
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
|
||||||
|
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; onEdit?: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<any[] | null>(null);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let dead = false;
|
||||||
|
BandSlotQSOs(call, dxcc, band, cls)
|
||||||
|
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
|
||||||
|
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
|
||||||
|
return () => { dead = true; };
|
||||||
|
}, [call, dxcc, band, cls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Capture phase: the app's global ESC handler resets the entry form, and
|
||||||
|
// closing a dialog must not also wipe what the operator was typing.
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-[2px] p-4" onClick={onClose}>
|
||||||
|
<div className="bg-card border border-border rounded-xl shadow-2xl w-[760px] max-w-full max-h-[72vh] flex flex-col overflow-hidden ring-1 ring-black/5"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border bg-muted/30">
|
||||||
|
<span className="inline-flex items-center justify-center h-6 min-w-6 px-1.5 rounded-md bg-primary/15 text-primary text-[11px] font-bold shrink-0 tabular-nums">{band}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-sm leading-tight truncate">
|
||||||
|
{cls}{entity && <span className="text-muted-foreground font-normal"> · {entity}</span>}
|
||||||
|
</div>
|
||||||
|
{onEdit && <div className="text-[10px] text-muted-foreground leading-tight">Click a callsign to edit the QSO</div>}
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{rows ? `${rows.length} QSO${rows.length > 1 ? 's' : ''}` : ''}</span>
|
||||||
|
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 rounded p-0.5 hover:bg-muted transition-colors">
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
|
||||||
|
{!rows ? (
|
||||||
|
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" /> Loading…
|
||||||
|
</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-medium px-3 py-1.5">Date UTC</th>
|
||||||
|
<th className="text-left font-medium px-3 py-1.5">Callsign</th>
|
||||||
|
<th className="text-left font-medium px-3 py-1.5">Mode</th>
|
||||||
|
<th className="text-left font-medium px-3 py-1.5">Freq</th>
|
||||||
|
<th className="text-left font-medium px-3 py-1.5">Name</th>
|
||||||
|
<th className="text-center font-medium px-3 py-1.5">Cfm</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((q, i) => {
|
||||||
|
const cfm = isQSLConfirmed(q.lotw_rcvd) || isQSLConfirmed(q.eqsl_rcvd) || isQSLConfirmed(q.qsl_rcvd);
|
||||||
|
const mine = q.callsign === call;
|
||||||
|
return (
|
||||||
|
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
|
||||||
|
<td className="px-3 py-1.5 tabular-nums text-muted-foreground whitespace-nowrap">
|
||||||
|
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{onEdit ? (
|
||||||
|
<button type="button" onClick={() => { onEdit(q.id as number); onClose(); }} title="Edit this QSO"
|
||||||
|
className={cn('font-mono cursor-pointer text-left hover:underline underline-offset-2', mine ? 'font-bold text-primary' : 'text-foreground hover:text-primary')}>
|
||||||
|
{q.callsign}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={cn('font-mono', mine && 'font-bold text-primary')}>{q.callsign}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">{q.mode}</td>
|
||||||
|
<td className="px-3 py-1.5 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
|
||||||
|
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[180px]">{q.name}</td>
|
||||||
|
<td className="px-3 py-1.5 text-center">{cfm ? <span className="text-success">✓</span> : <span className="text-muted-foreground/30">·</span>}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
||||||
|
{ id: 'qsl_sent_via', label: 'bulk.fQslSentVia', group: 'QSL / upload', kind: 'text' },
|
||||||
|
{ id: 'qsl_rcvd_via', label: 'bulk.fQslRcvdVia', group: 'QSL / upload', kind: 'text' },
|
||||||
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
@@ -46,6 +48,12 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||||
|
// qso.bulkEditableExtras). Status and date, like every other service here.
|
||||||
|
{ id: 'hamlog_sent', label: 'bulk.fHamlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
@@ -91,6 +99,17 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||||
|
// The contact itself — repair fields, not description fields. An import that
|
||||||
|
// mapped every QSO to SSB, or an ADIF with no MODE at all, is fixed here
|
||||||
|
// instead of one row at a time.
|
||||||
|
//
|
||||||
|
// Band is deliberately absent: it travels with the frequency below, because a
|
||||||
|
// band contradicting its own frequency is invalid ADIF and every export would
|
||||||
|
// carry the contradiction.
|
||||||
|
{ id: 'mode', label: 'bulk.fMode', group: 'The contact', kind: 'text', upper: true },
|
||||||
|
{ id: 'submode', label: 'bulk.fSubmode', group: 'The contact', kind: 'text', upper: true },
|
||||||
|
{ id: 'rst_sent', label: 'bulk.fRstSent', group: 'The contact', kind: 'text' },
|
||||||
|
{ id: 'rst_rcvd', label: 'bulk.fRstRcvd', group: 'The contact', kind: 'text' },
|
||||||
// Misc
|
// Misc
|
||||||
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||||
// logged on a stale/default frequency after CAT dropped.
|
// logged on a stale/default frequency after CAT dropped.
|
||||||
@@ -110,7 +129,12 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
|||||||
{ v: '_', label: 'bulk.statusBlank' },
|
{ v: '_', label: 'bulk.statusBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
// Derived from the fields themselves, in the order they are declared.
|
||||||
|
//
|
||||||
|
// This used to be a hand-written list, and a group added to FIELDS but not to it
|
||||||
|
// simply never rendered — the fields existed, passed every check, and could not
|
||||||
|
// be picked. Two lists that must agree, with nothing to make them.
|
||||||
|
const GROUPS = [...new Set(FIELDS.map((f) => f.group))];
|
||||||
// Maps the internal group key → its i18n label key.
|
// Maps the internal group key → its i18n label key.
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
'QSL / upload': 'bulk.groupQsl',
|
'QSL / upload': 'bulk.groupQsl',
|
||||||
@@ -119,6 +143,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
'Contest': 'bulk.groupContest',
|
'Contest': 'bulk.groupContest',
|
||||||
'Propagation': 'bulk.groupPropagation',
|
'Propagation': 'bulk.groupPropagation',
|
||||||
'Misc': 'bulk.groupMisc',
|
'Misc': 'bulk.groupMisc',
|
||||||
|
'The contact': 'bulk.groupContact',
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -177,7 +202,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{GROUP_LABELS[g] ? t(GROUP_LABELS[g]) : g}</div>
|
||||||
{FIELDS.filter((f) => f.group === g)
|
{FIELDS.filter((f) => f.group === g)
|
||||||
.map((f) => ({ f, txt: t(f.label) }))
|
.map((f) => ({ f, txt: t(f.label) }))
|
||||||
.sort((a, b) => a.txt.localeCompare(b.txt))
|
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -97,10 +98,10 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||||
{e.lotw_rcvd === 'Y' && (
|
{isQSLConfirmed(e.lotw_rcvd) && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||||||
)}
|
)}
|
||||||
{e.qsl_rcvd === 'Y' && (
|
{isQSLConfirmed(e.qsl_rcvd) && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// ChaseNewPanel — the stations PSK Reporter is hearing NEAR HERE that are new
|
||||||
|
// against the log.
|
||||||
|
//
|
||||||
|
// A DX cluster tells you what somebody chose to spot. This tells you what is
|
||||||
|
// actually being decoded in your own region, which is a different and often
|
||||||
|
// larger set: nobody spots the FT8 caller running ten watts from a rare square.
|
||||||
|
//
|
||||||
|
// The one thing an operator has to know, and the reason for the line at the
|
||||||
|
// bottom: PSK Reporter carries DIGITAL MODES ONLY. An empty panel means nothing
|
||||||
|
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Radar, Loader2, X } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export interface ChaseNewSpot {
|
||||||
|
call: string;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
freq_hz?: number;
|
||||||
|
grid?: string;
|
||||||
|
country?: string;
|
||||||
|
cont?: string;
|
||||||
|
dist_km?: number;
|
||||||
|
bearing?: number;
|
||||||
|
status?: string;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
new_grid?: boolean;
|
||||||
|
lotw?: boolean;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Tuning the rig to a row is the whole point — a station heard on 14.074 is
|
||||||
|
// only useful if you can get there in one click.
|
||||||
|
onPick?: (s: ChaseNewSpot) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The categories, in priority order. A station can be several at once — a new
|
||||||
|
// band AND a new prefix — but a row shows ONE, the first that matches here.
|
||||||
|
//
|
||||||
|
// The order is by what would make an operator leave what they are doing: a new
|
||||||
|
// entity beats a new band on it, which beats a new mode, which beats a new slot;
|
||||||
|
// a prefix or a square is worth knowing but never worth interrupting a QSO for.
|
||||||
|
// Two badges on one line also made the list unreadable at a glance, which is the
|
||||||
|
// only thing this panel is for.
|
||||||
|
type Category = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid';
|
||||||
|
|
||||||
|
const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
|
||||||
|
{ key: 'dxcc', labelKey: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||||
|
{ key: 'band', labelKey: 'clg2.newBand', colour: 'var(--danger)' },
|
||||||
|
{ key: 'mode', labelKey: 'clg2.newMode', colour: 'var(--danger)' },
|
||||||
|
{ key: 'slot', labelKey: 'clg2.newSlot', colour: 'var(--danger)' },
|
||||||
|
{ key: 'pfx', labelKey: 'clg2.newPfx', colour: markerColour('new_pfx') },
|
||||||
|
{ key: 'grid', labelKey: 'clg2.newGrid', colour: markerColour('new_grid') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// categoryOf is the single thing a row says about a station.
|
||||||
|
function categoryOf(s: ChaseNewSpot): Category | null {
|
||||||
|
switch (s.status) {
|
||||||
|
case 'new': return 'dxcc';
|
||||||
|
case 'new-band-mode': return 'band';
|
||||||
|
case 'new-band': return 'band';
|
||||||
|
case 'new-mode': return 'mode';
|
||||||
|
case 'new-slot': return 'slot';
|
||||||
|
}
|
||||||
|
if (s.new_pfx) return 'pfx';
|
||||||
|
if (s.new_grid) return 'grid';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_KEY = 'opslog.chaseNewFilters';
|
||||||
|
|
||||||
|
function loadFilters(): Set<Category> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FILTER_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const list = JSON.parse(raw) as Category[];
|
||||||
|
if (Array.isArray(list)) return new Set(list);
|
||||||
|
}
|
||||||
|
} catch { /* a corrupt preference is not worth a broken panel */ }
|
||||||
|
return new Set(CATEGORIES.map((c) => c.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [on, setOn] = useState<Set<Category>>(loadFilters);
|
||||||
|
|
||||||
|
// Polled rather than pushed: the feed can deliver several a second under an
|
||||||
|
// opening, and an event per row would be a redraw per row for a list nobody
|
||||||
|
// reads that fast.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
|
||||||
|
if (alive) { setSpots(r); setLoaded(true); }
|
||||||
|
} catch { /* the feed may not be up yet */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 5000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggle(k: Category) {
|
||||||
|
setOn((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(k)) next.delete(k); else next.add(k);
|
||||||
|
try { localStorage.setItem(FILTER_KEY, JSON.stringify([...next])); } catch { /* not worth failing over */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
return spots.filter((s) => {
|
||||||
|
const c = categoryOf(s);
|
||||||
|
return c !== null && on.has(c);
|
||||||
|
});
|
||||||
|
}, [spots, on]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col rounded-md border border-border bg-card overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||||
|
<Radar className="size-3.5 shrink-0 text-primary" />
|
||||||
|
<span className="shrink-0 text-xs font-semibold">{t('chn.title')}</span>
|
||||||
|
|
||||||
|
{/* Filters, in the same order and colours as the badges they hide. */}
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-1">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(c.key)}
|
||||||
|
title={t('chn.filterHint')}
|
||||||
|
className={cn(
|
||||||
|
'rounded border px-1 py-px text-[9px] font-bold uppercase tracking-wide transition-colors',
|
||||||
|
on.has(c.key) ? 'border-transparent' : 'border-border text-muted-foreground opacity-50',
|
||||||
|
)}
|
||||||
|
style={on.has(c.key) ? { color: c.colour, borderColor: c.colour } : undefined}
|
||||||
|
>
|
||||||
|
{t(c.labelKey).replace(/^(NEW|NOUV)\s+/i, '')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
{loaded ? t('chn.count', { n: shown.length }) : ''}
|
||||||
|
</span>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title={t('chn.close')}
|
||||||
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{!loaded ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 text-[11px] text-muted-foreground">
|
||||||
|
<Loader2 className="size-3 animate-spin" /> {t('chn.loading')}
|
||||||
|
</div>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
{spots.length === 0 ? t('chn.empty') : t('chn.allFiltered')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{shown.map((s, i) => {
|
||||||
|
const c = categoryOf(s);
|
||||||
|
const def = CATEGORIES.find((x) => x.key === c);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${s.call}-${s.band}-${s.mode}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick?.(s)}
|
||||||
|
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] hover:bg-accent/40"
|
||||||
|
title={[
|
||||||
|
s.country,
|
||||||
|
s.grid,
|
||||||
|
s.dist_km ? `${s.dist_km} km` : '',
|
||||||
|
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
>
|
||||||
|
<span className="w-[84px] shrink-0 truncate font-mono font-bold">{s.call}</span>
|
||||||
|
<span className="w-9 shrink-0 font-mono text-muted-foreground">{s.band}</span>
|
||||||
|
<span className="w-10 shrink-0 truncate text-muted-foreground">{s.mode}</span>
|
||||||
|
{/* No frequency column: clicking the row tunes the rig to it,
|
||||||
|
so the number was a thing to read and never to use. It is
|
||||||
|
still carried on the spot — the click is what needs it —
|
||||||
|
and shows in the row's tooltip for anyone who wants it.
|
||||||
|
The country takes the space it freed. */}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{s.country ?? ''}</span>
|
||||||
|
{def && (
|
||||||
|
<span className="shrink-0 text-[9px] font-bold" style={{ color: def.colour }}>
|
||||||
|
{t(def.labelKey)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.lotw && <span className="shrink-0 text-[9px] text-info" title="LoTW">L</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type RowDoubleClickedEvent,
|
||||||
} 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';
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -51,14 +53,35 @@ export type SpotStatusEntry = {
|
|||||||
country?: string;
|
country?: string;
|
||||||
continent?: string;
|
continent?: string;
|
||||||
worked_call?: boolean;
|
worked_call?: boolean;
|
||||||
|
// worked_slot: this exact callsign already worked on THIS band and mode.
|
||||||
|
// Always slot-scoped, unlike worked_call which follows the "same slot" option.
|
||||||
|
worked_slot?: boolean;
|
||||||
new_county?: boolean;
|
new_county?: boolean;
|
||||||
|
// The resolved US county and state, so the grid can SHOW them and not merely
|
||||||
|
// flag them. Filled from the offline ULS store, US callsigns only.
|
||||||
|
county?: string;
|
||||||
|
state?: string;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
pfx?: string;
|
||||||
|
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
||||||
|
lotw?: boolean;
|
||||||
|
spotter_continent?: string;
|
||||||
|
grid?: string;
|
||||||
|
new_grid?: boolean;
|
||||||
|
grid_state?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: ClusterSpot[];
|
rows: ClusterSpot[];
|
||||||
spotStatus: Record<string, SpotStatusEntry>;
|
spotStatus: Record<string, SpotStatusEntry>;
|
||||||
|
// A DOUBLE click works the spot: QSY, mode, callsign, the lot.
|
||||||
onSpotClick?: (s: ClusterSpot) => void;
|
onSpotClick?: (s: ClusterSpot) => void;
|
||||||
|
// A SINGLE click only fills the callsign. Reading the cluster means passing
|
||||||
|
// over dozens of lines, and every pass used to drag the radio with it — one
|
||||||
|
// stray click while looking took the operator off the station they were
|
||||||
|
// working. Looking and going are now two different gestures.
|
||||||
|
onSpotSelect?: (s: ClusterSpot) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
|
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
|
||||||
@@ -94,55 +117,93 @@ type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVis
|
|||||||
// statusFor resolves the precomputed spot status (new / new-band / new-slot /
|
// statusFor resolves the precomputed spot status (new / new-band / new-slot /
|
||||||
// worked-call) for an ag-Grid cell's row.
|
// worked-call) for an ag-Grid cell's row.
|
||||||
function statusFor(p: any): SpotStatusEntry | undefined {
|
function statusFor(p: any): SpotStatusEntry | undefined {
|
||||||
return p?.context?.spotStatus?.[
|
const s = p?.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
];
|
];
|
||||||
|
// One chokepoint: the colour, the badges and the Status text all read through
|
||||||
|
// here, so the two display options are applied once rather than in each
|
||||||
|
// renderer — and the band map applies the very same function.
|
||||||
|
return applySpotDisplay(s, readSpotDisplayOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusBadge maps a resolved spot status to a short labelled badge for the
|
// Spot status is shown on the CELL that carries the fact — filled background for
|
||||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
// a novelty, tinted text otherwise — never by a pill or a badge.
|
||||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
//
|
||||||
// there's nothing notable to show.
|
// The pills were dropped and stay dropped: a rounded box has its own height and
|
||||||
type Badge = { text: string; fg: string; bg: string; bd: string };
|
// padding, so it sat off the row's baseline and the callsign inside it no longer
|
||||||
|
// lined up with the plain callsigns above and below. A whole column of them read
|
||||||
|
// as chrome rather than as data. Same reasoning — and the same semantic tokens —
|
||||||
|
// as the Y/N/R QSL columns in lib/qslStatus.ts.
|
||||||
|
//
|
||||||
|
// Which cell is marked IS the message:
|
||||||
|
//
|
||||||
|
// filled call → new DXCC filled band → new band filled mode → new mode
|
||||||
|
// filled pfx → new prefix filled POTA → new park filled county → new county
|
||||||
|
// blue call → already worked (not a novelty, so text only)
|
||||||
|
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||||
|
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||||
|
|
||||||
// tok builds a badge from a semantic status token (success/warning/caution/
|
// FILLING the cell that carries the fact, rather than only tinting its text.
|
||||||
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
//
|
||||||
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
// This is not a return to the pills that were dropped. A pill is a box INSIDE
|
||||||
function tok(name: string, text: string): Badge {
|
// the cell: it has its own height and padding, so it sat off the row baseline
|
||||||
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
// and the callsign inside it stopped lining up with the plain callsigns above
|
||||||
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
// and below. A cell background has no geometry of its own — the text does not
|
||||||
}
|
// move by a single pixel — and it reads from across the room, which a tinted
|
||||||
|
// glyph does not.
|
||||||
|
//
|
||||||
|
// Which cell is filled is still the whole message: filled Band means new band,
|
||||||
|
// filled Pfx means new prefix, filled County means new county.
|
||||||
|
//
|
||||||
|
// The colours come from the same places they already came from — the semantic
|
||||||
|
// status tokens and lib/spotMarkers — so a fact keeps one colour across the
|
||||||
|
// grid, the band map and the filter chips. 22 % is the wash that survived both
|
||||||
|
// themes: enough to see at a glance, not enough to fight the text on it.
|
||||||
|
const fillStyle = (colour: string) => ({
|
||||||
|
background: `color-mix(in srgb, ${colour} 22%, transparent)`,
|
||||||
|
color: colour,
|
||||||
|
fontWeight: 700,
|
||||||
|
});
|
||||||
|
|
||||||
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
// cellText renders a cell value in an optional colour. An empty value keeps the
|
||||||
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
// muted dash the grid used before, so blank cells still read as "nothing here"
|
||||||
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
// rather than as a gap.
|
||||||
// null → plain text, no pill.
|
function cellText(value: any, color: string | null): any {
|
||||||
function cellChip(value: any, name: string | null): any {
|
|
||||||
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||||
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
||||||
// callsign stays the same size as the plain callsigns next to it — just tinted
|
|
||||||
// and rounded, not shrunk.
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
|
||||||
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
|
||||||
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
|
||||||
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
|
||||||
}}>{txt}</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
// statusColor is the colour for a resolved status in the Status column. County,
|
||||||
|
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
|
||||||
|
// DXCC story and an operator filters on them separately.
|
||||||
|
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||||
switch (s?.status) {
|
switch (s?.status) {
|
||||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
case 'new':
|
||||||
case 'new-band': return tok('warning', t('clg2.newBand'));
|
case 'new-band-mode':
|
||||||
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
case 'new-band':
|
||||||
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
case 'new-mode':
|
||||||
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
case 'new-slot':
|
||||||
|
case 'new-call':
|
||||||
|
return NEW;
|
||||||
|
default:
|
||||||
|
return s?.worked_call ? WKD : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDull reports a spot that "represents nothing" under the current worked/slot
|
||||||
|
// rules: the entity/band/mode is already worked (status resolved, not new in any
|
||||||
|
// dimension), the callsign itself isn't in the log, and there's no POTA / county
|
||||||
|
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
|
||||||
|
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
|
||||||
|
// what counts as dull follows those settings automatically. Unresolved statuses
|
||||||
|
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
|
||||||
|
function isDull(s: SpotStatusEntry | undefined): boolean {
|
||||||
|
if (!s || !s.status) return false;
|
||||||
|
if (s.status === 'new' || s.status === 'new-band-mode' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
|
||||||
|
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx || s.new_grid);
|
||||||
|
}
|
||||||
|
|
||||||
const makeColCatalog = (t: TFn): ColEntry[] => [
|
const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
|
||||||
@@ -166,15 +227,27 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
// NEW DXCC fills the call cell. Already worked only tints the text blue:
|
||||||
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
// 'worked' is not a novelty, and filling it would wash most of the rows.
|
||||||
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
cellStyle: (p: any) => (statusFor(p)?.status === 'new' ? fillStyle('var(--danger)') : null) as any,
|
||||||
// with the rest of the row instead of always shouting a colour.
|
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
const color = s?.status === 'new' ? null : s?.worked_call ? WKD : null;
|
||||||
const color = s?.worked_call ? 'var(--info)' : undefined;
|
return (
|
||||||
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
<span style={{ color: color ?? undefined, fontWeight: 700 }}>
|
||||||
|
{p.value ?? ''}
|
||||||
|
{/* A single letter rather than a word: the column is 120 px and holds a
|
||||||
|
callsign. It stays the muted-blue of a confirmation, never a status
|
||||||
|
colour — whether a station uploads to LoTW says nothing about
|
||||||
|
whether the spot is worth chasing. */}
|
||||||
|
{s?.lotw ? (
|
||||||
|
<span title={t('clu.lotwBadge')} style={{
|
||||||
|
marginLeft: 4, padding: '0 3px', borderRadius: 3, fontSize: 9, verticalAlign: 'middle',
|
||||||
|
background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)',
|
||||||
|
}}>L</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
@@ -185,38 +258,60 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||||
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
// Spells the status out in words so NEW SLOT (and the others) is obvious at
|
||||||
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
// the row level, not just a single coloured cell — NEW SLOT in particular
|
||||||
// and NEW POTA are orthogonal, so they stack as extra badges.
|
// colours no cell at all, since neither the band nor the mode is new on its
|
||||||
|
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
|
||||||
valueGetter: (p: any) => {
|
valueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||||
|
else if (s?.status === 'new-band-mode') parts.push(t('clg2.newBandMode'));
|
||||||
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||||
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||||
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
||||||
|
else if (s?.status === 'new-call') parts.push(t('clg2.newCall'));
|
||||||
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'));
|
||||||
|
if (s?.new_grid) parts.push(s.grid_state === 'unconf' ? t('clg2.newGridUnconf') : t('clg2.newGrid'));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
},
|
},
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
const badges: Badge[] = [];
|
const parts: { text: string; color: string; dim?: boolean }[] = [];
|
||||||
const b = statusBadge(t, s);
|
const main = statusColor(s);
|
||||||
if (b) badges.push(b);
|
if (main) {
|
||||||
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||||
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
: s?.status === 'new-band-mode' ? t('clg2.newBandMode')
|
||||||
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
: s?.status === 'new-band' ? t('clg2.newBand')
|
||||||
|
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||||||
|
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||||
|
: s?.status === 'new-call' ? t('clg2.newCall')
|
||||||
|
: t('clg2.wkdCall');
|
||||||
|
parts.push({ text: label, color: main });
|
||||||
|
}
|
||||||
|
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
||||||
|
// never one colour here and another there.
|
||||||
|
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
|
||||||
|
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
||||||
|
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
|
||||||
|
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
|
||||||
|
// held back, so it reads as "less" of the same thing rather than a
|
||||||
|
// different fact — and the label says which.
|
||||||
|
if (s?.new_grid) {
|
||||||
|
parts.push(s.grid_state === 'unconf'
|
||||||
|
? { text: t('clg2.newGridUnconf'), color: markerColour('new_grid'), dim: true }
|
||||||
|
: { text: t('clg2.newGrid'), color: markerColour('new_grid') });
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
{badges.map((bd, i) => (
|
{parts.map((pt, i) => (
|
||||||
<span key={i} style={{
|
<span key={i} style={{ color: pt.color, fontWeight: 700, opacity: pt.dim ? 0.5 : 1 }}>
|
||||||
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
{i > 0 ? ' · ' : ''}{pt.text}
|
||||||
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
</span>
|
||||||
fontWeight: 700, fontSize: 9,
|
|
||||||
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
|
||||||
}}>{bd.text}</span>
|
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -224,8 +319,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
|
||||||
|
if (s?.status === 'new-band-mode') return t('clg2.tipNewBandMode');
|
||||||
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
if (s?.status === 'new-band') return t('clg2.tipNewBand');
|
||||||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||||||
|
if (s?.status === 'new-call') return t('clg2.tipNewCall');
|
||||||
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
@@ -234,7 +331,9 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: 'var(--success)' },
|
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
||||||
|
? fillStyle(markerColour('new_pota'))
|
||||||
|
: { color: 'var(--success)' }) as any,
|
||||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -249,9 +348,18 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → small warning pill around the band text.
|
// NEW BAND for this entity → the band cell is filled. NEW BAND+MODE fills
|
||||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
// it too: the band really is new, and leaving it plain would say the
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
// opposite of the status column beside it.
|
||||||
|
cellStyle: (p: any) => {
|
||||||
|
const st = statusFor(p)?.status;
|
||||||
|
return (st === 'new-band' || st === 'new-band-mode' ? fillStyle(NEW) : null) as any;
|
||||||
|
},
|
||||||
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
|
tooltipValueGetter: (p: any) => {
|
||||||
|
const st = statusFor(p)?.status;
|
||||||
|
return st === 'new-band-mode' ? t('clg2.tipNewBandMode') : st === 'new-band' ? t('clg2.tipNewBand') : undefined;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
|
||||||
@@ -259,13 +367,18 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
// NEW MODE and NEW BAND+MODE pill the mode cell — in both the mode itself is
|
||||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
// genuinely new for the entity. NEW SLOT means band AND mode were each worked
|
||||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
// before (just not together), so highlighting the mode cell would wrongly
|
||||||
// that case is signalled by the Status badge alone.
|
// imply "CW is new"; that case is signalled by the Status badge alone.
|
||||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
cellStyle: (p: any) => {
|
||||||
|
const st = statusFor(p)?.status;
|
||||||
|
return (st === 'new-mode' || st === 'new-band-mode' ? fillStyle('var(--caution)') : null) as any;
|
||||||
|
},
|
||||||
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
|
if (st === 'new-band-mode') return t('clg2.tipNewBandMode');
|
||||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||||
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
if (st === 'new-slot') return t('clg2.tipNewSlot');
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -275,7 +388,37 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||||
cellStyle: { color: 'var(--muted-foreground)' },
|
// NEW PFX fills the prefix cell, in the prefix marker's own colour.
|
||||||
|
cellStyle: (p: any) => (statusFor(p)?.new_pfx
|
||||||
|
? fillStyle(markerColour('new_pfx'))
|
||||||
|
: { color: 'var(--muted-foreground)' }) as any,
|
||||||
|
tooltipValueGetter: (p: any) => (statusFor(p)?.new_pfx ? t('clg2.tipNewPfx') : undefined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Geo', label: t('clg2.c.grid'), colId: 'grid',
|
||||||
|
headerName: t('clg2.c.grid'), width: 70, cellClass: 'font-mono',
|
||||||
|
// Not on the spot: the square a station announced in a CQ that THIS receiver
|
||||||
|
// decoded over the WSJT-X link, so it is filled for the digital watering
|
||||||
|
// hole being monitored and empty everywhere else. A cluster line carries the
|
||||||
|
// spotter's grid at best, never the DX's.
|
||||||
|
valueGetter: (p: any) => statusFor(p)?.grid ?? '',
|
||||||
|
cellStyle: (p: any) => (statusFor(p)?.new_grid ? fillStyle(markerColour('new_grid')) : null) as any,
|
||||||
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
|
tooltipValueGetter: (p: any) => (statusFor(p)?.new_grid ? t('clg2.tipNewGrid') : undefined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Geo', label: t('clg2.c.county'), colId: 'county',
|
||||||
|
headerName: t('clg2.c.county'), width: 130, cellClass: 'font-mono',
|
||||||
|
// Not on the spot: resolved per callsign from the offline ULS store, so it
|
||||||
|
// stays empty for non-US calls and until that database is downloaded.
|
||||||
|
valueGetter: (p: any) => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
if (!s?.county) return '';
|
||||||
|
return s.state ? s.county + ', ' + s.state : s.county;
|
||||||
|
},
|
||||||
|
cellStyle: (p: any) => (statusFor(p)?.new_county ? fillStyle(markerColour('new_county')) : null) as any,
|
||||||
|
cellRenderer: (p: any) => cellText(p.value, null),
|
||||||
|
tooltipValueGetter: (p: any) => (statusFor(p)?.new_county ? t('clg2.tipNewCounty') : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||||
@@ -363,7 +506,7 @@ const GROUP_ORDER = ['Spot', 'Geo'];
|
|||||||
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
|
||||||
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
|
||||||
|
|
||||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: 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);
|
||||||
@@ -396,6 +539,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
|
// Dim "represents nothing" spots at the CELL level, via a class that
|
||||||
|
// refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on
|
||||||
|
// every status update, which re-rendered the whole grid continuously and
|
||||||
|
// pegged the CPU on a busy cluster (the 0.23.6 slowdown).
|
||||||
|
cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) },
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
// Pass spotStatus through AG Grid's context so cell renderers can look up
|
// Pass spotStatus through AG Grid's context so cell renderers can look up
|
||||||
@@ -405,12 +553,22 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||||
|
|
||||||
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||||
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
|
||||||
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
|
||||||
// turns blue once its status loads.
|
// refresh so e.g. a worked call turns blue once its status loads.
|
||||||
|
//
|
||||||
|
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
|
||||||
|
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
|
||||||
|
// into one refresh every 200 ms (still imperceptible) instead of one per update.
|
||||||
|
const refreshPending = useRef<number | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
gridRef.current?.api?.refreshCells({ force: true });
|
if (refreshPending.current !== undefined) return; // a refresh is already queued
|
||||||
|
refreshPending.current = window.setTimeout(() => {
|
||||||
|
refreshPending.current = undefined;
|
||||||
|
gridRef.current?.api?.refreshCells({ force: true });
|
||||||
|
}, 200);
|
||||||
}, [spotStatus]);
|
}, [spotStatus]);
|
||||||
|
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
|
||||||
|
|
||||||
// Restore AFTER the profile scope is known — this grid has no key= remount to
|
// Restore AFTER the profile scope is known — this grid has no key= remount to
|
||||||
// save it from reading the wrong (unscoped) cache key at first paint.
|
// save it from reading the wrong (unscoped) cache key at first paint.
|
||||||
@@ -430,7 +588,49 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// HOLDING THE LIST STILL WHILE IT IS BEING READ.
|
||||||
|
//
|
||||||
|
// A busy evening puts a spot on the list every second or two, each landing at
|
||||||
|
// the top and pushing everything below it down. At the top of the list that is
|
||||||
|
// exactly right — it is what makes the panel live. A few rows down it makes
|
||||||
|
// the panel unusable: the callsign under the pointer has moved by the time the
|
||||||
|
// click lands, and the operator ends up chasing the row they wanted.
|
||||||
|
//
|
||||||
|
// So the grid freezes as soon as it is scrolled away from the top, and says
|
||||||
|
// how many spots are waiting. Scrolling back to the top releases it, as does
|
||||||
|
// clicking the notice. Nothing is lost — the spots keep arriving in the shared
|
||||||
|
// list; this only decides when the GRID is allowed to redraw with them.
|
||||||
|
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
|
||||||
|
const shown = held ?? rows;
|
||||||
|
|
||||||
|
// How many arrived since the freeze. Counted by finding the frozen top row in
|
||||||
|
// the live list rather than by comparing lengths: the list is a ring buffer,
|
||||||
|
// so once it is full the length stops growing and a length comparison would
|
||||||
|
// report nothing new for the rest of the evening.
|
||||||
|
const spotID = (r: ClusterSpot) => `${(r as any).received_at}-${r.dx_call}-${(r as any).source_id}`;
|
||||||
|
const waiting = useMemo(() => {
|
||||||
|
if (!held || held.length === 0) return 0;
|
||||||
|
const top = spotID(held[0]);
|
||||||
|
const i = rows.findIndex((r) => spotID(r) === top);
|
||||||
|
return i < 0 ? rows.length : i; // fell out of the buffer: everything is new
|
||||||
|
}, [held, rows]);
|
||||||
|
|
||||||
|
const onBodyScroll = (e: { top: number }) => {
|
||||||
|
const down = e.top > 4; // a pixel or two of overscroll is not "scrolled down"
|
||||||
|
if (!down && held) setHeld(null);
|
||||||
|
if (down && !held) setHeld(rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = () => {
|
||||||
|
setHeld(null);
|
||||||
|
gridRef.current?.api?.ensureIndexVisible(0, 'top');
|
||||||
|
};
|
||||||
|
|
||||||
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
|
||||||
|
if (e.data && onSpotSelect) onSpotSelect(e.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<ClusterSpot>) {
|
||||||
if (e.data && onSpotClick) onSpotClick(e.data);
|
if (e.data && onSpotClick) onSpotClick(e.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +684,7 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
<AgGridReact<ClusterSpot>
|
<AgGridReact<ClusterSpot>
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
theme={hamlogTheme}
|
theme={hamlogTheme}
|
||||||
rowData={rows}
|
rowData={shown}
|
||||||
columnDefs={columnDefs}
|
columnDefs={columnDefs}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
context={context}
|
context={context}
|
||||||
@@ -495,11 +695,21 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
onColumnVisible={saveColumnState}
|
onColumnVisible={saveColumnState}
|
||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowClicked={handleRowClicked}
|
onRowClicked={handleRowClicked}
|
||||||
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onBodyScroll={onBodyScroll}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Only when something is actually waiting: a frozen list with nothing
|
||||||
|
new to show needs no announcement. */}
|
||||||
|
{waiting > 0 && (
|
||||||
|
<button type="button" onClick={release}
|
||||||
|
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 rounded-full border border-primary bg-primary px-3 py-1 text-[11px] font-semibold text-primary-foreground shadow-lg hover:opacity-90">
|
||||||
|
{t('clg2.newSpots', { n: waiting })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
@@ -510,6 +720,17 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
{t('clg2.pickerDesc')}
|
{t('clg2.pickerDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{/* Whole-dialog controls. showAll/hideAll with no group already mean
|
||||||
|
"every column"; only the buttons were missing, so hiding the lot
|
||||||
|
took one click per group. */}
|
||||||
|
<div className="flex items-center gap-1 px-1 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('clg2.allGroups')}</span>
|
||||||
|
<button type="button" className="text-[11px] text-primary hover:underline px-1"
|
||||||
|
onClick={() => showAll()}>{t('clg2.all')}</button>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
|
||||||
|
onClick={() => hideAll()}>{t('clg2.none')}</button>
|
||||||
|
</div>
|
||||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
@@ -42,6 +43,7 @@ export interface DetailsState {
|
|||||||
srx_string?: string;
|
srx_string?: string;
|
||||||
stx_string?: string;
|
stx_string?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
web?: string;
|
||||||
// Award references for the contacted station (set via the Awards tab picker).
|
// Award references for the contacted station (set via the Awards tab picker).
|
||||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||||
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||||
@@ -69,6 +71,18 @@ interface Props {
|
|||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||||
|
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
|
||||||
|
// read here: the list lives in Preferences, and App already reloads it when
|
||||||
|
// Preferences close — a panel reading it once at mount would need a restart.
|
||||||
|
satellites?: string[];
|
||||||
|
// When the entry form is empty and a QSO is selected in the log grid, the
|
||||||
|
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
|
||||||
|
// Only the matrix is redirected — every other tab still edits the live entry.
|
||||||
|
slotCall?: string;
|
||||||
|
slotBand?: string;
|
||||||
|
slotMode?: string;
|
||||||
|
slotWb?: any;
|
||||||
|
slotWbBusy?: boolean;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
onOpenImage?: () => void;
|
onOpenImage?: () => void;
|
||||||
// Optional controlled active tab (so the app can switch it via keyboard).
|
// Optional controlled active tab (so the app can switch it via keyboard).
|
||||||
@@ -77,6 +91,8 @@ interface Props {
|
|||||||
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
|
||||||
// shown as Ctrl+F1…F5 instead of F1…F5.
|
// shown as Ctrl+F1…F5 instead of F1…F5.
|
||||||
keyerActive?: boolean;
|
keyerActive?: boolean;
|
||||||
|
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
|
||||||
|
onEditQso?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||||
@@ -139,7 +155,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
@@ -215,6 +231,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 +288,16 @@ 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={slotCall ? slotWb : wb}
|
||||||
|
busy={slotCall ? !!slotWbBusy : !!wbBusy}
|
||||||
|
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||||
|
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||||
|
bands={bands}
|
||||||
|
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||||
|
forCall={slotCall}
|
||||||
|
onEditQso={onEditQso}
|
||||||
|
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -345,12 +379,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label={t('detp.qslMessage')} span={7}>
|
{/* QSL via gets the room, not the message. Width should follow use, and
|
||||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
these two are nowhere near equal: a manager's callsign is filled in
|
||||||
</Field>
|
constantly and a QSL message almost never. The message had 7 columns
|
||||||
<Field label={t('detp.qslVia')} span={5}>
|
of 12 for text most operators never type. */}
|
||||||
|
<Field label={t('detp.qslVia')} span={7}>
|
||||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={t('detp.qslMessage')} span={5}>
|
||||||
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -437,18 +475,36 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.rig')} span={3}>
|
<Field label={t('detp.rig')} span={3}>
|
||||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.antenna')} span={3}>
|
<Field label={t('detp.antenna')} span={3}>
|
||||||
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
{satelliteMode && (
|
{satelliteMode && (
|
||||||
<>
|
<>
|
||||||
<Field label={t('detp.satName')} span={3}>
|
<Field label={t('detp.satName')} span={3}>
|
||||||
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
{/* The station's own satellites, in alphabetical order, with the
|
||||||
|
box still open to anything typed: SAT_NAME is compared
|
||||||
|
character for character by the awards and by LoTW, so a
|
||||||
|
remembered spelling beats a fresh one every pass — but a bird
|
||||||
|
worked once and never added to the list must not be
|
||||||
|
impossible to log. */}
|
||||||
|
{/* showToggle: without the chevron this control is a text box that
|
||||||
|
happens to open on a keystroke, which nobody discovers — and
|
||||||
|
is exactly what "there is still no dropdown" meant.
|
||||||
|
allowFreeText: the list is the station's own, so a bird
|
||||||
|
worked once and never added to it must still be loggable. */}
|
||||||
|
<Combobox
|
||||||
|
value={details.sat_name}
|
||||||
|
options={satellites}
|
||||||
|
placeholder={t('detp.satName')}
|
||||||
|
showToggle
|
||||||
|
allowFreeText
|
||||||
|
onChange={(v) => onChange({ sat_name: v })}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.satelliteMode')} span={3}>
|
<Field label={t('detp.satelliteMode')} span={3}>
|
||||||
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -466,8 +522,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<Field label="STX">
|
<Field label="STX">
|
||||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.contactedEmail')} span={3}>
|
{/* No placeholder: a greyed-out sample address in an empty field reads
|
||||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
as a value the lookup found, and that is exactly the field an
|
||||||
|
operator checks to see whether it found one. */}
|
||||||
|
<Field label={t('detp.contactedEmail')} span={2}>
|
||||||
|
<Input value={details.email} onChange={(e) => onChange({ email: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('detp.contactedWeb')}>
|
||||||
|
<Input value={details.web ?? ''} onChange={(e) => onChange({ web: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, Power, Activity, AudioLines, SlidersHorizontal } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
||||||
|
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
|
||||||
|
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
|
||||||
|
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, NudgeKenwoodRIT,
|
||||||
|
GetCATState,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { WheelRange } from '@/components/WheelRange';
|
||||||
|
|
||||||
|
type KenwoodState = {
|
||||||
|
available: boolean; model?: string; elecraft: boolean; mode?: string;
|
||||||
|
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
||||||
|
s_meter: number; s_meter_raw: number;
|
||||||
|
power_meter: number; swr: number; swr_raw: number;
|
||||||
|
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
|
||||||
|
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
||||||
|
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
|
||||||
|
meters_provisional: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: KenwoodState = {
|
||||||
|
available: false, elecraft: false, transmitting: false, split: false,
|
||||||
|
s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0,
|
||||||
|
rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0,
|
||||||
|
preamp: false, att: false, nb: false, nr: false,
|
||||||
|
filter_hz: 0, antenna: 0, rit: false, xit: false, rit_offset: 0, key_speed: 0,
|
||||||
|
meters_provisional: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8
|
||||||
|
// kHz; the rest of the range is reachable from the radio's own knob, and a
|
||||||
|
// panel offering thirty widths is slower to use than the knob it replaces.
|
||||||
|
// 4.0k is the K3 maximum and the one FT8 wants: a 2.8 kHz filter clips the
|
||||||
|
// top of the FT8 sub-band, and the decodes that go missing are the ones
|
||||||
|
// nobody notices are missing.
|
||||||
|
const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800, 4000];
|
||||||
|
|
||||||
|
// Raw S-meter → S units. The K3 answers 0-21 across S0…S9+60; S9 is taken at
|
||||||
|
// raw 9 and each step above it as 6 dB. PROVISIONAL, like the rest of the
|
||||||
|
// scaling: the raw value is on screen and in the log, so a real radio settles
|
||||||
|
// it rather than this comment.
|
||||||
|
const S9_RAW = 9;
|
||||||
|
const DB_PER_RAW = 6;
|
||||||
|
function sParts(rawV: number): { s: number; over: number; label: string } {
|
||||||
|
if (rawV >= S9_RAW) {
|
||||||
|
const over = Math.max(0, Math.round((rawV - S9_RAW) * DB_PER_RAW));
|
||||||
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||||
|
}
|
||||||
|
const s = Math.max(0, Math.min(9, rawV));
|
||||||
|
return { s, over: 0, label: `S${s}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment colour, printed the way a radio's own meter is: green up to S9, amber
|
||||||
|
// through the S9+ range, red once the signal would be reported as 59+20 or more.
|
||||||
|
function sSegColor(frac: number) {
|
||||||
|
if (frac > 0.78) return '#dc2626';
|
||||||
|
if (frac > 0.55) return '#f59e0b';
|
||||||
|
return '#16a34a';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card shell the Yaesu and Icom consoles use, so the three read alike.
|
||||||
|
function Card({ icon: Icon, title, children }: { icon: any; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4 text-primary" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One button shape for every on/off control, so the panel reads as one
|
||||||
|
// instrument instead of a collection of differently-styled switches.
|
||||||
|
function Toggle({ label, on, off, onClick }: { label: string; on: boolean; off: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" disabled={off} onClick={onClick}
|
||||||
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold tracking-wide border transition-all disabled:opacity-30',
|
||||||
|
on ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:border-primary/60')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [st, setSt] = useState<KenwoodState>(ZERO);
|
||||||
|
const [freqHz, setFreqHz] = useState(0);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
// Optimistic overlay: a slider must follow the finger, not the poll. Dropped
|
||||||
|
// once the radio has had time to answer with the value it actually took.
|
||||||
|
const [local, setLocal] = useState<{ rf_power?: number; af_gain?: number; rf_gain?: number; mic_gain?: number; squelch?: number; key_speed?: number }>({});
|
||||||
|
const localAtRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = (await GetKenwoodState()) as KenwoodState;
|
||||||
|
const c = (await GetCATState()) as any;
|
||||||
|
if (!alive) return;
|
||||||
|
setSt(s);
|
||||||
|
setFreqHz(c?.split && c?.freq_rx_hz > 0 ? c.freq_rx_hz : (c?.freq_hz ?? 0));
|
||||||
|
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||||
|
setErr('');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (alive) setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const view = { ...st, ...local };
|
||||||
|
const off = !st.available;
|
||||||
|
|
||||||
|
const setErrMsg = (e: any) => setErr(String(e?.message ?? e));
|
||||||
|
|
||||||
|
const put = (patch: typeof local, run: () => Promise<any>) => {
|
||||||
|
setLocal((p) => ({ ...p, ...patch }));
|
||||||
|
localAtRef.current = Date.now();
|
||||||
|
run().catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
{/* Same wrapper as the Yaesu, Icom and Flex consoles: capped width and
|
||||||
|
CENTRED. A console stretched across a 2000 px window puts a slider a
|
||||||
|
hand's width from its own label, and the panel stops reading as one
|
||||||
|
instrument. */}
|
||||||
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
|
{/* VFO + status */}
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Radio className="size-3.5" />
|
||||||
|
{st.elecraft ? 'Elecraft' : 'Kenwood'} {st.model}
|
||||||
|
<span className={cn('size-2 rounded-full', off ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-mono tabular-nums font-bold">
|
||||||
|
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
||||||
|
<SlidersHorizontal className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{off && <div className="text-xs text-muted-foreground px-1">{t('k3.waiting')}</div>}
|
||||||
|
{!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
|
||||||
|
|
||||||
|
{/* Meters */}
|
||||||
|
<Card icon={Activity} title={t('k3.meters')}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<MeterBar label="S-METER" value={view.transmitting ? 0 : view.s_meter} lo={0} hi={100}
|
||||||
|
accent="#16a34a" segColor={sSegColor}
|
||||||
|
display={view.transmitting ? '—' : sParts(view.s_meter_raw).label}
|
||||||
|
onClick={() => {
|
||||||
|
if (view.transmitting || !onReportRST) return;
|
||||||
|
const sp = sParts(view.s_meter_raw);
|
||||||
|
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
||||||
|
}}
|
||||||
|
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
||||||
|
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9" />
|
||||||
|
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
||||||
|
a match that looks ideal on an antenna nobody has measured is the one
|
||||||
|
reading that can cost a radio. */}
|
||||||
|
<MeterBar label="SWR" value={view.transmitting && view.swr > 0 ? view.swr : 1} lo={1} hi={4}
|
||||||
|
accent="#f59e0b"
|
||||||
|
display={view.transmitting && view.swr > 0 ? view.swr.toFixed(1) : '—'} />
|
||||||
|
</div>
|
||||||
|
{view.meters_provisional && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('k3.provisional')}</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* MOX + TUNE */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => SetKenwoodTX(!view.transmitting).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
view.transmitting
|
||||||
|
? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50'
|
||||||
|
: 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
||||||
|
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => TuneKenwoodATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('k3.tuneHint')}
|
||||||
|
className="flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
||||||
|
<Activity className="size-4 inline mr-1 -mt-0.5" /> TUNE
|
||||||
|
</button>
|
||||||
|
{/* The HOLD of the same switch: tuner in line or bypassed. Two buttons
|
||||||
|
because they are two things on the radio — tuning is a cycle you
|
||||||
|
start, bypassing is a state you leave it in. */}
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => ToggleKenwoodATU().catch(setErrMsg)}
|
||||||
|
title={t('k3.atuHint')}
|
||||||
|
className="px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
|
||||||
|
ATU
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Levels — two columns, so a slider stays beside the label it belongs to
|
||||||
|
instead of running the width of the window. */}
|
||||||
|
<Card icon={SlidersHorizontal} title={t('k3.levels')}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
|
||||||
|
<WheelRange min={0} max={110} step={5} disabled={off}
|
||||||
|
value={view.rf_power ?? 0}
|
||||||
|
onChange={(n) => put({ rf_power: n }, () => SetKenwoodPower(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground flex items-center gap-1">
|
||||||
|
<AudioLines className="size-3.5" /> {t('k3.volume')}
|
||||||
|
</span>
|
||||||
|
<WheelRange min={0} max={100} disabled={off}
|
||||||
|
value={view.af_gain ?? 0}
|
||||||
|
onChange={(n) => put({ af_gain: n }, () => SetKenwoodAFGain(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.af_gain ?? 0}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.rfGain')}</span>
|
||||||
|
<WheelRange min={0} max={100} disabled={off}
|
||||||
|
value={view.rf_gain ?? 0}
|
||||||
|
onChange={(n) => put({ rf_gain: n }, () => SetKenwoodRFGain(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.rf_gain ?? 0}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.micGain')}</span>
|
||||||
|
<WheelRange min={0} max={100} disabled={off}
|
||||||
|
value={view.mic_gain ?? 0}
|
||||||
|
onChange={(n) => put({ mic_gain: n }, () => SetKenwoodMicGain(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.mic_gain ?? 0}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.squelch')}</span>
|
||||||
|
<WheelRange min={0} max={100} disabled={off}
|
||||||
|
value={view.squelch ?? 0}
|
||||||
|
onChange={(n) => put({ squelch: n }, () => SetKenwoodSquelch(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.squelch ?? 0}</span>
|
||||||
|
</label>
|
||||||
|
{/* CW keyer speed — the radio's own keyer, the one the K3 sends with. */}
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.keySpeed')}</span>
|
||||||
|
<WheelRange min={8} max={50} disabled={off}
|
||||||
|
value={view.key_speed || 20}
|
||||||
|
onChange={(n) => put({ key_speed: n }, () => SetKenwoodKeySpeed(n))} />
|
||||||
|
<span className="w-12 text-right font-mono tabular-nums">{view.key_speed || 0} wpm</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Receive chain. Toggles, because that is what they are on the radio:
|
||||||
|
one press each, and the state comes back from the rig rather than from
|
||||||
|
what the button was asked to do. */}
|
||||||
|
<Card icon={AudioLines} title={t('k3.receive')}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Toggle label="PRE" on={view.preamp} off={off} onClick={() => SetKenwoodPreamp(!view.preamp).catch(setErrMsg)} />
|
||||||
|
<Toggle label="ATT" on={view.att} off={off} onClick={() => SetKenwoodAtt(!view.att).catch(setErrMsg)} />
|
||||||
|
<Toggle label="NB" on={view.nb} off={off} onClick={() => SetKenwoodNB(!view.nb).catch(setErrMsg)} />
|
||||||
|
<Toggle label="NR" on={view.nr} off={off} onClick={() => SetKenwoodNR(!view.nr).catch(setErrMsg)} />
|
||||||
|
<span className="w-2" />
|
||||||
|
{['OFF', 'SLOW', 'FAST'].map((a) => (
|
||||||
|
<Toggle key={a} label={a} on={(view.agc || '').toUpperCase() === a} off={off}
|
||||||
|
onClick={() => SetKenwoodAGC(a).catch(setErrMsg)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIT / XIT. Clear zeroes both offsets at once — the radio's own RC,
|
||||||
|
and what an operator means by "clear it". */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Toggle label="RIT" on={view.rit} off={off} onClick={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)} />
|
||||||
|
<Toggle label="XIT" on={view.xit} off={off} onClick={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)} />
|
||||||
|
{/* The offset itself. A lit RIT button says the feature is on and
|
||||||
|
nothing about where it has put the receiver. */}
|
||||||
|
{[-100, -10, 10, 100].map((d) => (
|
||||||
|
<Toggle key={d} label={(d > 0 ? '+' : '') + d} on={false} off={off}
|
||||||
|
onClick={() => NudgeKenwoodRIT(d).catch(setErrMsg)} />
|
||||||
|
))}
|
||||||
|
<span className="text-[11px] font-mono tabular-nums text-muted-foreground w-16">
|
||||||
|
{view.rit_offset > 0 ? '+' : ''}{view.rit_offset || 0} Hz
|
||||||
|
</span>
|
||||||
|
<Toggle label={t('k3.clear')} on={false} off={off} onClick={() => ClearKenwoodRIT().catch(setErrMsg)} />
|
||||||
|
{/* Antenna only when the radio answered AN — a K3 without the internal
|
||||||
|
ATU has one socket and no switch to offer. */}
|
||||||
|
{view.antenna > 0 && (<>
|
||||||
|
<span className="w-2" />
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('k3.antenna')}</span>
|
||||||
|
{[1, 2].map((n) => (
|
||||||
|
<Toggle key={n} label={'ANT' + n} on={view.antenna === n} off={off}
|
||||||
|
onClick={() => SetKenwoodAntenna(n).catch(setErrMsg)} />
|
||||||
|
))}
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter width */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('k3.filter')}</span>
|
||||||
|
{FILTERS.map((hz) => (
|
||||||
|
<Toggle key={hz} label={hz >= 1000 ? (hz / 1000).toFixed(1) + 'k' : String(hz)}
|
||||||
|
on={view.filter_hz === hz} off={off}
|
||||||
|
onClick={() => SetKenwoodFilter(hz).catch(setErrMsg)} />
|
||||||
|
))}
|
||||||
|
{view.filter_hz > 0 && (
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">{view.filter_hz} Hz</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,6 +86,13 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
|||||||
return [...m.entries()];
|
return [...m.entries()];
|
||||||
}, [official]);
|
}, [official]);
|
||||||
|
|
||||||
|
// Every tag the dialog can offer — the extras AND every group — so "select
|
||||||
|
// all" means what it says rather than "all the official ones".
|
||||||
|
const everyTag = useMemo(
|
||||||
|
() => [...extras, ...groups.flatMap(([, list]) => list.map((d) => d.name))],
|
||||||
|
[extras, groups],
|
||||||
|
);
|
||||||
|
|
||||||
const toggle = (name: string, on: boolean) =>
|
const toggle = (name: string, on: boolean) =>
|
||||||
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||||
const setMany = (names: string[], on: boolean) =>
|
const setMany = (names: string[], on: boolean) =>
|
||||||
@@ -114,11 +121,25 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
|||||||
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Whole-dialog select/clear, next to the count they change. Per-group
|
||||||
|
links alone meant clearing the selection was one click PER GROUP —
|
||||||
|
a dozen of them to get to "just the handful I want". */}
|
||||||
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||||
<span>{t('exf.chosen', { n: sel.size })}</span>
|
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||||
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
<span className="ml-auto inline-flex items-center gap-1">
|
||||||
{t('exf.defaults')}
|
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
|
||||||
</Button>
|
onClick={() => setSel(new Set(everyTag))}>
|
||||||
|
{t('exf.selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={sel.size === 0}
|
||||||
|
onClick={() => setSel(new Set())}>
|
||||||
|
{t('exf.selectNone')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||||
|
{t('exf.defaults')}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
@@ -13,7 +13,8 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
|
|
||||||
export type FilterOp =
|
export type FilterOp =
|
||||||
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
|
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
|
||||||
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty';
|
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty'
|
||||||
|
| 'in' | 'notin';
|
||||||
|
|
||||||
export interface FilterCondition { field: string; op: FilterOp; value: string }
|
export interface FilterCondition { field: string; op: FilterOp; value: string }
|
||||||
export interface QueryFilter {
|
export interface QueryFilter {
|
||||||
@@ -32,6 +33,8 @@ type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
|||||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||||
|
// When the record entered the logbook — the way to isolate what an import added.
|
||||||
|
{ value: 'created_at', label: 'fltb.fCreated', type: 'date' },
|
||||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||||
@@ -67,6 +70,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
|
||||||
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
|
||||||
|
{ value: 'qsl_sent_via', label: 'fltb.fQslSentVia', type: 'text' },
|
||||||
|
{ value: 'qsl_rcvd_via', label: 'fltb.fQslRcvdVia', type: 'text' },
|
||||||
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
|
||||||
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
|
||||||
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
|
||||||
@@ -83,6 +88,12 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||||
|
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
||||||
|
// qso.filterableExtras).
|
||||||
|
{ value: 'hamlog_sent', label: 'fltb.fHamlogSent', type: 'text' },
|
||||||
|
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
||||||
|
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
@@ -121,9 +132,15 @@ const OPS: { value: FilterOp; label: string }[] = [
|
|||||||
{ value: 'le', label: 'fltb.opLe' },
|
{ value: 'le', label: 'fltb.opLe' },
|
||||||
{ value: 'empty', label: 'fltb.opEmpty' },
|
{ value: 'empty', label: 'fltb.opEmpty' },
|
||||||
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
{ value: 'notempty', label: 'fltb.opNotEmpty' },
|
||||||
|
// 'in' is what makes a real question askable. Every condition is joined by ONE
|
||||||
|
// AND or OR, so "2 m or 70 cm, in FT8, since January" had no expression: AND
|
||||||
|
// killed the two bands, OR let every FT8 QSO through. The OR lives inside the
|
||||||
|
// condition instead, and the rest keeps ANDing.
|
||||||
|
{ value: 'in', label: 'fltb.opIn' },
|
||||||
|
{ value: 'notin', label: 'fltb.opNotIn' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
|
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'in', 'notin', 'empty', 'notempty'];
|
||||||
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
|
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
|
||||||
|
|
||||||
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
function opsFor(field: string): { value: FilterOp; label: string }[] {
|
||||||
@@ -183,6 +200,17 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
|
|
||||||
function apply() { onApply(buildFilter()); }
|
function apply() { onApply(buildFilter()); }
|
||||||
|
|
||||||
|
// Same silence as the alert rules: the preset landed in the list, but the list
|
||||||
|
// is not where the operator is looking when they press Save.
|
||||||
|
const [savedMsg, setSavedMsg] = useState('');
|
||||||
|
const savedTimer = useRef(0);
|
||||||
|
function flashSaved(text: string) {
|
||||||
|
setSavedMsg(text);
|
||||||
|
window.clearTimeout(savedTimer.current);
|
||||||
|
savedTimer.current = window.setTimeout(() => setSavedMsg(''), 3000);
|
||||||
|
}
|
||||||
|
useEffect(() => () => window.clearTimeout(savedTimer.current), []);
|
||||||
|
|
||||||
function saveCurrentPreset() {
|
function saveCurrentPreset() {
|
||||||
const name = presetName.trim();
|
const name = presetName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -190,6 +218,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
savePresets(next);
|
savePresets(next);
|
||||||
setPresets(next);
|
setPresets(next);
|
||||||
setPresetName('');
|
setPresetName('');
|
||||||
|
flashSaved(t('fltb.presetSaved', { name }));
|
||||||
}
|
}
|
||||||
function loadPreset(name: string) {
|
function loadPreset(name: string) {
|
||||||
const f = presets[name];
|
const f = presets[name];
|
||||||
@@ -234,7 +263,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
<SelectItem key={n} value={n}>
|
<SelectItem key={n} value={n}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{n}
|
{n}
|
||||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||||
|
fires too late — the menu has already loaded the preset and
|
||||||
|
closed. Intercept pointer-down (preventDefault stops Radix
|
||||||
|
claiming it) and delete there instead. */}
|
||||||
|
<Trash2
|
||||||
|
className="size-3 text-muted-foreground hover:text-destructive"
|
||||||
|
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -250,6 +286,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
{conditions.map((c, i) => {
|
{conditions.map((c, i) => {
|
||||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||||
|
const isList = c.op === 'in' || c.op === 'notin';
|
||||||
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -274,19 +311,28 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
// An ADIF date is picked with a calendar but STORED as the
|
// An ADIF date is picked with a calendar but STORED as the
|
||||||
// 8-digit form the column holds, so the comparison stays a
|
// 8-digit form the column holds, so the comparison stays a
|
||||||
// plain string one on both sides.
|
// plain string one on both sides.
|
||||||
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
// A list is typed by hand, commas and all, so it stays a text
|
||||||
|
// box even on a date or number field: a calendar cannot express
|
||||||
|
// "any one of these".
|
||||||
|
type={isList ? 'text'
|
||||||
|
: fieldType === 'date' || fieldType === 'adifdate' ? 'date'
|
||||||
|
: fieldType === 'number' ? 'number' : 'text'}
|
||||||
className="h-8 flex-1 text-xs"
|
className="h-8 flex-1 text-xs"
|
||||||
disabled={!needsValue}
|
disabled={!needsValue}
|
||||||
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
|
placeholder={!needsValue ? '—'
|
||||||
|
: isList ? t('fltb.listPh')
|
||||||
|
: fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')}
|
||||||
// \d, not d: the escape was missing, so the test never matched
|
// \d, not d: the escape was missing, so the test never matched
|
||||||
// an 8-digit ADIF date and the calendar input was handed
|
// an 8-digit ADIF date and the calendar input was handed
|
||||||
// "20260728" — which type=date rejects, showing an empty box
|
// "20260728" — which type=date rejects, showing an empty box
|
||||||
// over a value that was really there.
|
// over a value that was really there.
|
||||||
value={fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
|
// The ADIF reshaping is skipped for a list: it would eat the
|
||||||
|
// commas and leave one unreadable run of digits.
|
||||||
|
value={!isList && fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
|
||||||
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
|
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
|
||||||
: c.value}
|
: c.value}
|
||||||
onChange={(e) => setCond(i, {
|
onChange={(e) => setCond(i, {
|
||||||
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
value: !isList && fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
|
||||||
})}
|
})}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||||
/>
|
/>
|
||||||
@@ -309,6 +355,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
|
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
|
||||||
<Save className="size-3.5 mr-1" /> {t('fltb.savePreset')}
|
<Save className="size-3.5 mr-1" /> {t('fltb.savePreset')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{savedMsg && <span className="text-[11px] text-success truncate">{savedMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
|
GetFlexRSTChase, SaveFlexRSTChase, SetFlexRSTChaseEnabled,
|
||||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||||
@@ -304,6 +305,25 @@ function powerLevelLabel(pl?: string): string {
|
|||||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// Split-pile-up chaser (see flexrstchase.go). Held here so the button can
|
||||||
|
// paint its state without polling the backend for it.
|
||||||
|
const [rstChase, setRstChase] = useState<{ enabled: boolean; markers: string; offset_hz: number; split_only: boolean }>(
|
||||||
|
{ enabled: false, markers: '599', offset_hz: 0, split_only: true });
|
||||||
|
const [rstChaseOffsetText, setRstChaseOffsetText] = useState('0');
|
||||||
|
// The marker text is edited as raw text and only parsed on blur: it is a
|
||||||
|
// comma-separated list, and normalising it on every keystroke would eat the
|
||||||
|
// comma the moment it is typed.
|
||||||
|
const [rstChaseMarkersText, setRstChaseMarkersText] = useState('599');
|
||||||
|
// The marker field is summoned by right-clicking the button, not parked on
|
||||||
|
// the row: it is set once to agree with SDC and then never touched, while the
|
||||||
|
// row it was sitting in is the busiest in the panel.
|
||||||
|
const [rstChaseEditing, setRstChaseEditing] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetFlexRSTChase().then((c: any) => {
|
||||||
|
if (!c) return;
|
||||||
|
setRstChase(c); setRstChaseOffsetText(String(c.offset_hz ?? 0)); setRstChaseMarkersText(String(c.markers ?? '599'));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
const [st, setSt] = useState<FlexState>(ZERO);
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
||||||
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
||||||
@@ -328,7 +348,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
// longer and the meters visibly trail the end of a transmission, which reads
|
// longer and the meters visibly trail the end of a transmission, which reads
|
||||||
// as the app lagging rather than as a peak being held.
|
// as the app lagging rather than as a peak being held.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
const peakHold = (key: string, val: number, windowMs = 1000) => {
|
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
|
||||||
|
// live=false throws the held value away at once (e.g. the carrier dropped),
|
||||||
|
// so the meter falls immediately instead of coasting for the window.
|
||||||
|
if (!live) { delete peak.current[key]; return val; }
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const p = peak.current[key];
|
const p = peak.current[key];
|
||||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
@@ -362,7 +385,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 2000);
|
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -649,6 +672,64 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||||||
SPLIT
|
SPLIT
|
||||||
</button>
|
</button>
|
||||||
|
{/* The split chaser. On the panel and not in a settings page:
|
||||||
|
it is switched on when a DXpedition appears and off when it is
|
||||||
|
in the log, which is done mid-QSO with one hand. The offset
|
||||||
|
sits beside it because it is the one number retuned while
|
||||||
|
chasing — everything else about the feature is configured once. */}
|
||||||
|
{/* CW ONLY. The marker comes from a skimmer decoding a CW report,
|
||||||
|
so on SSB or FT8 there is nothing to chase — and a switch that
|
||||||
|
cannot fire is an invitation to wonder why it does nothing. */}
|
||||||
|
{isCW && (<>
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
title={t('flxp.rstChaseHint', { m: rstChase.markers })}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setRstChaseEditing(true); }}
|
||||||
|
onClick={() => { const on = !rstChase.enabled; setRstChase({ ...rstChase, enabled: on }); SetFlexRSTChaseEnabled(on).catch(() => {}); }}
|
||||||
|
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
rstChase.enabled ? 'bg-success text-success-foreground border-success shadow-[0_0_12px] shadow-success/50' : 'bg-card text-success border-success hover:bg-success-muted')}>
|
||||||
|
{rstChase.markers.split(',')[0].trim() || '599'}
|
||||||
|
</button>
|
||||||
|
{/* The marker is whatever SDC was told to write — "599", "5NN", or
|
||||||
|
several. It has to agree with the skimmer or nothing ever fires,
|
||||||
|
so it is edited here rather than in a settings page — but only
|
||||||
|
when asked for, since it is set once and then left alone. */}
|
||||||
|
{rstChaseEditing && (
|
||||||
|
<input type="text" autoFocus value={rstChaseMarkersText}
|
||||||
|
onChange={(e) => setRstChaseMarkersText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === 'Escape') { setRstChaseMarkersText(rstChase.markers); setRstChaseEditing(false); }
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const v = rstChaseMarkersText.trim() || '599';
|
||||||
|
setRstChaseMarkersText(v);
|
||||||
|
setRstChaseEditing(false);
|
||||||
|
if (v === rstChase.markers) return;
|
||||||
|
const next = { ...rstChase, markers: v };
|
||||||
|
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||||
|
}}
|
||||||
|
title={t('flxp.rstChaseMarkerHint')}
|
||||||
|
className="w-16 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono uppercase" />
|
||||||
|
)}
|
||||||
|
{rstChase.enabled && (
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-muted-foreground whitespace-nowrap ml-2">
|
||||||
|
{t('flxp.rstChaseOffset')}
|
||||||
|
<input type="number" step={10} value={rstChaseOffsetText}
|
||||||
|
onChange={(e) => setRstChaseOffsetText(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
// Kept as text while typing: normalising every keystroke
|
||||||
|
// makes a minus sign impossible to enter.
|
||||||
|
const n = Math.round(Number(rstChaseOffsetText));
|
||||||
|
const v = Number.isFinite(n) ? n : 0;
|
||||||
|
setRstChaseOffsetText(String(v));
|
||||||
|
const next = { ...rstChase, offset_hz: v };
|
||||||
|
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||||
|
}}
|
||||||
|
className="w-16 h-6 rounded border border-input bg-background px-1.5 text-[11px] font-mono" />
|
||||||
|
Hz
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
{st.split && !!st.tx_freq_hz && (
|
{st.split && !!st.tx_freq_hz && (
|
||||||
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||||||
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
||||||
@@ -1114,11 +1195,32 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||||
and shown compact so they sit with the amp controls. */}
|
and shown compact so they sit with the amp controls. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
// Prefer the radio's VITA amp meters when they are flowing (local or
|
||||||
|
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
|
||||||
|
// right temperature sensor). Fall back to the amp's OWN link only when
|
||||||
|
// the radio isn't streaming them (a remote link without VITA).
|
||||||
const meters = st.meters || [];
|
const meters = st.meters || [];
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (off || amp.length === 0) return null;
|
if (off || amp.length === 0) {
|
||||||
|
const P = pg as any;
|
||||||
|
if (!P.connected) return null;
|
||||||
|
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
|
||||||
|
// random points, so peak-hold it (live=txing drops it the instant
|
||||||
|
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
|
||||||
|
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
|
||||||
|
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
|
||||||
|
const idA = txing ? Number(P.id) || 0 : 0;
|
||||||
|
const temp = Number(P.temperature) || 0;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
|
||||||
|
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
|
||||||
|
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Power comes from the radio's meter stream and nothing else. The
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
// amplifier also reports a "peakfwd", and using it was a mistake
|
// amplifier also reports a "peakfwd", and using it was a mistake
|
||||||
// twice over: it is a latched maximum that is never reset, and it
|
// twice over: it is a latched maximum that is never reset, and it
|
||||||
@@ -1133,14 +1235,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
|
||||||
// value itself is fine — only the bar's range was wrong. Give it a
|
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
|
||||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
// unusable reported range but keeping a sane one.
|
||||||
// only override an unusable reported range, not a sane one.
|
|
||||||
let lo = m.lo, hi = m.hi;
|
let lo = m.lo, hi = m.hi;
|
||||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
lo = 0;
|
lo = 0;
|
||||||
hi = m.hi >= 25 ? m.hi : 25;
|
hi = m.hi >= 50 ? m.hi : 50;
|
||||||
}
|
}
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
import { GridSquares } from '../../wailsjs/go/main/App';
|
||||||
|
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
|
||||||
|
//
|
||||||
|
// A square, not a marker. The question this answers is "where have I been
|
||||||
|
// heard", and the shape of the answer is an area: a wall of squares across the
|
||||||
|
// Atlantic and a bare Pacific says something about an antenna that a scatter of
|
||||||
|
// pins does not. Confirmed and merely worked are two fills of the same hue —
|
||||||
|
// the distinction is a STATE of one thing, not two categories, so it must not
|
||||||
|
// be two unrelated colours.
|
||||||
|
//
|
||||||
|
// 4 characters, never 6: at world zoom a 6-character square is sub-pixel, and
|
||||||
|
// aggregating to the big square is also how the count means "contacts in this
|
||||||
|
// square" rather than "contacts at this exact spot".
|
||||||
|
|
||||||
|
type Square = { grid: string; count: number; confirmed: boolean; band?: string; mode?: string };
|
||||||
|
|
||||||
|
// The SAME basemaps the Main-tab world map offers, imported rather than copied:
|
||||||
|
// two lists of tile servers is how one map ends up on a provider the other has
|
||||||
|
// already been blocked by. This map opened on a hardcoded dark Carto, which is
|
||||||
|
// the least legible of the four under translucent squares.
|
||||||
|
|
||||||
|
// Leaflet geometry is painted onto a CANVAS, and canvas takes a colour, not a
|
||||||
|
// stylesheet: "var(--success)" handed to fillStyle is simply invalid and the
|
||||||
|
// shape is silently not drawn — a map with a correct square count and nothing
|
||||||
|
// on it. So the token is RESOLVED to its literal value here, once per theme,
|
||||||
|
// which is also why every other map in this app passes hex.
|
||||||
|
// The Earth, in the projection's own terms. Latitude stops at ±85.0511 because
|
||||||
|
// that is where Web Mercator does.
|
||||||
|
const WORLD_BOUNDS = L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180));
|
||||||
|
|
||||||
|
function cssColour(token: string, fallback: string): string {
|
||||||
|
try {
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
|
||||||
|
return v || fallback;
|
||||||
|
} catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode scope. The names come straight from the backend's own classes ("ALL",
|
||||||
|
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
|
||||||
|
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
|
||||||
|
// not a square worked on FT8.
|
||||||
|
const SCOPES = [
|
||||||
|
{ key: 'ALL', label: 'gsm.all' },
|
||||||
|
{ key: 'PHONE', label: 'gsm.phone' },
|
||||||
|
{ key: 'CW', label: 'gsm.cw' },
|
||||||
|
{ key: 'DIGI', label: 'gsm.digital' },
|
||||||
|
{ key: 'FTX', label: 'gsm.ftx' },
|
||||||
|
] as const;
|
||||||
|
type ScopeKey = typeof SCOPES[number]['key'];
|
||||||
|
|
||||||
|
const SCOPE_KEY = 'opslog.gridMapScope';
|
||||||
|
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
||||||
|
// stays the default: the tokens already track the four themes, and freezing a
|
||||||
|
// hex at first run would leave a dark-theme map painted in the light palette.
|
||||||
|
const COL_CONFIRMED_KEY = 'opslog.gridMapColorConfirmed';
|
||||||
|
const COL_WORKED_KEY = 'opslog.gridMapColorWorked';
|
||||||
|
|
||||||
|
// A colour input only accepts #rrggbb. The theme tokens ARE plain hex today, so
|
||||||
|
// this normally just passes them through — but a token that ever becomes oklch()
|
||||||
|
// or a named colour would silently drive the swatch to black, and a fallback is
|
||||||
|
// cheaper than that debugging session.
|
||||||
|
function asHex(v: string, fallback: string): string {
|
||||||
|
return /^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const [squares, setSquares] = useState<Square[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [scope, setScope] = useState<ScopeKey>(
|
||||||
|
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
||||||
|
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
||||||
|
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
|
||||||
|
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
|
||||||
|
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
|
||||||
|
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
||||||
|
// from theme tokens resolved at draw time, so a theme switch leaves them on
|
||||||
|
// the old palette until something forces a redraw.
|
||||||
|
const [themeTick, setThemeTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const obs = new MutationObserver(() => setThemeTick((n) => n + 1));
|
||||||
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = async (sc: ScopeKey = scope) => {
|
||||||
|
setBusy(true); setErr('');
|
||||||
|
try {
|
||||||
|
const r = (await GridSquares(sc)) as any;
|
||||||
|
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
setSquares([]);
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
|
||||||
|
|
||||||
|
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
||||||
|
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
||||||
|
// pan.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current || mapRef.current) return;
|
||||||
|
const m = L.map(hostRef.current, {
|
||||||
|
preferCanvas: true,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
// ONE world, not a row of them. worldCopyJump exists to make a path look
|
||||||
|
// continuous across the date line; here it only meant the same continent
|
||||||
|
// appeared two or three times, each carrying the same squares.
|
||||||
|
worldCopyJump: false,
|
||||||
|
// Zoom 0 is the whole planet in 256 pixels — the level at which Greenland
|
||||||
|
// and Antarctica are both on screen. The old floor of 1 made that
|
||||||
|
// impossible on any window wider than it is tall: one step out was already
|
||||||
|
// too far in.
|
||||||
|
minZoom: 0,
|
||||||
|
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
|
||||||
|
}).setView([20, 0], 2);
|
||||||
|
// The whole world, once, whatever the window is shaped like — computed by
|
||||||
|
// Leaflet from the container rather than guessed with a zoom number.
|
||||||
|
m.fitWorld({ animate: false });
|
||||||
|
// Latitude only: the poles are the edge of the projection and there is
|
||||||
|
// nothing beyond them, while leaving longitude free keeps a drag from
|
||||||
|
// fighting the operator near the date line.
|
||||||
|
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
|
||||||
|
mapRef.current = m;
|
||||||
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
// Leaflet measures its container ONCE, when the map is created, and never
|
||||||
|
// looks again. Here that measurement happens while the panel is still
|
||||||
|
// laying out — so the map kept the height it had at that instant and the
|
||||||
|
// rest of the panel stayed blank underneath it, whatever the window size.
|
||||||
|
// Watching the host is the only fix that also survives a window resize, a
|
||||||
|
// split-pane drag and the tab being shown for the first time.
|
||||||
|
// fitWorld above ran against a container that may still have had no size —
|
||||||
|
// the tab is mounted hidden. Fit ONCE more the first time it really has one,
|
||||||
|
// and never again: after that the view belongs to the operator.
|
||||||
|
let fitted = false;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
m.invalidateSize({ animate: false });
|
||||||
|
const el = hostRef.current;
|
||||||
|
if (!fitted && el && el.clientWidth > 0 && el.clientHeight > 0) {
|
||||||
|
fitted = true;
|
||||||
|
m.fitWorld({ animate: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(hostRef.current);
|
||||||
|
return () => { ro.disconnect(); m.remove(); mapRef.current = null; layerRef.current = null; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The chosen basemap, shared with the Main-tab map so picking one there and
|
||||||
|
// finding another here cannot happen.
|
||||||
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m) return;
|
||||||
|
// noWrap stops the world REPEATING; bounds stops Leaflet asking for the
|
||||||
|
// empty columns either side of it. What is left beside the planet is then
|
||||||
|
// the panel's own background rather than a wall of grey apologies.
|
||||||
|
addBasemap(m, basemap, baseRef, labelsRef, { noWrap: true, bounds: WORLD_BOUNDS });
|
||||||
|
baseRef.current?.bringToBack();
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// Redraw the squares.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
// Resolved once for the whole redraw, not per square: getComputedStyle
|
||||||
|
// forces a style flush, and doing that a thousand times is a visible stall.
|
||||||
|
const confirmedColour = confColour || cssColour('--success', '#16a34a');
|
||||||
|
const workedFill = workedColour || cssColour('--chart-1', '#2a78d6');
|
||||||
|
const meColour = cssColour('--warning', '#f59e0b');
|
||||||
|
for (const sq of squares ?? []) {
|
||||||
|
const b = gridSquareBounds(sq.grid);
|
||||||
|
if (!b) continue;
|
||||||
|
// One hue, two states. Confirmed is the solid, saturated one; worked is
|
||||||
|
// the same colour held back — so the eye reads "more" and "less" of the
|
||||||
|
// same thing rather than two unrelated facts.
|
||||||
|
const colour = sq.confirmed ? confirmedColour : workedFill;
|
||||||
|
L.rectangle([[b.south, b.west], [b.north, b.east]], {
|
||||||
|
color: colour,
|
||||||
|
weight: 0.5,
|
||||||
|
opacity: sq.confirmed ? 0.9 : 0.5,
|
||||||
|
fillColor: colour,
|
||||||
|
fillOpacity: sq.confirmed ? 0.55 : 0.22,
|
||||||
|
})
|
||||||
|
.bindTooltip(
|
||||||
|
`${sq.grid} — ${sq.count} QSO${sq.count > 1 ? 's' : ''}` +
|
||||||
|
`${sq.band ? ` · ${sq.band}` : ''}${sq.mode ? ` ${sq.mode}` : ''}` +
|
||||||
|
`${sq.confirmed ? ` · ${t('gsm.confirmed')}` : ''}`,
|
||||||
|
{ sticky: true },
|
||||||
|
)
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
// The operator's own square, so the pattern has an origin to be read from.
|
||||||
|
const me = myGrid ? gridToLatLon(myGrid) : null;
|
||||||
|
if (me) {
|
||||||
|
L.circleMarker([me.lat, me.lon], {
|
||||||
|
radius: 4, color: meColour, weight: 2,
|
||||||
|
fillColor: meColour, fillOpacity: 1,
|
||||||
|
}).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer);
|
||||||
|
}
|
||||||
|
}, [squares, myGrid, t, themeTick, confColour, workedColour]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const list = squares ?? [];
|
||||||
|
return { total: list.length, confirmed: list.filter((s) => s.confirmed).length };
|
||||||
|
}, [squares]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// isolate is load-bearing, not tidiness. Leaflet puts its panes at z-index
|
||||||
|
// 400 and its zoom control at 1000, in the PAGE's stacking context — and the
|
||||||
|
// modals here are z-50. Without a stacking context of its own the map floats
|
||||||
|
// over Preferences and hides the Save and Close buttons. isolation:isolate
|
||||||
|
// confines every z-index Leaflet sets to this element.
|
||||||
|
<section className={cn('isolate relative flex flex-col min-h-0 rounded-lg border border-border bg-card overflow-hidden', className)}>
|
||||||
|
<header className="flex items-center gap-2 px-2.5 py-1.5 border-b border-border bg-muted/40 shrink-0 flex-wrap">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('gsm.title')}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{SCOPES.map((s, i) => (
|
||||||
|
<button key={s.key} type="button"
|
||||||
|
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
|
||||||
|
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||||
|
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
||||||
|
{t(s.label)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={basemap}
|
||||||
|
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
|
||||||
|
title={t('gsm.basemap')}
|
||||||
|
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||||
|
>
|
||||||
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
|
<option key={k} value={k}>{BASEMAPS[k].label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{/* Beside the basemap, because they answer the same question: what this
|
||||||
|
map looks like. Written straight through — there is no Save here. */}
|
||||||
|
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colConfirmed')}>
|
||||||
|
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={asHex(confColour || cssColour('--success', '#16a34a'), '#16a34a')}
|
||||||
|
onChange={(e) => { setConfColour(e.target.value); writeUiPref(COL_CONFIRMED_KEY, e.target.value); }} />
|
||||||
|
{t('gsm.confirmed')}
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colWorked')}>
|
||||||
|
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={asHex(workedColour || cssColour('--chart-1', '#2a78d6'), '#2a78d6')}
|
||||||
|
onChange={(e) => { setWorkedColour(e.target.value); writeUiPref(COL_WORKED_KEY, e.target.value); }} />
|
||||||
|
{t('gsm.worked')}
|
||||||
|
</label>
|
||||||
|
{(confColour || workedColour) && (
|
||||||
|
<button type="button" title={t('gsm.colReset')}
|
||||||
|
onClick={() => {
|
||||||
|
setConfColour(''); setWorkedColour('');
|
||||||
|
writeUiPref(COL_CONFIRMED_KEY, ''); writeUiPref(COL_WORKED_KEY, '');
|
||||||
|
}}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
||||||
|
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{err && <p className="px-2.5 py-1 text-[11px] text-destructive shrink-0">{err}</p>}
|
||||||
|
{/* The map host must have a real height or Leaflet renders nothing at all
|
||||||
|
— flex-1 + min-h-0, never a percentage. */}
|
||||||
|
{/* The ground beside the planet is the panel's own background: Leaflet
|
||||||
|
paints its container, and left to its default that surround was a
|
||||||
|
bright white slab against a dark theme. */}
|
||||||
|
<div ref={hostRef} className="flex-1 min-h-0 bg-card" />
|
||||||
|
<footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block size-2.5 rounded-[2px]"
|
||||||
|
style={{ background: confColour || 'var(--success)', opacity: 0.75 }} />
|
||||||
|
{t('gsm.confirmed')}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="inline-block size-2.5 rounded-[2px]"
|
||||||
|
style={{ background: workedColour || 'var(--chart-1)', opacity: 0.35 }} />
|
||||||
|
{t('gsm.worked')}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,12 +47,33 @@ const ZERO: IcomState = {
|
|||||||
|
|
||||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||||
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
||||||
const BANDS: { l: string; hz: number }[] = [
|
type Band = { l: string; hz: number };
|
||||||
|
|
||||||
|
const HF_BANDS: Band[] = [
|
||||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
||||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||||
{ l: '6', hz: 50_150_000 },
|
|
||||||
];
|
];
|
||||||
|
const B6 = { l: '6', hz: 50_150_000 };
|
||||||
|
const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
||||||
|
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||||
|
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||||
|
|
||||||
|
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||||
|
// the plain SetFrequency command — no band-stacking codes needed.
|
||||||
|
//
|
||||||
|
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||||
|
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||||
|
// through 6 and none of the bands it actually covers: ten dead buttons, and no
|
||||||
|
// way to change band from here on the only rig where you would want to.
|
||||||
|
function bandsFor(model?: string): Band[] {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
if (m.includes('9700')) return [B2, B70, B23]; // VHF/UHF/SHF only
|
||||||
|
if (m.includes('705')) return [...HF_BANDS, B6, B2, B70];
|
||||||
|
if (m.includes('9100')) return [...HF_BANDS, B6, B2, B70, B23];
|
||||||
|
// 7300 / 7610 / 7100 / unknown: HF + 6 m, the historical list.
|
||||||
|
return [...HF_BANDS, B6];
|
||||||
|
}
|
||||||
|
|
||||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||||||
// SSB by frequency and the rig's data variant for digital modes.
|
// SSB by frequency and the rig's data variant for digital modes.
|
||||||
@@ -85,7 +106,10 @@ function bandOfHz(hz?: number): string {
|
|||||||
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
|
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
|
||||||
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
|
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
|
||||||
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
|
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
|
||||||
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0], ['70', 430.0, 450.0],
|
// Labels must match the band buttons' exactly — this is only used to light
|
||||||
|
// the button for the band the rig is on, and '70' never matched '70cm'.
|
||||||
|
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0],
|
||||||
|
['70cm', 430.0, 450.0], ['23cm', 1240.0, 1300.0],
|
||||||
];
|
];
|
||||||
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||||
return '';
|
return '';
|
||||||
@@ -749,7 +773,7 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
{/* Band buttons + antenna selection. */}
|
{/* Band buttons + antenna selection. */}
|
||||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{BANDS.map((b) => {
|
{bandsFor(st.model).map((b) => {
|
||||||
const here = bandOfHz(mainHz) === b.l;
|
const here = bandOfHz(mainHz) === b.l;
|
||||||
return (
|
return (
|
||||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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.
|
||||||
|
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
|
||||||
|
// the oldest fall out of the fetched text entirely, so a small buffer dropped
|
||||||
|
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
|
||||||
|
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
|
||||||
|
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
|
||||||
|
// useful backlog while staying smooth. The backend caps this at 4 MB.
|
||||||
|
const TAIL_BYTES = 512 * 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,24 +43,35 @@ function unwrapLon(ring: [number, number][]): [number, number][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||||
const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
||||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||||
const OSM_ATTR = '© OpenStreetMap contributors';
|
|
||||||
|
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
|
||||||
|
// and their usage policy does not cover a desktop application handed to an
|
||||||
|
// unbounded number of operators — they blocked OpsLog for it, and every user's
|
||||||
|
// map filled with "Access blocked / 403" tiles at once.
|
||||||
|
//
|
||||||
|
// The replacements are tile services whose terms do cover redistributed apps,
|
||||||
|
// with no API key: Carto (whose tiles are OSM-derived, hence the attribution
|
||||||
|
// still credits OpenStreetMap) and Esri. Anything added here later must be
|
||||||
|
// checked the same way — a free tile URL is not the same thing as a tile URL we
|
||||||
|
// are allowed to ship.
|
||||||
|
const ESRI_STREET = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
|
||||||
|
const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, © OpenStreetMap contributors';
|
||||||
|
|
||||||
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
||||||
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||||
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
||||||
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
export type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||||
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
||||||
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||||
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||||
attr: CARTO_ATTR, subdomains: 'abcd' },
|
attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||||
street: { label: 'Street', url: OSM, attr: OSM_ATTR },
|
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_ATTR },
|
||||||
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||||
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||||
};
|
};
|
||||||
function loadBasemap(): BasemapKey {
|
export function loadBasemap(): BasemapKey {
|
||||||
const v = localStorage.getItem('opslog.mapBasemap');
|
const v = localStorage.getItem('opslog.mapBasemap');
|
||||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||||
}
|
}
|
||||||
@@ -69,18 +80,32 @@ function loadBasemap(): BasemapKey {
|
|||||||
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||||
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||||
// layers WebView2 has to hold — is double every other basemap's.
|
// layers WebView2 has to hold — is double every other basemap's.
|
||||||
function addBasemap(
|
export function addBasemap(
|
||||||
m: L.Map,
|
m: L.Map,
|
||||||
key: BasemapKey,
|
key: BasemapKey,
|
||||||
base: React.MutableRefObject<L.TileLayer | null>,
|
base: React.MutableRefObject<L.TileLayer | null>,
|
||||||
labels: React.MutableRefObject<L.TileLayer | null>,
|
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
// noWrap draws the world ONCE instead of repeating it east and west.
|
||||||
|
//
|
||||||
|
// The great-circle map wants the repeats: a path from France to Japan reads
|
||||||
|
// better crossing a second copy of Asia than wrapping round the back. A map of
|
||||||
|
// where you have been heard does not — three North Americas is three times the
|
||||||
|
// same answer, and it makes the picture harder to read, not wider.
|
||||||
|
// bounds stops Leaflet asking for tiles outside the world at all: Esri answers
|
||||||
|
// those with a grey "Map data not yet available" image, and zoomed out on a
|
||||||
|
// wide window that is most of the screen.
|
||||||
|
opts?: { noWrap?: boolean; bounds?: L.LatLngBoundsExpression },
|
||||||
) {
|
) {
|
||||||
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||||||
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||||
const bm = BASEMAPS[key];
|
const bm = BASEMAPS[key];
|
||||||
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
const tileOpts: L.TileLayerOptions = {
|
||||||
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
noWrap: !!opts?.noWrap,
|
||||||
|
...(opts?.bounds ? { bounds: opts.bounds } : {}),
|
||||||
|
};
|
||||||
|
base.current = L.tileLayer(bm.url, { ...tileOpts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, tileOpts).addTo(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dot(color: string): L.DivIcon {
|
function dot(color: string): L.DivIcon {
|
||||||
@@ -341,7 +366,10 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||||
<div ref={worldRef} className="absolute inset-0" />
|
<div ref={worldRef} className="absolute inset-0" />
|
||||||
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
||||||
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
{/* Clear of the zoom buttons, not merely next to them: at left-12 the
|
||||||
|
first basemap sat a few pixels from the − button and operators kept
|
||||||
|
hitting Light when they meant to zoom out. */}
|
||||||
|
<div className="absolute top-1 left-[4.5rem] z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
<button
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
@@ -416,7 +444,14 @@ export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
|||||||
if (locatorRef.current && !locatorMap.current) {
|
if (locatorRef.current && !locatorMap.current) {
|
||||||
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
||||||
.setView([20, 0], 2);
|
.setView([20, 0], 2);
|
||||||
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
// Same restraint as the world map: fetch on idle, not mid-pan, and keep a
|
||||||
|
// single ring of buffer tiles. Requesting tiles as fast as the pointer
|
||||||
|
// moves is what gets an app blocked, and the next provider is under no
|
||||||
|
// more obligation to tolerate it than the last one was.
|
||||||
|
L.tileLayer(CARTO_LIGHT, {
|
||||||
|
attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19,
|
||||||
|
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
|
||||||
|
}).addTo(m);
|
||||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||||
locatorMap.current = m;
|
locatorMap.current = m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// MotorAntennaWidget — the Ultrabeam / SteppIR control, in one place.
|
||||||
|
//
|
||||||
|
// Lives in its own file because it is shown in TWO: the Station Control tab and
|
||||||
|
// the dock beside the entry strip. Two copies of a control that keys hardware is
|
||||||
|
// how one of them quietly stops matching the other — a pattern button that sets
|
||||||
|
// a different direction, a band list that tunes somewhere else.
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
|
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record<string, number> };
|
||||||
|
|
||||||
|
// Where each band button points the antenna.
|
||||||
|
//
|
||||||
|
// Not the arithmetic centre of the band. An antenna is tuned for where people
|
||||||
|
// actually work: 20 m centres on 14175 but nobody lives there, and 10 m spans
|
||||||
|
// 1.7 MHz of which the top half is empty. These are the points that leave the
|
||||||
|
// elements closest to right for the whole band, and one nudge away from the rest.
|
||||||
|
const ANT_BAND_KHZ: Record<string, number> = {
|
||||||
|
'40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
|
||||||
|
'15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
|
||||||
|
};
|
||||||
|
|
||||||
|
// NUDGE_KHZ is the up/down step. 25 kHz because that is also the finest tracking
|
||||||
|
// threshold: a nudge smaller than the tracking step would be undone by the next
|
||||||
|
// poll while tracking is on.
|
||||||
|
const NUDGE_KHZ = 25;
|
||||||
|
|
||||||
|
// bandOfKHz names the band a frequency sits in, so a band button can light up
|
||||||
|
// when the ANTENNA is already there. Deliberately generous at the edges: the
|
||||||
|
// antenna's reported frequency is where it was commanded, which can sit slightly
|
||||||
|
// outside the allocation.
|
||||||
|
function bandOfKHz(khz: number): string {
|
||||||
|
const edges: [number, number, string][] = [
|
||||||
|
[6900, 7300, '40m'], [10050, 10200, '30m'], [13900, 14400, '20m'],
|
||||||
|
[18000, 18200, '17m'], [20900, 21500, '15m'], [24800, 25000, '12m'],
|
||||||
|
[27900, 29800, '10m'], [49900, 50600, '6m'],
|
||||||
|
];
|
||||||
|
for (const [lo, hi, b] of edges) if (khz >= lo && khz <= hi) return b;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||||
|
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||||
|
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||||
|
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||||
|
|
||||||
|
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||||
|
// 1 = driven element, then Director 1, 2, 3…
|
||||||
|
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||||
|
if (i === 0) return t('station.reflector');
|
||||||
|
if (i === 1) return t('station.driven');
|
||||||
|
return `${t('station.director')} ${i - 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }: {
|
||||||
|
ant: AntStatus;
|
||||||
|
refetch: () => void;
|
||||||
|
t: (k: string, v?: any) => string;
|
||||||
|
// onClose adds the dock's close button. The Station Control tab passes none:
|
||||||
|
// there the widget is a tile in a grid, and a tile that closes itself would
|
||||||
|
// leave a hole the operator cannot fill back in.
|
||||||
|
onClose?: () => void;
|
||||||
|
// essentialsOnly drops what an operator sets once and then leaves alone:
|
||||||
|
// tracking with its mode and step, and the per-element lengths. They belong in
|
||||||
|
// Station Control, where there is room to think; in the dock they are height
|
||||||
|
// spent on controls nobody touches between contacts, pushing the ones they do
|
||||||
|
// touch — pattern, bands, nudge — off the bottom.
|
||||||
|
essentialsOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||||
|
const [reading, setReading] = useState(false);
|
||||||
|
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||||
|
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||||
|
const [editVal, setEditVal] = useState('');
|
||||||
|
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
const isUB = ant.type !== 'steppir';
|
||||||
|
|
||||||
|
const readLengths = useCallback(async () => {
|
||||||
|
setReading(true); setErr('');
|
||||||
|
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setReading(false); }
|
||||||
|
}, []);
|
||||||
|
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||||
|
// +/- starts from the real values rather than blind.
|
||||||
|
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||||
|
|
||||||
|
// Send an absolute length to one element and remember it as the new baseline.
|
||||||
|
const setLen = async (i: number, mm: number) => {
|
||||||
|
const next = Math.max(0, Math.round(mm));
|
||||||
|
const prev = lengths[i] ?? 0;
|
||||||
|
setBusyEl(i); setErr('');
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||||
|
try { await MotorSetElement(i, next); refetch(); }
|
||||||
|
catch (e: any) {
|
||||||
|
// Rejected by the controller — most often the element is at its travel
|
||||||
|
// limit for this band (extending on a low band). Revert the optimistic
|
||||||
|
// value so the display stays truthful, and explain the likely cause when
|
||||||
|
// the failed move was an extension.
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||||
|
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
finally { setBusyEl(null); }
|
||||||
|
};
|
||||||
|
// Nudge one element by ±2 mm from its current known length.
|
||||||
|
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||||
|
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||||
|
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||||
|
const commitEdit = (i: number) => {
|
||||||
|
const v = parseInt(editVal, 10);
|
||||||
|
setEditingEl(null);
|
||||||
|
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||||
|
};
|
||||||
|
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full flex flex-col min-h-0">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border/60 bg-muted/30 shrink-0">
|
||||||
|
<AntennaIcon className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||||
|
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||||
|
</div>
|
||||||
|
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||||
|
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" onClick={onClose} title={t('station.hideWidget')}
|
||||||
|
className="text-muted-foreground hover:text-foreground shrink-0">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 space-y-2 flex-1 min-h-0 overflow-auto">
|
||||||
|
<div>
|
||||||
|
{/* No heading: N / 180° / Bi say what they are, and in a docked widget
|
||||||
|
a line of height costs more than the word explains. */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{dirs.map(([d, lbl]) => (
|
||||||
|
<button key={d} type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(SetUltrabeamDirection(d))}
|
||||||
|
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||||
|
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Bands, then a nudge, then tracking — in the order an operator uses
|
||||||
|
them: get to the band, fine-tune inside it, decide whether the
|
||||||
|
antenna should follow the rig from here. */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-0.5">{t('station.bands')}</div>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{(ant.bands ?? []).map((b: string) => {
|
||||||
|
// Where this band tunes is resolved by the backend — the operator's
|
||||||
|
// per-band choice from Settings, or the default. ANT_BAND_KHZ is only
|
||||||
|
// the floor for a status poll that hasn't landed yet.
|
||||||
|
const khz = ant.band_freqs?.[b] || ANT_BAND_KHZ[b];
|
||||||
|
if (!khz) return null;
|
||||||
|
// "On this band" from the antenna's own frequency, not the rig's:
|
||||||
|
// the widget must show where the ANTENNA is, which is the whole
|
||||||
|
// reason for tuning it by hand.
|
||||||
|
const here = ant.frequency > 0 && bandOfKHz(ant.frequency) === b;
|
||||||
|
return (
|
||||||
|
<button key={b} type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(MotorTuneKHz(khz))}
|
||||||
|
title={`${(khz / 1000).toFixed(3)} MHz`}
|
||||||
|
className={cn('rounded-md border py-0.5 text-[11px] font-mono font-semibold transition-colors disabled:opacity-40',
|
||||||
|
here ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(MotorNudgeKHz(-NUDGE_KHZ))}
|
||||||
|
title={t('station.nudgeDown', { n: NUDGE_KHZ })}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
||||||
|
<ChevronDown className="size-3.5" /> {NUDGE_KHZ}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(MotorNudgeKHz(NUDGE_KHZ))}
|
||||||
|
title={t('station.nudgeUp', { n: NUDGE_KHZ })}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
||||||
|
<ChevronUp className="size-3.5" /> {NUDGE_KHZ}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tracking. Here rather than only in Settings because it is an operating
|
||||||
|
decision — off to park the antenna, on to resume — not something set
|
||||||
|
up once. Mode and step only show when tracking is on: settings for
|
||||||
|
something switched off are questions the operator cannot act on. And
|
||||||
|
the step only shows in step mode, where it is the one thing it means. */}
|
||||||
|
{!essentialsOnly && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
||||||
|
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold transition-colors',
|
||||||
|
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
||||||
|
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||||
|
</button>
|
||||||
|
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
||||||
|
<select
|
||||||
|
value={String(ant.step_khz || 50)}
|
||||||
|
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
|
||||||
|
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||||
|
title={t('station.trackStepTip')}
|
||||||
|
>
|
||||||
|
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ant.follow && (
|
||||||
|
<select
|
||||||
|
value={ant.track_mode || 'step'}
|
||||||
|
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
||||||
|
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
||||||
|
title={t('station.trackModeTip')}
|
||||||
|
>
|
||||||
|
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
|
||||||
|
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
|
||||||
|
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(UltrabeamRetract())}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
|
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isUB && !essentialsOnly && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||||
|
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||||
|
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||||
|
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lengths.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||||
|
up structural bytes past the real data (varying by band), which
|
||||||
|
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||||
|
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||||
|
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{editingEl === i ? (
|
||||||
|
<input autoFocus type="number" value={editVal}
|
||||||
|
onChange={(e) => setEditVal(e.target.value)}
|
||||||
|
onBlur={() => commitEdit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||||
|
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||||
|
title={t('station.setExactLen')}
|
||||||
|
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||||
|
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ type QSOMenuHandlers = {
|
|||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -41,6 +41,7 @@ const SERVICES = [
|
|||||||
{ v: 'hrdlog', label: 'HRDLog.net' },
|
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
|
{ v: 'hamlog', label: 'HAMLOG.online' },
|
||||||
{ v: 'pota', label: 'POTA hunter log' },
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
];
|
];
|
||||||
@@ -54,12 +55,14 @@ const QSL_STATUSES = [
|
|||||||
{ v: 'I', label: 'qslm.ignore' },
|
{ v: 'I', label: 'qslm.ignore' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
|
// QSL routing methods for the paper-QSL route dropdowns, one per direction.
|
||||||
|
// These are the ADIF QSL Via enumeration (B/D/E) and go to QSL_SENT_VIA and
|
||||||
|
// QSL_RCVD_VIA — NOT to QSL_VIA, which holds the manager's callsign.
|
||||||
const QSL_VIA_OPTIONS = [
|
const QSL_VIA_OPTIONS = [
|
||||||
{ v: '_', label: 'qslm.leave' },
|
{ v: '_', label: 'qslm.leave' },
|
||||||
{ v: 'Bureau', label: 'qslm.viaBureau' },
|
{ v: 'B', label: 'qslm.viaBureau' },
|
||||||
{ v: 'Direct', label: 'qslm.viaDirect' },
|
{ v: 'D', label: 'qslm.viaDirect' },
|
||||||
{ v: 'Electronic', label: 'qslm.viaElectronic' },
|
{ v: 'E', label: 'qslm.viaElectronic' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Maps a service value → its i18n label key (only for services with
|
// Maps a service value → its i18n label key (only for services with
|
||||||
@@ -109,7 +112,38 @@ export function fmtQslDate(s?: string): string {
|
|||||||
|
|
||||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||||
// and download confirmations, while the rest of the app stays usable.
|
// and download confirmations, while the rest of the app stays usable.
|
||||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
// GridActions are the row actions the context menu offers, forwarded straight to
|
||||||
|
// whichever grid this panel is showing.
|
||||||
|
//
|
||||||
|
// The FILTERED exports are deliberately absent. "Export everything the filter
|
||||||
|
// matches" means the Recent-QSOs filter, and the rows here come from a different
|
||||||
|
// query entirely — offering it would export something other than what is on the
|
||||||
|
// screen, which is the one thing an export must never do. Selection-based
|
||||||
|
// exports have no such ambiguity: the ids are the rows you ticked.
|
||||||
|
export type GridActions = {
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||||
|
onEditQSO?: (id: number) => void;
|
||||||
|
actions?: GridActions;
|
||||||
|
// A callsign to open the Paper QSL view on, sent from elsewhere in the app
|
||||||
|
// (the awards grid). Carries a counter rather than being a bare string: the
|
||||||
|
// panel is force-mounted and keeps its state, so asking twice for the SAME
|
||||||
|
// callsign has to be two requests, not one unchanged prop.
|
||||||
|
paperRequest?: { call: string; n: number };
|
||||||
|
} = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [service, setService] = useState('lotw');
|
const [service, setService] = useState('lotw');
|
||||||
// The callsign this profile signs/uploads/downloads as for the selected
|
// The callsign this profile signs/uploads/downloads as for the selected
|
||||||
@@ -120,6 +154,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||||
}, [service]);
|
}, [service]);
|
||||||
|
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
|
||||||
|
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
|
||||||
|
// bleed its log into the next one.
|
||||||
|
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
|
||||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
const [potaErr, setPotaErr] = useState('');
|
||||||
@@ -145,7 +183,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [qslSent, setQslSent] = useState('_');
|
const [qslSent, setQslSent] = useState('_');
|
||||||
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||||
const [qslSentDate, setQslSentDate] = useState('');
|
const [qslSentDate, setQslSentDate] = useState('');
|
||||||
const [qslVia, setQslVia] = useState('_');
|
const [qslVia, setQslVia] = useState('_'); // how the card was SENT
|
||||||
|
const [qslRcvdVia, setQslRcvdVia] = useState('_'); // how it CAME BACK
|
||||||
const [qslNotes, setQslNotes] = useState('');
|
const [qslNotes, setQslNotes] = useState('');
|
||||||
const [qslComment, setQslComment] = useState('');
|
const [qslComment, setQslComment] = useState('');
|
||||||
|
|
||||||
@@ -165,6 +204,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
}, [paperCall]);
|
}, [paperCall]);
|
||||||
|
|
||||||
|
|
||||||
|
// Honour an incoming request: switch to Paper QSL, fill the callsign, search.
|
||||||
|
// The search runs from the effect below once paperCall has actually changed —
|
||||||
|
// searchPaper closes over paperCall, so calling it here would search the
|
||||||
|
// PREVIOUS callsign.
|
||||||
|
// The pending request's callsign, so the search fires for THAT callsign and
|
||||||
|
// for nothing else: the effect below also wakes on every keystroke in the
|
||||||
|
// field, and searching on each of them would query the log letter by letter.
|
||||||
|
const pendingPaperCall = useRef('');
|
||||||
|
useEffect(() => {
|
||||||
|
const call = paperRequest?.call?.trim().toUpperCase();
|
||||||
|
if (!call) return;
|
||||||
|
setService('paper');
|
||||||
|
setPaperCall(call);
|
||||||
|
pendingPaperCall.current = call;
|
||||||
|
}, [paperRequest?.call, paperRequest?.n]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingPaperCall.current || paperCall !== pendingPaperCall.current) return;
|
||||||
|
pendingPaperCall.current = '';
|
||||||
|
searchPaper();
|
||||||
|
}, [paperCall, searchPaper]);
|
||||||
|
|
||||||
async function applyPaper() {
|
async function applyPaper() {
|
||||||
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -177,6 +237,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
sent_date: ymd(qslSentDate),
|
sent_date: ymd(qslSentDate),
|
||||||
rcvd_date: ymd(qslRcvdDate),
|
rcvd_date: ymd(qslRcvdDate),
|
||||||
via: qslVia === '_' ? '' : qslVia,
|
via: qslVia === '_' ? '' : qslVia,
|
||||||
|
rcvd_via: qslRcvdVia === '_' ? '' : qslRcvdVia,
|
||||||
notes: qslNotes,
|
notes: qslNotes,
|
||||||
comment: qslComment,
|
comment: qslComment,
|
||||||
} as any);
|
} as any);
|
||||||
@@ -289,6 +350,34 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HAMLOG.online has no download verb — their agent protocol only uploads —
|
||||||
|
// so confirmations come back as an ADIF exported from their site. It is read
|
||||||
|
// here rather than through the ordinary ADIF import because that one matches
|
||||||
|
// on the UTC minute and INSERTS whatever fails to match: a few hundred copies
|
||||||
|
// of contacts already in the log. This path only ever stamps QSOs it finds.
|
||||||
|
async function importHamlogCfm() {
|
||||||
|
const path = await OpenADIFFile();
|
||||||
|
if (!path) return;
|
||||||
|
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||||
|
try { await ImportHamlogConfirmations(path); }
|
||||||
|
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unmatched half of an import. Offered as a file because a few hundred
|
||||||
|
// discrepancies are a list to work through, not something to read in a log
|
||||||
|
// window.
|
||||||
|
async function exportHamlogUnmatched() {
|
||||||
|
try {
|
||||||
|
const path = await SaveADIFFile();
|
||||||
|
if (!path) return;
|
||||||
|
const n = await ExportHamlogUnmatched(path);
|
||||||
|
setLogLines((p) => [...p, `Exported ${n} unmatched confirmation(s) → ${path}`]);
|
||||||
|
setShowLog(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setShowLog(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function viewResults() {
|
function viewResults() {
|
||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
if (logAction === 'upload') selectRequired();
|
if (logAction === 'upload') selectRequired();
|
||||||
@@ -435,6 +524,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
total={paperRows.length}
|
total={paperRows.length}
|
||||||
selectAllSignal={paperSelAllSig}
|
selectAllSignal={paperSelAllSig}
|
||||||
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
||||||
|
{...actions}
|
||||||
|
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -541,6 +632,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
total={rows.length}
|
total={rows.length}
|
||||||
selectAllSignal={uploadSelAllSig}
|
selectAllSignal={uploadSelAllSig}
|
||||||
onRowSelected={onUploadRowSelected}
|
onRowSelected={onUploadRowSelected}
|
||||||
|
{...actions}
|
||||||
|
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -549,6 +642,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{/* Paper-QSL apply form */}
|
{/* Paper-QSL apply form */}
|
||||||
{service === 'paper' && (
|
{service === 'paper' && (
|
||||||
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
|
{/* Each direction carries its own route, and each sits with the status
|
||||||
|
and date it belongs to. A stack confirmed in one go usually arrived
|
||||||
|
the same way — a bureau delivery is exactly that — which is why the
|
||||||
|
route belongs on this form and not one QSO at a time in the editor. */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.qslReceived')}</label>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
@@ -557,6 +654,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title={t('qslm.qslRcvdDateTitle')} />
|
||||||
|
<Select value={qslRcvdVia} onValueChange={setQslRcvdVia}>
|
||||||
|
<SelectTrigger className="h-8 w-32" title={t('qslm.rcvdViaTitle')}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -567,15 +668,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{t(s.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title={t('qslm.qslSentDateTitle')} />
|
||||||
|
<Select value={qslVia} onValueChange={setQslVia}>
|
||||||
|
<SelectTrigger className="h-8 w-32" title={t('qslm.sentViaTitle')}><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.via')}</label>
|
|
||||||
<Select value={qslVia} onValueChange={setQslVia}>
|
|
||||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('qslm.notes')}</label>
|
||||||
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder={t('qslm.notesPlaceholder')} />
|
||||||
@@ -596,11 +694,25 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{service !== 'pota' && service !== 'paper' && (
|
{service !== 'pota' && service !== 'paper' && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{service === 'hamlog' ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||||
|
title={t('qslm.hamlogImportTitle')}>
|
||||||
|
<DownloadCloud className="size-3.5" /> {t('qslm.hamlogImportCfm')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={exportHamlogUnmatched} disabled={busy}
|
||||||
|
title={t('qslm.hamlogUnmatchedTitle')}>
|
||||||
|
<UploadCloud className="size-3.5 rotate-180" /> {t('qslm.hamlogUnmatched')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||||
title={t('qslm.downloadTitle')}>
|
title={t('qslm.downloadTitle')}>
|
||||||
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{/* Date window */}
|
{/* Date window */}
|
||||||
|
{service !== 'hamlog' && (<>
|
||||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
||||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -624,6 +736,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||||
{t('qslm.addNotFound')}
|
{t('qslm.addNotFound')}
|
||||||
</label>
|
</label>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2, MapPin } from 'lucide-react';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
@@ -7,16 +7,22 @@ export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
|||||||
type Props = {
|
type Props = {
|
||||||
menu: QSOMenuState;
|
menu: QSOMenuState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdateFromCty: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
// Only passed once the offline US county database has been downloaded —
|
||||||
|
// an entry that can only ever answer "no database" is not worth a line here.
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
// Same rows, but keeping the APP_OPSLOG_* tags — award references above all.
|
||||||
|
onExportSelectedFull?: (ids: number[]) => void;
|
||||||
onExportSelectedFields?: (ids: number[]) => void;
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportFilteredFull?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
@@ -28,6 +34,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||||
{ service: 'lotw', name: 'LoTW' },
|
{ service: 'lotw', name: 'LoTW' },
|
||||||
|
{ service: 'hamlog', name: 'HAMLOG.online' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
@@ -36,7 +43,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||||
@@ -95,20 +102,28 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
{t('qctx.selected', { n })}
|
{t('qctx.selected', { n })}
|
||||||
</div>
|
</div>
|
||||||
<button
|
{/* Conditional like every other entry. These two were drawn
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
unconditionally, and the grid passes them through an arrow that calls
|
||||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
an optional handler — so in a grid that wires neither (the QSL
|
||||||
>
|
Manager) both appeared and did precisely nothing when clicked. */}
|
||||||
<Globe2 className="size-4 text-primary" />
|
{onUpdateFromCty && (
|
||||||
<span>{t('qctx.fixCountry')}</span>
|
<button
|
||||||
</button>
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
<button
|
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
>
|
||||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
<Globe2 className="size-4 text-primary" />
|
||||||
>
|
<span>{t('qctx.fixCountry')}</span>
|
||||||
<RefreshCw className="size-4 text-info" />
|
</button>
|
||||||
<span>{t('qctx.updateQrz')}</span>
|
)}
|
||||||
</button>
|
{onUpdateFromQRZ && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.updateQrz')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onUpdateFromClublog && (
|
{onUpdateFromClublog && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
@@ -118,6 +133,16 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.updateClublog')}</span>
|
<span>{t('qctx.updateClublog')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onUpdateCountyFromULS && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onUpdateCountyFromULS(menu.ids); onClose(); }}
|
||||||
|
title={t('qctx.updateUlsCountyTitle')}
|
||||||
|
>
|
||||||
|
<MapPin className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.updateUlsCounty')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{(onSendRecording || onSendEQSL) && (
|
{(onSendRecording || onSendEQSL) && (
|
||||||
<>
|
<>
|
||||||
@@ -168,6 +193,20 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* The same rows WITH the OpsLog fields.
|
||||||
|
The export above is standard ADIF — what another logger should be
|
||||||
|
given — and it drops every APP_ tag, award references included. A
|
||||||
|
backup, or a move to another OpsLog, needs them: RDA@KR-04 lives in
|
||||||
|
APP_OPSLOG_AWARDREFS and nowhere else. */}
|
||||||
|
{onExportSelectedFull && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportSelectedFull(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.exportSelectedAdifFull', { n })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onExportSelectedFields && (
|
{onExportSelectedFields && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
@@ -186,6 +225,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExportFilteredFull && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportFilteredFull(); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.exportFilteredAdifFull')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onExportCabrilloSelected && (
|
{onExportCabrilloSelected && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
|
||||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
@@ -40,9 +41,14 @@ function pfxOf(call: string): string {
|
|||||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||||
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||||
// label holds an i18n key (resolved with t() at render time).
|
// label holds an i18n key (resolved with t() at render time).
|
||||||
|
// V is in the list because the log HOLDS it: a LoTW download writes "verified"
|
||||||
|
// rather than "yes". A dropdown without it renders a verified contact as blank,
|
||||||
|
// which reads as "nothing recorded" — and the operator's next click would replace
|
||||||
|
// the strongest confirmation they have with whatever they picked instead.
|
||||||
const QSL_STATUSES = [
|
const QSL_STATUSES = [
|
||||||
{ value: '_', label: 'qedit.qslDash' },
|
{ value: '_', label: 'qedit.qslDash' },
|
||||||
{ value: 'Y', label: 'qedit.qslYes' },
|
{ value: 'Y', label: 'qedit.qslYes' },
|
||||||
|
{ value: 'V', label: 'qedit.qslVerified' },
|
||||||
{ value: 'N', label: 'qedit.qslNo' },
|
{ value: 'N', label: 'qedit.qslNo' },
|
||||||
{ value: 'R', label: 'qedit.qslRequested' },
|
{ value: 'R', label: 'qedit.qslRequested' },
|
||||||
{ value: 'I', label: 'qedit.qslIgnore' },
|
{ value: 'I', label: 'qedit.qslIgnore' },
|
||||||
@@ -56,10 +62,12 @@ type ConfDef = {
|
|||||||
key: string; label: string;
|
key: string; label: string;
|
||||||
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
|
||||||
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
|
||||||
via?: keyof QSOForm;
|
// How the card travelled, each way (ADIF QSL_SENT_VIA / QSL_RCVD_VIA). Paper
|
||||||
|
// only — the electronic channels below ARE the route.
|
||||||
|
sentVia?: keyof QSOForm; rcvdVia?: keyof QSOForm;
|
||||||
};
|
};
|
||||||
const CONFIRMATIONS: ConfDef[] = [
|
const CONFIRMATIONS: ConfDef[] = [
|
||||||
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
|
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', sentVia: 'qsl_sent_via', rcvdVia: 'qsl_rcvd_via' },
|
||||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||||
@@ -72,6 +80,24 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
|||||||
QSL: 'qedit.confQslPaper',
|
QSL: 'qedit.confQslPaper',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||||
|
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||||
|
// the channel picker and the status table alongside the rest.
|
||||||
|
const OPSLOG_CONF = 'OPSLOG';
|
||||||
|
|
||||||
|
// HAMLOG.online. Out of CONFIRMATIONS for the same reason as the OpsLog card —
|
||||||
|
// it is stored in the ADIF extras, not in QSO columns — but it is an upload
|
||||||
|
// service like QRZ.com or Club Log, so it gets the ordinary sent/received/date
|
||||||
|
// editor rather than a special one. The received key is THEIR field name, the
|
||||||
|
// one their ADIF export writes; see award.HamlogQSLKey.
|
||||||
|
const HAMLOG_CONF = 'HAMLOG';
|
||||||
|
const HAMLOG_KEYS = {
|
||||||
|
sent: 'APP_OPSLOG_HAMLOG_SENT',
|
||||||
|
sentDate: 'APP_OPSLOG_HAMLOG_SENT_DATE',
|
||||||
|
rcvd: 'APP_HAMLOG_QSO_CFM',
|
||||||
|
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||||
|
};
|
||||||
|
|
||||||
// Colour-coded status cell for the confirmation grid.
|
// Colour-coded status cell for the confirmation grid.
|
||||||
function StatusCell({ value }: { value?: string }) {
|
function StatusCell({ value }: { value?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -91,9 +117,14 @@ function StatusCell({ value }: { value?: string }) {
|
|||||||
// every row; painting that orange (as it used to be, in the
|
// every row; painting that orange (as it used to be, in the
|
||||||
// same orange as Requested) made the table shout about a
|
// same orange as Requested) made the table shout about a
|
||||||
// non-problem and told you nothing apart.
|
// non-problem and told you nothing apart.
|
||||||
|
// Verified green — ADIF's V: confirmed AND validated by the awarding body.
|
||||||
|
// It is what a LoTW download writes, and this table used
|
||||||
|
// to fall through to "No" for it — reporting a verified
|
||||||
|
// contact as unconfirmed, in the one place an operator
|
||||||
|
// goes to check.
|
||||||
// Ignore dashed — deliberately excluded, on purpose.
|
// Ignore dashed — deliberately excluded, on purpose.
|
||||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
const label = v === 'Y' ? t('qedit.qslYes') : v === 'V' ? t('qedit.qslVerified') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||||
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
|
const cls = v === 'Y' || v === 'V' ? 'bg-success text-success-foreground border border-success'
|
||||||
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||||||
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||||||
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||||||
@@ -210,6 +241,28 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ADIF QSL Via enumeration. M (manager) is import-only in the standard, so
|
||||||
|
// it is not offered here — a file that arrives carrying it keeps it, and this
|
||||||
|
// list is what an operator may choose.
|
||||||
|
const QSL_VIA_CHOICES = [
|
||||||
|
{ value: '_', label: 'qedit.qslDash' },
|
||||||
|
{ value: 'B', label: 'qedit.viaBureau' },
|
||||||
|
{ value: 'D', label: 'qedit.viaDirect' },
|
||||||
|
{ value: 'E', label: 'qedit.viaElectronic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{QSL_VIA_CHOICES.map((s) => <SelectItem key={s.value} value={s.value}>{t(s.label)}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||||
@@ -334,7 +387,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||||
// stale row) otherwise stayed for its whole 30-day life and the button
|
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||||
// appeared to do nothing.
|
// appeared to do nothing.
|
||||||
const r: any = await LookupCallsignFresh(call);
|
// The QSO's OWN date: a ClubLog exception is resolved as of when the
|
||||||
|
// contact happened, not as of today. Re-looking-up a DXpedition contact
|
||||||
|
// months later must not move it to whatever the prefix means now.
|
||||||
|
const r: any = await LookupCallsignFresh(call, (dateOn || '').slice(0, 10));
|
||||||
// The lookup WINS over what is in the record — that is the point of asking
|
// The lookup WINS over what is in the record — that is the point of asking
|
||||||
// for it. But an EMPTY result must never blank a good value: `??` only
|
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||||
@@ -428,6 +484,26 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
[draft.extras],
|
[draft.extras],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// OpsLog QSL "received" marker (ADIF extra). Drives the card's PSE/TNX stamp:
|
||||||
|
// received → TNX QSL (thanks for your card), otherwise PSE QSL (please send one).
|
||||||
|
// Saved with the normal Save via draft.extras.
|
||||||
|
const OPSLOG_QSL_RCVD = 'APP_OPSLOG_QSL_RCVD';
|
||||||
|
const qslReceived = !!String(draft.extras?.[OPSLOG_QSL_RCVD] ?? '').trim();
|
||||||
|
// Sent side, for the confirmations table. Two key names because the marker was
|
||||||
|
// renamed once and old QSOs still carry the first one — same test the Recent
|
||||||
|
// QSOs "OpsLog QSL" column makes.
|
||||||
|
const opslogQslSent = !!(draft.extras?.['APP_OPSLOG_QSL_SENT'] || draft.extras?.['APP_OPSLOG_QSL_CARD_SENT']);
|
||||||
|
const toggleQslReceived = (on: boolean) => {
|
||||||
|
// Reflect immediately in the draft (for the PSE/TNX indicator)…
|
||||||
|
const next = { ...(draft.extras ?? {}) };
|
||||||
|
if (on) next[OPSLOG_QSL_RCVD] = new Date().toISOString();
|
||||||
|
else delete next[OPSLOG_QSL_RCVD];
|
||||||
|
set('extras', next as any);
|
||||||
|
// …and persist right away with a targeted write that reliably sets OR clears
|
||||||
|
// the key (the modal's extras-merge on Save wouldn't clear a removed key).
|
||||||
|
if ((draft as any).id) SetOpsLogQSLReceived((draft as any).id, on).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
@@ -466,7 +542,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
{/* Top: Callsign + RST + Fetch */}
|
{/* Top: Callsign + RST + Fetch */}
|
||||||
<div className="flex items-end gap-2 mb-3">
|
<div className="flex items-end gap-2 mb-3">
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Label>{t('qedit.callsign')}</Label>
|
<div className="flex items-center">
|
||||||
|
<Label>{t('qedit.callsign')}</Label>
|
||||||
|
{(draft.callsign ?? '').trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
|
||||||
|
onClick={() => {
|
||||||
|
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
||||||
|
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
|
||||||
|
}}
|
||||||
|
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
|
||||||
|
>
|
||||||
|
QRZ ↗
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
|
||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -483,7 +574,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||||
{/* ── Left column ── */}
|
{/* ── Left column ── */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
|
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)}
|
||||||
|
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
|
||||||
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||||
@@ -547,10 +639,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||||
|
<div className="flex flex-col w-24"><Label>{t('qedit.gridExt')}</Label><Input title={t('qedit.gridExtTip')} value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} className="font-mono uppercase" /></div>
|
||||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
|
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
|
||||||
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
|
onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
|
||||||
|
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
|
||||||
|
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -571,7 +666,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
|
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)}
|
||||||
|
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></div>
|
||||||
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
|
||||||
</div>
|
</div>
|
||||||
{/* Right column */}
|
{/* Right column */}
|
||||||
@@ -581,8 +677,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||||
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
|
{/* The OpsLog QSL marker used to sit here, under QSL Msg. It
|
||||||
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
|
belongs with the other confirmation channels — see the QSL
|
||||||
|
Info tab. */}
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.qslMsg')}</Label>
|
||||||
|
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{/* The manager, and only the manager — ADIF QSL_VIA. How the
|
||||||
|
card travelled is a separate field, on the QSL Info tab. */}
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.qslVia')}</Label>
|
||||||
|
<Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} placeholder={t('qedit.qslViaPlaceholder')} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -621,6 +728,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||||
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||||
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||||
|
// The extras-backed channel reads and writes the same way, one
|
||||||
|
// level down. Save merges the extras rather than replacing them,
|
||||||
|
// so an emptied field keeps its stored value — which is why the
|
||||||
|
// statuses are written as explicit Y/N and never removed.
|
||||||
|
const exVal = (k: string) => String(draft.extras?.[k] ?? '');
|
||||||
|
const exPut = (k: string, v: any) => {
|
||||||
|
const next = { ...(draft.extras ?? {}) };
|
||||||
|
next[k] = String(v ?? '');
|
||||||
|
set('extras', next as any);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* Left: edit one confirmation channel at a time */}
|
{/* Left: edit one confirmation channel at a time */}
|
||||||
@@ -629,25 +746,88 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Label>{t('qedit.manageConf')}</Label>
|
<Label>{t('qedit.manageConf')}</Label>
|
||||||
<Select value={confSel} onValueChange={setConfSel}>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
|
<SelectContent>
|
||||||
|
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
|
||||||
|
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
||||||
|
QSO columns, and this channel lives in the ADIF
|
||||||
|
extras. It gets its own editor below rather than the
|
||||||
|
generic sent/received/date grid, which has no field
|
||||||
|
to bind to. */}
|
||||||
|
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||||||
|
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
{confSel === HAMLOG_CONF ? (
|
||||||
<div><Label>{t('qedit.received')}</Label>
|
<>
|
||||||
{def.rcvd
|
<div className="grid grid-cols-2 gap-3">
|
||||||
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||||
: <Input disabled value="—" />}
|
<div><Label>{t('qedit.received')}</Label><QslSelect value={exVal(HAMLOG_KEYS.rcvd)} onChange={(v) => exPut(HAMLOG_KEYS.rcvd, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.sentDate)} onChange={(v) => exPut(HAMLOG_KEYS.sentDate, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.rcvdDate)} onChange={(v) => exPut(HAMLOG_KEYS.rcvdDate, v)} /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : confSel === OPSLOG_CONF ? (
|
||||||
|
/* OpsLog's own card. "Sent" is stamped when the card
|
||||||
|
actually goes out, so it is shown, not offered: ticking
|
||||||
|
it by hand would record something that never happened.
|
||||||
|
"Received" drives the PSE/TNX stamp printed on the card,
|
||||||
|
which is the whole reason the flag exists — hence the
|
||||||
|
live indicator next to it. It writes immediately rather
|
||||||
|
than on Save, because clearing an extras key would not
|
||||||
|
survive the merge Save does. */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.sent')}</Label>
|
||||||
|
<Input disabled value={opslogQslSent ? t('qedit.qslYes') : t('qedit.qslNo')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('qedit.received')}</Label>
|
||||||
|
<label className="flex h-9 items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
|
||||||
|
{t('qedit.qslReceived')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{qslReceived ? 'TNX QSL' : 'PSE QSL'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('qedit.pseTnxHint')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('qedit.opslogSentHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
) : (
|
||||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
<>
|
||||||
{def.via && (
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
|
||||||
)}
|
<div><Label>{t('qedit.received')}</Label>
|
||||||
</div>
|
{def.rcvd
|
||||||
<p className="text-[11px] text-muted-foreground">
|
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
|
||||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
: <Input disabled value="—" />}
|
||||||
</p>
|
</div>
|
||||||
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||||
|
{/* How the card travelled, each way — ADIF's QSL Via
|
||||||
|
enumeration. Not the manager: that is a callsign
|
||||||
|
and lives on the Contact's details tab. */}
|
||||||
|
{def.sentVia && (
|
||||||
|
<div><Label>{t('qedit.sentVia')}</Label><QslViaSelect value={val(def.sentVia)} onChange={(v) => put(def.sentVia, v)} /></div>
|
||||||
|
)}
|
||||||
|
{def.rcvdVia && (
|
||||||
|
<div><Label>{t('qedit.rcvdVia')}</Label><QslViaSelect value={val(def.rcvdVia)} onChange={(v) => put(def.rcvdVia, v)} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: live status grid for every channel.
|
{/* Right: live status grid for every channel.
|
||||||
@@ -672,6 +852,23 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{/* OpsLog's own card, read from the ADIF extras rather
|
||||||
|
than a QSO column — hence a hand-written row instead
|
||||||
|
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
||||||
|
when the card actually goes out, so it stays
|
||||||
|
read-only here: an operator ticking it by hand would
|
||||||
|
be recording something that never happened. */}
|
||||||
|
<tr className="text-xs">
|
||||||
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
||||||
|
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
||||||
|
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
||||||
|
</tr>
|
||||||
|
{/* HAMLOG.online — extras again, same hand-written row. */}
|
||||||
|
<tr className="text-xs">
|
||||||
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
|
||||||
|
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
|
||||||
|
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -713,7 +910,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||||
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||||
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||||
<F label={t('qedit.gridExt')}><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
<F label={t('qedit.myGridExt')}><Input title={t('qedit.gridExtTip')} value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||||
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
||||||
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||||
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||||
@@ -749,6 +946,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
|
||||||
<div className="grid grid-cols-6 gap-3">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
{/* YOUR power first, then theirs. tx_pwr was saved by this
|
||||||
|
editor but had no field: a contact logged at the wrong
|
||||||
|
power could be read back and never corrected. */}
|
||||||
|
<F label={t('qedit.txPower')}><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||||
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||||
|
import { rowStyleFor, type RowColorSettings } from '@/lib/rowColors';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
@@ -56,16 +57,23 @@ type Props = {
|
|||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
onRowClicked?: (q: QSOForm) => void;
|
onRowClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (ids: number[]) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
|
// The full first selected row — the Stats (F1) panel shows its entity when the
|
||||||
|
// entry form is empty, so browsing the log answers "what have I got with this
|
||||||
|
// one?" without retyping the call.
|
||||||
|
onRowSelectedQso?: (row: QSOForm | null) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFull?: (ids: number[]) => void;
|
||||||
onExportSelectedFields?: (ids: number[]) => void;
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
onExportFilteredFull?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
@@ -76,6 +84,8 @@ type Props = {
|
|||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
|
// Whole-row colouring by QSL / LoTW status (Settings → Appearance).
|
||||||
|
rowColors?: RowColorSettings | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
@@ -128,6 +138,10 @@ function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
|||||||
|
|
||||||
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||||
// ── QSO basics ──
|
// ── QSO basics ──
|
||||||
|
// Position in the log, oldest = 1. Filled by the backend over the WHOLE log,
|
||||||
|
// so it does not change when a filter narrows what is shown.
|
||||||
|
{ group: 'QSO', label: t('rqg.c.number'), colId: 'number', headerName: t('rqg.h.number'), field: 'number' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||||
@@ -175,6 +189,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
|
||||||
|
{ group: 'QSL', label: t('rqg.c.qsl_sent_via'), colId: 'qsl_sent_via', headerName: t('rqg.h.qsl_sent_via'), field: 'qsl_sent_via' as any, width: 110 },
|
||||||
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_via'), colId: 'qsl_rcvd_via', headerName: t('rqg.h.qsl_rcvd_via'), field: 'qsl_rcvd_via' as any, width: 110 },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
{ group: 'QSL', label: t('rqg.c.qsl_msg'), colId: 'qsl_msg', headerName: t('rqg.c.qsl_msg'), field: 'qsl_msg' as any, width: 200 },
|
||||||
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
|
||||||
|
|
||||||
@@ -191,6 +207,16 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||||
|
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
|
||||||
|
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
// HAMLOG.online. No promoted column — the ADIF standard names a field for
|
||||||
|
// hamlog.EU and none for hamlog.ONLINE — so these read the extras, exactly
|
||||||
|
// like the OpsLog card columns above. Hidden by default: a column per
|
||||||
|
// service, shown to everyone, is how a grid becomes unreadable.
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
@@ -276,14 +302,24 @@ const GRP_KEYS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||||
|
|
||||||
// Award columns are governed SOLELY by the awardShown code-set, never by AG
|
// Award-column VISIBILITY is governed SOLELY by the awardShown code-set, never
|
||||||
// Grid's saved column state. Stripping them here (on both save and restore)
|
// by AG Grid's saved column state: dropping `hide` here (on both save and
|
||||||
// stops a stale saved state from re-hiding a shown award column on every
|
// restore) stops a stale saved state from re-hiding a shown award column on
|
||||||
// awardCols rebuild — the desync that made award columns vanish mid-session.
|
// every awardCols rebuild — the desync that made award columns vanish
|
||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
// mid-session.
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
//
|
||||||
|
// Their POSITION, however, has to stay in the state. Filtering the entries out
|
||||||
|
// entirely left applyColumnState({applyOrder:true}) with no place for them, and
|
||||||
|
// AG Grid appends what the state doesn't mention — so a column the operator
|
||||||
|
// dragged to sit AFTER an award column jumped back in front of it on reload.
|
||||||
|
const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
|
(st ?? []).map((s) => {
|
||||||
|
if (!String(s?.colId ?? '').startsWith('award_')) return s;
|
||||||
|
const { hide: _hide, ...rest } = s as any;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: 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);
|
||||||
@@ -464,12 +500,12 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
onGridApi?.(e.api);
|
onGridApi?.(e.api);
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (local) e.api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(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
|
||||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||||
loadRemote(colStateKey).then((remote) => {
|
loadRemote(colStateKey).then((remote) => {
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(remote)) as ColumnState[], applyOrder: true });
|
||||||
seedLocal(colStateKey, remote);
|
seedLocal(colStateKey, remote);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -493,7 +529,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
saveState(colStateKey, stripAwardCols(state));
|
saveState(colStateKey, sanitizeAwardCols(state));
|
||||||
// Award columns are stripped above, so persist their widths on the side.
|
// Award columns are stripped above, so persist their widths on the side.
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const s of state) {
|
for (const s of state) {
|
||||||
@@ -521,7 +557,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (api && local) api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
|
||||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
@@ -542,6 +578,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
setSelCount(sel.length);
|
setSelCount(sel.length);
|
||||||
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
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));
|
||||||
|
onRowSelectedQso?.(sel[0] ?? null);
|
||||||
}
|
}
|
||||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -604,6 +641,20 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
api.setColumnsVisible(ids, false);
|
api.setColumnsVisible(ids, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
}
|
}
|
||||||
|
// Whole-dialog show/hide. showAll/hideAll cover the CATALOGUE only; the award
|
||||||
|
// columns live in their own persisted code set, so a global that skipped them
|
||||||
|
// would leave a row of columns behind and look broken.
|
||||||
|
function setEverything(visible: boolean) {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
api.setColumnsVisible(COL_CATALOG.map((c) => c.colId!), visible);
|
||||||
|
if (awardCols && awardCols.length > 0) {
|
||||||
|
persistAwardShown(visible ? new Set(awardCols.map((a) => a.code.toUpperCase())) : new Set());
|
||||||
|
api.setColumnsVisible(awardCols.map((a) => `award_${a.code}`), visible);
|
||||||
|
}
|
||||||
|
saveColumnState();
|
||||||
|
}
|
||||||
|
|
||||||
function resetDefaults() {
|
function resetDefaults() {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -679,23 +730,31 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
animateRows={false}
|
animateRows={false}
|
||||||
suppressCellFocus
|
suppressCellFocus
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
|
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null, p.node.rowIndex ?? undefined)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Every handler is passed STRAIGHT through, never wrapped in an arrow: a
|
||||||
|
wrapper is always truthy, so the menu drew "Fix country" and "Update
|
||||||
|
from the callsign databases" even in a grid that wired neither — and
|
||||||
|
clicking them did nothing at all. */}
|
||||||
<QSOContextMenu
|
<QSOContextMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
onUpdateFromCty={onUpdateFromCty}
|
||||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
onUpdateFromQRZ={onUpdateFromQRZ}
|
||||||
onUpdateFromClublog={onUpdateFromClublog}
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
|
onUpdateCountyFromULS={onUpdateCountyFromULS}
|
||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFull={onExportSelectedFull}
|
||||||
onExportSelectedFields={onExportSelectedFields}
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
|
onExportFilteredFull={onExportFilteredFull}
|
||||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -709,6 +768,16 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
{t('rqg.pickerDesc')}
|
{t('rqg.pickerDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{/* Whole-dialog controls. The per-group links alone meant hiding
|
||||||
|
everything was one click PER GROUP, and there are a dozen. */}
|
||||||
|
<div className="flex items-center gap-1 px-5 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('rqg.pickerAllGroups')}</span>
|
||||||
|
<button type="button" className="text-[11px] text-primary hover:underline px-1"
|
||||||
|
onClick={() => setEverything(true)}>{t('rqg.all')}</button>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
|
||||||
|
onClick={() => setEverything(false)}>{t('rqg.none')}</button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
||||||
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||||
// to turn the antenna there.
|
// to turn the antenna there.
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||||
import { feature } from 'topojson-client';
|
import { feature } from 'topojson-client';
|
||||||
import landTopo from 'world-atlas/land-110m.json';
|
import landTopo from 'world-atlas/land-110m.json';
|
||||||
import { Compass, X } from 'lucide-react';
|
import { Compass, X, Play, Square } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
// Decode the coastline outline once (≈110 m simplified land polygons).
|
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||||
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
||||||
@@ -24,8 +25,16 @@ interface Props {
|
|||||||
centerLat?: number | null; // operator latitude (projection centre)
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
centerLon?: number | null; // operator longitude
|
centerLon?: number | null; // operator longitude
|
||||||
rotorEnabled?: boolean;
|
rotorEnabled?: boolean;
|
||||||
|
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||||
|
activeRotor?: number; // index of the selected rotor (0-based)
|
||||||
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||||
onGoto?: (az: number) => void; // click-to-turn
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
// Quick-turn buttons and the azimuth box, shown only where the caller wants
|
||||||
|
// them: Station Control draws its own GoTo/Stop around this compass, and two
|
||||||
|
// sets of the same controls side by side would be nothing but confusing.
|
||||||
|
presets?: { label: string; azimuth: number }[];
|
||||||
|
onStop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE = 168;
|
const SIZE = 168;
|
||||||
@@ -38,7 +47,58 @@ function pt(az: number, radius: number): [number, number] {
|
|||||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
// Raw text, not a number: binding the input to a normalised value makes
|
||||||
|
// Backspace fight the operator on the way from "230" to "23". It is parsed
|
||||||
|
// when it is sent, and only then.
|
||||||
|
const [azText, setAzText] = useState('');
|
||||||
|
const showControls = !!(presets || onStop);
|
||||||
|
|
||||||
|
// Which preset was just pressed, so it can light up for a moment.
|
||||||
|
//
|
||||||
|
// A rotor takes seconds to start moving and the needle barely twitches at
|
||||||
|
// first, so without this the only answer to "did that register?" is to press
|
||||||
|
// it again — which is how an antenna ends up ordered somewhere twice. The
|
||||||
|
// acknowledgement has to come from the button itself, at once.
|
||||||
|
const [flashIdx, setFlashIdx] = useState<number | null>(null);
|
||||||
|
const flashTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
|
||||||
|
const pressPreset = (i: number, az: number) => {
|
||||||
|
if (!onGoto) return;
|
||||||
|
setFlashIdx(i);
|
||||||
|
window.clearTimeout(flashTimer.current);
|
||||||
|
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
|
||||||
|
onGoto(az);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop needs the same acknowledgement, for the same reason and one more: it
|
||||||
|
// is pressed when something is already wrong, and a button that stays inert
|
||||||
|
// gets hit again and again. Its own flag, so stopping does not blank a preset
|
||||||
|
// that is still lit.
|
||||||
|
const [stopFlash, setStopFlash] = useState(false);
|
||||||
|
const stopTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
|
||||||
|
const pressStop = () => {
|
||||||
|
if (!onStop) return;
|
||||||
|
setStopFlash(true);
|
||||||
|
window.clearTimeout(stopTimer.current);
|
||||||
|
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
|
||||||
|
onStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
|
||||||
|
// almost always a typo for 36 or 306, and a rotor swinging through north on a
|
||||||
|
// slip of the finger is worth one rejected keypress.
|
||||||
|
const sendAz = () => {
|
||||||
|
const s = azText.trim();
|
||||||
|
if (s === '' || !onGoto) return;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
|
||||||
|
onGoto(n);
|
||||||
|
setAzText('');
|
||||||
|
};
|
||||||
|
|
||||||
const cardinals = useMemo(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
@@ -69,6 +129,35 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
|
|
||||||
const headLabel = headings.length ? headings[0] : null;
|
const headLabel = headings.length ? headings[0] : null;
|
||||||
|
|
||||||
|
// Short and long path to the DX, in figures.
|
||||||
|
//
|
||||||
|
// The bezel already carries the short path as a red marker, but a marker is a
|
||||||
|
// direction, not a number — the same pair sits in the status bar at 10px and
|
||||||
|
// operators reported not being able to read it. It lives here because it
|
||||||
|
// belongs to the compass: every place that draws one gets the readout, instead
|
||||||
|
// of each caller inventing its own. Clickable when the caller can turn, like
|
||||||
|
// the status bar's. Built as a value because it is placed in one of two
|
||||||
|
// columns depending on whether the controls are shown.
|
||||||
|
const pathReadout = (
|
||||||
|
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
||||||
|
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||||
|
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||||
|
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||||
|
className={
|
||||||
|
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
|
||||||
|
(az == null
|
||||||
|
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||||
|
: onGoto
|
||||||
|
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||||
|
: 'border-border text-muted-foreground cursor-default')
|
||||||
|
}>
|
||||||
|
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||||
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||||
@@ -100,7 +189,38 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-2 min-h-0">
|
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||||
|
{rotors && rotors.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||||
|
{rotors.map((nm, i) => {
|
||||||
|
const active = (activeRotor ?? 0) === i;
|
||||||
|
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelectRotor?.(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-success text-success-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dial on the left, controls on the right. The controls column is sized to
|
||||||
|
fit WITHIN the dial's height: the widget sits in a row whose height is
|
||||||
|
set by the entry strip, so it may grow sideways but never downwards. */}
|
||||||
|
<div className="flex items-start gap-2 p-2 min-h-0">
|
||||||
|
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
||||||
|
sibling of the <svg> landed beside it. */}
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||||
@@ -160,6 +280,93 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
); })}
|
); })}
|
||||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{/* With the controls column present the readout goes at the FOOT OF IT
|
||||||
|
instead: the dial sets the widget's height, the controls are shorter
|
||||||
|
than the dial, and that leftover space is exactly the right size for
|
||||||
|
the pair. Under the dial it would push the whole widget taller. */}
|
||||||
|
{!showControls && pathReadout}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick turns + free azimuth + Stop. */}
|
||||||
|
{showControls && (
|
||||||
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||||
|
{/* Two columns so six regions fit beside the dial rather than under
|
||||||
|
it. An operator with fewer keeps the same compact block. */}
|
||||||
|
{!!presets?.length && (
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{presets.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={`${p.label}-${i}`}
|
||||||
|
type="button"
|
||||||
|
disabled={!onGoto}
|
||||||
|
onClick={() => pressPreset(i, p.azimuth)}
|
||||||
|
title={`${p.label} — ${p.azimuth}°`}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
|
||||||
|
flashIdx === i
|
||||||
|
// Lit, and showing the azimuth it just sent: the label alone
|
||||||
|
// would only say the press landed, not what was ordered.
|
||||||
|
? 'border-success bg-success text-success-foreground scale-95'
|
||||||
|
: 'border-border bg-muted/40',
|
||||||
|
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
|
||||||
|
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flashIdx === i ? `${p.azimuth}°` : p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
|
||||||
|
-and-go without reaching for the mouse. */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={azText}
|
||||||
|
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
|
||||||
|
placeholder={t('rotor.azPh')}
|
||||||
|
title={t('rotor.azTitle')}
|
||||||
|
disabled={!onGoto}
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendAz}
|
||||||
|
disabled={!onGoto || azText.trim() === ''}
|
||||||
|
title={t('rotor.go')}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Play className="size-3" /> {t('rotor.go')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onStop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={pressStop}
|
||||||
|
title={t('rotor.stop')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
|
||||||
|
stopFlash
|
||||||
|
// Solid, not a tint: STOP reads the same in both languages, so
|
||||||
|
// the fill is the whole acknowledgement.
|
||||||
|
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
|
||||||
|
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Square className="size-3 fill-current" /> {t('rotor.stop')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* mt-auto: the readout sits at the FOOT of the column, level with the
|
||||||
|
bottom of the dial, instead of floating under the Stop button. */}
|
||||||
|
<div className="mt-auto">{pathReadout}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,32 @@ export interface RecentSpotQSO {
|
|||||||
freqKHz: number;
|
freqKHz: number;
|
||||||
mode: string;
|
mode: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
|
// Award references logged for this QSO, each one complete and ready to read
|
||||||
|
// on a cluster line ("POTA FR-11553"). Empty for an ordinary contact.
|
||||||
|
refs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPOT_COMMENT_MAX is the DX cluster comment field: 30 characters, and nodes
|
||||||
|
// truncate what does not fit without saying so. Only the comment BUILT here is
|
||||||
|
// held to it — whatever the operator types is their own business.
|
||||||
|
const SPOT_COMMENT_MAX = 30;
|
||||||
|
|
||||||
|
// spotComment puts the award references after the mode, the order a cluster
|
||||||
|
// line is read in: "CW POTA FR-11553".
|
||||||
|
//
|
||||||
|
// A reference goes in whole or not at all. Truncation would leave "CW POTA" or
|
||||||
|
// half a park number on the air, which is worse than saying nothing: a spot
|
||||||
|
// nobody can act on still costs everyone who reads it.
|
||||||
|
export function spotComment(mode: string, refs?: string[]): string {
|
||||||
|
let out = (mode || '').trim();
|
||||||
|
for (const ref of refs ?? []) {
|
||||||
|
const one = ref.trim();
|
||||||
|
if (!one) continue;
|
||||||
|
const next = out ? `${out} ${one}` : one;
|
||||||
|
if (next.length > SPOT_COMMENT_MAX) continue; // a shorter one may still fit
|
||||||
|
out = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,6 +49,9 @@ interface Props {
|
|||||||
defaultCall: string;
|
defaultCall: string;
|
||||||
defaultFreqKHz: number;
|
defaultFreqKHz: number;
|
||||||
defaultMode: string;
|
defaultMode: string;
|
||||||
|
// Award references on the QSO being entered (or on the last logged one),
|
||||||
|
// appended to the comment after the mode.
|
||||||
|
defaultRefs?: string[];
|
||||||
// Master cluster name, shown so the user knows where the spot goes.
|
// Master cluster name, shown so the user knows where the spot goes.
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
recent: RecentSpotQSO[];
|
recent: RecentSpotQSO[];
|
||||||
@@ -33,7 +62,7 @@ interface Props {
|
|||||||
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
||||||
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
||||||
// contact into the form.
|
// contact into the form.
|
||||||
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, defaultRefs, targetName, recent, onSend }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [call, setCall] = useState('');
|
const [call, setCall] = useState('');
|
||||||
const [freqKHz, setFreqKHz] = useState('');
|
const [freqKHz, setFreqKHz] = useState('');
|
||||||
@@ -48,12 +77,12 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setCall((defaultCall || '').toUpperCase());
|
setCall((defaultCall || '').toUpperCase());
|
||||||
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
||||||
setMessage(defaultMode || '');
|
setMessage(spotComment(defaultMode, defaultRefs));
|
||||||
setError('');
|
setError('');
|
||||||
setOk(false);
|
setOk(false);
|
||||||
// Focus the freq if the call is already known, else the call.
|
// Focus the freq if the call is already known, else the call.
|
||||||
setTimeout(() => callRef.current?.focus(), 50);
|
setTimeout(() => callRef.current?.focus(), 50);
|
||||||
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
|
}, [open, defaultCall, defaultFreqKHz, defaultMode, defaultRefs]);
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const c = call.trim().toUpperCase();
|
const c = call.trim().toUpperCase();
|
||||||
@@ -77,7 +106,7 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
function pick(q: RecentSpotQSO) {
|
function pick(q: RecentSpotQSO) {
|
||||||
setCall(q.callsign.toUpperCase());
|
setCall(q.callsign.toUpperCase());
|
||||||
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
||||||
if (q.mode) setMessage(q.mode);
|
setMessage(spotComment(q.mode, q.refs));
|
||||||
setError('');
|
setError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,91 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { AmpCard } from '@/components/AmpCard';
|
import { AmpCard } from '@/components/AmpCard';
|
||||||
import { TunerCard } from '@/components/TunerCard';
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import {
|
import {
|
||||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
|
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
GetAmpStatuses, GetFlexState,
|
GetAmpStatuses, GetFlexState,
|
||||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
|
GetPSUStatus, GetPSUSettings, SetPSUOutput,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||||
|
|
||||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
|
type PSUState = { connected: boolean; on: boolean; volts: number; amps: number; watts: number; set_volts: number; set_amps: number; protected: number; error?: string };
|
||||||
|
|
||||||
|
// The bench supply. One button — the output — and the three numbers that say
|
||||||
|
// what it is actually delivering.
|
||||||
|
//
|
||||||
|
// The SET points are shown beside them, small, because "13.8 V set" next to
|
||||||
|
// "0.02 A out" is how an operator sees at a glance that the supply is on but
|
||||||
|
// the radio is not drawing. They are not editable: OpsLog reads them and never
|
||||||
|
// writes them, which is the whole safety story of this device.
|
||||||
|
function PSUCard({ st, busy, onToggle, t }: {
|
||||||
|
st: PSUState; busy: boolean; onToggle: (on: boolean) => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const tripped = (st.protected ?? 0) !== 0;
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Power className="size-4 text-primary" />
|
||||||
|
<div className="text-sm font-semibold truncate">{t('psu.title')}</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st.connected ? t('station.online') : (st.error || t('psu.offline'))} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" disabled={!st.connected || busy}
|
||||||
|
onClick={() => onToggle(!st.on)}
|
||||||
|
className={cn('flex items-center gap-2 rounded-md border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||||||
|
st.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
<span className={cn('flex items-center justify-center size-6 rounded shrink-0',
|
||||||
|
st.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold">{t('psu.output')}</span>
|
||||||
|
<span className={cn('text-[10px] font-bold', st.on ? 'text-success' : 'text-muted-foreground/60')}>
|
||||||
|
{st.on ? t('station.on') : t('station.off')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0 font-mono tabular-nums text-right">
|
||||||
|
<span className="text-lg font-bold">{(st.volts ?? 0).toFixed(2)}</span><span className="text-xs text-muted-foreground"> V</span>
|
||||||
|
<span className="text-lg font-bold ml-3">{(st.amps ?? 0).toFixed(3)}</span><span className="text-xs text-muted-foreground"> A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground font-mono">
|
||||||
|
<span>{(st.watts ?? 0).toFixed(1)} W</span>
|
||||||
|
<span>{t('psu.setTo')} {(st.set_volts ?? 0).toFixed(2)} V / {(st.set_amps ?? 0).toFixed(3)} A</span>
|
||||||
|
</div>
|
||||||
|
{tripped && (
|
||||||
|
<div className="text-[11px] font-bold text-danger">{t('psu.tripped')} (0x{(st.protected ?? 0).toString(16).toUpperCase()})</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device = {
|
||||||
|
id: string; type: string; name: string; host: string;
|
||||||
|
user?: string; pass?: string; channels?: number; labels: string[];
|
||||||
|
// Generic HTTP board only. The per-relay URLs win over the patterns.
|
||||||
|
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string; insecure_tls?: boolean;
|
||||||
|
};
|
||||||
type Relay = { number: number; label: string; on: boolean };
|
type Relay = { number: number; label: string; on: boolean };
|
||||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
@@ -33,19 +96,27 @@ type DevStatus = { id: string; name: string; type: string; connected: boolean; e
|
|||||||
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||||
const GRID_GAP = 16; // px between cards, both axes
|
const GRID_GAP = 16; // px between cards, both axes
|
||||||
|
|
||||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2, httpgen: 4 };
|
||||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay', httpgen: 'HTTP relay' };
|
||||||
|
|
||||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||||
// the generic USB-serial board, whose channel count the user picks.
|
// the generic USB-serial board, whose channel count the user picks.
|
||||||
|
// chanCount is the relay count for a device. It MUST agree with
|
||||||
|
// deviceRelayCount in app.go: the two decide how many labels and URL rows the
|
||||||
|
// editor shows and how many relays the driver is built for, and a type known to
|
||||||
|
// one and not the other silently pins the count to its fixed default — which is
|
||||||
|
// exactly what happened when the HTTP board was added here and not below.
|
||||||
const chanCount = (d: Device): number =>
|
const chanCount = (d: Device): number =>
|
||||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
|
||||||
|
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
|
||||||
|
: d.type === 'httpgen' ? (d.channels && d.channels >= 1 ? d.channels : 4)
|
||||||
|
: (RELAY_COUNT[d.type] ?? 5);
|
||||||
|
|
||||||
function blankDevice(): Device {
|
function blankDevice(): Device {
|
||||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
}
|
}
|
||||||
|
|
||||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
|
||||||
|
|
||||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||||
@@ -74,12 +145,17 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex gap-4 items-start">
|
<div className="p-3 flex gap-4 items-start">
|
||||||
|
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||||
|
app carries it rather than each caller drawing its own. */}
|
||||||
<RotorCompass
|
<RotorCompass
|
||||||
bearing={bearing ?? null}
|
bearing={bearing ?? null}
|
||||||
headings={hd.ok ? [hd.azimuth] : []}
|
headings={hd.ok ? [hd.azimuth] : []}
|
||||||
centerLat={centerLat ?? null}
|
centerLat={centerLat ?? null}
|
||||||
centerLon={centerLon ?? null}
|
centerLon={centerLon ?? null}
|
||||||
rotorEnabled={hd.ok}
|
rotorEnabled={hd.ok}
|
||||||
|
rotors={hd.rotors}
|
||||||
|
activeRotor={hd.active}
|
||||||
|
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||||
onGoto={(az) => turn(az)}
|
onGoto={(az) => turn(az)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
@@ -110,155 +186,6 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };
|
|
||||||
|
|
||||||
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
|
||||||
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
|
||||||
// only — per-element length adjustment. Heading/state is polled by the panel.
|
|
||||||
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
|
||||||
|
|
||||||
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
|
||||||
// 1 = driven element, then Director 1, 2, 3…
|
|
||||||
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
|
||||||
if (i === 0) return t('station.reflector');
|
|
||||||
if (i === 1) return t('station.driven');
|
|
||||||
return `${t('station.director')} ${i - 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
|
||||||
const [err, setErr] = useState('');
|
|
||||||
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
|
||||||
const [reading, setReading] = useState(false);
|
|
||||||
const [busyEl, setBusyEl] = useState<number | null>(null);
|
|
||||||
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
|
||||||
const [editVal, setEditVal] = useState('');
|
|
||||||
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
|
||||||
const isUB = ant.type !== 'steppir';
|
|
||||||
|
|
||||||
const readLengths = useCallback(async () => {
|
|
||||||
setReading(true); setErr('');
|
|
||||||
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
|
||||||
finally { setReading(false); }
|
|
||||||
}, []);
|
|
||||||
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
|
||||||
// +/- starts from the real values rather than blind.
|
|
||||||
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
|
||||||
|
|
||||||
// Send an absolute length to one element and remember it as the new baseline.
|
|
||||||
const setLen = async (i: number, mm: number) => {
|
|
||||||
const next = Math.max(0, Math.round(mm));
|
|
||||||
const prev = lengths[i] ?? 0;
|
|
||||||
setBusyEl(i); setErr('');
|
|
||||||
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
|
||||||
try { await MotorSetElement(i, next); refetch(); }
|
|
||||||
catch (e: any) {
|
|
||||||
// Rejected by the controller — most often the element is at its travel
|
|
||||||
// limit for this band (extending on a low band). Revert the optimistic
|
|
||||||
// value so the display stays truthful, and explain the likely cause when
|
|
||||||
// the failed move was an extension.
|
|
||||||
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
|
||||||
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
|
||||||
}
|
|
||||||
finally { setBusyEl(null); }
|
|
||||||
};
|
|
||||||
// Nudge one element by ±2 mm from its current known length.
|
|
||||||
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
|
||||||
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
|
||||||
// the baseline when the auto-read is off, so +/- then work reliably.
|
|
||||||
const commitEdit = (i: number) => {
|
|
||||||
const v = parseInt(editVal, 10);
|
|
||||||
setEditingEl(null);
|
|
||||||
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
|
||||||
};
|
|
||||||
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
||||||
<AntennaIcon className="size-4 text-primary" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
|
||||||
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
|
||||||
</div>
|
|
||||||
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
|
||||||
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
|
||||||
title={ant.connected ? t('station.online') : t('station.offline')} />
|
|
||||||
</div>
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{dirs.map(([d, lbl]) => (
|
|
||||||
<button key={d} type="button" disabled={!ant.connected}
|
|
||||||
onClick={() => run(SetUltrabeamDirection(d))}
|
|
||||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
|
||||||
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
|
||||||
{lbl}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" disabled={!ant.connected}
|
|
||||||
onClick={() => run(UltrabeamRetract())}
|
|
||||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
|
||||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isUB && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
|
||||||
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
|
||||||
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
|
||||||
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{lengths.length === 0 ? (
|
|
||||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
|
||||||
up structural bytes past the real data (varying by band), which
|
|
||||||
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
|
||||||
and ModifyElement addresses elements 0..2 — so show just those. */}
|
|
||||||
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
|
||||||
<div key={i} className="flex items-center gap-2">
|
|
||||||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
|
||||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
|
||||||
onClick={() => nudge(i, -ELEMENT_STEP)}
|
|
||||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
|
||||||
<Minus className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
{editingEl === i ? (
|
|
||||||
<input autoFocus type="number" value={editVal}
|
|
||||||
onChange={(e) => setEditVal(e.target.value)}
|
|
||||||
onBlur={() => commitEdit(i)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
|
||||||
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
|
||||||
) : (
|
|
||||||
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
|
||||||
title={t('station.setExactLen')}
|
|
||||||
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
|
||||||
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
|
||||||
onClick={() => nudge(i, ELEMENT_STEP)}
|
|
||||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -326,6 +253,32 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Bench power supply. Polled slower than the tuner: it has no meters that
|
||||||
|
// track a transmission, and every poll is three Modbus exchanges on a 9600
|
||||||
|
// baud line the operator may also be using to switch the output.
|
||||||
|
const [psu, setPsu] = useState<PSUState>({ connected: false, on: false, volts: 0, amps: 0, watts: 0, set_volts: 0, set_amps: 0, protected: 0 });
|
||||||
|
const [psuEnabled, setPsuEnabled] = useState(false);
|
||||||
|
const [psuBusy, setPsuBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
try { const en: any = await GetPSUSettings(); if (alive) setPsuEnabled(!!en?.enabled); } catch {}
|
||||||
|
try { const st: any = await GetPSUStatus(); if (alive && st) setPsu(st as PSUState); } catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const togglePSU = useCallback(async (on: boolean) => {
|
||||||
|
setPsuBusy(true);
|
||||||
|
// No optimistic flip here, unlike the relays: the supply echoes the value it
|
||||||
|
// actually set, so showing ON before it confirms would be showing something
|
||||||
|
// OpsLog does not know. A power switch is the wrong place to guess.
|
||||||
|
try { await SetPSUOutput(on); const st: any = await GetPSUStatus(); if (st) setPsu(st as PSUState); }
|
||||||
|
catch { /* the poll will tell the truth */ }
|
||||||
|
finally { setPsuBusy(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
const loadDevices = useCallback(async () => {
|
||||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -336,19 +289,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||||
} catch { /* ignore transient */ }
|
} catch { /* ignore transient */ }
|
||||||
}, []);
|
}, []);
|
||||||
const pollRot = useCallback(async () => {
|
|
||||||
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
|
||||||
}, []);
|
|
||||||
const pollAnt = useCallback(async () => {
|
const pollAnt = useCallback(async () => {
|
||||||
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||||
|
// The heading comes from the shared poller: one loop for the whole app, fast
|
||||||
|
// while the antenna turns and slow while it is parked. Every poll opens and
|
||||||
|
// closes the controller's port, and this panel used to run its own alongside
|
||||||
|
// the status bar's — the same controller asked twice over.
|
||||||
|
useEffect(() => subscribeRotorHeading((h) => setRot(h as any)), []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll(); pollRot(); pollAnt();
|
poll(); pollAnt();
|
||||||
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
const id = window.setInterval(() => { poll(); pollAnt(); }, 3000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [poll, pollRot, pollAnt, devices.length]);
|
}, [poll, pollAnt, devices.length]);
|
||||||
|
|
||||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||||
// Reorder so `dragged` lands just before `target`.
|
// Reorder so `dragged` lands just before `target`.
|
||||||
@@ -396,16 +351,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
const deviceCard = (dev: Device) => {
|
const deviceCard = (dev: Device) => {
|
||||||
const st = status[dev.id];
|
const st = status[dev.id];
|
||||||
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||||
|
// The generic HTTP board has no address of its own and nothing to poll: its
|
||||||
|
// relays can each live on a different box, and no status endpoint is read
|
||||||
|
// back. So no host under the name, no online dot, and the buttons are never
|
||||||
|
// greyed out waiting for a connection that is never made.
|
||||||
|
const fireAndForget = dev.type === 'httpgen';
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
<PlugZap className="size-4 text-primary" />
|
<PlugZap className="size-4 text-primary" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||||||
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
||||||
|
{TYPE_LABEL[dev.type]}{fireAndForget || !dev.host ? '' : ` · ${dev.host}`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
{fireAndForget ? <span className="ml-auto" /> : (
|
||||||
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||||
|
)}
|
||||||
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||||||
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||||
@@ -418,7 +382,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
const key = `${dev.id}:${r.number}`;
|
const key = `${dev.id}:${r.number}`;
|
||||||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||||
return (
|
return (
|
||||||
<button key={r.number} type="button" disabled={!st?.connected}
|
<button key={r.number} type="button" disabled={!fireAndForget && !st?.connected}
|
||||||
title={label}
|
title={label}
|
||||||
onClick={() => toggle(dev, r.number, !r.on)}
|
onClick={() => toggle(dev, r.number, !r.on)}
|
||||||
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
||||||
@@ -445,7 +409,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
// full-width, and they need that room here too.
|
// full-width, and they need that room here too.
|
||||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||||
if (rot.enabled) {
|
if (rot.enabled) {
|
||||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
}
|
}
|
||||||
if (ant.enabled) {
|
if (ant.enabled) {
|
||||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||||
@@ -454,6 +418,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||||||
// Tuner Genius XL card (identical to the Flex panel's).
|
// Tuner Genius XL card (identical to the Flex panel's).
|
||||||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||||
|
if (psuEnabled) widgets.push({ id: 'psu', node: <PSUCard st={psu} busy={psuBusy} onToggle={togglePSU} t={t} /> });
|
||||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
@@ -552,8 +517,23 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
onChange({ ...device, channels, labels });
|
onChange({ ...device, channels, labels });
|
||||||
};
|
};
|
||||||
const isKM = device.type === 'kmtronic';
|
const isKM = device.type === 'kmtronic';
|
||||||
|
const isDingtian = device.type === 'dingtian';
|
||||||
const isDenkovi = device.type === 'denkovi';
|
const isDenkovi = device.type === 'denkovi';
|
||||||
const isUsbRelay = device.type === 'usbrelay';
|
const isUsbRelay = device.type === 'usbrelay';
|
||||||
|
const isHTTPGen = device.type === 'httpgen';
|
||||||
|
// {value} sends a relay's label, so a URL using it on an unnamed relay would
|
||||||
|
// go out with an empty parameter. Warn while it is being typed rather than at
|
||||||
|
// the moment an antenna fails to switch.
|
||||||
|
const valueNeedsLabels = isHTTPGen
|
||||||
|
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
|
||||||
|
.some((s) => (s ?? '').includes('{value}'))
|
||||||
|
&& device.labels.some((l) => !l.trim());
|
||||||
|
// Any https:// among this board's URLs. A relay box on the LAN signs its own
|
||||||
|
// certificate, so HTTPS to one cannot be verified — the operator has to say
|
||||||
|
// whether to accept that, and the question only arises once they type https.
|
||||||
|
const usesHTTPS = isHTTPGen
|
||||||
|
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
|
||||||
|
.some((u) => (u ?? '').trim().toLowerCase().startsWith('https://'));
|
||||||
// COM ports for the generic USB-serial relay picker.
|
// COM ports for the generic USB-serial relay picker.
|
||||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -602,8 +582,10 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||||
|
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
|
||||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||||
|
<SelectItem value="httpgen">{t('station.typeHttpGen')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -612,13 +594,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(isDenkovi || isUsbRelay) && (
|
{(isDenkovi || isUsbRelay || isDingtian || isHTTPGen) && (
|
||||||
<div className="space-y-1 max-w-[10rem]">
|
<div className="space-y-1 max-w-[10rem]">
|
||||||
<Label>{t('station.channels')}</Label>
|
<Label>{t('station.channels')}</Label>
|
||||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
|
||||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -660,12 +642,35 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : isHTTPGen ? null : (
|
||||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
/* No Host for the generic board: its driver never reads one. Each URL
|
||||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
below carries its own address, and they need not even share it — one
|
||||||
|
relay can sit on a different box from the next. A field that changes
|
||||||
|
nothing is worse than no field: it reads as the thing to fill in first,
|
||||||
|
and then the URLs look like they should be relative to it. */
|
||||||
|
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||||
|
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||||||
<Label>{t('station.host')}</Label>
|
<Label>{t('station.host')}</Label>
|
||||||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Dingtian: both are OFF on a factory board — the session ID only when
|
||||||
|
"HTTP Session" is enabled in its web page, the password only when a
|
||||||
|
relay password is set. */}
|
||||||
|
{isDingtian && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.dtSession')}</Label>
|
||||||
|
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
|
||||||
|
onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.dtPwd')}</Label>
|
||||||
|
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
|
||||||
|
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isKM && (
|
{isKM && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -680,8 +685,76 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Generic HTTP board: a URL to switch each relay on and one to switch it
|
||||||
|
off. Two ways to fill it, and the per-relay one is why this type
|
||||||
|
exists — a hand-made switch often has URLs with nothing in common
|
||||||
|
between channels, which no pattern can express. */}
|
||||||
|
{isHTTPGen && (
|
||||||
|
<div className="space-y-3 rounded-md border border-border/60 p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.onPattern')}</Label>
|
||||||
|
<Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=on"
|
||||||
|
value={device.on_pattern ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...device, on_pattern: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.offPattern')}</Label>
|
||||||
|
<Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=off"
|
||||||
|
value={device.off_pattern ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...device, off_pattern: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
|
||||||
|
{/* Shown only once an https:// URL is actually in use. A board on
|
||||||
|
plain HTTP has no certificate to argue about, and an option that
|
||||||
|
cannot matter yet is one more thing to wonder about. */}
|
||||||
|
{usesHTTPS && (
|
||||||
|
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||||
|
<Checkbox className="mt-0.5" checked={!!device.insecure_tls}
|
||||||
|
onCheckedChange={(c) => onChange({ ...device, insecure_tls: !!c })} />
|
||||||
|
<span>
|
||||||
|
{t('station.insecureTls')}
|
||||||
|
<span className="block text-[10px] text-muted-foreground">{t('station.insecureTlsHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.perRelayUrls')}</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{device.labels.map((_, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[2.5rem_1fr_1fr] items-center gap-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground">{i + 1}</span>
|
||||||
|
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.onUrlPh')}
|
||||||
|
value={device.on_urls?.[i] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const on_urls = [...(device.on_urls ?? [])];
|
||||||
|
while (on_urls.length < device.labels.length) on_urls.push('');
|
||||||
|
on_urls[i] = e.target.value;
|
||||||
|
onChange({ ...device, on_urls });
|
||||||
|
}} />
|
||||||
|
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.offUrlPh')}
|
||||||
|
value={device.off_urls?.[i] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const off_urls = [...(device.off_urls ?? [])];
|
||||||
|
while (off_urls.length < device.labels.length) off_urls.push('');
|
||||||
|
off_urls[i] = e.target.value;
|
||||||
|
onChange({ ...device, off_urls });
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('station.perRelayHint')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('station.labels')}</Label>
|
<Label>{t('station.labels')}</Label>
|
||||||
|
{/* {value} sends the label, so an unnamed relay would go out as "?on=".
|
||||||
|
Said here, beside the empty box, rather than when the antenna fails
|
||||||
|
to switch and the log is the only place that explains why. */}
|
||||||
|
{valueNeedsLabels && <p className="text-[10px] text-warning">{t('station.valueNeedsLabels')}</p>}
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{device.labels.map((lab, i) => (
|
{device.labels.map((lab, i) => (
|
||||||
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||||
@@ -697,12 +770,20 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
{/* No connection test for the generic board, and no host required to
|
||||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
save it. There is nothing to test: it has no address of its own and
|
||||||
{t('station.test')}
|
no status to read — its URLs are fired and forgotten. A button that
|
||||||
</Button>
|
can only ever say "OK, 4 relays" tests nothing, and a Save greyed
|
||||||
|
out for a missing host made a perfectly complete configuration —
|
||||||
|
four full URLs — impossible to store. */}
|
||||||
|
{!isHTTPGen && (
|
||||||
|
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
|
{t('station.test')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
<Button size="sm" onClick={onSave} disabled={!isHTTPGen && !device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type ContestRun = { id: string; year: number; count: number; start: string; end:
|
|||||||
type Stats = {
|
type Stats = {
|
||||||
total: number; unique_calls: number; entities: number; continents: number;
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
first_qso: string; last_qso: string;
|
first_qso: string; last_qso: string;
|
||||||
|
last_new_dxcc: string; last_new_dxcc_num: number; last_new_dxcc_call: string; last_new_dxcc_date: string;
|
||||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
@@ -681,6 +682,16 @@ export function StatsPanel() {
|
|||||||
// real operating session (which is exactly when an hourly rate chart exists).
|
// real operating session (which is exactly when an hourly rate chart exists).
|
||||||
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||||
|
|
||||||
|
// "last new: France - F5ABC - 2019-03-01": the entity most recently added to
|
||||||
|
// the log, and the contact that added it. Empty when no dated entity exists.
|
||||||
|
const lastNew = useMemo(() => {
|
||||||
|
if (!stats?.last_new_dxcc_date) return '';
|
||||||
|
const day = stats.last_new_dxcc_date.slice(0, 10);
|
||||||
|
const who = stats.last_new_dxcc_call ? ` · ${stats.last_new_dxcc_call}` : '';
|
||||||
|
const what = stats.last_new_dxcc || String(stats.last_new_dxcc_num || '');
|
||||||
|
return `${t('stats.lastNewDxcc')}: ${what}${who} · ${day}`;
|
||||||
|
}, [stats, t]);
|
||||||
|
|
||||||
const span = useMemo(() => {
|
const span = useMemo(() => {
|
||||||
if (!stats?.first_qso) return '';
|
if (!stats?.first_qso) return '';
|
||||||
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||||
@@ -774,7 +785,11 @@ export function StatsPanel() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||||
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||||
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||||
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
{/* The last one ADDED rides on the entities tile: it is the same
|
||||||
|
subject, and a tile of its own would push the five headline figures
|
||||||
|
onto two rows for one line of text. */}
|
||||||
|
<StatTile label={t('stats.entities')} value={nf(stats.entities)}
|
||||||
|
sub={lastNew || "DXCC"} accent="var(--chart-5)" />
|
||||||
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||||
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||||
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||||
@@ -24,7 +24,13 @@ type UDPConfig = {
|
|||||||
direction: 'inbound' | 'outbound';
|
direction: 'inbound' | 'outbound';
|
||||||
name: string;
|
name: string;
|
||||||
port: number;
|
port: number;
|
||||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo';
|
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log' | 'wsjt_relay' | 'custom';
|
||||||
|
// Custom rows only.
|
||||||
|
trigger?: string;
|
||||||
|
template?: string;
|
||||||
|
transport?: string;
|
||||||
|
url?: string;
|
||||||
|
line_end?: string;
|
||||||
multicast: boolean;
|
multicast: boolean;
|
||||||
multicast_group: string;
|
multicast_group: string;
|
||||||
destination_ip: string;
|
destination_ip: string;
|
||||||
@@ -77,6 +83,41 @@ const SERVICE_TYPES: Array<{
|
|||||||
hint: 'udpp.svcDbHint',
|
hint: 'udpp.svcDbHint',
|
||||||
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Announces the QSO on the WSJT-X UDP interface instead of as plain text.
|
||||||
|
// Named for the protocol, not for one program: Logger32 is what it was
|
||||||
|
// built against, but anything listening on that interface takes it, and a
|
||||||
|
// label saying "Logger32" would have every other operator scroll past.
|
||||||
|
// 2250 is Logger32's first additional socket — a starting point, not a rule.
|
||||||
|
id: 'wsjt_log',
|
||||||
|
direction: 'outbound',
|
||||||
|
label: 'udpp.svcWsjtLogLabel',
|
||||||
|
hint: 'udpp.svcWsjtLogHint',
|
||||||
|
defaults: { port: 2250, destination_ip: '127.0.0.1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Feeds a SECOND application the stream this OpsLog receives. WSJT-X, JTDX
|
||||||
|
// and MSHV each send to ONE address, so without a relay the choice is
|
||||||
|
// between OpsLog and JTAlert/GridTracker rather than both.
|
||||||
|
//
|
||||||
|
// 2238 by default: beside the 2237 the sender uses, and free. Pointing it AT
|
||||||
|
// 2237 would name an OpsLog listener, which the relay refuses.
|
||||||
|
id: 'wsjt_relay',
|
||||||
|
direction: 'outbound',
|
||||||
|
label: 'udpp.svcWsjtRelayLabel',
|
||||||
|
hint: 'udpp.svcWsjtRelayHint',
|
||||||
|
defaults: { port: 2238, destination_ip: '127.0.0.1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The general case: the operator picks the moment and writes the message.
|
||||||
|
// Mainly for antenna switches, which are driven by a URL and want the band
|
||||||
|
// change — but nothing about it is specific to them.
|
||||||
|
id: 'custom',
|
||||||
|
direction: 'outbound',
|
||||||
|
label: 'udpp.svcCustomLabel',
|
||||||
|
hint: 'udpp.svcCustomHint',
|
||||||
|
defaults: { port: 12000, destination_ip: '127.0.0.1' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pstrotator_freq',
|
id: 'pstrotator_freq',
|
||||||
direction: 'outbound',
|
direction: 'outbound',
|
||||||
@@ -93,6 +134,27 @@ const SERVICE_TYPES: Array<{
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The fields each trigger carries, and NOTHING else.
|
||||||
|
//
|
||||||
|
// This list is the point of the whole feature. A rotation has no frequency, a
|
||||||
|
// lookup has no report — and a placeholder the trigger cannot fill renders as
|
||||||
|
// nothing at all. Without seeing what is available, an operator writes {freq}
|
||||||
|
// on a band change, gets an empty string, and has no way to learn why. So the
|
||||||
|
// panel prints them under the box, for the trigger actually selected.
|
||||||
|
const TRIGGER_FIELDS: Record<string, string[]> = {
|
||||||
|
qso_logged: ['call', 'band', 'band_m', 'mode', 'freq_hz', 'freq_mhz', 'grid', 'name', 'country', 'dxcc', 'rst_s', 'rst_r', 'comment', 'date', 'time'],
|
||||||
|
rotator_goto: ['az', 'el', 'path'],
|
||||||
|
lookup_done: ['call', 'name', 'grid', 'country', 'qth', 'state', 'dxcc'],
|
||||||
|
band_change: ['band', 'band_m', 'freq_hz', 'freq_mhz', 'mode'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIGGERS = [
|
||||||
|
{ id: 'band_change', label: 'udpp.trgBand' },
|
||||||
|
{ id: 'qso_logged', label: 'udpp.trgQso' },
|
||||||
|
{ id: 'rotator_goto', label: 'udpp.trgRotator' },
|
||||||
|
{ id: 'lookup_done', label: 'udpp.trgLookup' },
|
||||||
|
];
|
||||||
|
|
||||||
type Props = { onError: (msg: string) => void };
|
type Props = { onError: (msg: string) => void };
|
||||||
|
|
||||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||||
@@ -124,6 +186,7 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
|||||||
destination_ip: preset.defaults.destination_ip ?? '',
|
destination_ip: preset.defaults.destination_ip ?? '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sort_order: items.filter((i) => i.direction === direction).length,
|
sort_order: items.filter((i) => i.direction === direction).length,
|
||||||
|
trigger: '', template: '', transport: 'udp', url: '', line_end: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +439,7 @@ function EditDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{draft.direction === 'outbound' && (
|
{draft.direction === 'outbound' && draft.service_type !== 'custom' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('udpp.destinationIp')}</Label>
|
<Label>{t('udpp.destinationIp')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -388,6 +451,10 @@ function EditDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{draft.service_type === 'custom' && (
|
||||||
|
<CustomFields draft={draft} setDraft={setDraft} />
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
@@ -413,3 +480,108 @@ function EditDialog({
|
|||||||
|
|
||||||
// silence unused-import for cn — kept for future styling tweaks
|
// silence unused-import for cn — kept for future styling tweaks
|
||||||
void cn;
|
void cn;
|
||||||
|
|
||||||
|
// CustomFields is the editor for a custom outbound row: what fires it, how it
|
||||||
|
// leaves, and what it says.
|
||||||
|
function CustomFields({ draft, setDraft }: {
|
||||||
|
draft: UDPConfig;
|
||||||
|
setDraft: React.Dispatch<React.SetStateAction<UDPConfig>>;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const fields = TRIGGER_FIELDS[draft.trigger ?? ''] ?? [];
|
||||||
|
const isURL = draft.transport === 'url';
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-md border border-border/60 p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.trigger')}</Label>
|
||||||
|
<Select value={draft.trigger || 'band_change'} onValueChange={(v) => setDraft((d) => ({ ...d, trigger: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TRIGGERS.map((x) => <SelectItem key={x.id} value={x.id}>{t(x.label)}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.transport')}</Label>
|
||||||
|
<Select value={draft.transport || 'udp'} onValueChange={(v) => setDraft((d) => ({ ...d, transport: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="udp">{t('udpp.transportUdp')}</SelectItem>
|
||||||
|
<SelectItem value="url">{t('udpp.transportUrl')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The one place these two features overlap. A relay board is a STATE per
|
||||||
|
band, not an event: Station Control tracks it, reads the boards at
|
||||||
|
startup and does not re-switch while the VFO moves inside a band. A
|
||||||
|
URL fired on a band change has none of that, and building the mapping
|
||||||
|
by hand here is work that is already done there. */}
|
||||||
|
{draft.trigger === 'band_change' && isURL && (
|
||||||
|
<div className="rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground leading-relaxed">
|
||||||
|
{t('udpp.relayInstead')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isURL ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.url')}</Label>
|
||||||
|
<Input
|
||||||
|
className="font-mono text-xs"
|
||||||
|
placeholder="http://192.168.1.50/antenna?band={band}"
|
||||||
|
value={draft.url ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('udpp.urlHint')}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.destinationIp')}</Label>
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
value={draft.destination_ip}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, destination_ip: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.lineEnd')}</Label>
|
||||||
|
<Select value={draft.line_end || 'none'} onValueChange={(v) => setDraft((d) => ({ ...d, line_end: v === 'none' ? '' : v }))}>
|
||||||
|
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">{t('udpp.lineEndNone')}</SelectItem>
|
||||||
|
<SelectItem value="lf">LF</SelectItem>
|
||||||
|
<SelectItem value="crlf">CRLF</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('udpp.template')}</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full min-h-[64px] rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
|
||||||
|
placeholder="<AZIMUT>{az}</AZIMUT>"
|
||||||
|
value={draft.template ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, template: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* What this trigger can actually fill in. */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('udpp.fieldsAvailable')}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<code key={f} className="rounded bg-muted px-1 py-px text-[10px] text-muted-foreground">{`{${f}}`}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{t('udpp.fieldsHint')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Upload, FolderOpen, Loader2, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetWebPublishConfig, SaveWebPublishConfig, WebPublishColumns,
|
||||||
|
TestWebPublishFTP, PublishLogNow, GetWebPublishStatus, PickBackupFolder,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type Cfg = {
|
||||||
|
enabled: boolean; format: string; folder: string; file_name: string; title: string;
|
||||||
|
count: number; interval_min: number; columns: string[];
|
||||||
|
ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string;
|
||||||
|
ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string;
|
||||||
|
};
|
||||||
|
type Col = { key: string; header: string; group: string };
|
||||||
|
|
||||||
|
export function WebPublishPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<Cfg | null>(null);
|
||||||
|
const [cols, setCols] = useState<Col[]>([]);
|
||||||
|
// The catalogue is 123 fields, so the picker is a dropdown with a search box
|
||||||
|
// rather than anything laid out on the page. Chosen columns stay visible above
|
||||||
|
// it, in publication order: after picking eight out of a hundred the question
|
||||||
|
// stops being 'what exists' and becomes 'what did I pick, and how will it print'.
|
||||||
|
const [colSearch, setColSearch] = useState('');
|
||||||
|
const [busy, setBusy] = useState<'' | 'test' | 'publish'>('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
// Raw text for the two numeric boxes: bound straight to the number they could
|
||||||
|
// not be cleared, the same trap as the Recent-QSOs Max field.
|
||||||
|
const [countText, setCountText] = useState('');
|
||||||
|
const [everyText, setEveryText] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [c, k, st] = await Promise.all([GetWebPublishConfig(), WebPublishColumns(), GetWebPublishStatus()]);
|
||||||
|
setCfg(c as any);
|
||||||
|
setCols((k ?? []) as Col[]);
|
||||||
|
setCountText(String((c as any).count ?? 100));
|
||||||
|
setEveryText(String((c as any).interval_min ?? 0));
|
||||||
|
const s: any = st;
|
||||||
|
if (s?.last_err) setErr(s.last_err);
|
||||||
|
else if (s?.last_run) setMsg(t('wpub.lastRun') + ' ' + s.last_run);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!cfg) return <p className="text-xs text-muted-foreground">…</p>;
|
||||||
|
const set = (p: Partial<Cfg>) => setCfg((c) => (c ? { ...c, ...p } : c));
|
||||||
|
|
||||||
|
// Every control saves immediately: this panel has no Save button of its own,
|
||||||
|
// matching the other "saved instantly" panels.
|
||||||
|
const save = async (next: Cfg) => {
|
||||||
|
setCfg(next);
|
||||||
|
try { await SaveWebPublishConfig(next as any); setErr(''); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
};
|
||||||
|
const patch = (p: Partial<Cfg>) => save({ ...cfg, ...p });
|
||||||
|
|
||||||
|
const toggleCol = (key: string) => {
|
||||||
|
const has = cfg.columns?.includes(key);
|
||||||
|
patch({ columns: has ? cfg.columns.filter((c) => c !== key) : [...(cfg.columns ?? []), key] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async (what: 'test' | 'publish') => {
|
||||||
|
setBusy(what); setMsg(''); setErr('');
|
||||||
|
try {
|
||||||
|
const r = what === 'test' ? await TestWebPublishFTP(cfg as any) : await PublishLogNow();
|
||||||
|
setMsg(String(r));
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setBusy(''); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-3xl">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{t('wpub.hint')}</p>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} />
|
||||||
|
{t('wpub.enable')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.enabled && (<>
|
||||||
|
{/* ── The file ── */}
|
||||||
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
|
<Label className="text-xs font-semibold">{t('wpub.fileSection')}</Label>
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
|
<Label className="text-sm">{t('wpub.format')}</Label>
|
||||||
|
<Select value={cfg.format} onValueChange={(v) => patch({ format: v })}>
|
||||||
|
<SelectTrigger className="h-8 w-56"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="html">{t('wpub.formatHtml')}</SelectItem>
|
||||||
|
<SelectItem value="csv">{t('wpub.formatCsv')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.folder')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input className="h-8 font-mono text-xs flex-1" value={cfg.folder}
|
||||||
|
onChange={(e) => set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} />
|
||||||
|
<Button variant="outline" size="sm" className="h-8" onClick={async () => {
|
||||||
|
try { const p = await PickBackupFolder(); if (p) patch({ folder: p }); } catch { /* cancelled */ }
|
||||||
|
}}><FolderOpen className="size-3.5" /> {t('wpub.browse')}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.fileName')}</Label>
|
||||||
|
<Input className="h-8 w-56 font-mono text-xs" value={cfg.file_name}
|
||||||
|
onChange={(e) => set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.title')}</Label>
|
||||||
|
<Input className="h-8" placeholder={t('wpub.titlePh')} value={cfg.title}
|
||||||
|
onChange={(e) => set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} />
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.count')}</Label>
|
||||||
|
<Input type="number" min={1} max={100000} className="h-8 w-28 font-mono text-xs" value={countText}
|
||||||
|
onChange={(e) => setCountText(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const n = Math.floor(Number(countText));
|
||||||
|
if (Number.isFinite(n) && n > 0) patch({ count: n }); else setCountText(String(cfg.count));
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<Label className="text-sm">{t('wpub.every')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input type="number" min={0} max={1440} className="h-8 w-28 font-mono text-xs" value={everyText}
|
||||||
|
onChange={(e) => setEveryText(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const n = Math.floor(Number(everyText));
|
||||||
|
if (Number.isFinite(n) && n >= 0) patch({ interval_min: n }); else setEveryText(String(cfg.interval_min));
|
||||||
|
}} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('wpub.everyHint')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Columns ── */}
|
||||||
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{t('wpub.columnsCount', { n: cfg.columns?.length ?? 0, total: cols.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CHOSEN, in publication order — this is the list that answers "what
|
||||||
|
will the page look like", which a sectioned catalogue cannot. */}
|
||||||
|
{(cfg.columns?.length ?? 0) > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{cfg.columns.map((k) => {
|
||||||
|
const c = cols.find((x) => x.key === k);
|
||||||
|
return (
|
||||||
|
<button key={k} type="button" onClick={() => toggleCol(k)}
|
||||||
|
title={t('wpub.removeColumn')}
|
||||||
|
className="px-2 py-0.5 rounded-full border border-primary bg-primary text-primary-foreground text-[11px] font-medium">
|
||||||
|
{c?.header ?? k} ×
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* One dropdown, alphabetical, filtered as you type. The catalogue is
|
||||||
|
123 fields: laid out on the page it buries every other setting, and
|
||||||
|
sorting by anything but the alphabet means hunting. The menu stays
|
||||||
|
open while ticking — picking eight columns should be one visit. */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="w-full justify-between h-8 text-xs font-normal">
|
||||||
|
{t('wpub.columnsPick')}
|
||||||
|
<ChevronsUpDown className="size-3.5 opacity-60" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
{/* The search box is OUTSIDE the scrolling area, not sticky inside it.
|
||||||
|
Sticky kept it in place but the rows scrolled over it: a menu item
|
||||||
|
carries its own background and its own stacking, so it wins over a
|
||||||
|
sticky sibling however high its z-index. A header that never
|
||||||
|
scrolls has nothing to lose the fight with. */}
|
||||||
|
<DropdownMenuContent align="start" className="w-72 p-0 flex flex-col max-h-80">
|
||||||
|
<div className="p-1.5 border-b border-border/60 bg-popover shrink-0">
|
||||||
|
<Input className="h-7 text-xs" placeholder={t('wpub.columnsSearch')}
|
||||||
|
value={colSearch}
|
||||||
|
onChange={(e) => setColSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()} />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto p-1">
|
||||||
|
{[...cols]
|
||||||
|
.sort((a, b) => a.header.localeCompare(b.header))
|
||||||
|
.filter((c) => {
|
||||||
|
const q = colSearch.trim().toLowerCase();
|
||||||
|
return !q || c.header.toLowerCase().includes(q) || c.key.toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
.map((c) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={c.key}
|
||||||
|
checked={cfg.columns?.includes(c.key) ?? false}
|
||||||
|
onCheckedChange={() => toggleCol(c.key)}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
<span className="ml-auto pl-2 text-[10px] text-muted-foreground font-mono">{c.group}</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Upload ── */}
|
||||||
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.ftp_enabled} onCheckedChange={(c) => patch({ ftp_enabled: !!c })} />
|
||||||
|
{t('wpub.ftpEnable')}
|
||||||
|
</label>
|
||||||
|
{cfg.ftp_enabled && (
|
||||||
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
|
<Label className="text-sm">{t('wpub.ftpHost')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input className="h-8 flex-1" placeholder="ftp.example.com" value={cfg.ftp_host}
|
||||||
|
onChange={(e) => set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} />
|
||||||
|
<Input type="number" className="h-8 w-24 font-mono text-xs" value={cfg.ftp_port}
|
||||||
|
onChange={(e) => set({ ftp_port: Number(e.target.value) || 21 })}
|
||||||
|
onBlur={() => patch({ ftp_port: cfg.ftp_port })} />
|
||||||
|
</div>
|
||||||
|
<Label className="text-sm">{t('wpub.ftpUser')}</Label>
|
||||||
|
<Input className="h-8" value={cfg.ftp_user}
|
||||||
|
onChange={(e) => set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} />
|
||||||
|
<Label className="text-sm">{t('wpub.ftpPassword')}</Label>
|
||||||
|
<Input type="password" className="h-8" value={cfg.ftp_password}
|
||||||
|
onChange={(e) => set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} />
|
||||||
|
<Label className="text-sm">{t('wpub.ftpFolder')}</Label>
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="/www/log" value={cfg.ftp_folder}
|
||||||
|
onChange={(e) => set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} />
|
||||||
|
<Label className="text-sm">{t('wpub.ftpFileName')}</Label>
|
||||||
|
<Input className="h-8 w-56 font-mono text-xs" value={cfg.ftp_file_name}
|
||||||
|
onChange={(e) => set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} />
|
||||||
|
<span />
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.ftp_tls} onCheckedChange={(c) => patch({ ftp_tls: !!c })} />
|
||||||
|
{t('wpub.ftpTls')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 border-t border-border/60 pt-3 flex-wrap">
|
||||||
|
<Button size="sm" onClick={() => run('publish')} disabled={busy !== ''}>
|
||||||
|
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
||||||
|
{t('wpub.publishNow')}
|
||||||
|
</Button>
|
||||||
|
{cfg.ftp_enabled && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => run('test')} disabled={busy !== ''}>
|
||||||
|
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
{t('wpub.testFtp')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{msg && <p className="text-xs text-success break-all">{msg}</p>}
|
||||||
|
{err && <p className="text-xs text-danger break-all">{err}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// A range slider the mouse wheel can move.
|
||||||
|
//
|
||||||
|
// Dragging a 4-pixel-tall slider to change the power by five watts is a fussy
|
||||||
|
// gesture; rolling the wheel over it is not, and it is what an operator reaches
|
||||||
|
// for after using the Flex and Yaesu consoles, which have had it for a while.
|
||||||
|
//
|
||||||
|
// React's own onWheel is registered PASSIVE, so preventDefault() inside it is
|
||||||
|
// ignored and the panel scrolls under the pointer while the value changes. The
|
||||||
|
// listener therefore has to be attached natively with { passive: false }, and
|
||||||
|
// the live values read through refs — the listener is installed once and would
|
||||||
|
// otherwise capture the first render's value for ever.
|
||||||
|
//
|
||||||
|
// The Flex and Yaesu panels each grew their own copy of this before it was worth
|
||||||
|
// sharing. They are left alone deliberately: they work, they are in daily use,
|
||||||
|
// and their styling differs in small ways that a merge would have to guess at.
|
||||||
|
export function WheelRange({ value, onChange, min = 0, max = 100, step = 1, disabled, accent = 'var(--primary)', className }: {
|
||||||
|
value: number; onChange: (v: number) => void;
|
||||||
|
min?: number; max?: number; step?: number; disabled?: boolean; accent?: string; className?: string;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(min, Math.min(max, value));
|
||||||
|
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const valRef = useRef(v); valRef.current = v;
|
||||||
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||||
|
const disRef = useRef(disabled); disRef.current = disabled;
|
||||||
|
const stepRef = useRef(step); stepRef.current = step;
|
||||||
|
const minRef = useRef(min); minRef.current = min;
|
||||||
|
const maxRef = useRef(max); maxRef.current = max;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (disRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||||
|
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + d));
|
||||||
|
if (nv !== valRef.current) cbRef.current(nv);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="range" min={min} max={max} step={step} value={v} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
|
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer',
|
||||||
|
className)}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%)`,
|
||||||
|
borderColor: accent,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -73,9 +73,17 @@ export function WinkeyerPanel({
|
|||||||
|
|
||||||
const connected = status.connected;
|
const connected = status.connected;
|
||||||
|
|
||||||
|
// Send-on-type only exists where the engine can key one character at a time:
|
||||||
|
// the WinKeyer and Flex CWX. The stored preference survives an engine change,
|
||||||
|
// though, and an Icom or Yaesu operator inherited a hidden switch that was on
|
||||||
|
// — the checkbox is not shown for them, so nothing keyed as they typed AND
|
||||||
|
// Enter sent nothing either, since it believes the text has already gone.
|
||||||
|
// Macros kept working, which is exactly how it looked from the outside.
|
||||||
|
const liveType = sendOnType && (source === 'winkeyer' || source === 'flex');
|
||||||
|
|
||||||
function sendText() {
|
function sendText() {
|
||||||
const t = cwText.trim();
|
const t = cwText.trim();
|
||||||
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
|
if (t && !liveType) onSend(t); // in send-on-type the text already went out
|
||||||
setCwText('');
|
setCwText('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +91,7 @@ export function WinkeyerPanel({
|
|||||||
// WinKeyer backspace for each deleted char (removes it from the buffer if it
|
// WinKeyer backspace for each deleted char (removes it from the buffer if it
|
||||||
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
|
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
|
||||||
function onCwChange(v: string) {
|
function onCwChange(v: string) {
|
||||||
if (sendOnType && connected) {
|
if (liveType && connected) {
|
||||||
const old = cwText;
|
const old = cwText;
|
||||||
if (v.length > old.length && v.startsWith(old)) {
|
if (v.length > old.length && v.startsWith(old)) {
|
||||||
onSendRaw(v.slice(old.length));
|
onSendRaw(v.slice(old.length));
|
||||||
@@ -101,16 +109,16 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
|
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{source === 'flex'
|
{source === 'flex'
|
||||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
: source === 'yaesu'
|
: source === 'yaesu' || source === 'kenwood'
|
||||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
@@ -200,13 +208,13 @@ export function WinkeyerPanel({
|
|||||||
value={cwText}
|
value={cwText}
|
||||||
onChange={(e) => onCwChange(e.target.value)}
|
onChange={(e) => onCwChange(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
||||||
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
|
placeholder={liveType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||||
disabled={!connected}
|
disabled={!connected}
|
||||||
className="font-mono uppercase"
|
className="font-mono uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
||||||
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
|
<Send className="size-3.5" /> {liveType ? t('wkp.clear') : t('wkp.send')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
||||||
<Square className="size-3.5" /> {t('wkp.stop')}
|
<Square className="size-3.5" /> {t('wkp.stop')}
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ type Props = {
|
|||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFull?: (ids: number[]) => void;
|
||||||
onExportSelectedFields?: (ids: number[]) => void;
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
@@ -57,7 +59,7 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportCabrilloSelected, onDelete, 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);
|
||||||
@@ -272,11 +274,13 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||||
onUpdateFromClublog={onUpdateFromClublog}
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
|
onUpdateCountyFromULS={onUpdateCountyFromULS}
|
||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFull={onExportSelectedFull}
|
||||||
onExportSelectedFields={onExportSelectedFields}
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -296,6 +300,20 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
{t('wbg.pickerDesc')}
|
{t('wbg.pickerDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{/* Whole-dialog controls — the award columns included, which the
|
||||||
|
catalogue-only helpers do not cover. */}
|
||||||
|
<div className="flex items-center gap-1 px-5 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('wbg.allGroups')}</span>
|
||||||
|
<button type="button" className="text-[11px] text-primary hover:underline px-1"
|
||||||
|
onClick={() => { showAll(); (awardCols ?? []).forEach((a) => setColVisible(`award_${a.code}`, true)); }}>
|
||||||
|
{t('wbg.all')}
|
||||||
|
</button>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
|
||||||
|
onClick={() => { hideAll(); (awardCols ?? []).forEach((a) => setColVisible(`award_${a.code}`, false)); }}>
|
||||||
|
{t('wbg.none')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lu
|
|||||||
import {
|
import {
|
||||||
GetYaesuState, RefreshYaesuPanel,
|
GetYaesuState, RefreshYaesuPanel,
|
||||||
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuAntenna, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||||
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||||
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
@@ -17,8 +17,9 @@ type YaesuState = {
|
|||||||
transmitting: boolean; split: boolean;
|
transmitting: boolean; split: boolean;
|
||||||
s_meter: number; power_meter: number; swr_meter: number;
|
s_meter: number; power_meter: number; swr_meter: number;
|
||||||
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||||
agc?: string; preamp: number; att: number;
|
agc?: string; preamp: number; att: number; antenna: number;
|
||||||
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; narrow_supported?: boolean; vox: boolean;
|
||||||
|
max_power?: number;
|
||||||
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ const ZERO: YaesuState = {
|
|||||||
available: false, transmitting: false, split: false,
|
available: false, transmitting: false, split: false,
|
||||||
s_meter: 0, power_meter: 0, swr_meter: 0,
|
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||||
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||||
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
preamp: 0, att: 0, antenna: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||||
@@ -74,6 +75,10 @@ function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
|||||||
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
||||||
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
||||||
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
||||||
|
// Three jacks is the most any of these rigs has (FTDX101); an FTDX10 answers
|
||||||
|
// with 1 or 2 and the third button simply never gets used. A rig with a single
|
||||||
|
// socket answers nothing at all and reports 0, and the row is not drawn.
|
||||||
|
const ANTENNAS = [{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }, { v: '3', l: 'ANT3' }];
|
||||||
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
||||||
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
||||||
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||||
@@ -424,6 +429,16 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
|||||||
<Row label="ATT">
|
<Row label="ATT">
|
||||||
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||||
</Row>
|
</Row>
|
||||||
|
{/* Only on a rig that HAS a choice: 0 means the AN command went
|
||||||
|
unanswered, which is what a single-socket FT-891 does. And the
|
||||||
|
choice is remembered for the band it was made on — no table to
|
||||||
|
fill in anywhere, the backend learns it here. */}
|
||||||
|
{view.antenna > 0 && (
|
||||||
|
<Row label="ANT">
|
||||||
|
<Segmented value={String(view.antenna)} options={ANTENNAS}
|
||||||
|
onChange={(v) => push('antenna', parseInt(v, 10), () => SetYaesuAntenna(parseInt(v, 10)))} />
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Noise + filter */}
|
{/* Noise + filter */}
|
||||||
@@ -431,7 +446,13 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
||||||
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||||
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
{/* NAR is the narrow IF filter. Shown only when the rig ANSWERED NA:
|
||||||
|
a button that does nothing when pressed reads as a fault in the
|
||||||
|
radio, which is how it was reported from an FTDX101. */}
|
||||||
|
{view.narrow_supported && (
|
||||||
|
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))}
|
||||||
|
label="NAR" title={t('yaesu.narrowHint')} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Row label="DNR">
|
<Row label="DNR">
|
||||||
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||||
@@ -448,7 +469,7 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
|||||||
<Row label="PWR">
|
<Row label="PWR">
|
||||||
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
||||||
percentage would be a second unit to reconcile every time. */}
|
percentage would be a second unit to reconcile every time. */}
|
||||||
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
|
<Slider value={view.rf_power || 5} min={5} max={Math.max(100, view.max_power || 100)} accent="var(--destructive)"
|
||||||
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
||||||
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { QSO_BOX_FIELDS, QSO_FIELD_LABELS } from './qslTypes';
|
||||||
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
|
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
|
||||||
import type { CardSelection } from './CardPreview';
|
import type { CardSelection } from './CardPreview';
|
||||||
import { StylePresetPicker, NumberField } from './StylePresetPicker';
|
import { StylePresetPicker, NumberField } from './StylePresetPicker';
|
||||||
@@ -199,6 +200,26 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
|||||||
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
||||||
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Columns</Label>
|
||||||
|
{/* Rebuilt from the canonical order every time rather than
|
||||||
|
appended to: a box whose columns come out in the order they
|
||||||
|
were ticked reads as a bug, and the widths are tuned for this
|
||||||
|
order. */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-2">
|
||||||
|
{QSO_BOX_FIELDS.map((f) => (
|
||||||
|
<label key={f} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<Checkbox
|
||||||
|
checked={box.fields.includes(f)}
|
||||||
|
onCheckedChange={(v) => onPatchBox({
|
||||||
|
fields: QSO_BOX_FIELDS.filter((k) => (k === f ? v === true : box.fields.includes(k))),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{QSO_FIELD_LABELS[f] ?? f}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
||||||
|
QSLListOtherProfileTemplates, QSLCopyTemplateToActiveProfile,
|
||||||
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
||||||
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
||||||
} from '../../../wailsjs/go/main/App';
|
} from '../../../wailsjs/go/main/App';
|
||||||
@@ -59,6 +60,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
||||||
|
const [foreign, setForeign] = useState<any[]>([]);
|
||||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||||
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||||||
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||||||
@@ -88,6 +90,8 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
if (url) p[t.id] = url;
|
if (url) p[t.id] = url;
|
||||||
}));
|
}));
|
||||||
setPreviews(p);
|
setPreviews(p);
|
||||||
|
// The designs this profile cannot see, offered for copying.
|
||||||
|
try { setForeign(((await QSLListOtherProfileTemplates()) ?? []) as any[]); } catch { /* nothing to offer */ }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -274,7 +278,29 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||||||
|
{/* Designs owned by another profile. A second profile is usually
|
||||||
|
the same operator with a different rig or locator — same
|
||||||
|
callsign, same cards — and redrawing one because the antenna
|
||||||
|
changed is work nobody should have to do. */}
|
||||||
|
{!!foreign.length && (
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={async (e) => {
|
||||||
|
const id = parseInt(e.target.value, 10);
|
||||||
|
if (!id) return;
|
||||||
|
try { await QSLCopyTemplateToActiveProfile(id); await refreshSaved(); }
|
||||||
|
catch (err) { setError(String(err)); }
|
||||||
|
}}
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs">
|
||||||
|
<option value="">Copy from another profile…</option>
|
||||||
|
{foreign.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.profile_name} — {f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{saved.map((t) => (
|
{saved.map((t) => (
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ export interface QSLPresetInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Human labels for the QSO box fields the renderer knows.
|
// Human labels for the QSO box fields the renderer knows.
|
||||||
|
// QSO_BOX_FIELDS is the canonical COLUMN ORDER of the confirmation box, and
|
||||||
|
// the whole list the designer offers. The renderer's column widths are tuned
|
||||||
|
// for it, so a box is always rebuilt from this order rather than from the
|
||||||
|
// order the operator happened to tick the boxes in.
|
||||||
|
export const QSO_BOX_FIELDS = ['qso_date', 'time_on', 'band', 'freq', 'mode', 'submode', 'rst_sent'] as const;
|
||||||
|
|
||||||
export const QSO_FIELD_LABELS: Record<string, string> = {
|
export const QSO_FIELD_LABELS: Record<string, string> = {
|
||||||
qso_date: 'Date',
|
qso_date: 'Date',
|
||||||
time_on: 'UTC',
|
time_on: 'UTC',
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -7,6 +9,7 @@ import { cn } from '@/lib/utils';
|
|||||||
// can't hold a typo'd value that isn't in the list.
|
// can't hold a typo'd value that isn't in the list.
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
||||||
|
showToggle = false,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
@@ -18,10 +21,26 @@ export function Combobox({
|
|||||||
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
||||||
// leaving the field uses the value just typed.
|
// leaving the field uses the value just typed.
|
||||||
commitOnType?: boolean;
|
commitOnType?: boolean;
|
||||||
|
// Draw a chevron that opens the full list on click.
|
||||||
|
//
|
||||||
|
// Without it this control is indistinguishable from a plain text box: it opens
|
||||||
|
// only on a keystroke or ArrowDown, both of which have to be known about
|
||||||
|
// first. That is fine for a field whose list is a convenience (RST), and wrong
|
||||||
|
// for one whose list is the ANSWER — the COM ports actually present on this
|
||||||
|
// machine, which nobody can be expected to recall.
|
||||||
|
showToggle?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
// Opened by the chevron rather than by typing: the whole list is on offer, not
|
||||||
|
// the part matching what is already in the field — a box holding COM7 would
|
||||||
|
// otherwise "open" onto COM7 alone.
|
||||||
|
const [browse, setBrowse] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
// Where the portalled menu goes. Measured from the field itself, and
|
||||||
|
// re-measured while it is open, so scrolling the panel underneath does not
|
||||||
|
// leave the list floating over the wrong row.
|
||||||
|
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onDoc(e: MouseEvent) {
|
function onDoc(e: MouseEvent) {
|
||||||
@@ -31,20 +50,44 @@ export function Combobox({
|
|||||||
return () => document.removeEventListener('mousedown', onDoc);
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = open
|
useLayoutEffect(() => {
|
||||||
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
|
if (!open) return;
|
||||||
: [];
|
const place = () => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
// Opens UPWARD when there is not enough room below — which is exactly
|
||||||
|
// where these fields tend to sit, at the bottom of a panel.
|
||||||
|
const height = 240;
|
||||||
|
const below = window.innerHeight - r.bottom;
|
||||||
|
const top = below < height + 8 ? Math.max(4, r.top - height - 4) : r.bottom + 4;
|
||||||
|
setMenuPos({ top, left: r.left, width: r.width });
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener('scroll', place, true);
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', place, true);
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filtered = !open ? []
|
||||||
|
: browse ? options.slice(0, 60)
|
||||||
|
: options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
|
||||||
|
|
||||||
function commit(v: string) {
|
function commit(v: string) {
|
||||||
onChange(v);
|
onChange(v);
|
||||||
setQuery(v);
|
setQuery(v);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setBrowse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBlur() {
|
function onBlur() {
|
||||||
// Defer so a click on an option registers first.
|
// Defer so a click on an option registers first.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setBrowse(false);
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
|
const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
|
||||||
// Only fire onChange when the value actually changed — committing an
|
// Only fire onChange when the value actually changed — committing an
|
||||||
@@ -68,6 +111,7 @@ export function Combobox({
|
|||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setQuery(v);
|
setQuery(v);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
setBrowse(false); // typing filters again
|
||||||
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||||
// without leaving the field uses what was just typed). With free text that's
|
// without leaving the field uses what was just typed). With free text that's
|
||||||
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||||
@@ -76,16 +120,44 @@ export function Combobox({
|
|||||||
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||||
}}
|
}}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
|
className={showToggle ? 'pr-7' : undefined}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); setBrowse(true); }
|
||||||
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
|
else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); }
|
||||||
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
|
else if (e.key === 'Escape') { setQuery(value); setOpen(false); }
|
||||||
// Tab: just let it move on; onBlur commits/closes. Options are
|
// Tab: just let it move on; onBlur commits/closes. Options are
|
||||||
// tabIndex=-1 so a single Tab leaves the field.
|
// tabIndex=-1 so a single Tab leaves the field.
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{open && filtered.length > 0 && (
|
{showToggle && (
|
||||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Show list"
|
||||||
|
className="absolute inset-y-0 right-0 flex w-7 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
// mousedown, not click: the input's blur fires first otherwise and
|
||||||
|
// closes the list the same instant this opens it.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (open) { setOpen(false); setBrowse(false); return; }
|
||||||
|
setQuery(value);
|
||||||
|
setBrowse(true);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* THE MENU IS PORTALLED to the body, and positioned from the field's own
|
||||||
|
rectangle. As an absolutely-positioned child it was clipped by whichever
|
||||||
|
scrolling or overflow-hidden box it happened to sit in: in the details
|
||||||
|
panel it was cut off after the first row, and a list showing one entry
|
||||||
|
of eight is worse than no list at all. */}
|
||||||
|
{open && filtered.length > 0 && createPortal(
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, width: menuPos.width }}
|
||||||
|
className="z-[100] max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg text-xs"
|
||||||
|
>
|
||||||
{filtered.map((o) => (
|
{filtered.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o}
|
key={o}
|
||||||
@@ -97,7 +169,8 @@ export function Combobox({
|
|||||||
{o}
|
{o}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Auto-call: let OpsLog answer a decode without the operator clicking it.
|
||||||
|
//
|
||||||
|
// WITHDRAWN FROM THE INTERFACE, because DXHunter already does it.
|
||||||
|
//
|
||||||
|
// Two programs from the same shack deciding on their own to answer the same
|
||||||
|
// decode is worse than either doing it alone: they cannot see each other, so
|
||||||
|
// they key over one another, and afterwards there is no telling which of them
|
||||||
|
// called. The duplicate is the one to remove, and DXHunter is where this lives.
|
||||||
|
//
|
||||||
|
// There is no switch in Preferences and no button on the decodes toolbar, and
|
||||||
|
// App.tsx returns before the loop can run. The file is kept whole: the rules
|
||||||
|
// below are the delicate part, argued over and tested, and rewriting them from
|
||||||
|
// memory later would be worse than leaving them here. Reinstating the feature
|
||||||
|
// means restoring all three — the settings page, the button, and the guard.
|
||||||
|
//
|
||||||
|
// This KEYS THE TRANSMITTER on its own, which is why the rules here are written
|
||||||
|
// as a series of refusals rather than a search for a reason to call. Everything
|
||||||
|
// below has to be true; anything unknown means no.
|
||||||
|
//
|
||||||
|
// The decision is made here, in one pure function, precisely because it is the
|
||||||
|
// dangerous part: it can be read, argued with and tested without a radio.
|
||||||
|
|
||||||
|
export type AutoCallCriteria = {
|
||||||
|
dxcc: boolean; // entity never worked
|
||||||
|
bandmode: boolean; // entity worked, but neither this band nor this mode
|
||||||
|
band: boolean; // entity never worked on this band
|
||||||
|
mode: boolean; // entity never worked in this mode
|
||||||
|
slot: boolean; // band and mode each worked, never together
|
||||||
|
grid: boolean; // square wanted under the grid scope
|
||||||
|
county: boolean; // US county never worked
|
||||||
|
pota: boolean; // park never worked
|
||||||
|
// No SOTA here, though the shape invites it: a decode carries no summit
|
||||||
|
// reference and the backend publishes no "new summit" flag, so a criterion
|
||||||
|
// for it could never be true. It WAS declared, translated and impossible to
|
||||||
|
// tick — a field that lies about what the feature can do.
|
||||||
|
pfx: boolean; // CQ WPX prefix never worked
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoCallSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
criteria: AutoCallCriteria;
|
||||||
|
// Callsigns to answer on sight, wildcards allowed (4S7*, */P). Each is still
|
||||||
|
// subject to `watchCriteria` — "call TM0HQ, but only if it is a new band" is
|
||||||
|
// the request, not "call it every time it appears".
|
||||||
|
watch: string[];
|
||||||
|
// Empty means call a watched callsign whenever it is not already worked.
|
||||||
|
watchCriteria: AutoCallCriteria;
|
||||||
|
// Seconds to ignore a callsign after calling it, so a station that keeps
|
||||||
|
// sending CQ is not re-answered every slot while the QSO is in progress.
|
||||||
|
cooldownSec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyCriteria: AutoCallCriteria = {
|
||||||
|
dxcc: false, bandmode: false, band: false, mode: false, slot: false,
|
||||||
|
grid: false, county: false, pota: false, pfx: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultAutoCall: AutoCallSettings = {
|
||||||
|
// OFF, and it stays off until asked for. Unattended transmit is not something
|
||||||
|
// to inherit from an upgrade.
|
||||||
|
enabled: false,
|
||||||
|
criteria: { ...emptyCriteria },
|
||||||
|
watch: [],
|
||||||
|
watchCriteria: { ...emptyCriteria },
|
||||||
|
cooldownSec: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AC_KEY = 'opslog.autoCall';
|
||||||
|
|
||||||
|
export function loadAutoCall(): AutoCallSettings {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(AC_KEY);
|
||||||
|
if (!raw) return { ...defaultAutoCall };
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
const out: AutoCallSettings = {
|
||||||
|
...defaultAutoCall,
|
||||||
|
...v,
|
||||||
|
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
|
||||||
|
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
|
||||||
|
watch: Array.isArray(v?.watch) ? v.watch : [],
|
||||||
|
};
|
||||||
|
// DISARMED ON SIGHT, and written back disabled.
|
||||||
|
//
|
||||||
|
// The runtime guard in App.tsx stops this build from calling anyone, but it
|
||||||
|
// leaves "enabled": true sitting in storage, where any build without the
|
||||||
|
// guard — an older one an operator reinstalls, a machine that upgrades
|
||||||
|
// later — reads it and keys the transmitter for a feature with no switch
|
||||||
|
// left to turn off. A withdrawn feature that keys a radio has to be
|
||||||
|
// disarmed where it is REMEMBERED, not only where it runs.
|
||||||
|
if (out.enabled) {
|
||||||
|
out.enabled = false;
|
||||||
|
try { localStorage.setItem(AC_KEY, JSON.stringify(out)); } catch { /* private mode: the guard still holds */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch { return { ...defaultAutoCall }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const autoCallKey = AC_KEY;
|
||||||
|
|
||||||
|
// A decode's resolved novelty, the same shape the panel already renders from.
|
||||||
|
export type DecodeStatus = {
|
||||||
|
status?: string;
|
||||||
|
worked_call?: boolean;
|
||||||
|
new_grid?: boolean;
|
||||||
|
grid_state?: string;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// matchesWildcard is the same rule the alert filters use: * is any run, ? is one.
|
||||||
|
export function matchesWildcard(pattern: string, call: string): boolean {
|
||||||
|
const p = pattern.trim().toUpperCase();
|
||||||
|
const c = call.trim().toUpperCase();
|
||||||
|
if (!p) return false;
|
||||||
|
const re = new RegExp('^' + p.split('').map((ch) => (
|
||||||
|
ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
)).join('') + '$');
|
||||||
|
return re.test(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyCriterion is false for an all-off set, which is what makes "watch this
|
||||||
|
// callsign, no conditions" expressible.
|
||||||
|
function anyCriterion(c: AutoCallCriteria): boolean {
|
||||||
|
return Object.values(c).some(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// meets reports whether a decode satisfies at least one ticked criterion.
|
||||||
|
function meets(c: AutoCallCriteria, e: DecodeStatus): boolean {
|
||||||
|
if (c.dxcc && e.status === 'new') return true;
|
||||||
|
// Each status is exclusive, so a station that is new on both counts matches
|
||||||
|
// ONLY this criterion — ticking "new band" alone would not catch it, which is
|
||||||
|
// the wrong way round: it is the better catch of the two.
|
||||||
|
if (c.bandmode && e.status === 'new-band-mode') return true;
|
||||||
|
if (c.band && e.status === 'new-band') return true;
|
||||||
|
if (c.mode && e.status === 'new-mode') return true;
|
||||||
|
if (c.slot && e.status === 'new-slot') return true;
|
||||||
|
// A square that is merely UNCONFIRMED is not called: the QSO is already made,
|
||||||
|
// and calling again would work a duplicate to chase a QSL.
|
||||||
|
if (c.grid && e.new_grid && e.grid_state !== 'unconf') return true;
|
||||||
|
if (c.county && e.new_county) return true;
|
||||||
|
if (c.pota && e.new_pota) return true;
|
||||||
|
if (c.pfx && e.new_pfx) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoCallDecode = {
|
||||||
|
call: string;
|
||||||
|
cq?: boolean;
|
||||||
|
msg?: string;
|
||||||
|
instance?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// shouldAutoCall decides whether to answer one decode. The reason is returned
|
||||||
|
// for the log: an automatic transmission with no record of WHY is the thing an
|
||||||
|
// operator cannot argue with after the fact.
|
||||||
|
export function shouldAutoCall(
|
||||||
|
s: AutoCallSettings,
|
||||||
|
d: AutoCallDecode,
|
||||||
|
e: DecodeStatus | undefined,
|
||||||
|
opts: {
|
||||||
|
// busy is "some receiver is mid-QSO", NOT "this one is transmitting".
|
||||||
|
//
|
||||||
|
// The distinction is the whole bug it fixes: with two instances the caller
|
||||||
|
// used to test a single global transmit flag, which belonged to whichever
|
||||||
|
// receiver reported last. So while slice A worked a station, slice B looked
|
||||||
|
// idle and auto-call started another QSO on it — and the moment either
|
||||||
|
// finished it chained straight into the next. One station at a time means
|
||||||
|
// one across ALL receivers, not one per receiver.
|
||||||
|
busy: boolean;
|
||||||
|
calledAt: Map<string, number>;
|
||||||
|
now: number;
|
||||||
|
myCall?: string;
|
||||||
|
},
|
||||||
|
): { call: boolean; reason: string } {
|
||||||
|
const no = (why: string) => ({ call: false, reason: why });
|
||||||
|
if (!s.enabled) return no('off');
|
||||||
|
if (!e) return no('status not resolved yet');
|
||||||
|
const call = (d.call ?? '').trim().toUpperCase();
|
||||||
|
if (!call) return no('no callsign');
|
||||||
|
// Never answer ourselves, however the decode reached us.
|
||||||
|
if (opts.myCall && call === opts.myCall.trim().toUpperCase()) return no('own callsign');
|
||||||
|
// NOT limited to a CQ, deliberately.
|
||||||
|
//
|
||||||
|
// It used to be, on the reasoning that answering a station mid-QSO is calling
|
||||||
|
// over somebody. That reasoning ignored the case the feature exists for: a
|
||||||
|
// DXpedition running a pileup never sends CQ at all — it works caller after
|
||||||
|
// caller — so the rule sat out the one contact auto-call was turned on for. A
|
||||||
|
// new entity on 15 m FT8, decode after decode, and not a single transmission.
|
||||||
|
//
|
||||||
|
// WHEN to transmit is not ours to decide either: the Reply goes to the
|
||||||
|
// decoder, and MSHV starts at once while JTDX waits for a CQ. Two correct
|
||||||
|
// behaviours, both belonging to the program that owns the timing. Here the
|
||||||
|
// question is only whether the station is one the operator wants — the
|
||||||
|
// criteria below answer that, and the cooldown and the busy check keep it from
|
||||||
|
// calling twice.
|
||||||
|
// Not while ANY receiver is mid-QSO — transmitting, or holding a DX call it
|
||||||
|
// has not finished with. Starting a second exchange before the first is done
|
||||||
|
// is what turned this into a machine that called without stopping.
|
||||||
|
if (opts.busy) return no('a QSO is already in progress');
|
||||||
|
const last = opts.calledAt.get(call);
|
||||||
|
if (last !== undefined && opts.now - last < s.cooldownSec * 1000) return no('called recently');
|
||||||
|
|
||||||
|
// The watch list first: an explicitly named station outranks the general
|
||||||
|
// criteria, and may carry conditions of its own.
|
||||||
|
const watched = s.watch.some((p) => matchesWildcard(p, call));
|
||||||
|
if (watched) {
|
||||||
|
if (!anyCriterion(s.watchCriteria)) {
|
||||||
|
// No conditions attached: call it unless it is already worked.
|
||||||
|
return e.worked_call ? no('watched, but already worked') : { call: true, reason: 'watch list' };
|
||||||
|
}
|
||||||
|
return meets(s.watchCriteria, e)
|
||||||
|
? { call: true, reason: 'watch list + criteria' }
|
||||||
|
: no('watched, but no criterion met');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anyCriterion(s.criteria)) return no('no criteria ticked');
|
||||||
|
return meets(s.criteria, e)
|
||||||
|
? { call: true, reason: 'criteria' }
|
||||||
|
: no('no criterion met');
|
||||||
|
}
|
||||||
@@ -154,3 +154,83 @@ export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field:
|
|||||||
}
|
}
|
||||||
return out.join(';');
|
return out.join(';');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COMPUTED_AWARD_FIELDS mirrors isComputedAwardField in app.go: awards whose
|
||||||
|
// reference is derived from the callsign and cty.dat, never assigned by hand.
|
||||||
|
//
|
||||||
|
// State and county are deliberately absent, there as here — an operator sets
|
||||||
|
// those themselves and they drive real predefined-list awards (WAS, WAJA, JCC).
|
||||||
|
const COMPUTED_AWARD_FIELDS = new Set([
|
||||||
|
'dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid', 'grid4',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// spotRefList turns a QSO's code→ref map into the strings a DX-cluster comment
|
||||||
|
// wants: ["SOTA HA/KM-018"].
|
||||||
|
//
|
||||||
|
// COMPUTED awards are dropped. A QSO's materialised references hold both kinds,
|
||||||
|
// and the derived ones — DXCC, WAC, WAZ, WPX — are exactly what every reader of
|
||||||
|
// the spot works out from the callsign anyway. Announcing "WAC EU WAZ 15 WPX HA5"
|
||||||
|
// spends the whole 30-character comment saying nothing, and buries the one
|
||||||
|
// reference worth chasing. Same rule as the QSO editor, which is why these are
|
||||||
|
// the references it lists on the LEFT and not in its read-only panel.
|
||||||
|
//
|
||||||
|
// fieldOf is award code → scanned field (from GetAwardDefs). A code missing from
|
||||||
|
// it is dropped rather than guessed: showing a reference on the air matters more
|
||||||
|
// than showing all of them.
|
||||||
|
//
|
||||||
|
// A code carrying several references (a two-fer park) yields one entry each, so
|
||||||
|
// a spot can take the one that fits instead of emitting a truncated pair.
|
||||||
|
// Shortest first: when they cannot all fit, more references beat fewer.
|
||||||
|
export function spotRefList(byCode: Record<string, string>, fieldOf: Record<string, string>): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const [code, refs] of Object.entries(byCode ?? {})) {
|
||||||
|
const field = fieldOf?.[code.toUpperCase()];
|
||||||
|
if (!field || COMPUTED_AWARD_FIELDS.has(field)) continue;
|
||||||
|
for (const ref of String(refs).split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
out.push(`${code.toUpperCase()} ${ref.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// withIOTARef adds an island reference from the callbook to an entry's award
|
||||||
|
// references, unless the operator has already set one.
|
||||||
|
//
|
||||||
|
// QRZ sends <iota> for an operator on an island and OpsLog used to read past it.
|
||||||
|
// It matters more here than it would for another award: unlike POTA there is no
|
||||||
|
// live "who is on an island right now" feed anywhere, so the callbook record is
|
||||||
|
// the practical source — and it is known before the contact is logged, which is
|
||||||
|
// when it is useful.
|
||||||
|
//
|
||||||
|
// A reference the operator typed or picked wins. A callbook entry can be years
|
||||||
|
// old, and the operator in front of the radio has just been told where the
|
||||||
|
// station is.
|
||||||
|
// withRDARef adds a Russian district from the callbook to an entry's award
|
||||||
|
// references, on the same terms as the island above.
|
||||||
|
//
|
||||||
|
// HamQTH publishes the district a station operates from TODAY. That is the
|
||||||
|
// right answer for the contact being typed and the wrong one for any other, so
|
||||||
|
// this is only ever called on the entry — never on a QSO already in the log.
|
||||||
|
// A station can move district; stamping today's on last year's contact turns a
|
||||||
|
// correct entry into a false one, and nothing downstream could tell.
|
||||||
|
export function withRDARef(awardRefs: string, rda: string): string {
|
||||||
|
const ref = (rda || '').trim().toUpperCase();
|
||||||
|
// Two letters, a hyphen, two digits. Anything else is not a district.
|
||||||
|
if (!/^[A-Z]{2}-\d{2}$/.test(ref)) return awardRefs;
|
||||||
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
|
if ((byCode['RDA'] ?? '').trim() !== '') return awardRefs; // already set: leave it
|
||||||
|
const sep = awardRefs.trim() === '' ? '' : ';';
|
||||||
|
return awardRefs + sep + 'RDA@' + ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withIOTARef(awardRefs: string, iota: string): string {
|
||||||
|
const ref = (iota || '').trim().toUpperCase();
|
||||||
|
// EU-048: two letters, a hyphen, three digits. Anything else is not an IOTA
|
||||||
|
// reference, and writing it into the award would make a reference that no
|
||||||
|
// list contains — which counts for nothing and has to be found by hand later.
|
||||||
|
if (!/^[A-Z]{2}-\d{3}$/.test(ref)) return awardRefs;
|
||||||
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
|
if ((byCode['IOTA'] ?? '').trim() !== '') return awardRefs; // already set: leave it
|
||||||
|
const sep = awardRefs.trim() === '' ? '' : ';';
|
||||||
|
return awardRefs + sep + 'IOTA@' + ref;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Cluster command macros — a named button for a command you would otherwise
|
||||||
|
// retype.
|
||||||
|
//
|
||||||
|
// Twelve slots, fixed. A list you can grow needs add/remove/reorder controls and
|
||||||
|
// a decision about what an empty row means; twelve boxes you fill in need
|
||||||
|
// neither, and nobody has thirteen cluster commands they use daily. A slot with
|
||||||
|
// no command is simply not drawn, so the toolbar is as long as the operator made
|
||||||
|
// it and no longer.
|
||||||
|
//
|
||||||
|
// Stored through writeUiPref like every other portable preference, so the
|
||||||
|
// buttons travel with data/ rather than living in one browser profile.
|
||||||
|
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type ClusterMacro = {
|
||||||
|
label: string; // what the button says
|
||||||
|
cmd: string; // what is sent to the master server
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CLUSTER_MACRO_COUNT = 12;
|
||||||
|
export const clusterMacrosKey = 'opslog.clusterMacros';
|
||||||
|
|
||||||
|
export const emptyClusterMacros = (): ClusterMacro[] =>
|
||||||
|
Array.from({ length: CLUSTER_MACRO_COUNT }, () => ({ label: '', cmd: '' }));
|
||||||
|
|
||||||
|
// loadClusterMacros always returns exactly CLUSTER_MACRO_COUNT entries, whatever
|
||||||
|
// was stored: a saved list from a build with fewer slots must not leave the
|
||||||
|
// editor rendering undefined rows.
|
||||||
|
export function loadClusterMacros(): ClusterMacro[] {
|
||||||
|
const out = emptyClusterMacros();
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(clusterMacrosKey);
|
||||||
|
if (!raw) return out;
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(v)) return out;
|
||||||
|
for (let i = 0; i < CLUSTER_MACRO_COUNT && i < v.length; i++) {
|
||||||
|
out[i] = {
|
||||||
|
label: String(v[i]?.label ?? '').slice(0, 24),
|
||||||
|
cmd: String(v[i]?.cmd ?? '').slice(0, 120),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch { /* a corrupt preference is not worth failing the panel over */ }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveClusterMacros(macros: ClusterMacro[]): void {
|
||||||
|
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
|
||||||
|
// what decides: a slot with a label and no command is a button that lies, and a
|
||||||
|
// command with no label still has something to show — its own text.
|
||||||
|
export function visibleClusterMacros(macros: ClusterMacro[]): ClusterMacro[] {
|
||||||
|
return macros
|
||||||
|
.filter((m) => m.cmd.trim() !== '')
|
||||||
|
.map((m) => ({ label: m.label.trim() || m.cmd.trim(), cmd: m.cmd.trim() }));
|
||||||
|
}
|
||||||
+262
-120
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
|||||||
|
// Operator overrides for the band/mode matrix palette.
|
||||||
|
//
|
||||||
|
// Applied as inline custom properties on <html>, which is what lets them be
|
||||||
|
// OVERRIDES rather than a palette: the twelve themes each define their own
|
||||||
|
// --mx-* ramp in style.css, an inline value wins over all of them, and removing
|
||||||
|
// it hands the colour straight back to the theme. Nothing has to know which
|
||||||
|
// theme is active, and switching theme with overrides off is a clean revert.
|
||||||
|
|
||||||
|
export type MatrixColors = {
|
||||||
|
enabled: boolean;
|
||||||
|
call_confirmed: string;
|
||||||
|
call_worked: string;
|
||||||
|
entity_confirmed: string;
|
||||||
|
entity_worked: string;
|
||||||
|
not_worked: string;
|
||||||
|
current_entry: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The six settings fields and the CSS custom property each one drives. Also the
|
||||||
|
// display order — the same order the legend under the matrix reads in, so the
|
||||||
|
// settings panel and the grid can never disagree about which green is which.
|
||||||
|
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
|
||||||
|
{ key: 'call_confirmed', cssVar: '--mx-call-conf', label: 'mx.callConf' },
|
||||||
|
{ key: 'call_worked', cssVar: '--mx-call-work', label: 'mx.callWork' },
|
||||||
|
{ key: 'entity_confirmed', cssVar: '--mx-dx-conf', label: 'mx.dxConf' },
|
||||||
|
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
|
||||||
|
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
|
||||||
|
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const emptyMatrixColors = (): MatrixColors => ({
|
||||||
|
enabled: false,
|
||||||
|
call_confirmed: '', call_worked: '', entity_confirmed: '',
|
||||||
|
entity_worked: '', not_worked: '', current_entry: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
|
||||||
|
// often as you like — it is the whole rendering path, so the settings panel uses
|
||||||
|
// it for a live preview and the app uses it once at startup.
|
||||||
|
export function applyMatrixColors(c?: MatrixColors | null): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const { key, cssVar } of MATRIX_VARS) {
|
||||||
|
const v = c?.enabled ? String(c[key] ?? '').trim() : '';
|
||||||
|
if (v) root.style.setProperty(cssVar, v);
|
||||||
|
else root.style.removeProperty(cssVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveMatrixColor reads what the matrix is ACTUALLY painting right now —
|
||||||
|
// the override if there is one, else the active theme's value. It is what seeds
|
||||||
|
// the colour pickers, so the operator starts from the colours in front of them
|
||||||
|
// instead of from a hardcoded palette that may belong to a different theme.
|
||||||
|
//
|
||||||
|
// A custom property's computed value has its var() references substituted, so
|
||||||
|
// --mx-cur resolves to the theme's --warning rather than to the literal text.
|
||||||
|
export function effectiveMatrixColor(cssVar: string): string {
|
||||||
|
try {
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
|
||||||
|
// <input type="color"> only accepts #rrggbb. Anything else (a theme that
|
||||||
|
// ever moves to oklch, an empty read during boot) falls back to mid grey
|
||||||
|
// rather than silently resetting the picker to black.
|
||||||
|
return /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : '#808080';
|
||||||
|
} catch {
|
||||||
|
return '#808080';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// One answer to "is this QSL received".
|
||||||
|
//
|
||||||
|
// ADIF's QSL_Rcvd enumeration has BOTH Y and V: Y is "received", V is
|
||||||
|
// "verified" — and V is what a LoTW download writes for a confirmation the ARRL
|
||||||
|
// has validated. Testing for 'Y' alone therefore misses exactly the
|
||||||
|
// confirmations an operator cares most about.
|
||||||
|
//
|
||||||
|
// It showed on screen: the Awards panel had Morocco validated on five bands
|
||||||
|
// while the band/mode matrix beside it showed the entity as merely worked. The
|
||||||
|
// award engine accepted Y or V; every other test in the app accepted Y.
|
||||||
|
//
|
||||||
|
// The Go side has the same rule twice over — award.isYes and qso.ConfirmedValues
|
||||||
|
// — and all three have to agree. If you add a value here, add it there.
|
||||||
|
export function isQSLConfirmed(v: unknown): boolean {
|
||||||
|
const s = String(v ?? '').trim().toUpperCase();
|
||||||
|
return s === 'Y' || s === 'V';
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { GetRotatorHeading } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// One poll loop for the antenna heading, shared by everything that shows it.
|
||||||
|
//
|
||||||
|
// TWO PROBLEMS THIS FIXES, both of them invisible until you count.
|
||||||
|
//
|
||||||
|
// The status bar polled, and the Station Control compass polled, and they polled
|
||||||
|
// the SAME binding — so with that tab open the rotator was asked twice as often
|
||||||
|
// as either component believed. Every backend builds a fresh client per call
|
||||||
|
// (spid.New, gs232.NewSerial, dcu1.NewSerial, rotgenius.New…), so one poll is
|
||||||
|
// one OPEN and CLOSE of a serial port or a TCP connection, not a read on a link
|
||||||
|
// already up.
|
||||||
|
//
|
||||||
|
// And a fixed interval has no good value. Three seconds moved the needle in
|
||||||
|
// steps of about thirteen degrees on a turning antenna — a compass that jumps
|
||||||
|
// rather than sweeps. Seven hundred milliseconds sweeps beautifully and opens
|
||||||
|
// the controller's port about ten thousand times an hour to watch an antenna
|
||||||
|
// that has not moved since breakfast.
|
||||||
|
//
|
||||||
|
// So: fast while it is turning, slow while it is not. "Turning" is not something
|
||||||
|
// these controllers report — a SPID answers a position and nothing else — so it
|
||||||
|
// is inferred from the position changing, and held for a few seconds after the
|
||||||
|
// last change so the tail of a movement stays smooth.
|
||||||
|
|
||||||
|
export type RotorHeading = {
|
||||||
|
enabled: boolean; ok: boolean; azimuth: number;
|
||||||
|
rotors?: string[]; active?: number; motorized?: boolean; raw?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOVING_MS = 500; // while the antenna is turning
|
||||||
|
const IDLE_MS = 3000; // while it is parked — the rate everything used before
|
||||||
|
const SETTLE_MS = 6000; // stay fast this long after the last movement
|
||||||
|
|
||||||
|
const subs = new Set<(h: RotorHeading) => void>();
|
||||||
|
let timer: number | undefined;
|
||||||
|
let inFlight = false;
|
||||||
|
let lastAz: number | null = null;
|
||||||
|
let lastMoveAt = 0;
|
||||||
|
// The last heading anyone received, replayed to whoever subscribes next.
|
||||||
|
//
|
||||||
|
// Without it, opening Station Control left the compass blank for one or two
|
||||||
|
// seconds while every other panel filled at once. Nothing was slow: the status
|
||||||
|
// bar already had the loop running with a tick pending, so a component mounting
|
||||||
|
// halfway through an idle interval simply waited out the rest of it. The
|
||||||
|
// heading was known the whole time — it just had nowhere to be read from.
|
||||||
|
let last: RotorHeading | null = null;
|
||||||
|
|
||||||
|
function schedule(delay: number) {
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(tick, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
// A slow controller must not stack requests behind itself: at 600 baud a SPID
|
||||||
|
// reply takes a fifth of a second on the wire alone, and a port that is still
|
||||||
|
// open from the last poll cannot be opened again.
|
||||||
|
if (inFlight) { schedule(MOVING_MS); return; }
|
||||||
|
inFlight = true;
|
||||||
|
try {
|
||||||
|
const h = (await GetRotatorHeading()) as unknown as RotorHeading;
|
||||||
|
if (h?.ok && typeof h.azimuth === 'number') {
|
||||||
|
if (lastAz !== null && h.azimuth !== lastAz) lastMoveAt = Date.now();
|
||||||
|
lastAz = h.azimuth;
|
||||||
|
}
|
||||||
|
last = h;
|
||||||
|
subs.forEach((fn) => { try { fn(h); } catch { /* a subscriber must not stop the loop */ } });
|
||||||
|
} catch {
|
||||||
|
// Leave the last heading alone: a single failed poll on a shared serial port
|
||||||
|
// is not news, and blanking the compass on one would make it flicker.
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
if (subs.size > 0) schedule(Date.now() - lastMoveAt < SETTLE_MS ? MOVING_MS : IDLE_MS);
|
||||||
|
else timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeRotorHeading starts the loop if it is not running and returns the
|
||||||
|
// unsubscribe. The loop stops when the last subscriber leaves.
|
||||||
|
export function subscribeRotorHeading(fn: (h: RotorHeading) => void): () => void {
|
||||||
|
subs.add(fn);
|
||||||
|
// Hand over what is already known, at once. An Alpha SPID poll is an open,
|
||||||
|
// a read at 600 baud and a close, so even an immediate one takes a moment —
|
||||||
|
// the cached heading is what makes the compass appear with the panel rather
|
||||||
|
// than after it. In a microtask, so a subscriber is never called back before
|
||||||
|
// subscribeRotorHeading has returned to it.
|
||||||
|
if (last) { const h = last; queueMicrotask(() => { if (subs.has(fn)) fn(h); }); }
|
||||||
|
if (timer === undefined && !inFlight) void tick();
|
||||||
|
return () => {
|
||||||
|
subs.delete(fn);
|
||||||
|
if (subs.size === 0 && timer !== undefined) { window.clearTimeout(timer); timer = undefined; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// pokeRotorHeading polls at once and switches to the fast rate — call it after
|
||||||
|
// commanding a move, so the needle starts sweeping on the click rather than on
|
||||||
|
// whatever was left of a three-second tick.
|
||||||
|
export function pokeRotorHeading(): void {
|
||||||
|
lastMoveAt = Date.now();
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
void tick();
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
|
// Row colouring for the log grid, by QSL status.
|
||||||
|
//
|
||||||
|
// Four categories, each scoped to the channels the operator cares about —
|
||||||
|
// paper QSL, LoTW, eQSL, QRZ.com. The rules are ORDERED and the first match
|
||||||
|
// wins, because a contact is usually several of them at once.
|
||||||
|
|
||||||
|
export type RowColorRule = {
|
||||||
|
id: string;
|
||||||
|
color: string;
|
||||||
|
enabled: boolean;
|
||||||
|
channels?: string[]; // empty = every channel
|
||||||
|
};
|
||||||
|
export type RowColorSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
style?: 'bar' | 'tint' | 'both';
|
||||||
|
intensity?: number;
|
||||||
|
bandmap_lotw?: boolean;
|
||||||
|
recent_zebra?: string; // '' = alternate (default), 'off' = every row alike
|
||||||
|
recent_zebra_color?: string; // '' = follow the theme
|
||||||
|
rules: RowColorRule[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// The banding the grid theme applies on its own. Repeated here because turning
|
||||||
|
// the option OFF means painting every row explicitly — the theme would
|
||||||
|
// otherwise keep striping underneath whatever we do per row.
|
||||||
|
const ZEBRA_DEFAULT = 'color-mix(in srgb, var(--muted) 40%, var(--card))';
|
||||||
|
|
||||||
|
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const;
|
||||||
|
|
||||||
|
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
|
||||||
|
// "upload status" rather than a QSL flag, but they carry the same Y / R letters.
|
||||||
|
const FIELDS: Record<string, { sent: string; rcvd: string }> = {
|
||||||
|
qsl: { sent: 'qsl_sent', rcvd: 'qsl_rcvd' },
|
||||||
|
lotw: { sent: 'lotw_sent', rcvd: 'lotw_rcvd' },
|
||||||
|
eqsl: { sent: 'eqsl_sent', rcvd: 'eqsl_rcvd' },
|
||||||
|
qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// HAMLOG.online has no column of its own: the ADIF standard names a field for
|
||||||
|
// hamlog.EU and none for hamlog.ONLINE, so its state lives in the extras — see
|
||||||
|
// internal/award/award.go, which reads the same keys for award confirmations.
|
||||||
|
// One vocabulary, two readers.
|
||||||
|
const HAMLOG_SENT = 'APP_OPSLOG_HAMLOG_SENT';
|
||||||
|
const HAMLOG_RCVD = ['APP_HAMLOG_QSO_CFM', 'APP_OPSLOG_HAMLOG_QSL', 'APP_HAMLOG_QSL', 'APP_HAMLOGONLINE_QSL', 'HAMLOG_QSL_RCVD'];
|
||||||
|
|
||||||
|
// hamlogState reads a row's extras. Any value that is not an explicit "no"
|
||||||
|
// counts, because their export could carry a date or a match id rather than Y.
|
||||||
|
function hamlogState(q: any, which: 'sent' | 'rcvd'): boolean {
|
||||||
|
const e = (q?.extras ?? {}) as Record<string, string>;
|
||||||
|
const keys = which === 'sent' ? [HAMLOG_SENT] : HAMLOG_RCVD;
|
||||||
|
return keys.some((k) => {
|
||||||
|
const v = String(e[k] ?? '').trim();
|
||||||
|
return v !== '' && v.toUpperCase() !== 'N' && v.toUpperCase() !== 'NO';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADIF QSL fields are single letters. Y is the only one that means "yes";
|
||||||
|
// R (requested) and Q (queued) mean it has not gone out yet — a different state,
|
||||||
|
// and the one an operator looks for when deciding what to send.
|
||||||
|
// Y or V — see lib/qsl. A LoTW-verified contact is confirmed, and colouring it
|
||||||
|
// as unconfirmed is the same bug the band/mode matrix had.
|
||||||
|
const yes = (v: any) => isQSLConfirmed(v);
|
||||||
|
const owed = (v: any) => {
|
||||||
|
const s = String(v ?? '').trim().toUpperCase();
|
||||||
|
return s === 'R' || s === 'Q';
|
||||||
|
};
|
||||||
|
|
||||||
|
const chansOf = (r: RowColorRule): readonly string[] =>
|
||||||
|
r.channels && r.channels.length ? r.channels : CHANNELS;
|
||||||
|
|
||||||
|
function ruleMatches(q: any, r: RowColorRule): boolean {
|
||||||
|
const cs = chansOf(r);
|
||||||
|
switch (r.id) {
|
||||||
|
case 'confirmed':
|
||||||
|
return cs.some((c) => (c === 'hamlog' ? hamlogState(q, 'rcvd') : yes(q[FIELDS[c]?.rcvd])));
|
||||||
|
case 'sent':
|
||||||
|
return cs.some((c) => (c === 'hamlog' ? hamlogState(q, 'sent') : yes(q[FIELDS[c]?.sent])));
|
||||||
|
case 'to_send':
|
||||||
|
// Nothing to request from HAMLOG: a QSO is either uploaded or it is not,
|
||||||
|
// there is no "card asked for" state to be owed.
|
||||||
|
return cs.some((c) => c !== 'hamlog' && owed(q[FIELDS[c]?.sent]));
|
||||||
|
case 'worked':
|
||||||
|
// The catch-all: nothing sent, nothing asked for, nothing back.
|
||||||
|
return !CHANNELS.some((c) => (c === 'hamlog'
|
||||||
|
? hamlogState(q, 'rcvd') || hamlogState(q, 'sent')
|
||||||
|
: yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent])));
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks the rules IN ORDER — the order is the priority, and the settings panel
|
||||||
|
// shows it numbered so it can be read rather than guessed.
|
||||||
|
export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule | null {
|
||||||
|
if (!q || !cfg?.rules) return null;
|
||||||
|
for (const r of cfg.rules) {
|
||||||
|
if (r.enabled && ruleMatches(q, r)) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The colour is never a fill by default.
|
||||||
|
//
|
||||||
|
// A log where nearly every contact has SOME QSL state ends up with every row
|
||||||
|
// painted, and colour that is always present stops being information. The
|
||||||
|
// default is a stripe down the left edge; a tint is offered at a chosen strength.
|
||||||
|
// rowIndex is optional: only the Recent QSOs grid bands its rows, and the other
|
||||||
|
// callers have nothing to say about odd and even.
|
||||||
|
export function rowStyleFor(q: any, cfg: RowColorSettings | null, rowIndex?: number): Record<string, string> | undefined {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
// Banding first, and INDEPENDENT of cfg.enabled: it is legibility, not QSL
|
||||||
|
// status, and an operator who wants plain rows should not have to turn the
|
||||||
|
// status colours off to get them.
|
||||||
|
if (typeof rowIndex === 'number') {
|
||||||
|
if (cfg?.recent_zebra === 'off') {
|
||||||
|
out.backgroundColor = 'var(--card)';
|
||||||
|
} else if (rowIndex % 2 === 1) {
|
||||||
|
out.backgroundColor = cfg?.recent_zebra_color || ZEBRA_DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const empty = () => (Object.keys(out).length ? out : undefined);
|
||||||
|
if (!cfg?.enabled) return empty();
|
||||||
|
const rule = matchRowRule(q, cfg);
|
||||||
|
if (!rule?.color) return empty();
|
||||||
|
|
||||||
|
const style = cfg.style ?? 'bar';
|
||||||
|
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
|
||||||
|
if (style === 'tint' || style === 'both') {
|
||||||
|
// Overrides the banding on purpose: the QSL colour is information, the
|
||||||
|
// banding is only there to help the eye keep its line.
|
||||||
|
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
|
||||||
|
}
|
||||||
|
if (style === 'bar' || style === 'both') {
|
||||||
|
// A background gradient, not a border and not a shadow.
|
||||||
|
//
|
||||||
|
// A border would shift the cells three pixels on coloured rows only, and
|
||||||
|
// the columns would stop lining up. An inset box-shadow was the first fix
|
||||||
|
// for that and drew the stripe correctly — but on the grid's transformed,
|
||||||
|
// absolutely-positioned rows it also bled a hairline of the same colour
|
||||||
|
// along the row edges, so every coloured row got a horizontal rule it was
|
||||||
|
// never meant to have. A gradient paints inside the box and nothing else:
|
||||||
|
// three pixels of colour, then transparent, and no edge to bleed from.
|
||||||
|
out.backgroundImage = `linear-gradient(to right, ${rule.color} 0, ${rule.color} 3px, transparent 3px)`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Two operator options that change how a spot LOOKS, shared by the DX-cluster
|
||||||
|
// list and the band map so the two panels can never disagree about the same
|
||||||
|
// spot — the lesson already learnt with the marker colours.
|
||||||
|
//
|
||||||
|
// muteWorked — a station already worked gets no colour and no badge. It
|
||||||
|
// stays in the list, it simply stops competing for attention.
|
||||||
|
// slotHighlight — a callsign not yet worked on THIS band and mode is coloured,
|
||||||
|
// whatever the entity says. For an operator filling slots, a
|
||||||
|
// common entity on a new band+mode is the whole point, and the
|
||||||
|
// entity-level status calls it "worked".
|
||||||
|
//
|
||||||
|
// They compose deliberately: mute what is done, light up what is not.
|
||||||
|
|
||||||
|
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
||||||
|
|
||||||
|
// Both options are withdrawn from the filter panel for now. The machinery below
|
||||||
|
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
||||||
|
// switches back is this one flag and the block they came from in App.tsx.
|
||||||
|
//
|
||||||
|
// The saved preferences are left untouched in localStorage rather than cleared:
|
||||||
|
// an operator who had either turned on gets them back exactly as they were the
|
||||||
|
// day the options return, instead of silently starting from off.
|
||||||
|
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
||||||
|
|
||||||
|
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
||||||
|
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) return { muteWorked: false, slotHighlight: false };
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||||
|
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { muteWorked: false, slotHighlight: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
status?: string;
|
||||||
|
worked_call?: boolean;
|
||||||
|
worked_slot?: boolean;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
new_grid?: boolean;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
// applySpotDisplay rewrites a status entry per the options, so every consumer —
|
||||||
|
// colour, badge, status text — follows from one decision instead of each panel
|
||||||
|
// re-deriving it.
|
||||||
|
export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions): T {
|
||||||
|
if (!s) return s;
|
||||||
|
let e = s;
|
||||||
|
|
||||||
|
// Slot promotion runs first: a callsign not yet worked on this band and mode
|
||||||
|
// is not done, whatever the entity says, so it earns a status before the mute
|
||||||
|
// below can take its colour away.
|
||||||
|
if (o.slotHighlight && e.worked_slot === false && (!e.status || e.status === 'worked')) {
|
||||||
|
e = { ...e, status: 'new-call' } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mute drops the blue already-worked-callsign mark, and NOTHING else.
|
||||||
|
//
|
||||||
|
// It used to blank the status as well, on the theory that a spot bringing no
|
||||||
|
// novelty should stop painting entirely. That was wrong twice over. The status
|
||||||
|
// is what the cluster list reads to DIM a row, so blanking it turned every
|
||||||
|
// quiet grey row bright white — the option made the list louder, not quieter.
|
||||||
|
// And an empty status means "entity not resolved" everywhere else, so muted
|
||||||
|
// spots had to carry a flag saying they did not really mean that.
|
||||||
|
//
|
||||||
|
// Leaving the status alone costs nothing: a worked entity already renders with
|
||||||
|
// no colour and gets dimmed, so removing the blue is the entire job.
|
||||||
|
if (o.muteWorked) {
|
||||||
|
e = { ...e, worked_call: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotIsWorked answers "does this spot bring me nothing?", which is what the
|
||||||
|
// "hide worked" filter is really asking.
|
||||||
|
//
|
||||||
|
// The extra markers are ORTHOGONAL to the entity status — a spot can be a worked
|
||||||
|
// entity AND a new park, prefix, county or grid — so they win. Without that, a
|
||||||
|
// new prefix in an entity worked years ago vanished the moment the filter went
|
||||||
|
// on, which is the opposite of what an operator hunting prefixes wants.
|
||||||
|
export function spotIsWorked(e: Entry): boolean {
|
||||||
|
if (!e) return false;
|
||||||
|
if (e.new_pota || e.new_county || e.new_pfx || e.new_grid) return false;
|
||||||
|
return !!e.worked_call || e.status === 'worked';
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// The "extra" spot markers — the ones ORTHOGONAL to the entity status.
|
||||||
|
//
|
||||||
|
// A spot's entity status (new DXCC / new band / new slot / worked) answers one
|
||||||
|
// question. These answer others that can be true at the same time: the park is
|
||||||
|
// new even if the entity is worked, the county is new even if the band is not.
|
||||||
|
// Both the DX-cluster list and the band map show them, and they have to agree —
|
||||||
|
// the same fact must not be green in one panel and violet in the next, so the
|
||||||
|
// colours live HERE and nowhere else. This is also the table a per-marker colour
|
||||||
|
// setting will drive, which is why it is a table rather than three constants.
|
||||||
|
//
|
||||||
|
// Colour choices:
|
||||||
|
// POTA green — parks; the association is worth keeping
|
||||||
|
// county violet — a chart hue, because every semantic token was already
|
||||||
|
// spoken for: red, orange and yellow are the entity
|
||||||
|
// statuses, blue is "callsign worked", green is POTA
|
||||||
|
// worked blue — matches the WKD-CALL badge the cluster list has always used
|
||||||
|
// prefix yellow — unchanged
|
||||||
|
// grid magenta — the last hue in the categorical set that is not already
|
||||||
|
// spoken for here and does not read as a status; a grid is
|
||||||
|
// never urgent the way a new entity is
|
||||||
|
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
|
||||||
|
|
||||||
|
export type SpotMarker = {
|
||||||
|
key: SpotMarkerKey;
|
||||||
|
colour: string; // a CSS colour — a var() reference, so it follows the theme
|
||||||
|
labelKey: string; // i18n key, short form (badge / legend)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order is the display order, in both panels.
|
||||||
|
export const SPOT_MARKERS: SpotMarker[] = [
|
||||||
|
{ key: 'new_pota', colour: 'var(--success)', labelKey: 'clg2.newPota' },
|
||||||
|
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
||||||
|
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
||||||
|
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
|
||||||
|
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const markerColour = (key: SpotMarkerKey): string =>
|
||||||
|
SPOT_MARKERS.find((m) => m.key === key)?.colour ?? 'var(--muted-foreground)';
|
||||||
|
|
||||||
|
// activeMarkers returns the markers set on a status entry, in display order.
|
||||||
|
export function activeMarkers(e: Record<string, unknown> | undefined): SpotMarker[] {
|
||||||
|
if (!e) return [];
|
||||||
|
return SPOT_MARKERS.filter((m) => !!e[m.key]);
|
||||||
|
}
|
||||||
@@ -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())}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Capitalisation helpers for the free-text QSO fields (Name / QTH / Comment /
|
||||||
|
// Note).
|
||||||
|
//
|
||||||
|
// The same station reaches the log SHOUTED by QRZ, lower-cased by a hurried
|
||||||
|
// operator, and in whatever case the other logger stored it in an imported
|
||||||
|
// ADIF — so one callsign ends up as "JEAN", "jean" and "Jean" across a log,
|
||||||
|
// and sorting or reading a QTH column becomes a mess.
|
||||||
|
//
|
||||||
|
// Both helpers are meant to run on BLUR, never per keystroke: rewriting the
|
||||||
|
// value while the operator is still typing a word fights them mid-word (see
|
||||||
|
// the controlled-input note in CLAUDE.md).
|
||||||
|
|
||||||
|
// titleCase upper-cases the first letter of every word and lower-cases the
|
||||||
|
// rest. Separators are kept, so "SAINT-JULIEN" → "Saint-Julien" and
|
||||||
|
// "o'brien" → "O'Brien".
|
||||||
|
export function titleCase(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/(^|[\s\-'’/.])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentenceCase upper-cases the first letter and leaves everything after it as
|
||||||
|
// typed. A comment routinely carries callsigns, modes and abbreviations
|
||||||
|
// ("TNX QSO F5ABC, FT8 59") that lower-casing would quietly destroy — which is
|
||||||
|
// why this is NOT titleCase.
|
||||||
|
export function sentenceCase(s: string): string {
|
||||||
|
return s.replace(/^(\s*)(\p{L})/u, (_m, sp: string, ch: string) => sp + ch.toUpperCase());
|
||||||
|
}
|
||||||
@@ -6,11 +6,16 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
|
|||||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||||
// travels with the data/ folder like the language).
|
// travels with the data/ folder like the language).
|
||||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
|
||||||
|
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
|
||||||
|
|
||||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
||||||
|
// then darks, with high-contrast last — it is an accessibility choice, not a
|
||||||
|
// taste one, and listing it among the moods buries it.
|
||||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||||
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
|
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
|
||||||
|
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
|
||||||
|
'high-contrast',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LS_KEY = 'opslog.theme';
|
export const LS_KEY = 'opslog.theme';
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.autofocusWB', // auto-focus Worked-before
|
'opslog.autofocusWB', // auto-focus Worked-before
|
||||||
'hamlog.filterPresets', // Filter Builder saved presets
|
'hamlog.filterPresets', // Filter Builder saved presets
|
||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
|
'opslog.rotorCompact', // rotor widget: dial + SP/LP only, no quick-turn buttons or Stop
|
||||||
|
'opslog.decodesGridMap',// FT decodes tab: grid-square map shown beside the table
|
||||||
|
'opslog.showChaseNew', // Chase New panel shown next to the keyers
|
||||||
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
||||||
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
@@ -38,7 +41,19 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
||||||
|
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
||||||
|
'opslog.mainQuad', // Main tab: the 2x2 cross, as {col,row} percentages
|
||||||
|
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
|
||||||
|
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||||
|
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||||
|
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||||
|
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
|
||||||
|
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
|
||||||
|
'opslog.clusterMacros', // cluster console: the twelve named command buttons
|
||||||
|
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
|
||||||
|
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
|
||||||
|
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
|
||||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
|||||||
+396
-3
@@ -85,6 +85,12 @@
|
|||||||
--mx-dx-conf: #3730a3; /* entity confirmed*/
|
--mx-dx-conf: #3730a3; /* entity confirmed*/
|
||||||
--mx-dx-work: #a5b4fc; /* entity worked */
|
--mx-dx-work: #a5b4fc; /* entity worked */
|
||||||
--mx-none: #e7e5e4; /* never worked */
|
--mx-none: #e7e5e4; /* never worked */
|
||||||
|
/* Ring on the cell the operator is entering. Declared ONCE, on :root, and
|
||||||
|
deliberately not repeated per theme: it follows --warning, which every theme
|
||||||
|
already tunes to its own background. The token exists so the matrix ring can
|
||||||
|
be recoloured on its own without dragging every other warning in the app
|
||||||
|
with it (Appearance → matrix colours). */
|
||||||
|
--mx-cur: var(--warning);
|
||||||
|
|
||||||
--scrollbar-thumb: #b8a880;
|
--scrollbar-thumb: #b8a880;
|
||||||
--scrollbar-thumb-hover: #968455;
|
--scrollbar-thumb-hover: #968455;
|
||||||
@@ -495,6 +501,362 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 5: Indigo — deep blue-violet, electric accent ----------------
|
||||||
|
The first seven themes are all neutral (beige, slate, graphite, black) with an
|
||||||
|
orange accent, so "another dark theme" only ever meant another grey. These
|
||||||
|
four carry a real hue in the SURFACES, not just in the accent, and each takes
|
||||||
|
a different primary — violet, cyan, pink, indigo — so they are told apart at a
|
||||||
|
glance in the picker.
|
||||||
|
|
||||||
|
Every one keeps the same contract: the semantic colours (success / warning /
|
||||||
|
caution / danger / info) must stay recognisable as themselves. A logger is
|
||||||
|
read for hours and "red = a problem" cannot become a decorative choice, so the
|
||||||
|
hue budget is spent on the surfaces and the primary, never on the meanings. */
|
||||||
|
[data-theme="dark-indigo"] {
|
||||||
|
--background: #0e0f1f;
|
||||||
|
--foreground: #e4e5f2;
|
||||||
|
--card: #181a30;
|
||||||
|
--card-foreground: #e4e5f2;
|
||||||
|
--popover: #181a30;
|
||||||
|
--popover-foreground: #e4e5f2;
|
||||||
|
--primary: #7c6cff; /* electric violet */
|
||||||
|
--primary-foreground: #0b0a1a;
|
||||||
|
--secondary: #222542;
|
||||||
|
--secondary-foreground: #e4e5f2;
|
||||||
|
--muted: #1e2038;
|
||||||
|
--muted-foreground: #9d9fbe;
|
||||||
|
--accent: #2a2d51;
|
||||||
|
--accent-foreground: #c7c9ee;
|
||||||
|
--destructive: #f2555a;
|
||||||
|
--destructive-foreground: #12030a;
|
||||||
|
--destructive-muted: #34131c;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #2a2d4c;
|
||||||
|
--input: #2a2d4c;
|
||||||
|
--ring: #9b8dff;
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #052018;
|
||||||
|
--success-muted: #0e2f27;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #165241;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #221803;
|
||||||
|
--warning-muted: #33280f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3e15;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #221e04;
|
||||||
|
--caution-muted: #322d0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4b4315;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #250912;
|
||||||
|
--danger-muted: #37151f;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #562533;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #051d29;
|
||||||
|
--info-muted: #0b2a3a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #12475c;
|
||||||
|
|
||||||
|
/* The entity ramp moves to CYAN here: violet is the primary, and a violet
|
||||||
|
"entity confirmed" cell would read as a button. */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2c7a52;
|
||||||
|
--mx-dx-conf: #22d3ee;
|
||||||
|
--mx-dx-work: #1b6b7c;
|
||||||
|
--mx-none: #2c2f4d;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #383c63;
|
||||||
|
--scrollbar-thumb-hover: #4c5182;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(228, 229, 242, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 6: Ocean — deep teal, cyan accent --------------------------- */
|
||||||
|
[data-theme="dark-teal"] {
|
||||||
|
--background: #061c21;
|
||||||
|
--foreground: #dcecee;
|
||||||
|
--card: #0d2c33;
|
||||||
|
--card-foreground: #dcecee;
|
||||||
|
--popover: #0d2c33;
|
||||||
|
--popover-foreground: #dcecee;
|
||||||
|
--primary: #22d3ee; /* cyan */
|
||||||
|
--primary-foreground: #041d24;
|
||||||
|
--secondary: #143b44;
|
||||||
|
--secondary-foreground: #dcecee;
|
||||||
|
--muted: #10333b;
|
||||||
|
--muted-foreground: #92b4ba;
|
||||||
|
--accent: #15424c;
|
||||||
|
--accent-foreground: #a5d8e0;
|
||||||
|
--destructive: #f2555a;
|
||||||
|
--destructive-foreground: #12030a;
|
||||||
|
--destructive-muted: #35161a;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #1a4652;
|
||||||
|
--input: #1a4652;
|
||||||
|
--ring: #5fe3f5;
|
||||||
|
|
||||||
|
--success: #4ade80;
|
||||||
|
--success-foreground: #04220f;
|
||||||
|
--success-muted: #0e3020;
|
||||||
|
--success-muted-foreground: #86efac;
|
||||||
|
--success-border: #17593a;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #221803;
|
||||||
|
--warning-muted: #33280f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3e15;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #221e04;
|
||||||
|
--caution-muted: #312d0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4a4315;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #250912;
|
||||||
|
--danger-muted: #38161f;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #572634;
|
||||||
|
|
||||||
|
/* Info moves to a BLUE: a cyan "info" would be the primary colour again. */
|
||||||
|
--info: #7aa2f7;
|
||||||
|
--info-foreground: #04122e;
|
||||||
|
--info-muted: #16233f;
|
||||||
|
--info-muted-foreground: #a9c2fb;
|
||||||
|
--info-border: #2b3d66;
|
||||||
|
|
||||||
|
--mx-call-conf: #4ade80;
|
||||||
|
--mx-call-work: #2b7a4d;
|
||||||
|
--mx-dx-conf: #a78bfa;
|
||||||
|
--mx-dx-work: #5b4a9c;
|
||||||
|
--mx-none: #1c4550;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #235a68;
|
||||||
|
--scrollbar-thumb-hover: #31788a;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(220, 236, 238, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 7: Plum — aubergine, magenta accent ------------------------- */
|
||||||
|
[data-theme="dark-plum"] {
|
||||||
|
--background: #180f1e;
|
||||||
|
--foreground: #eee0f0;
|
||||||
|
--card: #251830;
|
||||||
|
--card-foreground: #eee0f0;
|
||||||
|
--popover: #251830;
|
||||||
|
--popover-foreground: #eee0f0;
|
||||||
|
--primary: #f472b6; /* magenta-pink */
|
||||||
|
--primary-foreground: #250518;
|
||||||
|
--secondary: #33203f;
|
||||||
|
--secondary-foreground: #eee0f0;
|
||||||
|
--muted: #2b1b36;
|
||||||
|
--muted-foreground: #b39dbd;
|
||||||
|
--accent: #3b2648;
|
||||||
|
--accent-foreground: #dcc2e6;
|
||||||
|
--destructive: #f2555a;
|
||||||
|
--destructive-foreground: #12030a;
|
||||||
|
--destructive-muted: #3a1620;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #3a2547;
|
||||||
|
--input: #3a2547;
|
||||||
|
--ring: #f9a8d4;
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #052018;
|
||||||
|
--success-muted: #10302a;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #1a5745;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #221803;
|
||||||
|
--warning-muted: #352a12;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #524019;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #221e04;
|
||||||
|
--caution-muted: #342e12;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4e4519;
|
||||||
|
|
||||||
|
/* Danger goes RED here, not rose: a rose "danger" beside a pink primary is
|
||||||
|
indistinguishable, and this is the one colour that must never be missed. */
|
||||||
|
--danger: #f43f3f;
|
||||||
|
--danger-foreground: #2a0606;
|
||||||
|
--danger-muted: #3d1418;
|
||||||
|
--danger-muted-foreground: #fca5a5;
|
||||||
|
--danger-border: #5c2128;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #051d29;
|
||||||
|
--info-muted: #0f2b3a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #16485d;
|
||||||
|
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2f7d55;
|
||||||
|
--mx-dx-conf: #60a5fa;
|
||||||
|
--mx-dx-work: #35538c;
|
||||||
|
--mx-none: #3a2647;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #4a3159;
|
||||||
|
--scrollbar-thumb-hover: #644176;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(238, 224, 240, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 8: Nordic light — crisp cool white, indigo accent ----------- */
|
||||||
|
[data-theme="light-nordic"] {
|
||||||
|
--background: #eef1f7;
|
||||||
|
--foreground: #1b2130;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #1b2130;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #1b2130;
|
||||||
|
--primary: #4f46e5; /* indigo */
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #dfe4ee;
|
||||||
|
--secondary-foreground: #1b2130;
|
||||||
|
--muted: #e2e7f0;
|
||||||
|
--muted-foreground: #4a5468;
|
||||||
|
--accent: #dcdcfb;
|
||||||
|
--accent-foreground: #312a91;
|
||||||
|
--destructive: #b91c1c;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #c9d1e0;
|
||||||
|
--input: #c9d1e0;
|
||||||
|
--ring: #6366f1;
|
||||||
|
|
||||||
|
--success: #15803d;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #14532d;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #b45309;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #a16207;
|
||||||
|
--caution-foreground: #ffffff;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #fee2e2;
|
||||||
|
--danger-muted-foreground: #991b1b;
|
||||||
|
--danger-border: #fca5a5;
|
||||||
|
|
||||||
|
--info: #0369a1;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Entity ramp shifts to TEAL: indigo is the primary here. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #86efac;
|
||||||
|
--mx-dx-conf: #0f766e;
|
||||||
|
--mx-dx-work: #99f6e4;
|
||||||
|
--mx-none: #dde3ec;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b6c0d2;
|
||||||
|
--scrollbar-thumb-hover: #93a0b8;
|
||||||
|
--card-shadow: 0 1px 2px rgba(27, 33, 48, 0.06), 0 0 0 1px rgba(27, 33, 48, 0.03);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1e: Sahara — Warm light, taken a step deeper --------------------
|
||||||
|
Warm light's cream is kept for panels and the entry strip (#faf6eb, the one
|
||||||
|
surface an operator stares at while typing a call), but the page behind them
|
||||||
|
and every toolbar, tab bar and table header go one shade further into the
|
||||||
|
sand. That was the whole request: same family, just a little darker, so the
|
||||||
|
cream panels lift clearly instead of blending into the page.
|
||||||
|
Ink is a deep warm brown rather than black: pure black on sand glares.
|
||||||
|
The accent moves from Warm light's burnt orange to ochre to keep the two
|
||||||
|
themes apart at a glance in the theme picker. ---------------------------- */
|
||||||
|
[data-theme="sahara"] {
|
||||||
|
--background: #e6ded0; /* parchment — the page */
|
||||||
|
--foreground: #3a3227; /* warm brown-ink */
|
||||||
|
--card: #f3eee2; /* panels, entry strip, log rows */
|
||||||
|
--card-foreground: #3a3227;
|
||||||
|
--popover: #f3eee2;
|
||||||
|
--popover-foreground: #3a3227;
|
||||||
|
--primary: #c4501e; /* terracotta — buttons and the frequency */
|
||||||
|
--primary-foreground: #fdf8f0;
|
||||||
|
--secondary: #ded5c4;
|
||||||
|
--secondary-foreground: #3a3227;
|
||||||
|
--muted: #ded5c4; /* toolbars / tab bar / table headers */
|
||||||
|
--muted-foreground: #6a5f4e; /* readable on both parchments */
|
||||||
|
--accent: #ecd9c0; /* warm highlight */
|
||||||
|
--accent-foreground: #7a3410;
|
||||||
|
--destructive: #b3401b;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #f7e4da;
|
||||||
|
--destructive-muted-foreground: #8f3315;
|
||||||
|
--border: #cdc2ae; /* one step down, so edges read without lines */
|
||||||
|
--input: #cdc2ae;
|
||||||
|
--ring: #c4501e; /* the accent, so focus matches the buttons */
|
||||||
|
|
||||||
|
--success: #15803d;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcf3e1;
|
||||||
|
--success-muted-foreground: #14532d;
|
||||||
|
--success-border: #86d99f;
|
||||||
|
|
||||||
|
--warning: #b45309;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fbeacd;
|
||||||
|
--warning-muted-foreground: #7c3d05;
|
||||||
|
--warning-border: #e8b96a;
|
||||||
|
|
||||||
|
--caution: #a16207;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #f7edcb;
|
||||||
|
--caution-muted-foreground: #713f12;
|
||||||
|
--caution-border: #e0c069;
|
||||||
|
|
||||||
|
--danger: #be123c;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #fbe3e8;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #ee9aac;
|
||||||
|
|
||||||
|
--info: #0369a1;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0eef6;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #86c5e4;
|
||||||
|
|
||||||
|
/* Band/mode matrix — the app's blues kept, on a sand neutral so an empty slot
|
||||||
|
recedes into the grid instead of punching a hole in it. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #7fd6a4;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #ded5c4;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #c2b6a0;
|
||||||
|
--scrollbar-thumb-hover: #a5977e;
|
||||||
|
--card-shadow: 0 1px 2px rgba(58, 50, 39, 0.06), 0 0 0 1px rgba(58, 50, 39, 0.03);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||||
@@ -512,7 +874,9 @@
|
|||||||
:root,
|
:root,
|
||||||
[data-theme="light-warm"],
|
[data-theme="light-warm"],
|
||||||
[data-theme="light-cool"],
|
[data-theme="light-cool"],
|
||||||
[data-theme="light-sage"] {
|
[data-theme="light-sage"],
|
||||||
|
[data-theme="light-nordic"],
|
||||||
|
[data-theme="sahara"] {
|
||||||
--chart-1: #2a78d6; /* blue — default single-series hue */
|
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||||
--chart-2: #1baf7a; /* aqua */
|
--chart-2: #1baf7a; /* aqua */
|
||||||
--chart-3: #eda100; /* yellow */
|
--chart-3: #eda100; /* yellow */
|
||||||
@@ -533,7 +897,10 @@
|
|||||||
[data-theme="dim-slate"],
|
[data-theme="dim-slate"],
|
||||||
[data-theme="dark-warm"],
|
[data-theme="dark-warm"],
|
||||||
[data-theme="dark-graphite"],
|
[data-theme="dark-graphite"],
|
||||||
[data-theme="high-contrast"] {
|
[data-theme="high-contrast"],
|
||||||
|
[data-theme="dark-indigo"],
|
||||||
|
[data-theme="dark-teal"],
|
||||||
|
[data-theme="dark-plum"] {
|
||||||
--chart-1: #3987e5;
|
--chart-1: #3987e5;
|
||||||
--chart-2: #199e70;
|
--chart-2: #199e70;
|
||||||
--chart-3: #c98500;
|
--chart-3: #c98500;
|
||||||
@@ -613,6 +980,7 @@
|
|||||||
--color-mx-dx-conf: var(--mx-dx-conf);
|
--color-mx-dx-conf: var(--mx-dx-conf);
|
||||||
--color-mx-dx-work: var(--mx-dx-work);
|
--color-mx-dx-work: var(--mx-dx-work);
|
||||||
--color-mx-none: var(--mx-none);
|
--color-mx-none: var(--mx-none);
|
||||||
|
--color-mx-cur: var(--mx-cur);
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
@@ -623,6 +991,18 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
html, body, #root { height: 100%; }
|
html, body, #root { height: 100%; }
|
||||||
|
/* No rubber-band, and no swipe-to-navigate.
|
||||||
|
|
||||||
|
Chromium lets a scroll that has reached the end pull the content a few
|
||||||
|
pixels and spring back, and a two-finger horizontal swipe means "go back a
|
||||||
|
page". Both are browser behaviours that have no meaning in a logbook: the
|
||||||
|
first makes a table wobble at the bottom of a list, and the second can
|
||||||
|
navigate the WebView away from the app entirely — a blank window and a
|
||||||
|
restart. Neither shows up with a mouse, which is why they went unnoticed;
|
||||||
|
they arrive with a precision touchpad or a touchscreen.
|
||||||
|
|
||||||
|
A no-op wherever the effects don't occur. */
|
||||||
|
html, body { overscroll-behavior: none; }
|
||||||
/* Root rem base. Tailwind sizing (text/heights/padding/gaps) is in rem, so
|
/* Root rem base. Tailwind sizing (text/heights/padding/gaps) is in rem, so
|
||||||
this is the single global density knob — shrinks the whole UI uniformly
|
this is the single global density knob — shrinks the whole UI uniformly
|
||||||
while Leaflet maps (px-based) stay correct. Default browser base is 16px. */
|
while Leaflet maps (px-based) stay correct. Default browser base is 16px. */
|
||||||
@@ -681,8 +1061,21 @@
|
|||||||
help. */
|
help. */
|
||||||
Every temporal input carries the same glyph, so the rule names them all:
|
Every temporal input carries the same glyph, so the rule names them all:
|
||||||
datetime-local was left out at first and its icon stayed invisible. */
|
datetime-local was left out at first and its icon stayed invisible. */
|
||||||
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'])
|
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'],
|
||||||
|
[data-theme='dark-indigo'], [data-theme='dark-teal'], [data-theme='dark-plum'])
|
||||||
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
|
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
|
||||||
filter: invert(1) brightness(1.8);
|
filter: invert(1) brightness(1.8);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Every scrollable pane keeps its overscroll to itself.
|
||||||
|
|
||||||
|
"contain" (not "none") on the inside: the pane still scrolls normally, it
|
||||||
|
simply stops passing the leftover motion up to its parent — which is how
|
||||||
|
scrolling a grid to its end used to tug the panel behind it. */
|
||||||
|
.overflow-auto,
|
||||||
|
.overflow-y-auto,
|
||||||
|
.overflow-x-auto,
|
||||||
|
.overflow-scroll {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user