Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5388f76733 | ||
|
|
ceb88d2e29 | ||
|
|
ec3e60e47b | ||
|
|
5c6df90526 | ||
|
|
deeb654482 | ||
|
|
8e0865a71f | ||
|
|
d4d22eb4b2 | ||
|
|
592dd08835 | ||
|
|
c6294d9eb3 | ||
|
|
848ce68ec5 | ||
|
|
95e57fb812 | ||
|
|
4441b16699 | ||
|
|
5fd3b5b688 | ||
|
|
f582bbab3a | ||
|
|
b40dc8a597 | ||
|
|
14bb92fac0 | ||
|
|
9887b2aa78 | ||
|
|
2adf252942 | ||
|
|
660653ce14 | ||
|
|
f3b607b15a | ||
|
|
ae5aa7b547 | ||
|
|
f50bbc005c | ||
|
|
e8dfa0eaaf | ||
|
|
b53c56d508 | ||
|
|
67c85be23c | ||
|
|
e41a3a4c1c | ||
|
|
e4276ff66e | ||
|
|
1e87081058 | ||
|
|
12b0a861e0 | ||
|
|
712d83a012 | ||
|
|
bd540ef18b | ||
|
|
2f2fced15c | ||
|
|
97ae05d688 | ||
|
|
df1d2767cd | ||
|
|
ed099a660e | ||
|
|
6536d140ba | ||
|
|
6a6b7ad6c2 | ||
|
|
01a23ccb77 | ||
|
|
9b8168370f | ||
|
|
b2b93e2839 | ||
|
|
f9113b6ad3 | ||
|
|
e168ab4148 | ||
|
|
e4412955f0 | ||
|
|
78eb766789 | ||
|
|
d8064cc426 | ||
|
|
bd4f1b2325 | ||
|
|
b38b9030f0 | ||
|
|
efa711af78 | ||
|
|
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 | ||
|
|
f6cd6e999a | ||
|
|
296a4a55c0 | ||
|
|
acf73c73c7 | ||
|
|
5777c119cb | ||
|
|
85bf0da006 | ||
|
|
b6ea07e3a3 | ||
|
|
549adc8c0e | ||
|
|
0e1e7d9f3c | ||
|
|
3dd428d748 | ||
|
|
c62d992ad6 | ||
|
|
a83acb0f9a | ||
|
|
d8b7b86eb7 | ||
|
|
3e1c36d186 | ||
|
|
6255a9f218 | ||
|
|
599621a998 | ||
|
|
d7bcf21203 | ||
|
|
589e0b9b6a | ||
|
|
c21d6cc23a | ||
|
|
ae21ddb9d7 | ||
|
|
a4623e9ea3 | ||
|
|
06e34372ed | ||
|
|
d23c4c4037 | ||
|
|
d4bfd30636 | ||
|
|
258fa717f1 | ||
|
|
aa995c250c | ||
|
|
72fee7a090 | ||
|
|
ed67ed7fe3 | ||
|
|
fddb3c45c4 | ||
|
|
da1f3eb2bd | ||
|
|
5e0bb6e68e | ||
|
|
2b61f43780 | ||
|
|
2b66e5bc7f | ||
|
|
84b06ed47b | ||
|
|
39bd1ff414 | ||
|
|
6d54100bc9 | ||
|
|
4fe5811fd2 | ||
|
|
d42e6669e1 | ||
|
|
c14353a399 | ||
|
|
734931cf54 | ||
|
|
cad4ec4b72 | ||
|
|
5623b860c2 | ||
|
|
3e22c4d1a3 | ||
|
|
d90f953df4 | ||
|
|
e4217da010 | ||
|
|
c5ac945de0 | ||
|
|
eb40991482 | ||
|
|
781cf636ea | ||
|
|
62b6fe0a3a | ||
|
|
bd2a8524dc | ||
|
|
4afd7dda90 | ||
|
|
ed930667a1 | ||
|
|
311479c52f | ||
|
|
b25efabab8 | ||
|
|
a93f52d2b9 | ||
|
|
c9f7279a01 | ||
|
|
2560fced87 | ||
|
|
1718bf6f33 | ||
|
|
c4c5db3921 | ||
|
|
a1ceea978b | ||
|
|
f4956a63bb | ||
|
|
5a4ad800b3 | ||
|
|
e152ef0ee0 | ||
|
|
b6465ddc9d | ||
|
|
961357474d | ||
|
|
702ca4f0c9 | ||
|
|
93b4964b3b | ||
|
|
14c57035e1 | ||
|
|
7a7fa62af7 | ||
|
|
6ee4c430c7 | ||
|
|
0df6370c59 | ||
|
|
fa94657619 | ||
|
|
f5b74856c6 | ||
|
|
d42fdab85c | ||
|
|
83a8708d60 | ||
|
|
c0671bfe2e | ||
|
|
ec0cb95de9 | ||
|
|
9d691343cf | ||
|
|
c6088bb2de | ||
|
|
069728ac68 | ||
|
|
f221c0ee1e | ||
|
|
013492adb0 | ||
|
|
3a87b58ba3 | ||
|
|
e6b8a17772 | ||
|
|
802c6e8c43 | ||
|
|
f82631690a | ||
|
|
3b58e39eec | ||
|
|
45e3f3edb8 | ||
|
|
c931d8a762 | ||
|
|
eb271e8f20 | ||
|
|
e2d2485703 | ||
|
|
712a124081 | ||
|
|
08a932c9c1 | ||
|
|
fee2303625 | ||
|
|
4d5c5c3eb3 | ||
|
|
d322ea9a07 | ||
|
|
75c02ea2a0 | ||
|
|
ef84b04c99 | ||
|
|
fbd6cf400f | ||
|
|
b16cb973f2 | ||
|
|
da886de189 | ||
|
|
7759e09b4e | ||
|
|
a8990ff29d | ||
|
|
e3b810b4ff | ||
|
|
337392bb6d | ||
|
|
d98bb2e929 | ||
|
|
3ab6b95ff8 | ||
|
|
b0da6a3d14 | ||
|
|
2d7469a7f7 | ||
|
|
775f411606 | ||
|
|
6501e97895 | ||
|
|
f3807c21ba | ||
|
|
d536c39ef5 | ||
|
|
284a7a18c7 | ||
|
|
482f81fe45 | ||
|
|
5d7a9a9562 | ||
|
|
a036120871 | ||
|
|
ccceab9d02 | ||
|
|
35db1440e4 | ||
|
|
e3aabc06a4 | ||
|
|
ab9d0bfe0f | ||
|
|
a3fd32ec91 | ||
|
|
c297f91ca8 | ||
|
|
ee1f9ccf35 | ||
|
|
6fb7f06375 | ||
|
|
54caee37c4 | ||
|
|
15fdbce22a | ||
|
|
c7737aabd6 | ||
|
|
6f5acc2eef | ||
|
|
67a03c3ddc | ||
|
|
8da3a27803 | ||
|
|
e0cefb5c41 | ||
|
|
113faede14 | ||
|
|
8e491544dd | ||
|
|
cbe571a742 | ||
|
|
bdda7ad07a | ||
|
|
3fd6763ff0 | ||
|
|
a89c6e0df8 | ||
|
|
48d92ff547 | ||
|
|
adadb632fa | ||
|
|
b0b25c7f1f | ||
|
|
e120bd4f04 | ||
|
|
40df4fe22f | ||
|
|
d114a73729 | ||
|
|
d0666581c3 | ||
|
|
37dc2a07e5 | ||
|
|
7424bc6e81 | ||
|
|
01c93d979f | ||
|
|
0190255762 | ||
|
|
0cc806722e | ||
|
|
08f3e54afb | ||
|
|
520d17810c | ||
|
|
c9218310ae | ||
|
|
8cc6997eec | ||
|
|
6d309cada1 | ||
|
|
7153768579 | ||
|
|
df4155108f | ||
|
|
9bc5a14c69 | ||
|
|
842d4708a7 | ||
|
|
e2aba828a9 | ||
|
|
753d8d2ffa | ||
|
|
38b480a985 | ||
|
|
67005a8d50 | ||
|
|
9cfa7a4dd1 | ||
|
|
a1c4305f20 | ||
|
|
3b296b19ab | ||
|
|
8a0d76fa0c | ||
|
|
bd5f9c0746 | ||
|
|
29591c5f0c | ||
|
|
01bcf256e2 |
@@ -93,6 +93,8 @@ Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a
|
|||||||
|
|
||||||
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
|
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
|
||||||
|
|
||||||
|
**Append new entries at the BOTTOM of a version block**, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
|
||||||
|
|
||||||
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
|
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
|
||||||
|
|
||||||
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
|
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+241
-6
@@ -32,15 +32,33 @@ 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] }
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The voice keyer, through the radio's own link.
|
||||||
|
//
|
||||||
|
// Selecting the radio as the "To radio" output makes the voice keyer hand its
|
||||||
|
// messages to the CAT backend instead of a sound card. Everything around it is
|
||||||
|
// unchanged — the same PTT before and after, the same gain, the same files —
|
||||||
|
// which is the point: the audio takes a different road, not a different route.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tciTXPlayer hands one message to the radio.
|
||||||
|
//
|
||||||
|
// The controller is fetched on the CAT goroutine and the message is then played
|
||||||
|
// OFF it. Playing on it would hold that goroutine for the length of the
|
||||||
|
// message, and everything else about the rig — frequency, mode, PTT state —
|
||||||
|
// goes through the same place: a ten-second call would freeze the display and
|
||||||
|
// the antenna following for ten seconds. The TCI backend serialises its own
|
||||||
|
// writes, so this is safe to call from here.
|
||||||
|
func (a *App) tciTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT not initialized")
|
||||||
|
}
|
||||||
|
type txPlayer interface {
|
||||||
|
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
|
||||||
|
}
|
||||||
|
var player txPlayer
|
||||||
|
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||||
|
p, ok := t.(txPlayer)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
|
||||||
|
}
|
||||||
|
player = p
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// installTCITXPlayer offers the radio as an audio output, or withdraws it.
|
||||||
|
//
|
||||||
|
// Withdrawing matters as much as offering: a radio that has gone away must stop
|
||||||
|
// being a device the voice keyer will happily "play" to, or a message goes
|
||||||
|
// nowhere and the operator hears their own PTT click and assumes it worked.
|
||||||
|
func (a *App) installTCITXPlayer(on bool) {
|
||||||
|
if !on {
|
||||||
|
audio.SetNetworkPlayer(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audio.SetNetworkPlayer(a.tciTXPlayer)
|
||||||
|
applog.Printf("tci: the radio is available as an audio output — no virtual cable needed for the voice keyer")
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Feeding the QSO recorder from the TCI stream.
|
||||||
|
//
|
||||||
|
// The recorder works in 16 kHz mono, which is what its files and its mixing are
|
||||||
|
// built around; TCI delivers 48 kHz stereo float32. The conversion is the whole
|
||||||
|
// of this file, and it happens here rather than in internal/cat because the
|
||||||
|
// radio's job is to hand over what it sent, not to know what the recorder wants.
|
||||||
|
//
|
||||||
|
// Confirmed on a SunSDR (ExpertSDR3 1.5): 2048 samples a frame, 8192 bytes,
|
||||||
|
// four bytes per sample — and a test recording that plays back clean.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tciRecordSink pushes the receive stream into the QSO recorder.
|
||||||
|
//
|
||||||
|
// Installed whenever the TCI backend starts, and harmless when nothing is
|
||||||
|
// recording: PushRX drops what arrives unless a QSO is being captured, so the
|
||||||
|
// cost while idle is a decimation and a function call.
|
||||||
|
func (a *App) tciRecordSink(rate int, samples []float32) {
|
||||||
|
if a.qsoRec == nil || len(samples) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.qsoRec.PushRX(tciToRecorderPCM(rate, samples))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tciToRecorderPCM converts the stream's mono float samples to the recorder's
|
||||||
|
// 16-bit PCM at its own rate.
|
||||||
|
//
|
||||||
|
// Averaging rather than picking every third sample: dropping samples aliases
|
||||||
|
// everything above 8 kHz back down into the voice band, and on a receiver that
|
||||||
|
// is hiss — the one thing a QSO recording has plenty of. A three-tap mean is a
|
||||||
|
// crude low-pass, but it is a low-pass, and it costs two additions.
|
||||||
|
func tciToRecorderPCM(rate int, samples []float32) []byte {
|
||||||
|
if rate <= 0 {
|
||||||
|
rate = 48000
|
||||||
|
}
|
||||||
|
step := rate / audio.RecorderSampleRate
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, (len(samples)/step)*2)
|
||||||
|
for i := 0; i+step <= len(samples); i += step {
|
||||||
|
var sum float32
|
||||||
|
for j := 0; j < step; j++ {
|
||||||
|
sum += samples[i+j]
|
||||||
|
}
|
||||||
|
v := sum / float32(step)
|
||||||
|
if v > 1 {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
if v < -1 {
|
||||||
|
v = -1
|
||||||
|
}
|
||||||
|
var b [2]byte
|
||||||
|
binary.LittleEndian.PutUint16(b[:], uint16(int16(v*32767)))
|
||||||
|
out = append(out, b[0], b[1])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTCIRecording opens the receive stream and routes it to the recorder.
|
||||||
|
//
|
||||||
|
// Called when the TCI backend comes up, and only when the operator has asked
|
||||||
|
// for it by choosing the radio as their receive device: opening a 384 kB/s
|
||||||
|
// stream on a station that records nothing is work the radio does for nobody.
|
||||||
|
func (a *App) startTCIRecording() {
|
||||||
|
if a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// One switch, and it is the one an operator is already looking at: the
|
||||||
|
// "From radio" device. There used to be a tick box here as well, from when
|
||||||
|
// this was an experiment with no device to choose — two controls for one
|
||||||
|
// question, and the second was where nobody would look.
|
||||||
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
if cfg.FromRadio != audio.NetworkDeviceID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||||
|
if s, ok := t.(interface {
|
||||||
|
SetTCIAudioSink(func(int, []float32))
|
||||||
|
}); ok {
|
||||||
|
s.SetTCIAudioSink(a.tciRecordSink)
|
||||||
|
}
|
||||||
|
return t.StartTCIAudio(0, 48000)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("tci: could not open the receive stream for recording: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("tci: recording the receive audio over TCI — no virtual cable needed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The stream is 48 kHz and the recorder works at 16 — three to one. A
|
||||||
|
// recording that keeps every sample plays back three times too fast, which is
|
||||||
|
// the fault that gets blamed on the decoding rather than on the rate.
|
||||||
|
func TestTheStreamIsResampledToTheRecorderRate(t *testing.T) {
|
||||||
|
const in = 48000
|
||||||
|
samples := make([]float32, in/10) // a tenth of a second
|
||||||
|
pcm := tciToRecorderPCM(in, samples)
|
||||||
|
want := (audio.RecorderSampleRate / 10) * 2 // 16-bit
|
||||||
|
if len(pcm) != want {
|
||||||
|
t.Fatalf("a tenth of a second produced %d bytes, want %d", len(pcm), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full scale must arrive as full scale: a conversion that quietly halves the
|
||||||
|
// level turns a recording into evidence of a fault that is not there.
|
||||||
|
func TestFullScaleSurvivesTheConversion(t *testing.T) {
|
||||||
|
samples := make([]float32, 12)
|
||||||
|
for i := range samples {
|
||||||
|
samples[i] = 1
|
||||||
|
}
|
||||||
|
pcm := tciToRecorderPCM(48000, samples)
|
||||||
|
if len(pcm) < 2 {
|
||||||
|
t.Fatal("no samples came out")
|
||||||
|
}
|
||||||
|
v := int16(binary.LittleEndian.Uint16(pcm[:2]))
|
||||||
|
if v < 32000 {
|
||||||
|
t.Fatalf("full scale came out at %d", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rate the recorder already works in is passed through rather than mangled by
|
||||||
|
// a division that would round to nothing.
|
||||||
|
func TestAStreamAtTheRecorderRateIsNotDecimated(t *testing.T) {
|
||||||
|
samples := make([]float32, 160)
|
||||||
|
if got, want := len(tciToRecorderPCM(audio.RecorderSampleRate, samples)), 160*2; got != want {
|
||||||
|
t.Fatalf("%d bytes, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+1414
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Which class of power a mode falls into.
|
||||||
|
//
|
||||||
|
// This decides how much power leaves the amplifier, so an error here is not a
|
||||||
|
// display bug: a digital mode sorted as phone gets the SSB setting, which is
|
||||||
|
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
|
||||||
|
func TestFlexPowerClass(t *testing.T) {
|
||||||
|
for _, c := range []struct{ mode, want string }{
|
||||||
|
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
|
||||||
|
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
|
||||||
|
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
|
||||||
|
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
|
||||||
|
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
|
||||||
|
// Unknown or empty: no class, and the caller changes nothing. Setting a
|
||||||
|
// transmit power from a name we do not recognise is not a good guess.
|
||||||
|
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
|
||||||
|
} {
|
||||||
|
if got := flexPowerClass(c.mode); got != c.want {
|
||||||
|
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2957
-379
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>
|
||||||
|
|||||||
@@ -51,11 +51,14 @@ type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgx
|
|||||||
|
|
||||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||||
|
// One second: the same window as the docked widget, so the two never show
|
||||||
|
// different figures for the same amplifier, and neither trails the end of a
|
||||||
|
// transmission long enough to look like lag.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
const peakHold = (key: string, val: number) => {
|
const peakHold = (key: string, val: number) => {
|
||||||
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 > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
return p.v;
|
return p.v;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,9 +76,12 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||||
|
is off (no status = not "connected"), and serial only. */}
|
||||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!spe.connected}
|
||||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
@@ -168,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">
|
||||||
@@ -177,9 +222,6 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
{operate ? 'OPERATE' : 'STANDBY'}
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
|
||||||
</span>
|
|
||||||
{(pg.host || pg.connected) && (
|
{(pg.host || pg.connected) && (
|
||||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
@@ -201,13 +243,25 @@ 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
|
||||||
|
// 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
|
||||||
|
// the last-known status after the amp disconnects — so it claimed
|
||||||
|
// 1350 W from an old transmission while the radio was putting out 10.
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
{amps.map((m) => {
|
{amps.map((m) => {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { Flame, X, Power } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
|
||||||
|
// (which is the full-size card shown in the FlexRadio panel and Station
|
||||||
|
// Control). It covers all three families, since an operator running an SPE and
|
||||||
|
// a PowerGenius wants one widget, not two:
|
||||||
|
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
|
||||||
|
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
|
||||||
|
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
|
||||||
|
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||||
|
// amp id) chosen from the toolbar icon's dropdown.
|
||||||
|
|
||||||
|
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||||
|
|
||||||
|
// Full-scale watts for the output bar, from the model string.
|
||||||
|
function maxW(model?: string, fallback = 1300): number {
|
||||||
|
const m = (model || '').toUpperCase();
|
||||||
|
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||||
|
if (m.includes('15K')) return 1500;
|
||||||
|
if (m.includes('13K')) return 1300;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swrColour(swr?: number): string {
|
||||||
|
if (!swr || swr <= 0) return 'text-muted-foreground';
|
||||||
|
if (swr <= 1.5) return 'text-success';
|
||||||
|
if (swr <= 2.0) return 'text-warning';
|
||||||
|
return 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerMeter — the output-power LED bar that closes each column, straight under
|
||||||
|
// the controls (no filler above it: pushed to the bottom it left a band of dead
|
||||||
|
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
|
||||||
|
// so it reads exactly like the meters on the rig panels.
|
||||||
|
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
|
||||||
|
return (
|
||||||
|
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
|
||||||
|
display={`${Math.round(watts)} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// usePeakHold keeps the highest reading seen for a short window, so a reading
|
||||||
|
// sampled between two syllables doesn't collapse the meter. `live` is the key
|
||||||
|
// part: pass false the moment the carrier drops and the held value is thrown
|
||||||
|
// away at once — holding it there made releasing the PTT look like a five
|
||||||
|
// second lag, which read as the whole app being slow.
|
||||||
|
function usePeakHold(windowMs = 1000) {
|
||||||
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
|
return (key: string, val: number, live = true) => {
|
||||||
|
if (!live) { delete peak.current[key]; return val; }
|
||||||
|
const now = Date.now();
|
||||||
|
const p = peak.current[key];
|
||||||
|
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
|
return p.v;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat — one small labelled figure (power, SWR, temperature).
|
||||||
|
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
|
||||||
|
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
|
||||||
|
// three families (only where the state comes from differs).
|
||||||
|
function OperateButton({ operate, disabled, onClick, t }: {
|
||||||
|
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button type="button" disabled={disabled} onClick={onClick}
|
||||||
|
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
|
||||||
|
operate
|
||||||
|
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
|
||||||
|
<Power className="size-3.5" />
|
||||||
|
{operate ? t('ampw.operate') : t('ampw.standby')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmpBlock — one amplifier inside the widget.
|
||||||
|
function AmpBlock({ amp, flex, showName, t }: {
|
||||||
|
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const spe = amp.spe, acom = amp.acom;
|
||||||
|
const hold = usePeakHold();
|
||||||
|
|
||||||
|
if (spe || acom) {
|
||||||
|
const s = spe || acom;
|
||||||
|
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||||
|
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||||
|
// gets there first (the amp is polled on its own slower cycle).
|
||||||
|
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||||
|
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||||
|
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||||
|
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
||||||
|
// Power ON drives the remote-on control lines, so it stays available while
|
||||||
|
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||||
|
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
|
{showName && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||||
|
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
|
||||||
|
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button type="button" disabled={!canPowerOn}
|
||||||
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
|
||||||
|
{t('ampw.on')}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!s.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
|
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
|
||||||
|
{t('ampw.off')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
|
||||||
|
{spe && (
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{(['L', 'M', 'H'] as const).map((lvl) => {
|
||||||
|
const active = (s.power_level || '').trim().toUpperCase() === lvl;
|
||||||
|
return (
|
||||||
|
<button key={lvl} type="button" disabled={!s.connected}
|
||||||
|
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||||
|
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
|
||||||
|
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{t(`ampw.lvl${lvl}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.connected ? (
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
|
||||||
|
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
|
||||||
|
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||||
|
)}
|
||||||
|
{(s.warnings || s.alarms || s.err_text) && (
|
||||||
|
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||||
|
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
|
||||||
|
// reports the amp; the fan mode rides on our own GSCP link.
|
||||||
|
const pg = amp.pgxl || {};
|
||||||
|
const viaFlex = !!flex?.amp_available;
|
||||||
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||||
|
const connected = !!pg.connected || viaFlex;
|
||||||
|
const fault = flex?.amp_fault;
|
||||||
|
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
const fwd = meter(/^fwd|pwr/i);
|
||||||
|
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
|
||||||
|
// Whether there is power is the RADIO's transmit flag: it flips the instant
|
||||||
|
// the PTT does, where the amplifier's own status poll answers a second and a
|
||||||
|
// half late and holds TRANSMIT past the carrier.
|
||||||
|
//
|
||||||
|
// How MUCH is the live reading — the radio's meter stream, or the amp's own
|
||||||
|
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
|
||||||
|
// latched maximum which is never reset AND survives in the last-known status
|
||||||
|
// after the amp disconnects, so it reported 1350 W from an old transmission
|
||||||
|
// while the radio was putting out 10 W into a disconnected amplifier.
|
||||||
|
const txing = typeof flex?.transmitting === 'boolean'
|
||||||
|
? flex.transmitting
|
||||||
|
: 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 fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||||
|
const id = meter(/^ID$|current/i);
|
||||||
|
// 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 temp = meter(/temp/i);
|
||||||
|
// 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 (
|
||||||
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
|
{showName && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<OperateButton operate={operate} disabled={!connected} t={t}
|
||||||
|
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
|
||||||
|
{connected ? (
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
|
||||||
|
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
|
||||||
|
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||||
|
)}
|
||||||
|
{(pg.host || pg.connected) && (
|
||||||
|
<select
|
||||||
|
disabled={!pg.connected}
|
||||||
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
|
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||||
|
title={t('ampw.fan')}
|
||||||
|
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
|
||||||
|
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||||
|
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||||
|
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{fault && fault !== 'NONE' && (
|
||||||
|
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||||
|
{t('flxp.fault')}: {fault}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpWidget({ amps, flex, sel, onClose }: {
|
||||||
|
amps: Amp[];
|
||||||
|
flex: any;
|
||||||
|
sel: string; // "all" or an amp id
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
// An id that no longer matches (the amp was removed in Settings) falls back to
|
||||||
|
// showing everything rather than an empty widget.
|
||||||
|
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
|
||||||
|
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
|
||||||
|
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
|
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
|
||||||
|
made a widget taller than the dock row and both ended up half hidden. */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
|
||||||
|
{shown.map((a, i) => (
|
||||||
|
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
|
||||||
|
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -33,11 +33,14 @@ export type AwardDef = {
|
|||||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||||
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; 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">
|
||||||
@@ -227,7 +242,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
};
|
};
|
||||||
type Explanation = {
|
type Explanation = {
|
||||||
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||||
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
ref_count: number; steps: Step[]; manual?: string[]; ambiguous?: string[]; result: string[];
|
||||||
};
|
};
|
||||||
type TestRow = { qso: any; explanation: Explanation };
|
type TestRow = { qso: any; explanation: Explanation };
|
||||||
const [testCall, setTestCall] = useState('');
|
const [testCall, setTestCall] = useState('');
|
||||||
@@ -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) => (
|
||||||
@@ -554,16 +606,25 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Field2>
|
</Field2>
|
||||||
<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>
|
||||||
<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>
|
||||||
@@ -601,6 +662,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -620,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);
|
||||||
@@ -708,6 +794,13 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{(ex.ambiguous ?? []).length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
|
||||||
|
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(ex.manual ?? []).length > 0 && (
|
{(ex.manual ?? []).length > 0 && (
|
||||||
<div className="px-3 py-2 text-xs">
|
<div className="px-3 py-2 text-xs">
|
||||||
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||||
@@ -725,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}
|
||||||
/>
|
/>
|
||||||
@@ -744,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>
|
||||||
@@ -838,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);
|
||||||
@@ -883,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;
|
||||||
@@ -896,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;
|
||||||
@@ -977,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>
|
||||||
@@ -995,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,19 +1,24 @@
|
|||||||
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';
|
||||||
|
|
||||||
type BandCount = { band: string; worked: number; confirmed: number };
|
type BandCount = { band: string; worked: number; confirmed: number };
|
||||||
type AwardRef = {
|
type AwardRef = {
|
||||||
ref: string; name?: string; group?: string; subgrp?: string;
|
ref: string; name?: string; group?: string; subgrp?: string;
|
||||||
worked: boolean; confirmed: boolean; validated: boolean;
|
worked: boolean; confirmed: boolean; validated: boolean;
|
||||||
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
|
||||||
|
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
|
||||||
|
modes?: string[]; confirmed_modes?: string[];
|
||||||
};
|
};
|
||||||
type AwardResult = {
|
type AwardResult = {
|
||||||
code: string; name: string; dimension: string;
|
code: string; name: string; dimension: string;
|
||||||
@@ -59,13 +64,22 @@ 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
|
||||||
// first time it's selected (or when explicitly rescanned).
|
// first time it's selected (or when explicitly rescanned).
|
||||||
|
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
|
||||||
|
// are different results, and caching them under one key showed the previous
|
||||||
|
// mode’s numbers after switching.
|
||||||
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -74,6 +88,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||||
|
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||||
|
// confirmed" is two questions at once, and answering only one of them is what
|
||||||
|
// sends an operator to a spreadsheet.
|
||||||
|
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
|
||||||
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
|
||||||
const [showMissing, setShowMissing] = useState(false);
|
const [showMissing, setShowMissing] = useState(false);
|
||||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||||
@@ -112,13 +130,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||||
async function compute(code: string, force = false) {
|
async function compute(code: string, force = false) {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
if (!force && byCode[code]) { setSelected(code); return; }
|
const key = `${code}|${modeFilter}`;
|
||||||
|
if (!force && byCode[key]) { setSelected(code); return; }
|
||||||
setSelected(code);
|
setSelected(code);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
const r = (await GetAward(code)) as any as AwardResult;
|
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
|
||||||
setByCode((m) => ({ ...m, [code]: r }));
|
setByCode((m) => ({ ...m, [key]: r }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -126,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);
|
||||||
@@ -141,8 +167,17 @@ 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];
|
const current = byCode[`${selected}|${modeFilter}`];
|
||||||
|
// Recompute when the mode class changes: the bands, counts and confirmations
|
||||||
|
// all describe the selected mode, so they cannot be filtered client-side.
|
||||||
|
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
||||||
|
|
||||||
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||||
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||||
@@ -191,17 +226,81 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
return idx.length ? idx : all;
|
return idx.length ? idx : all;
|
||||||
}, [stats, awardBands]);
|
}, [stats, awardBands]);
|
||||||
|
|
||||||
|
// Sort order for the reference table. Reference is the default because that
|
||||||
|
// is how award lists are published and how an operator reads a paper list;
|
||||||
|
// description answers the other question — "have I worked anything in
|
||||||
|
// Alicante" — which reference order scatters across the whole table.
|
||||||
|
// Remembered through writeUiPref rather than localStorage, so the choice
|
||||||
|
// travels with a copied data/ folder like every other UI preference here.
|
||||||
|
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
|
||||||
|
() => {
|
||||||
|
const v = localStorage.getItem('opslog.awardRefSort');
|
||||||
|
return v === 'name' || v === 'group' ? v : 'ref';
|
||||||
|
});
|
||||||
|
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
|
||||||
|
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
|
||||||
|
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
|
||||||
|
if (k === refSort) {
|
||||||
|
const d = refSortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
setRefSortDir(d);
|
||||||
|
writeUiPref('opslog.awardRefSortDir', d);
|
||||||
|
} else {
|
||||||
|
setRefSort(k);
|
||||||
|
setRefSortDir('asc');
|
||||||
|
writeUiPref('opslog.awardRefSort', k);
|
||||||
|
writeUiPref('opslog.awardRefSortDir', 'asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredRefs = useMemo(() => {
|
const filteredRefs = useMemo(() => {
|
||||||
if (!current) return [];
|
if (!current) return [];
|
||||||
const q = refSearch.trim().toUpperCase();
|
const q = refSearch.trim().toUpperCase();
|
||||||
return (current.refs ?? []).filter((r) => {
|
const rows = (current.refs ?? []).filter((r) => {
|
||||||
if (refFilter === 'worked' && !r.worked) return false;
|
if (refFilter === 'worked' && !r.worked) return false;
|
||||||
if (refFilter === 'notworked' && r.worked) return false;
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
|
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||||
|
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||||
|
// a contradiction: the mode filter stands aside rather than emptying
|
||||||
|
// the list.
|
||||||
|
if (!(r.modes ?? []).includes(modeFilter)) return false;
|
||||||
|
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
|
||||||
|
// on CW and confirmed on SSB is still a CW entity to chase, and the
|
||||||
|
// whole point of the filter is to find those.
|
||||||
|
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
|
||||||
|
}
|
||||||
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [current, refSearch, refFilter]);
|
const dir = refSortDir === 'asc' ? 1 : -1;
|
||||||
|
return rows.sort((a, b) => {
|
||||||
|
if (refSort === 'name') {
|
||||||
|
// localeCompare, not a byte comparison: these lists are Spanish, French
|
||||||
|
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
|
||||||
|
// than after "Zaragoza".
|
||||||
|
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
|
||||||
|
if (c !== 0) return c * dir;
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}, [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">
|
||||||
@@ -240,7 +339,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||||
{awardList.map((a) => {
|
{awardList.map((a) => {
|
||||||
const r = byCode[a.code];
|
const r = byCode[`${a.code}|${modeFilter}`];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={a.code}
|
key={a.code}
|
||||||
@@ -325,7 +424,22 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||||
|
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
|
||||||
|
<button key={k} onClick={() => setModeFilter(k)}
|
||||||
|
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
{/* Only for an award scoped to a DXCC entity. "In this award's
|
||||||
|
scope but with no reference" needs a scope to be in: on a
|
||||||
|
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||||
|
contact anywhere would qualify, so the answer is always none.
|
||||||
|
The button used to be there regardless and could only ever
|
||||||
|
open a window saying it had found nothing. */}
|
||||||
|
{awardList.find((a) => a.code === selected)?.scoped && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
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"
|
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"
|
||||||
@@ -333,6 +447,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
>
|
>
|
||||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||||
</button>
|
</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">
|
||||||
@@ -386,8 +501,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-card">
|
<tr className="bg-card">
|
||||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
|
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
|
||||||
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
|
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.ref')}
|
||||||
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</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">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.description')}
|
||||||
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
{gridBands.map((b) => (
|
{gridBands.map((b) => (
|
||||||
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
|
||||||
))}
|
))}
|
||||||
@@ -397,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 (
|
||||||
@@ -424,9 +558,24 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
|
<th className="py-1.5 pr-2 font-medium w-24">
|
||||||
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
|
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
|
{t('awp.ref')}
|
||||||
|
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium">
|
||||||
|
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
|
||||||
|
{t('awp.name')}
|
||||||
|
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
@@ -459,7 +608,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cell && current && (
|
{cell && current && (
|
||||||
<CellQSOModal code={current.code} cell={cell} 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} />
|
||||||
@@ -473,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[]>([]);
|
||||||
@@ -481,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('');
|
||||||
@@ -494,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(() => {
|
||||||
@@ -576,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}>
|
||||||
@@ -644,17 +839,24 @@ 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, onClose }: { code: string; cell: { ref: string; band: string; name?: 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);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
AwardCellQSOs(code, cell.ref, cell.band)
|
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
|
||||||
.then((r) => setQsos((r ?? []) as any))
|
.then((r) => setQsos((r ?? []) as any))
|
||||||
.catch(() => setQsos([]))
|
.catch(() => setQsos([]))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [code, cell.ref, cell.band]);
|
}, [code, cell.ref, cell.band, modeClass]);
|
||||||
|
|
||||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||||
|
|
||||||
@@ -681,10 +883,18 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
|
|||||||
{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>
|
||||||
|
{/* A readout, not a switch.
|
||||||
|
|
||||||
|
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>
|
<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={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
)}
|
||||||
|
<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 },
|
||||||
@@ -82,6 +90,7 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
// Contacted station (location / activation refs / SIG)
|
// Contacted station (location / activation refs / SIG)
|
||||||
|
{ id: 'grid', label: 'bulk.fGrid', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
@@ -90,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.
|
||||||
@@ -109,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',
|
||||||
@@ -118,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 = {
|
||||||
@@ -176,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(() => {
|
||||||
|
if (refreshPending.current !== undefined) return; // a refresh is already queued
|
||||||
|
refreshPending.current = window.setTimeout(() => {
|
||||||
|
refreshPending.current = undefined;
|
||||||
gridRef.current?.api?.refreshCells({ force: true });
|
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
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
|
||||||
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 { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -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.
|
||||||
@@ -53,6 +55,14 @@ interface Props {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||||
remoteGrid: string; // entry-strip Grid value — destination
|
remoteGrid: string; // entry-strip Grid value — destination
|
||||||
|
// Entry-strip text fields. Live award detection matches on these too — a DARC
|
||||||
|
// DOK keys off QTH, not the address — and sending only the address meant such
|
||||||
|
// an award stayed silent until the QSO was logged.
|
||||||
|
qth?: string;
|
||||||
|
name?: string;
|
||||||
|
country?: string;
|
||||||
|
comment?: string;
|
||||||
|
note?: string;
|
||||||
details: DetailsState;
|
details: DetailsState;
|
||||||
onChange: (patch: Partial<DetailsState>) => void;
|
onChange: (patch: Partial<DetailsState>) => void;
|
||||||
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
|
||||||
@@ -61,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).
|
||||||
@@ -69,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';
|
||||||
@@ -113,28 +137,57 @@ function numOrUndef(v: string): number | undefined {
|
|||||||
return isNaN(n) ? undefined : n;
|
return isNaN(n) ? undefined : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
|
||||||
|
// for literal class names, so an interpolated one is never emitted.
|
||||||
|
const SPAN_CLASS: Record<number, string> = {
|
||||||
|
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
|
||||||
|
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
|
||||||
|
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
||||||
|
};
|
||||||
|
|
||||||
// Compact field helper to keep the JSX dense.
|
// Compact field helper to keep the JSX dense.
|
||||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
|
||||||
<Label className="mb-1">{label}</Label>
|
<Label className="mb-1">{label}</Label>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, 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
|
||||||
|
|
||||||
// Live award detection: run the SAME engine used at log time over the current
|
// Live award detection: run the SAME engine used at log time over the current
|
||||||
// contact (callsign + looked-up address/state/zones) so award references the
|
// contact — callsign, the looked-up address/state/zones AND the entry-strip
|
||||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
// text (QTH, name, country, comment, note, grid). Sending only the address was
|
||||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
|
||||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
// while the award editor's Test tab, which passes the whole QSO, matched it.
|
||||||
|
// References the QSO will earn — WAPC matching "Beijing" inside the address,
|
||||||
|
// a DOK matching the town — are surfaced and auto-added the moment you enter a
|
||||||
|
// call or click a spot, instead of only appearing after logging. Pickable
|
||||||
|
// matches are merged into award_refs
|
||||||
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
|
||||||
|
|
||||||
|
// NEW badge on the county: the same question the cluster's NEW CTY badge
|
||||||
|
// answers, for the station being worked right now. Debounced because the
|
||||||
|
// county field is typed into as well as filled by the lookup.
|
||||||
|
const [newCounty, setNewCounty] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const cnty = (details.cnty ?? '').trim();
|
||||||
|
if (!cnty) { setNewCounty(false); return; }
|
||||||
|
let alive = true;
|
||||||
|
const id = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||||
|
if (alive) setNewCounty(!!isNew);
|
||||||
|
} catch { if (alive) setNewCounty(false); }
|
||||||
|
}, 300);
|
||||||
|
return () => { alive = false; window.clearTimeout(id); };
|
||||||
|
}, [details.state, details.cnty]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||||
const t = window.setTimeout(async () => {
|
const t = window.setTimeout(async () => {
|
||||||
@@ -142,6 +195,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
const q: any = {
|
const q: any = {
|
||||||
callsign, band, mode,
|
callsign, band, mode,
|
||||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||||
|
qth: qth ?? '', name: name ?? '', country: country ?? '',
|
||||||
|
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
|
||||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||||
qso_date: new Date().toISOString(),
|
qso_date: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -151,7 +206,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
const have = new Set(cur.split(';').filter(Boolean));
|
const have = new Set(cur.split(';').filter(Boolean));
|
||||||
let next = cur;
|
let next = cur;
|
||||||
for (const r of all) {
|
for (const r of all) {
|
||||||
if (!r.pickable) continue;
|
// An ambiguous candidate is offered, never auto-added: adding one would
|
||||||
|
// be the guess the award's "one reference per QSO" setting exists to refuse.
|
||||||
|
if (!r.pickable || r.ambiguous) continue;
|
||||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||||
}
|
}
|
||||||
@@ -160,7 +217,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
}, 400);
|
}, 400);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
|
||||||
// Bearing/distance from operator's home grid to the remote station.
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
// Recomputed only when either grid actually changes.
|
// Recomputed only when either grid actually changes.
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
@@ -174,6 +231,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
}
|
}
|
||||||
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`;
|
||||||
|
|
||||||
@@ -222,19 +288,40 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open === 'info' && (
|
{open === 'info' && (
|
||||||
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
|
||||||
<Field label={t('detp.statePref')}>
|
{/* Twelve columns, not six: the fields hold very different things. A US
|
||||||
|
county name is long, a CQ zone is two digits — on an even six-column
|
||||||
|
grid they were the same width, so the county truncated while the
|
||||||
|
zones sat mostly empty. */}
|
||||||
|
<Field label={t('detp.statePref')} span={2}>
|
||||||
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.county')}>
|
<Field label={t('detp.county')} span={4} className="relative">
|
||||||
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
|
||||||
|
{newCounty && (
|
||||||
|
<span
|
||||||
|
title={t('detp.newCountyTip')}
|
||||||
|
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
|
||||||
|
>
|
||||||
|
{t('detp.newCounty')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.prefix')}>
|
<Field label={t('detp.prefix')} span={2}>
|
||||||
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.cqZone')}>
|
<Field label={t('detp.cqZone')}>
|
||||||
@@ -248,7 +335,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
{/* DXCC # closes the top row (next to the zones); Continent and
|
{/* DXCC # closes the top row (next to the zones); Continent and
|
||||||
Azimuth SP live in the main entry strip / bandeau. The long-path
|
Azimuth SP live in the main entry strip / bandeau. The long-path
|
||||||
bearing and distances move to the row below. */}
|
bearing and distances move to the row below. */}
|
||||||
<Field label={t('detp.dxcc')}>
|
<Field label={t('detp.dxcc')} span={2}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -257,7 +344,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.azimuthLp')}>
|
{/* The bearing and the two distances are read-only and hold at most
|
||||||
|
"12345 km", so they take a fixed width and the address — the one
|
||||||
|
field here that is never wide enough — gets everything left over. */}
|
||||||
|
<div className="col-span-12 flex gap-2">
|
||||||
|
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -266,7 +357,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.distanceSp')}>
|
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -275,7 +366,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.distanceLp')}>
|
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -284,15 +375,20 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.address')} span={3}>
|
<Field label={t('detp.address')} className="flex-1 min-w-0">
|
||||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('detp.qslMessage')} span={3}>
|
</div>
|
||||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
{/* QSL via gets the room, not the message. Width should follow use, and
|
||||||
</Field>
|
these two are nowhere near equal: a manager's callsign is filled in
|
||||||
<Field label={t('detp.qslVia')} span={2}>
|
constantly and a QSL message almost never. The message had 7 columns
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -305,10 +401,37 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||||
heightClass="flex-1 min-h-0"
|
heightClass="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
{detected.length > 0 && (
|
{/* Ambiguous candidates: the award allows one reference per QSO and
|
||||||
|
several matched, so none was kept. Click one to settle it while the
|
||||||
|
contact is still in front of you. */}
|
||||||
|
{detected.some((r) => r.ambiguous) && (
|
||||||
|
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
|
||||||
|
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
|
||||||
|
{detected.filter((r) => r.ambiguous).map((r) => (
|
||||||
|
<button
|
||||||
|
key={`amb-${r.code}@${r.ref}`}
|
||||||
|
type="button"
|
||||||
|
title={r.name ?? ''}
|
||||||
|
onClick={() => {
|
||||||
|
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
|
||||||
|
const cur = details.award_refs ?? '';
|
||||||
|
// Only one of the candidates can be right — replace any
|
||||||
|
// sibling already picked for this award rather than stacking.
|
||||||
|
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
|
||||||
|
onChange({ award_refs: [...kept, entry].join(';') });
|
||||||
|
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
|
||||||
|
}}
|
||||||
|
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
|
||||||
|
>
|
||||||
|
{r.code}@{r.ref}{r.name ? ` — ${r.name}` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detected.some((r) => !r.ambiguous) && (
|
||||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||||
{detected.map((r) => (
|
{detected.filter((r) => !r.ambiguous).map((r) => (
|
||||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||||
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -352,18 +475,36 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -381,8 +522,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LogUIError } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// A render error used to empty the window and leave nothing behind: no line in
|
||||||
|
// opslog.log, no dialog, no way for the operator to say more than "white screen".
|
||||||
|
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
|
||||||
|
// auto-call — stayed undiagnosed for weeks for exactly that reason.
|
||||||
|
//
|
||||||
|
// So this does two things, in order of importance:
|
||||||
|
//
|
||||||
|
// 1. Writes the error and its stack to the app log through LogUIError, so the
|
||||||
|
// next report arrives WITH the cause instead of a description of a blank
|
||||||
|
// screen.
|
||||||
|
// 2. Shows it, with the text selectable and a button to copy it, because the
|
||||||
|
// person who can act on it is the one looking at the screen.
|
||||||
|
//
|
||||||
|
// It deliberately does NOT try to recover the tree. React cannot guarantee the
|
||||||
|
// state is sane after a render throw, and a half-working logger is worse than
|
||||||
|
// one that says plainly what happened and offers a reload.
|
||||||
|
|
||||||
|
type Props = { children: React.ReactNode };
|
||||||
|
type State = { error: Error | null; info: string };
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<Props, State> {
|
||||||
|
state: State = { error: null, info: '' };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
const stack = (info?.componentStack || error.stack || '').trim();
|
||||||
|
this.setState({ info: stack });
|
||||||
|
// Never let the reporting path throw: it runs while the app is already broken.
|
||||||
|
try {
|
||||||
|
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
|
||||||
|
} catch {
|
||||||
|
/* the backend may be gone too — the on-screen copy still works */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error, info } = this.state;
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
|
const report = `${error.name}: ${error.message}\n\n${info}`;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||||
|
overflow: 'auto', zIndex: 9999,
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: 820, width: '100%' }}>
|
||||||
|
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
|
||||||
|
OpsLog hit an error and stopped drawing
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
|
||||||
|
Your log is safe — this is the window, not the database. The details below
|
||||||
|
are already in the app log; send them with your report.
|
||||||
|
</p>
|
||||||
|
<pre style={{
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
|
||||||
|
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
|
||||||
|
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
|
||||||
|
}}>{report}</pre>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { void navigator.clipboard?.writeText(report); }}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
|
||||||
|
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Copy the details
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
|
||||||
|
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Reload the window
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
|
||||||
|
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
|
||||||
|
// so they leave no trace at all — and an auto-call loop lives entirely in
|
||||||
|
// callbacks, which is precisely where a white screen was reported.
|
||||||
|
export function installGlobalErrorLogging() {
|
||||||
|
const report = (kind: string, message: string, stack?: string) => {
|
||||||
|
try {
|
||||||
|
LogUIError(kind, message, stack || '');
|
||||||
|
} catch {
|
||||||
|
/* nothing more we can do from here */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', (e: ErrorEvent) => {
|
||||||
|
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
|
||||||
|
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||||
|
const r: any = e.reason;
|
||||||
|
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
|
||||||
|
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')}
|
{t('exf.defaults')}
|
||||||
</Button>
|
</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,15 +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 ? '—'
|
||||||
value={fieldType === 'adifdate' && /^d{8}$/.test(c.value)
|
: isList ? t('fltb.listPh')
|
||||||
|
: fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')}
|
||||||
|
// \d, not d: the escape was missing, so the test never matched
|
||||||
|
// an 8-digit ADIF date and the calendar input was handed
|
||||||
|
// "20260728" — which type=date rejects, showing an empty box
|
||||||
|
// over a value that was really there.
|
||||||
|
// 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(); }}
|
||||||
/>
|
/>
|
||||||
@@ -305,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
|
||||||
@@ -323,13 +343,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [st.cw_speed]);
|
}, [st.cw_speed]);
|
||||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
// Peak-hold: keep the highest reading for a second so the jittery VITA-49
|
||||||
// read steadily instead of jumping every poll.
|
// meters read steadily instead of jumping every poll. One second, not two —
|
||||||
|
// longer and the meters visibly trail the end of a transmission, which reads
|
||||||
|
// 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) => {
|
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 > 2000) { 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; }
|
||||||
return p.v;
|
return p.v;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -355,12 +380,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -647,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)}
|
||||||
@@ -978,10 +1061,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||||
|
is off (no status = not "connected"), and serial only. */}
|
||||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||||
|
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!spe.connected}
|
||||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||||
@@ -1083,9 +1168,6 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
|
||||||
</span>
|
|
||||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||||
PowerGenius). The dot shows the direct-connection state; the
|
PowerGenius). The dot shows the direct-connection state; the
|
||||||
selector is disabled until connected (hover it for the error). */}
|
selector is disabled until connected (hover it for the error). */}
|
||||||
@@ -1113,11 +1195,38 @@ 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
|
||||||
|
// 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 the last-known status after the amp disconnects — so
|
||||||
|
// it claimed 1350 W from an old transmission while the radio was
|
||||||
|
// putting out 10.
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
{amp.map((m) => {
|
{amp.map((m) => {
|
||||||
@@ -1126,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
@@ -42,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';
|
||||||
}
|
}
|
||||||
@@ -68,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 {
|
||||||
@@ -115,6 +141,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||||
// across restarts. Default on.
|
// across restarts. Default on.
|
||||||
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
|
||||||
|
|
||||||
|
// Grey line — the day/night terminator with its twilight band. Off by
|
||||||
|
// default: it is a working tool for the operator who wants it, not decoration
|
||||||
|
// for the one who does not.
|
||||||
|
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
|
||||||
|
const greylineLayer = useRef<L.LayerGroup | null>(null);
|
||||||
const autoZoomRef = useRef(autoZoom);
|
const autoZoomRef = useRef(autoZoom);
|
||||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||||
|
|
||||||
@@ -152,6 +184,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
|
// Draw the grey line, and keep it moving.
|
||||||
|
//
|
||||||
|
// Redrawn every minute: the terminator travels a quarter of a degree in that
|
||||||
|
// time, which is invisible, but a map left on all day would otherwise be
|
||||||
|
// silently wrong by evening — and the whole point of the display is knowing
|
||||||
|
// where the line is NOW.
|
||||||
|
//
|
||||||
|
// It lives in its own pane below the overlay pane so the path and beam are
|
||||||
|
// never hidden behind the shading, and is non-interactive so it can never
|
||||||
|
// swallow a click meant for the map.
|
||||||
|
useEffect(() => {
|
||||||
|
const m = worldMap.current;
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
if (!greyline) {
|
||||||
|
greylineLayer.current?.remove();
|
||||||
|
greylineLayer.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m.getPane('greyline')) {
|
||||||
|
const pane = m.createPane('greyline');
|
||||||
|
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
|
||||||
|
pane.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const now = new Date();
|
||||||
|
greylineLayer.current?.remove();
|
||||||
|
const g = L.layerGroup([], { pane: 'greyline' });
|
||||||
|
|
||||||
|
// The rings span −180…+180 once, but this map repeats the world sideways
|
||||||
|
// (worldCopyJump, and zoomed out several copies are on screen at once). A
|
||||||
|
// single ring therefore ends in a hard vertical edge wherever a copy
|
||||||
|
// begins — the shading looked like a rectangle laid over the planet.
|
||||||
|
//
|
||||||
|
// So each ring is drawn again shifted a full turn either way. Three copies
|
||||||
|
// cover every zoom level this map reaches; they cost nothing, being three
|
||||||
|
// polygons on a canvas layer.
|
||||||
|
const COPIES = [-360, 0, 360];
|
||||||
|
const shifted = (ring: [number, number][], by: number): [number, number][] =>
|
||||||
|
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
|
||||||
|
|
||||||
|
// Two bands, drawn dark-to-light so they read as one gradient into night:
|
||||||
|
// full darkness (Sun below the horizon) and civil twilight (down to −6°),
|
||||||
|
// which is the band operators actually mean by "grey line".
|
||||||
|
const nightRing = nightPolygon(now, 0);
|
||||||
|
const twilightRing = nightPolygon(now, -6);
|
||||||
|
// The terminator itself, so the line is readable at a glance rather than
|
||||||
|
// being inferred from where the shading fades. Dropping the last two
|
||||||
|
// points leaves the terminator alone, without the segment that closes the
|
||||||
|
// polygon along the dark pole.
|
||||||
|
const lineRing = nightRing.slice(0, -2);
|
||||||
|
|
||||||
|
for (const by of COPIES) {
|
||||||
|
L.polygon(shifted(twilightRing, by), {
|
||||||
|
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
L.polygon(shifted(nightRing, by), {
|
||||||
|
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
L.polyline(shifted(lineRing, by), {
|
||||||
|
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
|
||||||
|
}).addTo(g);
|
||||||
|
}
|
||||||
|
g.addTo(m);
|
||||||
|
greylineLayer.current = g;
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
const id = window.setInterval(draw, 60_000);
|
||||||
|
return () => { window.clearInterval(id); };
|
||||||
|
}, [greyline]);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wm = worldMap.current, wo = worldOverlay.current;
|
const wm = worldMap.current, wo = worldOverlay.current;
|
||||||
@@ -260,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}
|
||||||
@@ -293,6 +402,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
>
|
>
|
||||||
Zoom DX
|
Zoom DX
|
||||||
</button>
|
</button>
|
||||||
|
{/* Grey line toggle, under the zoom button. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const v = !greyline;
|
||||||
|
setGreyline(v);
|
||||||
|
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
|
||||||
|
}}
|
||||||
|
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
|
||||||
|
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||||
|
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Grey line
|
||||||
|
</button>
|
||||||
{path && (
|
{path && (
|
||||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||||
@@ -320,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')} />
|
||||||
</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}>
|
<Select value={qslVia} onValueChange={setQslVia}>
|
||||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
<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>
|
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{t(o.label)}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</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 } 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,8 +43,14 @@ 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);
|
||||||
|
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||||
|
// known. Rendering at the cursor first avoids a visible jump for the common
|
||||||
|
// case where it already fits.
|
||||||
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||||
|
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
@@ -50,21 +63,50 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
};
|
};
|
||||||
}, [menu, onClose]);
|
}, [menu, onClose]);
|
||||||
|
|
||||||
|
// Position AFTER measuring.
|
||||||
|
//
|
||||||
|
// The menu used to be clamped against a guessed height — 230 px when the
|
||||||
|
// upload submenu was present, 110 otherwise. It is far taller than that with
|
||||||
|
// a selection, so right-clicking near the bottom of the window cut half the
|
||||||
|
// entries off. Its height also depends on which actions the caller passes, so
|
||||||
|
// no constant can be right for long.
|
||||||
|
//
|
||||||
|
// Measured instead: if it does not fit below the cursor it is placed above,
|
||||||
|
// and failing that pinned to the top with its own scrollbar.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!menu || !boxRef.current) return;
|
||||||
|
const r = boxRef.current.getBoundingClientRect();
|
||||||
|
const pad = 6;
|
||||||
|
let x = menu.x;
|
||||||
|
let y = menu.y;
|
||||||
|
if (x + r.width > window.innerWidth - pad) {
|
||||||
|
x = Math.max(pad, window.innerWidth - r.width - pad);
|
||||||
|
}
|
||||||
|
if (y + r.height > window.innerHeight - pad) {
|
||||||
|
// Above the cursor, if there is room there.
|
||||||
|
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
|
||||||
|
}
|
||||||
|
setPos({ x, y });
|
||||||
|
}, [menu, onSendTo]);
|
||||||
|
|
||||||
if (!menu) return null;
|
if (!menu) return null;
|
||||||
const n = menu.ids.length;
|
const n = menu.ids.length;
|
||||||
// Keep the menu on-screen near the cursor.
|
|
||||||
const x = Math.min(menu.x, window.innerWidth - 248);
|
|
||||||
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
ref={boxRef}
|
||||||
style={{ left: x, top: y }}
|
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
|
||||||
|
style={{ left: pos.x, top: pos.y }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
{/* Conditional like every other entry. These two were drawn
|
||||||
|
unconditionally, and the grid passes them through an arrow that calls
|
||||||
|
an optional handler — so in a grid that wires neither (the QSL
|
||||||
|
Manager) both appeared and did precisely nothing when clicked. */}
|
||||||
|
{onUpdateFromCty && (
|
||||||
<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"
|
||||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||||
@@ -72,6 +114,8 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<Globe2 className="size-4 text-primary" />
|
<Globe2 className="size-4 text-primary" />
|
||||||
<span>{t('qctx.fixCountry')}</span>
|
<span>{t('qctx.fixCountry')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{onUpdateFromQRZ && (
|
||||||
<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"
|
||||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||||
@@ -79,6 +123,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<RefreshCw className="size-4 text-info" />
|
<RefreshCw className="size-4 text-info" />
|
||||||
<span>{t('qctx.updateQrz')}</span>
|
<span>{t('qctx.updateQrz')}</span>
|
||||||
</button>
|
</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"
|
||||||
@@ -88,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) && (
|
||||||
<>
|
<>
|
||||||
@@ -138,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"
|
||||||
@@ -156,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">
|
||||||
|
<div className="flex items-center">
|
||||||
<Label>{t('qedit.callsign')}</Label>
|
<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,9 +746,64 @@ 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>
|
||||||
|
|
||||||
|
{confSel === HAMLOG_CONF ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||||
|
<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 className="grid grid-cols-2 gap-3">
|
<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>
|
<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><Label>{t('qedit.received')}</Label>
|
||||||
@@ -641,13 +813,21 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></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>
|
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
|
||||||
{def.via && (
|
{/* How the card travelled, each way — ADIF's QSL Via
|
||||||
<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>
|
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>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
</p>
|
</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>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
|
import { qslStatusCellClass } from '@/lib/qslStatus';
|
||||||
|
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
@@ -16,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/
|
||||||
@@ -54,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;
|
||||||
@@ -74,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';
|
||||||
@@ -85,23 +97,13 @@ function fmtMhzDots(hz?: number): string {
|
|||||||
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both formatters follow the display preference (Settings → General). What is
|
||||||
|
// STORED never changes — see lib/dateFormat.
|
||||||
function fmtDateUTC(s: any): string {
|
function fmtDateUTC(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateTimeUTC(s);
|
||||||
const d = new Date(s);
|
|
||||||
if (isNaN(d.getTime())) return s;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
||||||
}
|
}
|
||||||
function fmtDateOnly(s: any): string {
|
function fmtDateOnly(s: any): string {
|
||||||
if (!s) return '';
|
return formatDateOnly(s);
|
||||||
const t = String(s).trim();
|
|
||||||
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
|
|
||||||
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
||||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
||||||
const d = new Date(t);
|
|
||||||
if (isNaN(d.getTime())) return t;
|
|
||||||
const p = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
|
||||||
@@ -136,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 },
|
||||||
@@ -178,41 +184,53 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
|
|
||||||
// ── QSL ──
|
// ── QSL ──
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
|
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
|
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ 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 },
|
||||||
|
|
||||||
// ── LoTW ──
|
// ── LoTW ──
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
|
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
|
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
// ── eQSL ──
|
// ── eQSL ──
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_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) },
|
{ 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: 'font-mono', 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: 'font-mono', 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 },
|
||||||
|
|
||||||
// ── Uploads (online logbooks) ──
|
// ── Uploads (online logbooks) ──
|
||||||
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
|
||||||
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
// for QRZ only, a "download status/date" (= it came back confirmed). We
|
||||||
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
|
||||||
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
|
||||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
@@ -284,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);
|
||||||
@@ -303,7 +331,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
// Rebuild the columns when the format changes: the formatters are captured
|
||||||
|
// inside the column definitions, so nothing else would notice.
|
||||||
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||||
|
|
||||||
// Right-click: if the clicked row isn't already part of the selection,
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
// select just it; then open the bulk-action menu on the whole selection.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
@@ -444,10 +476,18 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
if (widthUps.length) api.setColumnWidths(widthUps);
|
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||||
}, [awardCols, awardShown]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
|
// No column filters here, deliberately. This grid holds only the most recent
|
||||||
|
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
|
||||||
|
// an empty result then reads as "the log does not contain it" rather than "it
|
||||||
|
// is not on this page". An operator hunting an imported CONTEST_ID across
|
||||||
|
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
|
||||||
|
//
|
||||||
|
// The advanced filter queries the whole logbook and is the honest tool for it.
|
||||||
|
// Removing the filter also gives each header back the width of its menu icon.
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: !passOrder,
|
sortable: !passOrder,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
filter: true,
|
filter: false,
|
||||||
suppressMovable: false,
|
suppressMovable: false,
|
||||||
}), [passOrder]);
|
}), [passOrder]);
|
||||||
|
|
||||||
@@ -460,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -489,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) {
|
||||||
@@ -517,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);
|
||||||
@@ -538,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(() => {
|
||||||
@@ -600,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;
|
||||||
@@ -675,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}
|
||||||
@@ -705,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,44 +1,122 @@
|
|||||||
import { useCallback, useEffect, 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[] };
|
||||||
|
|
||||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
|
||||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
|
||||||
|
// packed tighter but left the row edges ragged, which read as untidy on a wide
|
||||||
|
// screen; aligned rows are what an operator expects from a dashboard.
|
||||||
|
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||||
|
const GRID_GAP = 16; // px between cards, both axes
|
||||||
|
|
||||||
|
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)', 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
|
||||||
@@ -67,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">
|
||||||
@@ -103,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();
|
||||||
@@ -269,6 +203,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||||
// row so cards wrap onto further lines even when there's horizontal room.
|
// row so cards wrap onto further lines even when there's horizontal room.
|
||||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||||
|
|
||||||
|
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
|
||||||
|
// cards as the window allows; an explicit count is honoured as asked, even
|
||||||
|
// past that width — the operator can see his own screen.
|
||||||
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [colCount, setColCount] = useState(1);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
|
||||||
|
const el = gridRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [cols]);
|
||||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||||
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||||
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||||
@@ -302,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 */ }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -312,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`.
|
||||||
@@ -372,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>
|
||||||
|
{fireAndForget ? <span className="ml-auto" /> : (
|
||||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
<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'))} />
|
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')}
|
||||||
@@ -394,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',
|
||||||
@@ -415,13 +403,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// `wide` cards span the whole dashboard instead of sitting in one masonry
|
// `wide` cards take two grid columns instead of one. The amplifier and tuner
|
||||||
// column. The amplifier and tuner carry meter rows and a channel selector that
|
// carry meter rows and a channel selector that are unreadable squeezed into a
|
||||||
// are unreadable squeezed into a ~430px column — they are the same cards the
|
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||||
// FlexRadio panel shows 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} /> });
|
||||||
@@ -430,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; };
|
||||||
@@ -446,7 +435,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||||
card can sit on a further line even with horizontal room to spare. */}
|
card can sit on a further line even with horizontal room to spare. */}
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||||
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
|
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
|
||||||
<button key={c} type="button"
|
<button key={c} type="button"
|
||||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
@@ -466,22 +455,26 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
|
{/* Dashboard grid: equal columns, and every card stretches to the height of
|
||||||
they pack tightly by height (no ragged gaps under short cards). "Auto"
|
the tallest one on its row, so the rows line up instead of ending
|
||||||
fits as many ~430px columns as the window allows; a fixed count caps the
|
ragged. One column each, two for the amplifier/tuner whose meter rows
|
||||||
container width to that many columns. Each card has a grip handle (left
|
need the room. Each card has a grip handle (left rail) as the drag
|
||||||
rail) as the drag initiator (the body is full of buttons). */}
|
initiator, since the body is full of buttons. */}
|
||||||
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
<div ref={gridRef} style={{
|
||||||
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||||||
|
gap: `${GRID_GAP}px`,
|
||||||
|
}}>
|
||||||
{ordered.map((w) => (
|
{ordered.map((w) => (
|
||||||
// column-span:all lifts a wide card out of the columns and across the
|
<div key={w.id}
|
||||||
// full container, while keeping it in document order — so drag-reorder
|
// A wide card takes two columns — but never more than there are, or
|
||||||
// still works between wide and normal cards. Capped so it stays a card
|
// the grid would grow a phantom column on a narrow window.
|
||||||
// and not a banner on an ultra-wide window.
|
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||||||
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
|
|
||||||
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
|
|
||||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||||
|
{/* h-full down the chain is what makes the card fill the row height
|
||||||
|
rather than sit at its natural size in a taller cell. */}
|
||||||
|
<div className="flex items-stretch h-full">
|
||||||
<div draggable
|
<div draggable
|
||||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
onDragEnd={() => { dragId.current = null; }}
|
onDragEnd={() => { dragId.current = null; }}
|
||||||
@@ -490,7 +483,8 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
dragId.current === w.id && 'opacity-60')}>
|
dragId.current === w.id && 'opacity-60')}>
|
||||||
<GripVertical className="size-4" />
|
<GripVertical className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">{w.node}</div>
|
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -523,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(() => {
|
||||||
@@ -573,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>
|
||||||
@@ -583,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>
|
||||||
@@ -631,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">
|
||||||
@@ -651,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"
|
||||||
@@ -668,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">
|
||||||
|
{/* No connection test for the generic board, and no host required to
|
||||||
|
save it. There is nothing to test: it has no address of its own and
|
||||||
|
no status to read — its URLs are fired and forgotten. A button that
|
||||||
|
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()}>
|
<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" />}
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
{t('station.test')}
|
{t('station.test')}
|
||||||
</Button>
|
</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)" />
|
||||||
|
|||||||
@@ -76,13 +76,16 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
|||||||
const peakHold = (key: string, val: number) => {
|
const peakHold = (key: string, val: number) => {
|
||||||
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 > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
return p.v;
|
return p.v;
|
||||||
};
|
};
|
||||||
|
|
||||||
const connected = !!status.connected;
|
const connected = !!status.connected;
|
||||||
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
// Prefer the device's own peak detector over the sampled reading; the JS hold
|
||||||
|
// below still smooths what is left between polls.
|
||||||
|
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||||
|
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
|
||||||
const fwdW = peakHold('fwd', rawFwdW);
|
const fwdW = peakHold('fwd', rawFwdW);
|
||||||
|
|
||||||
// SWR needs RF to mean anything: sampled on receive the device reports a
|
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||||
@@ -93,13 +96,14 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
|||||||
// That expiry is the point: the first version of this held the last TX value
|
// That expiry is the point: the first version of this held the last TX value
|
||||||
// with no timeout at all, so the meter sat frozen on the previous transmission
|
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||||
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||||
// Same 2 s window as the power peak above, so the two meters clear together.
|
// Same one-second window as the power peak above, so the two meters clear
|
||||||
|
// together.
|
||||||
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||||
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||||
const heldSwr = swrHold.current;
|
const heldSwr = swrHold.current;
|
||||||
const vswr = rawFwdW >= 1
|
const vswr = rawFwdW >= 1
|
||||||
? rawVswr
|
? rawVswr
|
||||||
: heldSwr && Date.now() - heldSwr.t < 2000
|
: heldSwr && Date.now() - heldSwr.t < 1000
|
||||||
? heldSwr.v
|
? heldSwr.v
|
||||||
: undefined;
|
: undefined;
|
||||||
const active = status.active ?? 1;
|
const active = status.active ?? 1;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type TGChannel = {
|
|||||||
};
|
};
|
||||||
export type TGStatus = {
|
export type TGStatus = {
|
||||||
connected: boolean; host?: string; last_error?: string;
|
connected: boolean; host?: string; last_error?: string;
|
||||||
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||||
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||||
antenna?: number; three_way?: boolean; message?: string;
|
antenna?: number; three_way?: boolean; message?: string;
|
||||||
a?: TGChannel; b?: TGChannel;
|
a?: TGChannel; b?: TGChannel;
|
||||||
@@ -69,6 +69,10 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
const active = status.active ?? 1;
|
const active = status.active ?? 1;
|
||||||
|
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
|
||||||
|
// between syllables as often as on a peak, so the plain figure showed a few
|
||||||
|
// hundred watts on a kilowatt transmission.
|
||||||
|
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
@@ -103,7 +107,7 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
|||||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||||
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||||
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
|
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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'; // 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,15 +109,17 @@ 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' : '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 === '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 === 'kenwood'
|
||||||
|
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
) : !connected ? (
|
) : !connected ? (
|
||||||
@@ -198,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')}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||||
|
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
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';
|
||||||
@@ -34,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;
|
||||||
@@ -56,14 +59,16 @@ 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);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
// Localized column catalog (shared with the Recent QSOs grid).
|
// Localized column catalog (shared with the Recent QSOs grid).
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||||
|
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||||
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
@@ -269,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}
|
||||||
@@ -293,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);
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetYaesuState, RefreshYaesuPanel,
|
||||||
|
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||||
|
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuAntenna, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||||
|
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||||
|
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, 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';
|
||||||
|
|
||||||
|
type YaesuState = {
|
||||||
|
available: boolean; model?: string; mode?: string; raw_mode?: string;
|
||||||
|
transmitting: boolean; split: boolean;
|
||||||
|
s_meter: number; power_meter: number; swr_meter: number;
|
||||||
|
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||||
|
agc?: string; preamp: number; att: number; antenna: number;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: YaesuState = {
|
||||||
|
available: false, transmitting: false, split: false,
|
||||||
|
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||||
|
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||||
|
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
|
||||||
|
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
|
||||||
|
// the radio's own band keys do. That is why these are band names, not Hz.
|
||||||
|
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||||
|
|
||||||
|
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
|
||||||
|
// the operator is the one who knows which they want, so each shows its sideband
|
||||||
|
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
|
||||||
|
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
|
||||||
|
// the band plan dictates, and AM/FM have none.
|
||||||
|
//
|
||||||
|
// PSK rides on the rig's DATA mode, like the other digital modes — the button
|
||||||
|
// exists because the operator thinks in modes, not in what the radio calls them.
|
||||||
|
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
|
||||||
|
const MODES: ModeBtn[] = [
|
||||||
|
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
|
||||||
|
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
|
||||||
|
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
|
||||||
|
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
|
||||||
|
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
|
||||||
|
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
|
||||||
|
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Which button the rig's current raw mode belongs to, and on which sideband.
|
||||||
|
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
||||||
|
switch ((raw || '').toUpperCase()) {
|
||||||
|
case 'USB': return { id: 'SSB', side: 'U' };
|
||||||
|
case 'LSB': return { id: 'SSB', side: 'L' };
|
||||||
|
case 'CW-U': return { id: 'CW', side: 'U' };
|
||||||
|
case 'CW-L': return { id: 'CW', side: 'L' };
|
||||||
|
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
|
||||||
|
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
|
||||||
|
case 'DATA-U': return { id: 'DIGI', side: 'U' };
|
||||||
|
case 'DATA-L': return { id: 'DIGI', side: 'L' };
|
||||||
|
case 'AM': return { id: 'AM', side: 'U' };
|
||||||
|
case 'FM': return { id: 'FM', side: 'U' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
|
||||||
|
// 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.
|
||||||
|
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' }];
|
||||||
|
// 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' }];
|
||||||
|
|
||||||
|
function fmtVFO(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '––.–––.––';
|
||||||
|
const mhz = Math.floor(hz / 1_000_000);
|
||||||
|
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||||||
|
const h2 = Math.floor((hz % 1000) / 10);
|
||||||
|
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandOfHz(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '';
|
||||||
|
const mhz = hz / 1_000_000;
|
||||||
|
const bands: [string, number, number][] = [
|
||||||
|
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
|
||||||
|
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
|
||||||
|
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
|
||||||
|
];
|
||||||
|
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Split the 0-100 S-meter reading into S units + dB over S9.
|
||||||
|
//
|
||||||
|
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
|
||||||
|
// falls; the front panel puts it at roughly half travel, which is the 50 used
|
||||||
|
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
|
||||||
|
// unit off on a radio, this is the number to correct, not the RST helper.
|
||||||
|
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
|
||||||
|
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
|
||||||
|
|
||||||
|
function sParts(v: number): { s: number; over: number; label: string } {
|
||||||
|
if (v >= S9_PCT) {
|
||||||
|
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
|
||||||
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||||
|
}
|
||||||
|
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
|
||||||
|
return { s, over: 0, label: `S${s}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
|
||||||
|
// through the S9+ range, red once the signal is strong enough to be reported as
|
||||||
|
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
|
||||||
|
// change always lands exactly where the numbers say it should — if the S9 point
|
||||||
|
// is ever corrected, the colours follow on their own.
|
||||||
|
const RED_OVER_DB = 20;
|
||||||
|
function sSegColor(frac: number): string {
|
||||||
|
const pct = frac * 100;
|
||||||
|
if (pct < S9_PCT) return '#16a34a';
|
||||||
|
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
|
||||||
|
return '#dc2626';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(min, Math.min(max, value));
|
||||||
|
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
// React's onWheel is passive, so preventDefault is ignored there — attach a
|
||||||
|
// native non-passive listener, and read live values through refs so the
|
||||||
|
// handler never closes over a stale value.
|
||||||
|
const valRef = useRef(value); valRef.current = value;
|
||||||
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||||
|
const disRef = useRef(disabled); disRef.current = disabled;
|
||||||
|
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 nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
||||||
|
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} value={v} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
|
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
|
||||||
|
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
|
||||||
|
// The filled side was invisible against the dark theme: --muted is barely
|
||||||
|
// lighter than the card it sits on, so the whole track read as one bar.
|
||||||
|
// An explicit translucent track keeps both halves distinct in either theme.
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
|
||||||
|
borderColor: accent,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segmented({ value, options, onChange }: {
|
||||||
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||||
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} title={title}
|
||||||
|
className={cn('shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: 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" style={{ color: accent ?? 'var(--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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||||
|
onReportRST?: (rst: string) => void;
|
||||||
|
// Told whenever the operator moves the CW speed here, so the app can keep the
|
||||||
|
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
|
||||||
|
// the timing and the rig's internal keyer speed changes nothing audible — the
|
||||||
|
// slider looked broken because it was driving the wrong keyer.
|
||||||
|
onKeySpeed?: (wpm: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [st, setSt] = useState<YaesuState>(ZERO);
|
||||||
|
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
|
||||||
|
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
|
||||||
|
// main display showed the operator the frequency they transmit on and an
|
||||||
|
// offset of 0 kHz against itself.
|
||||||
|
const [freqHz, setFreqHz] = useState(0);
|
||||||
|
const [txHz, setTxHz] = useState(0);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
// Optimistic local values for the sliders. Without them a drag fights the
|
||||||
|
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
|
||||||
|
const [local, setLocal] = useState<Partial<YaesuState>>({});
|
||||||
|
const localAtRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = (await GetYaesuState()) as YaesuState;
|
||||||
|
const c = await GetCATState();
|
||||||
|
if (!alive) return;
|
||||||
|
setSt(s);
|
||||||
|
const cs = c as any;
|
||||||
|
const tx = cs?.freq_hz ?? 0;
|
||||||
|
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
|
||||||
|
setFreqHz(rx);
|
||||||
|
setTxHz(tx);
|
||||||
|
// Drop the optimistic overlay once the rig has had time to answer with
|
||||||
|
// the new value — 1.2 s covers the slow-beat settings read.
|
||||||
|
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||||
|
setErr('');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (alive) setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 400);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const view = { ...st, ...local };
|
||||||
|
|
||||||
|
// Every setter follows the same shape: show the value at once, remember when,
|
||||||
|
// and let the poll take over. A rejected command surfaces as an error rather
|
||||||
|
// than as a control that silently springs back.
|
||||||
|
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
|
||||||
|
setLocal((l) => ({ ...l, [key]: value }));
|
||||||
|
localAtRef.current = Date.now();
|
||||||
|
fn().catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const band = bandOfHz(freqHz);
|
||||||
|
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
|
||||||
|
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
|
||||||
|
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
|
||||||
|
|
||||||
|
if (!st.available) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex items-center justify-center p-6 text-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Radio className="size-8 mx-auto text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
|
||||||
|
{err && <p className="text-xs text-destructive">{err}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
|
||||||
|
it without mx-auto left the console pinned to the left edge with a
|
||||||
|
window of empty space beside it. */}
|
||||||
|
<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">{st.model || 'Yaesu'}</div>
|
||||||
|
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
|
||||||
|
{view.split && txHz > 0 && (
|
||||||
|
<div className="text-[11px] font-mono tabular-nums text-warning">
|
||||||
|
{t('yaesu.txOn')} {fmtVFO(txHz)}
|
||||||
|
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{st.transmitting && (
|
||||||
|
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
|
||||||
|
)}
|
||||||
|
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
|
||||||
|
{/* The usual pile-up offsets. Which one is idiomatic depends on the
|
||||||
|
mode — up 5 on phone, up 1 on CW — so both are offered rather than
|
||||||
|
guessed, and each turns split on in the same action. */}
|
||||||
|
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.splitUpHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
|
||||||
|
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.splitUpHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
|
||||||
|
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.tuneHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
TUNE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <p className="text-xs text-destructive px-1">{err}</p>}
|
||||||
|
|
||||||
|
{/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three
|
||||||
|
consoles read alike instead of each having its own instrument style. */}
|
||||||
|
<Card icon={Activity} title={t('yaesu.meters')}>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
|
||||||
|
display={sParts(view.s_meter).label}
|
||||||
|
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
|
||||||
|
title={onReportRST ? t('yaesu.sToRst') : undefined} />
|
||||||
|
{/* Watts as MEASURED, not the power setting scaled by a percentage:
|
||||||
|
the setting says what was asked for, the meter says what left. */}
|
||||||
|
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
|
||||||
|
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
|
||||||
|
{/* The RATIO, as the rig shows it — a percentage of meter travel is
|
||||||
|
not something an operator can act on. The bar keeps the travel. */}
|
||||||
|
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
|
||||||
|
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bands + modes */}
|
||||||
|
<Card icon={Antenna} title={t('yaesu.bandMode')}>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{BANDS.map((b) => (
|
||||||
|
<button key={b} type="button"
|
||||||
|
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{b.replace('m', '')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{MODES.map((m) => {
|
||||||
|
const act = activeMode(view.raw_mode);
|
||||||
|
const on = act?.id === m.id;
|
||||||
|
// The sideband shown is the rig's when this mode is active, else the
|
||||||
|
// one the band plan implies — so a button says what pressing it will
|
||||||
|
// actually do rather than a stale letter.
|
||||||
|
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
|
||||||
|
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
|
||||||
|
return (
|
||||||
|
<button key={m.id} type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Already on this mode → the click means "the other sideband".
|
||||||
|
const target = m.id === 'SSB'
|
||||||
|
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
|
||||||
|
: m.rig(on && m.sideband ? flip : side);
|
||||||
|
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
}}
|
||||||
|
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
|
||||||
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{m.sideband ? m.label + '-' + side : m.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Receive */}
|
||||||
|
<Card icon={AudioLines} title={t('yaesu.receive')}>
|
||||||
|
<Row label="AF">
|
||||||
|
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="RF">
|
||||||
|
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="SQL">
|
||||||
|
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="AGC">
|
||||||
|
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="FRONT">
|
||||||
|
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="ATT">
|
||||||
|
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Noise + filter */}
|
||||||
|
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
|
||||||
|
<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.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||||
|
{/* 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>
|
||||||
|
<Row label="DNR">
|
||||||
|
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||||
|
the radio's own display counts 1-15, and matching it is what makes
|
||||||
|
the panel readable next to the front panel. */}
|
||||||
|
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
|
||||||
|
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transmit */}
|
||||||
|
<Card icon={Mic} title={t('yaesu.transmit')}>
|
||||||
|
<Row label="PWR">
|
||||||
|
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
||||||
|
percentage would be a second unit to reconcile every time. */}
|
||||||
|
<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))} />
|
||||||
|
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||||
|
</Row>
|
||||||
|
{/* Microphone gain and VOX are meaningless in CW — the rig ignores both
|
||||||
|
— so they are hidden rather than shown as dead controls. */}
|
||||||
|
{!isCW && (
|
||||||
|
<>
|
||||||
|
<Row label="MIC">
|
||||||
|
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="VOX">
|
||||||
|
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Row label="">
|
||||||
|
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
{t('yaesu.refresh')}
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* CW — only in CW, where these replace the phone controls above. */}
|
||||||
|
{isCW && (
|
||||||
|
<Card icon={Radio} title={t('yaesu.cw')}>
|
||||||
|
<Row label="SPEED">
|
||||||
|
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
|
||||||
|
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
|
||||||
|
</Row>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
|
||||||
|
label="BK-IN" title={t('yaesu.breakInHint')} />
|
||||||
|
{/* ZIN is a one-shot: the rig retunes so the station being received
|
||||||
|
lands on the operator's own CW pitch. Not a toggle, so it is a
|
||||||
|
plain button rather than a chip that would look latched. */}
|
||||||
|
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('yaesu.zinHint')}
|
||||||
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
ZIN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
// No focus ring on the panel itself. Radix makes the content focusable and
|
||||||
|
// moves focus into it when a tab is selected, so clicking anywhere in the
|
||||||
|
// lower half drew a full-width orange rule under the tab strip. The panel is
|
||||||
|
// a container — what it holds carries its own focus styling.
|
||||||
|
className={cn('mt-2 focus-visible:outline-none', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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() }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// How dates are DISPLAYED. Nothing here touches what is stored.
|
||||||
|
//
|
||||||
|
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
|
||||||
|
// upload. This is the reading layer only, because an operator reading
|
||||||
|
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
|
||||||
|
// and that is a display problem, not a data one.
|
||||||
|
//
|
||||||
|
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
|
||||||
|
// it is what ADIF specifies, and a log whose stored format followed a UI
|
||||||
|
// preference would become unreadable the day the preference changed.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type DateFormat = 'iso' | 'fr' | 'us';
|
||||||
|
|
||||||
|
const KEY = 'opslog.dateFormat';
|
||||||
|
|
||||||
|
function read(): DateFormat {
|
||||||
|
const v = localStorage.getItem(KEY);
|
||||||
|
return v === 'fr' || v === 'us' ? v : 'iso';
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: DateFormat = read();
|
||||||
|
|
||||||
|
// A minimal store so every grid and panel re-renders the moment the format
|
||||||
|
// changes, rather than showing the old one until the next restart.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function getDateFormat(): DateFormat {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDateFormat(f: DateFormat): void {
|
||||||
|
current = f;
|
||||||
|
writeUiPref(KEY, f);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeDateFormat(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadDateFormat re-reads the stored value — for after the portable prefs
|
||||||
|
// have been synced from the database at startup, which happens AFTER this
|
||||||
|
// module's first read.
|
||||||
|
export function reloadDateFormat(): void {
|
||||||
|
const v = read();
|
||||||
|
if (v !== current) {
|
||||||
|
current = v;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// order arranges the three parts. Separators stay '-' in every format: a slash
|
||||||
|
// in the French one would be conventional but it also invites the reader to
|
||||||
|
// take it for a US date, which is exactly the confusion being fixed.
|
||||||
|
function order(y: number, m: number, d: number): string {
|
||||||
|
switch (current) {
|
||||||
|
case 'fr':
|
||||||
|
return `${pad(d)}-${pad(m)}-${y}`;
|
||||||
|
case 'us':
|
||||||
|
return `${pad(m)}-${pad(d)}-${y}`;
|
||||||
|
}
|
||||||
|
return `${y}-${pad(m)}-${pad(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDateTimeUTC renders a timestamp with the time, in UTC.
|
||||||
|
//
|
||||||
|
// UTC is not negotiable and is not part of the preference: a logbook is kept in
|
||||||
|
// UTC, and a date shown in local time would disagree with the QSO's own record
|
||||||
|
// twice a year.
|
||||||
|
export function formatDateTimeUTC(s: unknown): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(String(s));
|
||||||
|
if (isNaN(d.getTime())) return String(s);
|
||||||
|
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDateOnly renders a date with no time. It accepts both the ADIF form
|
||||||
|
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
|
||||||
|
export function formatDateOnly(s: unknown): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const t = String(s).trim();
|
||||||
|
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||||
|
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||||
|
const d = new Date(t);
|
||||||
|
if (isNaN(d.getTime())) return t;
|
||||||
|
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Grey line — the day/night terminator, and the twilight band around it where
|
||||||
|
// HF propagation briefly does things it does at no other time.
|
||||||
|
//
|
||||||
|
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
|
||||||
|
// used by NOAA's solar calculator): good to well under a degree, which is far
|
||||||
|
// finer than the band being drawn. No ephemeris, no network, no dependency —
|
||||||
|
// this has to work in a field shack with no internet.
|
||||||
|
//
|
||||||
|
// Everything here is pure so it can be tested against known positions; the
|
||||||
|
// drawing lives in the map component.
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
// julianDay for a date, including the fraction of the day.
|
||||||
|
export function julianDay(d: Date): number {
|
||||||
|
return d.getTime() / 86400000 + 2440587.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sun's declination and Greenwich hour angle, both in degrees.
|
||||||
|
//
|
||||||
|
// Returned together because they are computed from the same intermediates and
|
||||||
|
// every caller needs both — splitting them into two exported functions would
|
||||||
|
// double the work at each call for the sake of a tidier signature.
|
||||||
|
export function sunPosition(date: Date): { dec: number; gha: number } {
|
||||||
|
const jd = julianDay(date);
|
||||||
|
const n = jd - 2451545.0; // days from J2000.0
|
||||||
|
|
||||||
|
// Mean longitude and mean anomaly of the Sun.
|
||||||
|
const L = (280.460 + 0.9856474 * n) % 360;
|
||||||
|
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
|
||||||
|
|
||||||
|
// Ecliptic longitude: the equation of centre applied to the mean longitude.
|
||||||
|
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
|
||||||
|
|
||||||
|
// Obliquity of the ecliptic, slowly decreasing.
|
||||||
|
const eps = (23.439 - 0.0000004 * n) * DEG;
|
||||||
|
|
||||||
|
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
|
||||||
|
|
||||||
|
// Right ascension, kept in the same quadrant as the ecliptic longitude —
|
||||||
|
// atan2 alone lands 180° out for half the year, which mirrors the whole
|
||||||
|
// terminator about the prime meridian. Six months of a plausible-looking
|
||||||
|
// wrong answer is exactly the kind of bug that survives review.
|
||||||
|
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
|
||||||
|
if (ra < 0) ra += 360;
|
||||||
|
|
||||||
|
// Greenwich mean sidereal time, in degrees.
|
||||||
|
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
|
||||||
|
|
||||||
|
let gha = gmst - ra;
|
||||||
|
gha = ((gha % 360) + 360) % 360;
|
||||||
|
return { dec, gha };
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
|
||||||
|
// sits at `altitude` degrees above (or below) the horizon.
|
||||||
|
//
|
||||||
|
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
|
||||||
|
// of the grey line as operators use the term.
|
||||||
|
//
|
||||||
|
// Returns null where no such latitude exists at that longitude — inside the
|
||||||
|
// polar day or polar night, where the Sun never crosses that altitude at all.
|
||||||
|
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
|
||||||
|
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||||
|
const d = dec * DEG;
|
||||||
|
const h = altitude * DEG;
|
||||||
|
|
||||||
|
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
|
||||||
|
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
|
||||||
|
//
|
||||||
|
// That has TWO solutions per meridian, and picking the wrong one is not a
|
||||||
|
// small error — it mirrors the answer about the equator. New York at 02:00
|
||||||
|
// local in December came out as daylight because the branch chosen put the
|
||||||
|
// terminator at +63° where it belongs at −63°: a shaded map that looks
|
||||||
|
// entirely plausible and is exactly wrong.
|
||||||
|
//
|
||||||
|
// So anchor on the geometric terminator, where the answer is unambiguous and
|
||||||
|
// classical, and take whichever branch is nearest it. At altitude 0 that
|
||||||
|
// reproduces it exactly; for the twilight band it follows it by continuity,
|
||||||
|
// which is the physical requirement — the twilight curve is a small offset
|
||||||
|
// from the terminator, never on the other side of the planet.
|
||||||
|
const A = Math.sin(d);
|
||||||
|
const B = Math.cos(d) * Math.cos(ha);
|
||||||
|
const R = Math.hypot(A, B);
|
||||||
|
if (R === 0) return null;
|
||||||
|
const s = Math.sin(h) / R;
|
||||||
|
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
|
||||||
|
|
||||||
|
// The classical terminator. Division by zero is deliberate and correct here:
|
||||||
|
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
|
||||||
|
// exactly what atan(±Infinity) = ±90° expresses.
|
||||||
|
const t = Math.tan(d);
|
||||||
|
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
|
||||||
|
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
|
||||||
|
|
||||||
|
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
|
||||||
|
const fold = (x: number) => {
|
||||||
|
let v = ((x + 180) % 360 + 360) % 360 - 180;
|
||||||
|
if (v > 90) v = 180 - v;
|
||||||
|
if (v < -90) v = -180 - v;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
const c1 = fold((Math.asin(s) - phi) / DEG);
|
||||||
|
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
|
||||||
|
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// nightPolygon returns a ring covering the part of the world where the Sun is
|
||||||
|
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
|
||||||
|
//
|
||||||
|
// The ring runs along the terminator from west to east and closes along
|
||||||
|
// whichever pole is in darkness — which pole that is flips with the season, and
|
||||||
|
// getting it wrong shades the lit half of the planet.
|
||||||
|
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
|
||||||
|
const { dec, gha } = sunPosition(date);
|
||||||
|
const ring: [number, number][] = [];
|
||||||
|
|
||||||
|
for (let lon = -180; lon <= 180; lon += stepDeg) {
|
||||||
|
const lat = terminatorLatitude(lon, dec, gha, altitude);
|
||||||
|
// Inside a polar day/night there is no crossing; clamp to the pole so the
|
||||||
|
// ring stays closed rather than tearing open across the map.
|
||||||
|
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Northern summer (positive declination) → the SOUTH pole is dark.
|
||||||
|
const pole = dec > 0 ? -90 : 90;
|
||||||
|
ring.push([pole, 180]);
|
||||||
|
ring.push([pole, -180]);
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
+290
-138
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user